Adds an end-to-end project-optional path for Schriftsatz drafts:
- Migration 120 drops NOT NULL on paliad.submission_drafts.project_id
and rewrites the four RLS policies to gate purely on user_id when
project_id IS NULL, otherwise on paliad.can_see_project. Down
refuses to run if project-less rows exist (safer than silent
data corruption).
- SubmissionDraft.ProjectID becomes *uuid.UUID end-to-end. Service
layer skips project/parties/deadline lookups when nil and exposes
DraftPatch.ProjectID for the "Projekt zuweisen" affordance.
ListAllForUser LEFT JOINs paliad.projects so project-less drafts
surface in the global index next to project-scoped ones.
- New HTTP surface:
GET /submissions/new (picker page)
GET /submissions/draft/{draft_id} (editor for any draft)
GET /api/submissions/catalog (catalog without project)
POST /api/submission-drafts (project-less or attached)
GET/PATCH/DELETE /api/submission-drafts/{draft_id}
POST /api/submission-drafts/{draft_id}/export
Existing /api/projects/{id}/submissions/... routes remain bit-
identical so the project-scoped flow keeps working unchanged.
- Frontend: /submissions/new lists the full cross-proceeding catalog
grouped by proceeding, filterable by text + chip. Each row offers
"Ohne Projekt" (instant draft) or "Mit Projekt…" (modal picker
with autocomplete over visible projects). /submissions index gains
a prominent "Neuer Entwurf" CTA and an empty-state CTA pointing at
the picker. The editor renders a banner + "Projekt zuweisen"
action when project_id is null; assigning persists project_id and
redirects to the project-scoped URL.
Audit + project-event writes detect d.ProjectID == nil; the audit
row's scope flips to 'user' (scope_root = user_id) and the
project_events row is skipped entirely.
369 lines
13 KiB
TypeScript
369 lines
13 KiB
TypeScript
import { initI18n, getLang } from "./i18n";
|
|
import { initSidebar } from "./sidebar";
|
|
|
|
// t-paliad-243 — client for /submissions/new. Fetches the
|
|
// cross-proceeding submission catalog, groups it by proceeding, filters
|
|
// by text + chip, and offers two start paths per row: with project
|
|
// (modal picker) or without (project-less draft → /submissions/draft/{id}).
|
|
|
|
interface CatalogEntry {
|
|
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 CatalogResponse {
|
|
entries: CatalogEntry[];
|
|
}
|
|
|
|
interface ProjectRow {
|
|
id: string;
|
|
title: string;
|
|
reference?: string | null;
|
|
}
|
|
|
|
interface State {
|
|
entries: CatalogEntry[];
|
|
activeProceeding: string | null; // null = all
|
|
searchTerm: string;
|
|
pickerForCode: string | null;
|
|
}
|
|
|
|
const state: State = {
|
|
entries: [],
|
|
activeProceeding: null,
|
|
searchTerm: "",
|
|
pickerForCode: null,
|
|
};
|
|
|
|
function isEN(): boolean {
|
|
return getLang() === "en";
|
|
}
|
|
|
|
function esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function partyLabel(role: string | undefined): 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 "";
|
|
}
|
|
}
|
|
|
|
async function loadCatalog(): Promise<void> {
|
|
const loading = document.getElementById("submissions-new-loading")!;
|
|
const error = document.getElementById("submissions-new-error")!;
|
|
const wrap = document.getElementById("submissions-new-tablewrap")!;
|
|
|
|
try {
|
|
const resp = await fetch("/api/submissions/catalog");
|
|
if (!resp.ok) {
|
|
loading.style.display = "none";
|
|
error.style.display = "";
|
|
return;
|
|
}
|
|
const data = (await resp.json()) as CatalogResponse;
|
|
state.entries = data.entries ?? [];
|
|
} catch {
|
|
loading.style.display = "none";
|
|
error.style.display = "";
|
|
return;
|
|
}
|
|
|
|
loading.style.display = "none";
|
|
wrap.style.display = "";
|
|
renderChips();
|
|
renderTable();
|
|
}
|
|
|
|
function renderChips(): void {
|
|
const host = document.getElementById("submissions-new-proceeding-chips");
|
|
if (!host) return;
|
|
const seen = new Map<string, string>();
|
|
for (const e of state.entries) {
|
|
if (!seen.has(e.proceeding_code)) {
|
|
seen.set(e.proceeding_code, isEN() && e.proceeding_name_en ? e.proceeding_name_en : e.proceeding_name);
|
|
}
|
|
}
|
|
const chips: string[] = [];
|
|
const allLabel = isEN() ? "All" : "Alle";
|
|
const allActive = state.activeProceeding === null;
|
|
chips.push(`<button type="button" class="submissions-new-chip${allActive ? " submissions-new-chip--active" : ""}" data-code="">${esc(allLabel)}</button>`);
|
|
for (const [code, name] of seen) {
|
|
const active = state.activeProceeding === code;
|
|
chips.push(`<button type="button" class="submissions-new-chip${active ? " submissions-new-chip--active" : ""}" data-code="${esc(code)}">${esc(name)} <span class="submissions-new-chip-code">${esc(code)}</span></button>`);
|
|
}
|
|
host.innerHTML = chips.join("");
|
|
host.querySelectorAll<HTMLButtonElement>(".submissions-new-chip").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const code = btn.dataset.code ?? "";
|
|
state.activeProceeding = code === "" ? null : code;
|
|
renderChips();
|
|
renderTable();
|
|
});
|
|
});
|
|
}
|
|
|
|
function filtered(): CatalogEntry[] {
|
|
const term = state.searchTerm.trim().toLowerCase();
|
|
return state.entries.filter((e) => {
|
|
if (state.activeProceeding !== null && e.proceeding_code !== state.activeProceeding) {
|
|
return false;
|
|
}
|
|
if (term === "") return true;
|
|
const name = isEN() && e.name_en ? e.name_en : e.name;
|
|
const hay = [
|
|
name,
|
|
e.submission_code,
|
|
e.legal_source ?? "",
|
|
e.proceeding_code,
|
|
e.proceeding_name,
|
|
e.proceeding_name_en,
|
|
].join(" ").toLowerCase();
|
|
return hay.includes(term);
|
|
});
|
|
}
|
|
|
|
function renderTable(): void {
|
|
const body = document.getElementById("submissions-new-body");
|
|
const empty = document.getElementById("submissions-new-empty");
|
|
const wrap = document.getElementById("submissions-new-tablewrap");
|
|
if (!body || !empty || !wrap) return;
|
|
|
|
const rows = filtered();
|
|
if (rows.length === 0) {
|
|
wrap.style.display = "none";
|
|
empty.style.display = "";
|
|
return;
|
|
}
|
|
wrap.style.display = "";
|
|
empty.style.display = "none";
|
|
|
|
// Group by proceeding.
|
|
const groups = new Map<string, { name: string; entries: CatalogEntry[] }>();
|
|
for (const e of rows) {
|
|
const gname = isEN() && e.proceeding_name_en ? e.proceeding_name_en : e.proceeding_name;
|
|
const bucket = groups.get(e.proceeding_code);
|
|
if (bucket) {
|
|
bucket.entries.push(e);
|
|
} else {
|
|
groups.set(e.proceeding_code, { name: gname, entries: [e] });
|
|
}
|
|
}
|
|
|
|
const colspan = 4;
|
|
const html: string[] = [];
|
|
for (const [code, group] of groups) {
|
|
html.push(`<tr class="entity-table-group-header"><th colspan="${colspan}" scope="colgroup"><span class="entity-table-group-header__name">${esc(group.name)}</span> <span class="entity-table-group-header__code">${esc(code)}</span></th></tr>`);
|
|
for (const entry of group.entries) {
|
|
html.push(renderRow(entry));
|
|
}
|
|
}
|
|
body.innerHTML = html.join("");
|
|
|
|
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-no-project").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const code = btn.dataset.code;
|
|
if (code) void startDraft(code, null);
|
|
});
|
|
});
|
|
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-with-project").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const code = btn.dataset.code;
|
|
if (code) openProjectPicker(code);
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderRow(entry: CatalogEntry): string {
|
|
const name = isEN() && entry.name_en ? entry.name_en : entry.name;
|
|
const source = entry.legal_source ?? "";
|
|
const templateBadge = entry.has_template
|
|
? ""
|
|
: ` <span class="submission-template-badge" title="${esc(isEN() ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage")}">${esc(isEN() ? "universal" : "universell")}</span>`;
|
|
const withProject = isEN() ? "Mit Projekt…" : "Mit Projekt…";
|
|
const noProject = isEN() ? "Ohne Projekt" : "Ohne Projekt";
|
|
|
|
return `<tr class="submission-row">
|
|
<td>
|
|
<span class="submission-name">${esc(name)}</span>
|
|
<span class="submission-code">${esc(entry.submission_code)}</span>${templateBadge}
|
|
</td>
|
|
<td>${esc(partyLabel(entry.primary_party))}</td>
|
|
<td>${esc(source)}</td>
|
|
<td class="submission-action-cell">
|
|
<button type="button" class="btn-secondary btn-small submissions-new-start-with-project" data-code="${esc(entry.submission_code)}">${esc(withProject)}</button>
|
|
<button type="button" class="btn-primary btn-cta-lime btn-small submissions-new-start-no-project" data-code="${esc(entry.submission_code)}">${esc(noProject)}</button>
|
|
</td>
|
|
</tr>`;
|
|
}
|
|
|
|
async function startDraft(submissionCode: string, projectID: string | null): Promise<void> {
|
|
try {
|
|
const resp = await fetch("/api/submission-drafts", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ submission_code: submissionCode, project_id: projectID }),
|
|
});
|
|
if (!resp.ok) {
|
|
let detail = "";
|
|
try {
|
|
const data = (await resp.json()) as { error?: string };
|
|
detail = data.error ?? "";
|
|
} catch { /* ignore */ }
|
|
alert((isEN() ? "Failed to create draft." : "Entwurf konnte nicht angelegt werden.") + (detail ? `\n\n${detail}` : ""));
|
|
return;
|
|
}
|
|
const view = await resp.json() as { draft: { id: string; project_id: string | null; submission_code: string } };
|
|
const id = view.draft.id;
|
|
const pid = view.draft.project_id;
|
|
const code = view.draft.submission_code;
|
|
if (pid) {
|
|
window.location.href = `/projects/${pid}/submissions/${encodeURIComponent(code)}/draft/${id}`;
|
|
} else {
|
|
window.location.href = `/submissions/draft/${id}`;
|
|
}
|
|
} catch (err) {
|
|
console.error("submissions-new createDraft:", err);
|
|
alert(isEN() ? "Failed to create draft." : "Entwurf konnte nicht angelegt werden.");
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Project picker modal
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
let pickerProjects: ProjectRow[] = [];
|
|
let pickerLoaded = false;
|
|
|
|
function openProjectPicker(submissionCode: string): void {
|
|
state.pickerForCode = submissionCode;
|
|
const modal = document.getElementById("submissions-new-project-modal");
|
|
if (modal) modal.style.display = "";
|
|
if (!pickerLoaded) {
|
|
void loadPickerProjects();
|
|
} else {
|
|
renderPickerList();
|
|
}
|
|
const searchInput = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
|
if (searchInput) {
|
|
searchInput.value = "";
|
|
setTimeout(() => searchInput.focus(), 50);
|
|
}
|
|
}
|
|
|
|
function closeProjectPicker(): void {
|
|
state.pickerForCode = null;
|
|
const modal = document.getElementById("submissions-new-project-modal");
|
|
if (modal) modal.style.display = "none";
|
|
}
|
|
|
|
async function loadPickerProjects(): Promise<void> {
|
|
const loadingEl = document.getElementById("submissions-new-project-loading");
|
|
if (loadingEl) loadingEl.style.display = "";
|
|
try {
|
|
const resp = await fetch("/api/projects?status=active");
|
|
if (!resp.ok) throw new Error(`projects list ${resp.status}`);
|
|
const rows = (await resp.json()) as ProjectRow[];
|
|
pickerProjects = rows ?? [];
|
|
pickerLoaded = true;
|
|
} catch (err) {
|
|
console.error("submissions-new loadPickerProjects:", err);
|
|
pickerProjects = [];
|
|
} finally {
|
|
if (loadingEl) loadingEl.style.display = "none";
|
|
}
|
|
renderPickerList();
|
|
}
|
|
|
|
function renderPickerList(): void {
|
|
const list = document.getElementById("submissions-new-project-list");
|
|
const empty = document.getElementById("submissions-new-project-empty");
|
|
if (!list || !empty) return;
|
|
|
|
const searchInput = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
|
const term = (searchInput?.value ?? "").trim().toLowerCase();
|
|
|
|
const matches = pickerProjects.filter((p) => {
|
|
if (term === "") return true;
|
|
const hay = [p.title, p.reference ?? ""].join(" ").toLowerCase();
|
|
return hay.includes(term);
|
|
}).slice(0, 50);
|
|
|
|
if (matches.length === 0) {
|
|
list.innerHTML = "";
|
|
empty.style.display = "";
|
|
return;
|
|
}
|
|
empty.style.display = "none";
|
|
|
|
list.innerHTML = matches.map((p) => {
|
|
const ref = p.reference ? `<span class="entity-ref">${esc(p.reference)}</span> ` : "";
|
|
return `<li class="submissions-new-project-item" data-id="${esc(p.id)}">${ref}<span class="submissions-new-project-title">${esc(p.title)}</span></li>`;
|
|
}).join("");
|
|
|
|
list.querySelectorAll<HTMLLIElement>(".submissions-new-project-item").forEach((li) => {
|
|
li.addEventListener("click", () => {
|
|
const pid = li.dataset.id;
|
|
const code = state.pickerForCode;
|
|
if (pid && code) {
|
|
closeProjectPicker();
|
|
void startDraft(code, pid);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Boot
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
function wireToolbar(): void {
|
|
const search = document.getElementById("submissions-new-search") as HTMLInputElement | null;
|
|
if (search) {
|
|
search.addEventListener("input", () => {
|
|
state.searchTerm = search.value;
|
|
renderTable();
|
|
});
|
|
}
|
|
|
|
const closeBtn = document.getElementById("submissions-new-project-modal-close");
|
|
if (closeBtn) closeBtn.addEventListener("click", () => closeProjectPicker());
|
|
|
|
const modal = document.getElementById("submissions-new-project-modal");
|
|
if (modal) {
|
|
modal.addEventListener("click", (e) => {
|
|
if (e.target === modal) closeProjectPicker();
|
|
});
|
|
}
|
|
|
|
const pickerSearch = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
|
if (pickerSearch) {
|
|
pickerSearch.addEventListener("input", () => renderPickerList());
|
|
}
|
|
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape" && state.pickerForCode) closeProjectPicker();
|
|
});
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
initI18n();
|
|
initSidebar();
|
|
wireToolbar();
|
|
void loadCatalog();
|
|
});
|