Per m's 2026-05-23 ask: from any project, surface every available
template/generator instead of just the project's own proceeding.
Backend (GET /api/projects/{id}/submissions):
- drop the proceeding_type_id filter; JOIN deadline_rules with
proceeding_types to return every active+published filing rule
across every active proceeding
- response gains proceeding_code, proceeding_name, proceeding_name_en
per row plus project_proceeding_code at the top so the frontend
can pin the project's own group
- has_template now reflects "per-submission .docx wired in
submissionTemplateRegistry"; the editor still falls back to the
universal HL Patents Style for everything else (t-paliad-238)
- can_see_project gate unchanged; rules are static reference data
- sorted by (proceeding_code, submission_code)
Frontend:
- client/submissions.ts renders a grouped table: project's own
proceeding pinned to the top with a lime border + "(dieses
Projekt)" suffix, every other proceeding alphabetised below
- "Generieren" + "Bearbeiten" buttons stay on every row (editor
handles missing variables via [KEIN WERT: …])
- "universell"/"universal" badge surfaces for rules without a
per-submission template — informational, not blocking
- soften the no_proceeding hint so the catalog still renders below
- entity-table-group-header CSS, including --own modifier and a
read-only override so group rows don't pretend to be clickable
Verified: 103 filing rules across 19 proceedings surface (de.inf.lg,
upc.inf.cfi, epa.opp.opd, etc.). go build + go vet + go test
./internal/... + bun run build clean.
270 lines
9.8 KiB
TypeScript
270 lines
9.8 KiB
TypeScript
// Submissions panel — fetches the full submission catalog across every
|
|
// proceeding and renders it grouped by proceeding, with the project's
|
|
// own proceeding pinned at the top.
|
|
//
|
|
// t-paliad-215 Slice 1 introduced the per-project list. t-paliad-242
|
|
// broadened it to the catalog: from any project a lawyer can pick a
|
|
// Statement of Defence under UPC.INF.CFI, a Klageerwiderung under
|
|
// DE.INF.LG, an Opposition under EPO, etc. — the editor (t-paliad-238)
|
|
// handles missing variables gracefully via the [KEIN WERT: …] marker,
|
|
// so cross-proceeding picks still render cleanly.
|
|
|
|
function escapeHtml(s: string): string {
|
|
return s
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
interface SubmissionEntry {
|
|
submission_code: string;
|
|
name: string;
|
|
name_en: string;
|
|
event_type?: string;
|
|
primary_party?: string;
|
|
legal_source?: string;
|
|
has_template: boolean;
|
|
proceeding_code: string;
|
|
proceeding_name: string;
|
|
proceeding_name_en: string;
|
|
}
|
|
|
|
interface SubmissionListResponse {
|
|
project_id: string;
|
|
proceeding_type_id?: number;
|
|
project_proceeding_code?: string;
|
|
entries: SubmissionEntry[];
|
|
}
|
|
|
|
// Module state — set once per page load when the user first opens the
|
|
// tab. Subsequent activations re-use the cached result so the lawyer
|
|
// doesn't pay for repeat list calls flipping between tabs.
|
|
let cached: { projectID: string; data: SubmissionListResponse } | null = null;
|
|
let loading = false;
|
|
|
|
/**
|
|
* Load + render the submissions panel for the given project.
|
|
*
|
|
* Idempotent: safe to call on every tab activation. The second call
|
|
* paints from cache instantly; the first call shows a loading state
|
|
* until the list response arrives.
|
|
*/
|
|
export async function loadAndRenderSubmissions(projectID: string): Promise<void> {
|
|
if (loading) return;
|
|
if (cached && cached.projectID === projectID) {
|
|
render(cached.data);
|
|
return;
|
|
}
|
|
loading = true;
|
|
try {
|
|
const resp = await fetch(`/api/projects/${projectID}/submissions`);
|
|
if (!resp.ok) {
|
|
renderError();
|
|
return;
|
|
}
|
|
const data = (await resp.json()) as SubmissionListResponse;
|
|
cached = { projectID, data };
|
|
render(data);
|
|
} catch {
|
|
renderError();
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function render(data: SubmissionListResponse): void {
|
|
const empty = document.getElementById("project-submissions-empty");
|
|
const noProc = document.getElementById("project-submissions-no-proceeding");
|
|
const wrap = document.getElementById("project-submissions-tablewrap");
|
|
const body = document.getElementById("project-submissions-body");
|
|
if (!empty || !noProc || !wrap || !body) return;
|
|
|
|
// t-paliad-242: the catalog is shown to every project regardless of
|
|
// whether a proceeding is bound — the no-proceeding hint stays as a
|
|
// soft nudge above the table, but no longer hides the catalog.
|
|
noProc.style.display = data.proceeding_type_id == null || data.proceeding_type_id === 0
|
|
? ""
|
|
: "none";
|
|
|
|
if (data.entries.length === 0) {
|
|
empty.style.display = "";
|
|
wrap.style.display = "none";
|
|
return;
|
|
}
|
|
empty.style.display = "none";
|
|
wrap.style.display = "";
|
|
|
|
const isEN = document.documentElement.lang === "en";
|
|
|
|
// Group entries by proceeding_code. Build a stable group order:
|
|
// project's own proceeding first (when present), then alphabetical
|
|
// by proceeding_code for the rest.
|
|
const groups = new Map<string, { name: string; entries: SubmissionEntry[] }>();
|
|
for (const entry of data.entries) {
|
|
const key = entry.proceeding_code || "";
|
|
const groupName = isEN && entry.proceeding_name_en
|
|
? entry.proceeding_name_en
|
|
: entry.proceeding_name;
|
|
const bucket = groups.get(key);
|
|
if (bucket) {
|
|
bucket.entries.push(entry);
|
|
} else {
|
|
groups.set(key, { name: groupName, entries: [entry] });
|
|
}
|
|
}
|
|
|
|
const ownCode = data.project_proceeding_code ?? "";
|
|
const orderedCodes: string[] = [];
|
|
if (ownCode && groups.has(ownCode)) orderedCodes.push(ownCode);
|
|
for (const code of Array.from(groups.keys()).sort()) {
|
|
if (code !== ownCode) orderedCodes.push(code);
|
|
}
|
|
|
|
const ownSuffix = isEN ? " (this project)" : " (dieses Projekt)";
|
|
const colspan = 4;
|
|
|
|
const html: string[] = [];
|
|
for (const code of orderedCodes) {
|
|
const group = groups.get(code);
|
|
if (!group) continue;
|
|
const isOwn = code === ownCode;
|
|
const label = group.name + (isOwn ? ownSuffix : "");
|
|
const headerClass = isOwn
|
|
? "entity-table-group-header entity-table-group-header--own"
|
|
: "entity-table-group-header";
|
|
html.push(`<tr class="${headerClass}">`
|
|
+ `<th colspan="${colspan}" scope="colgroup">`
|
|
+ `<span class="entity-table-group-header__name">${escapeHtml(label)}</span>`
|
|
+ ` <span class="entity-table-group-header__code">${escapeHtml(code)}</span>`
|
|
+ `</th></tr>`);
|
|
for (const entry of group.entries) {
|
|
html.push(renderRow(entry, data.project_id, isEN));
|
|
}
|
|
}
|
|
body.innerHTML = html.join("");
|
|
|
|
// Wire button clicks. One handler per render to avoid stale closures
|
|
// from the previous render's data.
|
|
body.querySelectorAll<HTMLButtonElement>(".submission-generate-btn").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
void onGenerateClick(btn);
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderRow(entry: SubmissionEntry, projectID: string, isEN: boolean): string {
|
|
const name = isEN && entry.name_en ? entry.name_en : entry.name;
|
|
const party = formatParty(entry.primary_party, isEN);
|
|
const source = entry.legal_source ?? "";
|
|
const draftHref = `/projects/${encodeURIComponent(projectID)}/submissions/${encodeURIComponent(entry.submission_code)}/draft`;
|
|
const templateBadge = entry.has_template
|
|
? ""
|
|
: ` <span class="submission-template-badge" title="${isEN ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage"}">${isEN ? "universal" : "universell"}</span>`;
|
|
const editBtn = `<a href="${escapeHtml(draftHref)}" class="btn-primary btn-cta-lime btn-small submission-edit-btn"
|
|
data-code="${escapeHtml(entry.submission_code)}"
|
|
data-i18n="projects.detail.submissions.action.edit">${isEN ? "Edit" : "Bearbeiten"}</a>`;
|
|
const generateBtn = `<button type="button" class="btn-secondary btn-small submission-generate-btn"
|
|
data-code="${escapeHtml(entry.submission_code)}"
|
|
data-project="${escapeHtml(projectID)}"
|
|
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`;
|
|
const action = `${editBtn} ${generateBtn}`;
|
|
return `<tr class="submission-row">
|
|
<td>
|
|
<span class="submission-name">${escapeHtml(name)}</span>
|
|
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>${templateBadge}
|
|
</td>
|
|
<td>${escapeHtml(party)}</td>
|
|
<td>${escapeHtml(source)}</td>
|
|
<td class="submission-action-cell">${action}</td>
|
|
</tr>`;
|
|
}
|
|
|
|
function renderError(): void {
|
|
const empty = document.getElementById("project-submissions-empty");
|
|
const noProc = document.getElementById("project-submissions-no-proceeding");
|
|
const wrap = document.getElementById("project-submissions-tablewrap");
|
|
if (!empty || !noProc || !wrap) return;
|
|
noProc.style.display = "none";
|
|
wrap.style.display = "none";
|
|
empty.style.display = "";
|
|
empty.textContent = document.documentElement.lang === "en"
|
|
? "Failed to load submissions list."
|
|
: "Schriftsatzliste konnte nicht geladen werden.";
|
|
}
|
|
|
|
function formatParty(role: string | undefined, isEN: boolean): string {
|
|
switch ((role ?? "").toLowerCase()) {
|
|
case "claimant": return isEN ? "Claimant" : "Klägerin";
|
|
case "defendant": return isEN ? "Defendant" : "Beklagte";
|
|
case "both": return isEN ? "Both" : "Beide";
|
|
case "court": return isEN ? "Court" : "Gericht";
|
|
default: return "";
|
|
}
|
|
}
|
|
|
|
// onGenerateClick triggers a download. Disables the button while the
|
|
// request is in flight to prevent double-submits and surfaces an
|
|
// inline error on failure.
|
|
async function onGenerateClick(btn: HTMLButtonElement): Promise<void> {
|
|
const code = btn.dataset.code;
|
|
const projectID = btn.dataset.project;
|
|
if (!code || !projectID) return;
|
|
|
|
const originalLabel = btn.textContent ?? "";
|
|
btn.disabled = true;
|
|
btn.textContent = document.documentElement.lang === "en" ? "Generating…" : "Wird generiert…";
|
|
|
|
try {
|
|
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/generate`;
|
|
const resp = await fetch(url, { method: "POST" });
|
|
if (!resp.ok) {
|
|
let detail = "";
|
|
try {
|
|
const data = await resp.json() as { error?: string };
|
|
detail = data.error ?? "";
|
|
} catch {
|
|
// fallthrough
|
|
}
|
|
alert(
|
|
(document.documentElement.lang === "en"
|
|
? "Generation failed."
|
|
: "Generieren fehlgeschlagen.") + (detail ? `\n\n${detail}` : ""),
|
|
);
|
|
return;
|
|
}
|
|
const blob = await resp.blob();
|
|
const filename = parseFilename(resp.headers.get("Content-Disposition") ?? "")
|
|
?? `${code}.docx`;
|
|
triggerDownload(blob, filename);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = originalLabel;
|
|
}
|
|
}
|
|
|
|
// parseFilename pulls the filename out of a Content-Disposition
|
|
// header. Supports both unquoted and quoted forms.
|
|
function parseFilename(header: string): string | null {
|
|
const m = /filename\s*=\s*"?([^";]+)"?/i.exec(header);
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
// triggerDownload creates an <a> with an object URL, clicks it, and
|
|
// revokes the URL. Standard browser-side download pattern.
|
|
function triggerDownload(blob: Blob, filename: string): void {
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
// Revoke on next tick so the click actually triggers the download
|
|
// before the URL is gone.
|
|
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
}
|