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.
131 lines
4.0 KiB
TypeScript
131 lines
4.0 KiB
TypeScript
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
|
import { initSidebar } from "./sidebar";
|
|
|
|
// t-paliad-240 — global Schriftsätze drafts index. Loads
|
|
// /api/user/submission-drafts and renders one entity-table row per
|
|
// draft. Row click → editor at /projects/{project_id}/submissions/
|
|
// {submission_code}/draft/{draft_id}. Per project CLAUDE.md row-click
|
|
// contract: a table whose rows look clickable must navigate on click;
|
|
// inner links / buttons keep their own affordance.
|
|
|
|
interface DraftRow {
|
|
id: string;
|
|
project_id: string | null;
|
|
project_title: string | null;
|
|
project_reference?: string | null;
|
|
submission_code: string;
|
|
name: string;
|
|
last_exported_at?: string | null;
|
|
updated_at: string;
|
|
created_at: string;
|
|
}
|
|
|
|
let drafts: DraftRow[] = [];
|
|
|
|
function esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function fmtDate(iso: string): string {
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return "";
|
|
const isEN = getLang() === "en";
|
|
return d.toLocaleDateString(isEN ? "en-GB" : "de-DE", {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
async function load(): Promise<void> {
|
|
const loading = document.getElementById("submissions-index-loading")!;
|
|
const empty = document.getElementById("submissions-index-empty")!;
|
|
const error = document.getElementById("submissions-index-error")!;
|
|
const wrap = document.getElementById("submissions-index-tablewrap")!;
|
|
|
|
try {
|
|
const resp = await fetch("/api/user/submission-drafts");
|
|
if (!resp.ok) {
|
|
loading.style.display = "none";
|
|
error.style.display = "";
|
|
return;
|
|
}
|
|
const data = await resp.json();
|
|
drafts = (data.drafts ?? []) as DraftRow[];
|
|
} catch {
|
|
loading.style.display = "none";
|
|
error.style.display = "";
|
|
return;
|
|
}
|
|
|
|
loading.style.display = "none";
|
|
|
|
if (drafts.length === 0) {
|
|
empty.style.display = "";
|
|
wrap.style.display = "none";
|
|
return;
|
|
}
|
|
empty.style.display = "none";
|
|
wrap.style.display = "";
|
|
render();
|
|
}
|
|
|
|
function render(): void {
|
|
const body = document.getElementById("submissions-index-body")!;
|
|
|
|
const isEN = getLang() === "en";
|
|
const noProjectLabel = isEN ? "(no project)" : "(kein Projekt)";
|
|
|
|
body.innerHTML = drafts.map((d) => {
|
|
const projectCell = (() => {
|
|
if (!d.project_id) {
|
|
return `<span class="submissions-index-no-project">${esc(noProjectLabel)}</span>`;
|
|
}
|
|
const title = esc(d.project_title ?? "");
|
|
if (d.project_reference) {
|
|
return `<a href="/projects/${esc(d.project_id)}" class="checklist-instance-project"><span class="entity-ref">${esc(d.project_reference)}</span> ${title}</a>`;
|
|
}
|
|
return `<a href="/projects/${esc(d.project_id)}" class="checklist-instance-project">${title}</a>`;
|
|
})();
|
|
|
|
const href = d.project_id
|
|
? `/projects/${esc(d.project_id)}/submissions/${esc(d.submission_code)}/draft/${esc(d.id)}`
|
|
: `/submissions/draft/${esc(d.id)}`;
|
|
|
|
return `<tr class="submissions-index-row" data-href="${esc(href)}">
|
|
<td>${projectCell}</td>
|
|
<td>${esc(d.submission_code)}</td>
|
|
<td><a href="${esc(href)}" class="submissions-index-draft-name">${esc(d.name)}</a></td>
|
|
<td>${esc(fmtDate(d.updated_at))}</td>
|
|
</tr>`;
|
|
}).join("");
|
|
|
|
body.querySelectorAll<HTMLTableRowElement>(".submissions-index-row").forEach((row) => {
|
|
const href = row.dataset.href!;
|
|
row.addEventListener("click", (e) => {
|
|
// Inner <a> elements (project link, draft name) handle their own
|
|
// navigation — let the browser dispatch them.
|
|
if ((e.target as HTMLElement).closest("a, button")) return;
|
|
window.location.href = href;
|
|
});
|
|
});
|
|
|
|
// Keep tsc happy for the imported `t` (used only via data-i18n on
|
|
// static markup — keep the import so future dynamic strings can hook
|
|
// in without re-importing).
|
|
void t;
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
initI18n();
|
|
initSidebar();
|
|
onLangChange(() => {
|
|
if (drafts.length > 0) render();
|
|
});
|
|
void load();
|
|
});
|