Files
paliad/frontend/src/dashboard.tsx
mAi d555d5f679 fix(dashboard): preserve query string on /deadlines → /events redirect
m's 2026-05-21 14:20 report: dashboard "Diese Woche" card linked to
/deadlines?status=this_week but the 301 to /events?type=deadline dropped
the query string, landing on the default Pending filter instead of the
This-Week bucket.

Two-part fix:

1. handleDeadlinesListRedirect now appends r.URL.RawQuery to the
   target so any filter (status, project_id, event_type, …) survives
   the redirect. Regression test pins all three shapes (no query,
   single param, multi param).

2. Dashboard summary cards point at the canonical
   /events?type=deadline&status=… URL directly — saves the 301 bounce
   and matches the URL the events page itself reads on load.

The five card values (overdue/today/this_week/next_week/later) are all
in STATUS_OPTIONS_DEADLINE in frontend/src/client/events.ts, so the
events page filter chip picks them up natively.
2026-05-21 14:23:04 +02:00

293 lines
17 KiB
TypeScript

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";
// The three /* __PALIAD_DASHBOARD_*__ */ tokens below are replaced at
// request time by the Go handler (internal/handlers/dashboard_shell.go)
// with JSON blobs assigned to window.__PALIAD_DASHBOARD__,
// window.__PALIAD_DASHBOARD_LAYOUT__, and window.__PALIAD_DASHBOARD_CATALOG__.
// Keep each token intact and exactly once in the output. The latter two
// power the per-user configurable layout (t-paliad-219).
const HYDRATION_SCRIPT =
"/*__PALIAD_DASHBOARD_DATA__*//*__PALIAD_DASHBOARD_LAYOUT__*//*__PALIAD_DASHBOARD_CATALOG__*/";
// Chevron used as the collapsible-section disclosure indicator. CSS rotates
// it 90deg clockwise when the section is open via the
// .dashboard-section[aria-expanded="true"] selector — see global.css.
const ICON_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>';
// Render a collapsible dashboard section. The toggle button is the entire
// header row so the heading text doubles as the affordance. State is
// hydrated client-side from localStorage by client/dashboard.ts; SSR
// renders all sections expanded so unstyled fallback is sensible.
function CollapsibleSection(props: {
id: string;
widgetKey: string;
headingI18n: string;
headingDe: string;
children: any;
}): string {
return (
<section className="dashboard-section" data-collapse-key={props.id} data-widget-key={props.widgetKey} aria-expanded="true">
<button type="button" className="dashboard-section-toggle" aria-expanded="true">
<h3 className="dashboard-section-heading" data-i18n={props.headingI18n}>{props.headingDe}</h3>
<span className="dashboard-section-chevron" aria-hidden="true"
dangerouslySetInnerHTML={{ __html: ICON_CHEVRON }} />
</button>
<div className="dashboard-section-body">
{props.children}
</div>
</section>
);
}
export function renderDashboard(): 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="dashboard.title">Dashboard &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
<script dangerouslySetInnerHTML={{ __html: HYDRATION_SCRIPT }} />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/dashboard" />
<BottomNav currentPath="/dashboard" />
<main>
<section className="tool-page">
<div className="container">
<div className="dashboard-header">
<div>
<h1 className="dashboard-greeting">
<span data-i18n="dashboard.greeting.prefix">Guten Tag</span>
<span className="dashboard-greeting-name" id="dashboard-greeting-name"></span>
</h1>
<p className="dashboard-subline">
<span className="dashboard-office-chip" id="dashboard-office-chip" style="display:none"></span>
<span className="dashboard-date" id="dashboard-date"></span>
</p>
</div>
{/* "Anpassen" toggle (t-paliad-219 Slice B). Off by
default — when on, body.dashboard-editing reveals
drag handles / ↑↓ / x / ⚙ chrome on each widget plus
the edit-footer below the widget stack. */}
<button
type="button"
id="dashboard-edit-toggle"
className="btn btn-ghost dashboard-edit-toggle"
aria-pressed="false"
data-i18n="dashboard.edit.toggle"
>Anpassen</button>
</div>
<div id="dashboard-unavailable" className="dashboard-unavailable" style="display:none">
<p data-i18n="dashboard.unavailable">
Dashboard ben&ouml;tigt die Datenbank &mdash; bitte Administrator kontaktieren.
</p>
</div>
<div id="dashboard-onboarding" className="dashboard-unavailable" style="display:none">
<p data-i18n="dashboard.onboarding">
Bitte schlie&szlig;en Sie das Onboarding ab, damit Ihnen Fristen und Akten angezeigt werden k&ouml;nnen.
</p>
</div>
{/* Configurable widget grid (t-paliad-227 overhaul). All
widgets live as direct children of the single
.dashboard-grid container so applyLayout can place them
via grid-column/grid-row inline styles. Pre-overhaul
this stack had nested wrappers (.dashboard-columns,
standalone <section>s) that fought the layout engine
and made cross-row drags appear to fail. */}
<div className="dashboard-grid" id="dashboard-grid">
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
<CollapsibleSection id="summary" widgetKey="deadline-summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
<div className="dashboard-summary-grid">
<a href="/events?type=deadline&status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
<div className="dashboard-card-count" id="dashboard-count-overdue">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.overdue">&Uuml;berf&auml;llig</div>
</a>
<a href="/events?type=deadline&status=today" className="dashboard-card dashboard-card-today" id="dashboard-card-today">
<div className="dashboard-card-count" id="dashboard-count-today">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.today">Heute</div>
</a>
<a href="/events?type=deadline&status=this_week" className="dashboard-card dashboard-card-amber" id="dashboard-card-thisweek">
<div className="dashboard-card-count" id="dashboard-count-this-week">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.this_week">Diese Woche</div>
</a>
<a href="/events?type=deadline&status=next_week" className="dashboard-card dashboard-card-green" id="dashboard-card-nextweek">
<div className="dashboard-card-count" id="dashboard-count-next-week">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.next_week">N&auml;chste Woche</div>
</a>
<a href="/events?type=deadline&status=later" className="dashboard-card dashboard-card-later" id="dashboard-card-later">
<div className="dashboard-card-count" id="dashboard-count-later">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.later">Sp&auml;ter</div>
</a>
</div>
</CollapsibleSection>
{/* Matter summary — uses CollapsibleSection now so it
participates in the grid like every other widget. The
inner card heading was redundant with the section
heading; we keep the stats grid + the projects link. */}
<CollapsibleSection id="matters" widgetKey="matter-summary" headingI18n="dashboard.matters.heading" headingDe="Meine Akten">
<a href="/projects" className="dashboard-matter-card">
<div className="dashboard-matter-stats">
<div>
<div className="dashboard-matter-num" id="dashboard-matter-active">0</div>
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.active">Aktiv</div>
</div>
<div>
<div className="dashboard-matter-num" id="dashboard-matter-archived">0</div>
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.archived">Archiviert</div>
</div>
<div>
<div className="dashboard-matter-num" id="dashboard-matter-total">0</div>
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.total">Gesamt</div>
</div>
</div>
</a>
</CollapsibleSection>
<CollapsibleSection id="deadlines" widgetKey="upcoming-deadlines" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
<ul className="dashboard-list" id="dashboard-deadlines-list"></ul>
<div className="dashboard-calendar" id="dashboard-deadlines-calendar" style="display:none"></div>
<p className="dashboard-empty" id="dashboard-deadlines-empty" style="display:none" data-i18n="dashboard.deadlines.empty">
Keine Fristen in den n&auml;chsten 7 Tagen.
</p>
</CollapsibleSection>
<CollapsibleSection id="appointments" widgetKey="upcoming-appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
<ul className="dashboard-list" id="dashboard-appointments-list"></ul>
<div className="dashboard-calendar" id="dashboard-appointments-calendar" style="display:none"></div>
<p className="dashboard-empty" id="dashboard-appointments-empty" style="display:none" data-i18n="dashboard.appointments.empty">
Keine Termine in den n&auml;chsten 7 Tagen.
</p>
</CollapsibleSection>
{/* Inline Agenda (t-paliad-162). Same item shape as the
standalone /agenda page, rendered via the shared
agenda-render module. The dashboard variant is read-only:
no chip filters, no URL state — a 30-day window of
upcoming items grouped by day. The standalone /agenda
route is unchanged for direct-link compatibility. */}
<CollapsibleSection id="agenda" widgetKey="inline-agenda" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
<div className="dashboard-agenda">
<div className="agenda-timeline" id="dashboard-agenda-timeline" />
<ul className="dashboard-list" id="dashboard-agenda-list" style="display:none"></ul>
<p className="dashboard-empty" id="dashboard-agenda-empty" style="display:none" data-i18n="dashboard.agenda.empty">
Keine F&auml;lligkeiten in den n&auml;chsten 30 Tagen.
</p>
<p className="dashboard-agenda-link">
<a href="/agenda" data-i18n="dashboard.agenda.full_link">Vollst&auml;ndige Agenda &ouml;ffnen &rarr;</a>
</p>
</div>
</CollapsibleSection>
{/* Inbox-approvals widget (t-paliad-219 — new in v1). The
list mirrors /inbox's "Approver" axis but capped at the
widget's count setting. Renders the empty state when
the user has no open approvals to review. */}
<CollapsibleSection id="inbox-approvals" widgetKey="inbox-approvals" headingI18n="dashboard.inbox.heading" headingDe="Offene Freigaben">
<div className="dashboard-inbox">
<p className="dashboard-inbox-summary" id="dashboard-inbox-summary" style="display:none"></p>
<ul className="dashboard-list" id="dashboard-inbox-list"></ul>
<p className="dashboard-empty" id="dashboard-inbox-empty" style="display:none" data-i18n="dashboard.inbox.empty">
Keine offenen Freigaben.
</p>
<p className="dashboard-agenda-link">
<a href="/inbox" data-i18n="dashboard.inbox.full_link">Vollst&auml;ndigen Posteingang &ouml;ffnen &rarr;</a>
</p>
</div>
</CollapsibleSection>
{/* Activity feed — moved under Agenda per m's design call
(t-paliad-162). */}
<CollapsibleSection id="activity" widgetKey="recent-activity" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
<ul className="dashboard-activity-list" id="dashboard-activity-list"></ul>
<p className="dashboard-empty" id="dashboard-activity-empty" style="display:none" data-i18n="dashboard.activity.empty">
Noch keine Aktivit&auml;t erfasst.
</p>
</CollapsibleSection>
{/* Pinned-projects widget (t-paliad-219 Slice C). Reads
PinService via DashboardData.pinned_projects (server-
joined to titles + refs). Default-hidden — users opt
in via the picker. */}
<CollapsibleSection id="pinned-projects" widgetKey="pinned-projects" headingI18n="dashboard.pinned.heading" headingDe="Angepinnte Akten">
<ul className="dashboard-list" id="dashboard-pinned-list"></ul>
<p className="dashboard-empty" id="dashboard-pinned-empty" style="display:none" data-i18n="dashboard.pinned.empty">
Noch keine Akten angepinnt.
</p>
<p className="dashboard-agenda-link">
<a href="/projects" data-i18n="dashboard.pinned.full_link">Alle Akten &ouml;ffnen &rarr;</a>
</p>
</CollapsibleSection>
{/* Quick-actions widget (t-paliad-219 Slice C). Pure UI;
no backend data path. Default-hidden — surfaced via the
picker. */}
<CollapsibleSection id="quick-actions" widgetKey="quick-actions" headingI18n="dashboard.quick.heading" headingDe="Schnellzugriff">
<div className="dashboard-quick-actions">
<a href="/projects/new" className="btn btn-primary dashboard-quick-btn" data-i18n="dashboard.quick.new_project">+ Akte</a>
<a href="/deadlines/new" className="btn btn-secondary dashboard-quick-btn" data-i18n="dashboard.quick.new_deadline">+ Frist</a>
<a href="/appointments/new" className="btn btn-secondary dashboard-quick-btn" data-i18n="dashboard.quick.new_appointment">+ Termin</a>
</div>
</CollapsibleSection>
</div>
{/* Edit-mode footer (t-paliad-219 Slice B). Hidden via CSS
unless body.dashboard-editing — see dashboard.ts.
Slice C added the admin "Promote to firm default"
button — it stays hidden unless data.user.global_role
is 'global_admin'; dashboard.ts toggles it. */}
<div id="dashboard-edit-footer" className="dashboard-edit-footer">
<button
type="button"
id="dashboard-edit-add"
className="btn btn-secondary dashboard-edit-add"
data-i18n="dashboard.edit.add_widget"
>Widget hinzuf&uuml;gen</button>
<button
type="button"
id="dashboard-edit-promote"
className="btn btn-ghost dashboard-edit-promote"
style="display:none"
data-i18n="dashboard.edit.promote"
>Als Firmen-Standard speichern</button>
<button
type="button"
id="dashboard-edit-reset"
className="dashboard-edit-reset-link"
data-i18n="dashboard.edit.reset"
>Auf Standard zur&uuml;cksetzen</button>
</div>
{/* Save toast slot — managed by dashboard.ts. */}
<div
id="dashboard-save-toast"
className="dashboard-save-toast"
role="status"
aria-live="polite"
></div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/dashboard.js"></script>
</body>
</html>
);
}