feat(submissions): t-paliad-240 — Schriftsätze sidebar + global drafts index

Add a top-level Schriftsätze entry under the Werkzeuge sidebar group
plus a new /submissions page that lists every draft the caller owns
across visible projects. Each row links to the per-project editor at
/projects/{id}/submissions/{code}/draft/{draft_id}.

Backend: SubmissionDraftService.ListAllForUser joins paliad.submission_drafts
with paliad.projects, gated by paliad.can_see_project for visibility. New
GET /api/user/submission-drafts endpoint exposes the rows; the page route
GET /submissions is gateOnboarded'd alongside the other project surfaces.

Frontend: submissions-index.tsx renders an entity-table; submissions-index.ts
hydrates from /api/user/submission-drafts and wires the row-click contract
(skip clicks on inner a/button). DE primary, EN secondary i18n.
This commit is contained in:
mAi
2026-05-23 01:29:56 +02:00
parent 2c5f85b802
commit 436c1b41bb
9 changed files with 356 additions and 0 deletions

View File

@@ -19,6 +19,7 @@ import { renderProjectsNew } from "./src/projects-new";
import { renderProjectsDetail } from "./src/projects-detail";
import { renderProjectsChart } from "./src/projects-chart";
import { renderSubmissionDraft } from "./src/submission-draft";
import { renderSubmissionsIndex } from "./src/submissions-index";
import { renderEvents } from "./src/events";
import { renderDeadlinesNew } from "./src/deadlines-new";
import { renderDeadlinesDetail } from "./src/deadlines-detail";
@@ -254,6 +255,7 @@ async function build() {
join(import.meta.dir, "src/client/projects-detail.ts"),
join(import.meta.dir, "src/client/projects-chart.ts"),
join(import.meta.dir, "src/client/submission-draft.ts"),
join(import.meta.dir, "src/client/submissions-index.ts"),
join(import.meta.dir, "src/client/events.ts"),
join(import.meta.dir, "src/client/deadlines-new.ts"),
join(import.meta.dir, "src/client/deadlines-detail.ts"),
@@ -379,6 +381,7 @@ async function build() {
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart());
await Bun.write(join(DIST, "submission-draft.html"), renderSubmissionDraft());
await Bun.write(join(DIST, "submissions-index.html"), renderSubmissionsIndex());
// t-paliad-115 — shared EventsPage at the canonical /events URL.
// One HTML output; defaultType="all" baked in. Sidebar Fristen /
// Termine entries point at /events?type=… and events.ts re-highlights

View File

@@ -27,6 +27,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.glossar": "Glossar",
"nav.gebuehrentabellen": "Geb\u00fchrentabellen",
"nav.checklisten": "Checklisten",
"nav.submissions": "Schriftsätze",
"nav.gerichte": "Gerichte",
"nav.logout": "Abmelden",
"nav.akten": "Akten",
@@ -1450,6 +1451,18 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.name.placeholder": "Name dieses Entwurfs",
"submissions.draft.preview.title": "Vorschau",
"submissions.draft.preview.hint": "Read-only Vorschau — finale Bearbeitung in Word.",
// t-paliad-240 — global Schriftsätze drafts index page.
"submissions.index.title": "Schriftsätze — Paliad",
"submissions.index.heading": "Schriftsätze",
"submissions.index.subtitle": "Ihre Schriftsatz-Entwürfe über alle sichtbaren Projekte.",
"submissions.index.loading": "Lädt…",
"submissions.index.empty": "Noch keine Entwürfe. Öffnen Sie ein Projekt und legen Sie auf der Schriftsätze-Tab los.",
"submissions.index.empty.cta": "Zu den Projekten",
"submissions.index.error": "Schriftsätze konnten nicht geladen werden.",
"submissions.index.col.project": "Projekt",
"submissions.index.col.submission": "Schriftsatz",
"submissions.index.col.draft": "Entwurf",
"submissions.index.col.updated": "Zuletzt geändert",
"projects.detail.verlauf.empty": "Noch keine Ereignisse aufgezeichnet.",
"projects.detail.verlauf.loadMore": "Mehr laden",
// SmartTimeline (t-paliad-171, Slice 1).
@@ -2939,6 +2952,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.glossar": "Glossary",
"nav.gebuehrentabellen": "Fee Schedules",
"nav.checklisten": "Checklists",
"nav.submissions": "Submissions",
"nav.gerichte": "Courts",
"nav.logout": "Sign Out",
"nav.akten": "Matters",
@@ -4340,6 +4354,18 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.name.placeholder": "Name of this draft",
"submissions.draft.preview.title": "Preview",
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
// t-paliad-240 — global submissions drafts index page.
"submissions.index.title": "Submissions — Paliad",
"submissions.index.heading": "Submissions",
"submissions.index.subtitle": "Your submission drafts across every visible project.",
"submissions.index.loading": "Loading…",
"submissions.index.empty": "No drafts yet. Open a project and start from its Submissions tab.",
"submissions.index.empty.cta": "Go to projects",
"submissions.index.error": "Could not load submissions.",
"submissions.index.col.project": "Project",
"submissions.index.col.submission": "Submission",
"submissions.index.col.draft": "Draft",
"submissions.index.col.updated": "Last updated",
"projects.detail.verlauf.empty": "No events recorded yet.",
"projects.detail.verlauf.loadMore": "Load more",
"projects.detail.smarttimeline.empty": "No events captured yet.",

View File

@@ -0,0 +1,122 @@
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;
project_title: string;
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")!;
body.innerHTML = drafts.map((d) => {
const projectCell = (() => {
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 = `/projects/${esc(d.project_id)}/submissions/${esc(d.submission_code)}/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();
});

View File

@@ -13,6 +13,10 @@ const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
// at a glance.
const ICON_BOOK_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H2z"/><path d="M22 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>';
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
// Document-with-lines icon for /submissions (t-paliad-240) — distinct
// from ICON_BOOK / ICON_BOOK_OPEN / ICON_NEWSPAPER so the Schriftsätze
// affordance reads as "a draft document" at a glance.
const ICON_FILE_TEXT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></svg>';
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
const ICON_GLOBE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/></svg>';
const ICON_BUILDING = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 9h3a2 2 0 0 1 2 2v10"/><path d="M9 7h2"/><path d="M9 11h2"/><path d="M9 15h2"/></svg>';
@@ -175,6 +179,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{group("nav.group.werkzeuge", "Werkzeuge",
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
navItem("/tools/verfahrensablauf", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
navItem("/submissions", ICON_FILE_TEXT, "nav.submissions", "Schriftsätze", currentPath) +
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +

View File

@@ -1904,6 +1904,7 @@ export type I18nKey =
| "nav.paliadin"
| "nav.projekte"
| "nav.soon.tooltip"
| "nav.submissions"
| "nav.team"
| "nav.termine"
| "nav.user_views.new"
@@ -2507,6 +2508,17 @@ export type I18nKey =
| "submissions.draft.preview.title"
| "submissions.draft.switcher.label"
| "submissions.draft.title"
| "submissions.index.col.draft"
| "submissions.index.col.project"
| "submissions.index.col.submission"
| "submissions.index.col.updated"
| "submissions.index.empty"
| "submissions.index.empty.cta"
| "submissions.index.error"
| "submissions.index.heading"
| "submissions.index.loading"
| "submissions.index.subtitle"
| "submissions.index.title"
| "team.broadcast.body"
| "team.broadcast.body_placeholder"
| "team.broadcast.button"

View File

@@ -0,0 +1,78 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// t-paliad-240 — global Schriftsätze drafts index. Top-level sidebar
// entry that lists every draft the caller owns across visible projects.
// Per-project editor stays at /projects/{id}/submissions/{code}/draft —
// this page only adds a discovery surface and click-through to it.
export function renderSubmissionsIndex(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="submissions.index.title">Schriftsätze &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/submissions" />
<BottomNav currentPath="/submissions" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="submissions.index.heading">Schriftsätze</h1>
<p className="tool-subtitle" data-i18n="submissions.index.subtitle">
Ihre Schriftsatz-Entw&uuml;rfe &uuml;ber alle sichtbaren Projekte.
</p>
</div>
<p className="entity-events-empty" id="submissions-index-loading"
data-i18n="submissions.index.loading">L&auml;dt&hellip;</p>
<div className="entity-empty" id="submissions-index-empty" style="display:none">
<p data-i18n="submissions.index.empty">
Noch keine Entw&uuml;rfe. &Ouml;ffnen Sie ein Projekt und legen Sie auf der Schrifts&auml;tze-Tab los.
</p>
<a href="/projects" className="btn-secondary"
data-i18n="submissions.index.empty.cta">Zu den Projekten</a>
</div>
<div className="entity-empty" id="submissions-index-error" style="display:none">
<p data-i18n="submissions.index.error">Schrifts&auml;tze konnten nicht geladen werden.</p>
</div>
<div className="entity-table-wrap" id="submissions-index-tablewrap" style="display:none">
<table className="entity-table">
<thead>
<tr>
<th data-i18n="submissions.index.col.project">Projekt</th>
<th data-i18n="submissions.index.col.submission">Schriftsatz</th>
<th data-i18n="submissions.index.col.draft">Entwurf</th>
<th data-i18n="submissions.index.col.updated">Zuletzt ge&auml;ndert</th>
</tr>
</thead>
<tbody id="submissions-index-body" />
</table>
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/submissions-index.js"></script>
</body>
</html>
);
}

View File

@@ -276,6 +276,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/checklist-instances/{id}/reset", handleResetChecklistInstance)
protected.HandleFunc("DELETE /api/checklist-instances/{id}", handleDeleteChecklistInstance)
protected.HandleFunc("GET /api/projects/{id}/checklists", handleListChecklistInstancesForProject)
// t-paliad-240 — global Schriftsätze drafts index (top-level sidebar
// entry). Lists every draft the caller owns across visible projects.
// The per-project Schriftsätze tab keeps the editor itself project-
// scoped; this index is the cross-project landing.
protected.HandleFunc("GET /submissions", gateOnboarded(handleSubmissionsIndexPage))
protected.HandleFunc("GET /courts", handleCourtsPage)
protected.HandleFunc("GET /api/courts", handleCourtsAPI)
protected.HandleFunc("POST /api/courts/feedback", handleCourtsFeedback)
@@ -329,6 +334,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("DELETE /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handleDeleteSubmissionDraft)
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts/{draft_id}/preview", handlePreviewSubmissionDraft)
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/drafts/{draft_id}/export", handleExportSubmissionDraft)
// t-paliad-240 — global drafts index (across visible projects).
protected.HandleFunc("GET /api/user/submission-drafts", handleListUserSubmissionDrafts)
// /counterclaim creates a CCR sub-project linked via the new
// paliad.projects.counterclaim_of FK (t-paliad-174 Slice 3).
protected.HandleFunc("POST /api/projects/{id}/counterclaim", handleCreateProjectCounterclaim)

View File

@@ -109,6 +109,70 @@ type submissionDraftPatchInput struct {
// Handlers
// ─────────────────────────────────────────────────────────────────────
// userSubmissionDraftRow is the on-the-wire shape for the global
// /submissions index — each draft enriched with the project's title +
// reference for the row.
type userSubmissionDraftRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ProjectTitle string `json:"project_title"`
ProjectReference *string `json:"project_reference,omitempty"`
SubmissionCode string `json:"submission_code"`
Name string `json:"name"`
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
}
// handleListUserSubmissionDrafts returns every draft the caller owns
// across every visible project, ordered by updated_at DESC. Backs the
// global /submissions index page (t-paliad-240).
func handleListUserSubmissionDrafts(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionDraft == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "submission drafts not configured",
})
return
}
rows, err := dbSvc.submissionDraft.ListAllForUser(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
out := make([]userSubmissionDraftRow, 0, len(rows))
for i := range rows {
d := &rows[i]
out = append(out, userSubmissionDraftRow{
ID: d.ID,
ProjectID: d.ProjectID,
ProjectTitle: d.ProjectTitle,
ProjectReference: d.ProjectReference,
SubmissionCode: d.SubmissionCode,
Name: d.Name,
LastExportedAt: d.LastExportedAt,
UpdatedAt: d.UpdatedAt,
CreatedAt: d.CreatedAt,
})
}
writeJSON(w, http.StatusOK, map[string]any{"drafts": out})
}
// handleSubmissionsIndexPage serves dist/submissions-index.html for the
// global /submissions index — lists every draft the caller owns across
// visible projects. Sits at top level alongside /checklists, /courts etc.
func handleSubmissionsIndexPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/submissions-index.html")
}
// handleListSubmissionDrafts returns every draft the caller owns for
// the given (project, submission_code).
func handleListSubmissionDrafts(w http.ResponseWriter, r *http.Request) {

View File

@@ -117,6 +117,45 @@ func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uui
return rows, nil
}
// DraftWithProject is the row shape for the global /submissions index —
// a draft joined with the minimal project metadata the table needs.
// Visibility is gated by paliad.can_see_project in the SELECT itself.
type DraftWithProject struct {
SubmissionDraft
ProjectTitle string `db:"project_title" json:"project_title"`
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
}
// ListAllForUser returns every draft the user owns across visible
// projects, ordered by updated_at DESC. Joined with paliad.projects for
// the row's project name + reference; gated through can_see_project so
// a draft on a project the user no longer has access to is silently
// dropped from the result.
func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid.UUID) ([]DraftWithProject, error) {
var rows []DraftWithProject
err := s.db.SelectContext(ctx, &rows,
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name,
d.variables, d.last_exported_at, d.last_exported_sha,
d.created_at, d.updated_at,
p.title AS project_title,
p.reference AS project_reference
FROM paliad.submission_drafts d
JOIN paliad.projects p ON p.id = d.project_id
WHERE d.user_id = $1
AND paliad.can_see_project(d.project_id)
ORDER BY d.updated_at DESC`,
userID)
if err != nil {
return nil, fmt.Errorf("list all submission drafts for user: %w", err)
}
for i := range rows {
if err := rows[i].decodeVariables(); err != nil {
return nil, err
}
}
return rows, nil
}
// Get returns a single draft by id, gated on project visibility AND
// owner-only — the caller can only fetch drafts they own. RLS in the
// DB enforces this independently; the Go check makes the 404 semantics