Merge Phase G: Dashboard landing
# Conflicts: # frontend/build.ts # frontend/src/styles/global.css
This commit is contained in:
@@ -58,6 +58,7 @@ func main() {
|
||||
Calculator: services.NewDeadlineCalculator(holidays),
|
||||
Users: users,
|
||||
Fristenrechner: services.NewFristenrechnerService(rules, holidays),
|
||||
Dashboard: services.NewDashboardService(pool, users),
|
||||
}
|
||||
log.Println("Phase B services initialised")
|
||||
} else {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { renderFristen } from "./src/fristen";
|
||||
import { renderFristenNeu } from "./src/fristen-neu";
|
||||
import { renderFristenDetail } from "./src/fristen-detail";
|
||||
import { renderFristenKalender } from "./src/fristen-kalender";
|
||||
import { renderDashboard } from "./src/dashboard";
|
||||
|
||||
const DIST = join(import.meta.dir, "dist");
|
||||
|
||||
@@ -47,6 +48,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/fristen-neu.ts"),
|
||||
join(import.meta.dir, "src/client/fristen-detail.ts"),
|
||||
join(import.meta.dir, "src/client/fristen-kalender.ts"),
|
||||
join(import.meta.dir, "src/client/dashboard.ts"),
|
||||
],
|
||||
outdir: join(DIST, "assets"),
|
||||
naming: "[name].js",
|
||||
@@ -86,6 +88,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "fristen-neu.html"), renderFristenNeu());
|
||||
await Bun.write(join(DIST, "fristen-detail.html"), renderFristenDetail());
|
||||
await Bun.write(join(DIST, "fristen-kalender.html"), renderFristenKalender());
|
||||
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
|
||||
|
||||
console.log("Build complete \u2192 dist/");
|
||||
}
|
||||
|
||||
318
frontend/src/client/dashboard.ts
Normal file
318
frontend/src/client/dashboard.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface DashboardUser {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
office: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface DeadlineSummary {
|
||||
overdue: number;
|
||||
this_week: number;
|
||||
upcoming: number;
|
||||
completed_this_week: number;
|
||||
}
|
||||
|
||||
interface MatterSummary {
|
||||
active: number;
|
||||
archived: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface UpcomingDeadline {
|
||||
id: string;
|
||||
title: string;
|
||||
due_date: string;
|
||||
akte_id: string;
|
||||
akte_title: string;
|
||||
akte_ref: string;
|
||||
urgency: "overdue" | "today" | "urgent" | "soon";
|
||||
}
|
||||
|
||||
interface UpcomingAppointment {
|
||||
id: string;
|
||||
title: string;
|
||||
start_at: string;
|
||||
end_at: string | null;
|
||||
type: string | null;
|
||||
akte_id: string | null;
|
||||
akte_title: string | null;
|
||||
akte_ref: string | null;
|
||||
}
|
||||
|
||||
interface ActivityEntry {
|
||||
timestamp: string;
|
||||
actor_email: string | null;
|
||||
actor_name: string | null;
|
||||
akte_id: string;
|
||||
akte_title: string;
|
||||
akte_ref: string;
|
||||
action: string | null;
|
||||
details: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
user: DashboardUser | null;
|
||||
deadline_summary: DeadlineSummary;
|
||||
matter_summary: MatterSummary;
|
||||
upcoming_deadlines: UpcomingDeadline[];
|
||||
upcoming_appointments: UpcomingAppointment[];
|
||||
recent_activity: ActivityEntry[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PALIAD_DASHBOARD__?: DashboardData | null;
|
||||
}
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
let data: DashboardData | null = null;
|
||||
|
||||
async function loadDashboard(): Promise<void> {
|
||||
const unavailable = document.getElementById("dashboard-unavailable")!;
|
||||
try {
|
||||
const resp = await fetch("/api/dashboard");
|
||||
if (resp.status === 503) {
|
||||
unavailable.style.display = "block";
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
unavailable.style.display = "block";
|
||||
return;
|
||||
}
|
||||
data = await resp.json();
|
||||
render();
|
||||
} catch {
|
||||
unavailable.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
if (!data) return;
|
||||
renderGreeting(data.user);
|
||||
renderSummary(data.deadline_summary);
|
||||
renderMatters(data.matter_summary);
|
||||
renderDeadlines(data.upcoming_deadlines);
|
||||
renderAppointments(data.upcoming_appointments);
|
||||
renderActivity(data.recent_activity);
|
||||
toggleOnboardingHint(data.user);
|
||||
}
|
||||
|
||||
function renderGreeting(user: DashboardUser | null): void {
|
||||
const nameEl = document.getElementById("dashboard-greeting-name")!;
|
||||
const chip = document.getElementById("dashboard-office-chip")!;
|
||||
const dateEl = document.getElementById("dashboard-date")!;
|
||||
|
||||
if (user) {
|
||||
nameEl.textContent = user.display_name ? `, ${user.display_name}` : "";
|
||||
const officeLabel = t(`office.${user.office}`) || user.office;
|
||||
chip.textContent = officeLabel;
|
||||
chip.className = `dashboard-office-chip akten-office-chip akten-office-${user.office}`;
|
||||
chip.style.display = "inline-block";
|
||||
} else {
|
||||
nameEl.textContent = "";
|
||||
chip.style.display = "none";
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
dateEl.textContent = now.toLocaleDateString(
|
||||
getLang() === "de" ? "de-DE" : "en-GB",
|
||||
{ weekday: "long", year: "numeric", month: "long", day: "numeric" },
|
||||
);
|
||||
}
|
||||
|
||||
function renderSummary(s: DeadlineSummary): void {
|
||||
setCount("dashboard-count-overdue", s.overdue);
|
||||
setCount("dashboard-count-this-week", s.this_week);
|
||||
setCount("dashboard-count-upcoming", s.upcoming);
|
||||
setCount("dashboard-count-completed", s.completed_this_week);
|
||||
|
||||
// Tone down the red card when there's nothing overdue — reduces alarm
|
||||
// fatigue when the user has a clean slate.
|
||||
const overdueCard = document.getElementById("dashboard-card-overdue")!;
|
||||
overdueCard.classList.toggle("dashboard-card-quiet", s.overdue === 0);
|
||||
}
|
||||
|
||||
function renderMatters(s: MatterSummary): void {
|
||||
setCount("dashboard-matter-active", s.active);
|
||||
setCount("dashboard-matter-archived", s.archived);
|
||||
setCount("dashboard-matter-total", s.total);
|
||||
}
|
||||
|
||||
function renderDeadlines(items: UpcomingDeadline[]): void {
|
||||
const list = document.getElementById("dashboard-deadlines-list")!;
|
||||
const empty = document.getElementById("dashboard-deadlines-empty")!;
|
||||
|
||||
if (!items.length) {
|
||||
list.innerHTML = "";
|
||||
list.style.display = "none";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
list.style.display = "";
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = items.map((d) => {
|
||||
const urgencyClass = `dashboard-urgency-${d.urgency}`;
|
||||
const urgencyLabel = t(`dashboard.urgency.${d.urgency}`);
|
||||
return `<li class="dashboard-list-item">
|
||||
<a href="/akten/${esc(d.akte_id)}/fristen" class="dashboard-list-link">
|
||||
<div class="dashboard-list-main">
|
||||
<span class="dashboard-list-title">${esc(d.title)}</span>
|
||||
<span class="dashboard-list-ref">${esc(d.akte_ref)} · ${esc(d.akte_title)}</span>
|
||||
</div>
|
||||
<div class="dashboard-list-meta">
|
||||
<span class="dashboard-urgency-badge ${urgencyClass}" title="${escAttr(urgencyLabel)}">${esc(formatRelative(d.due_date))}</span>
|
||||
</div>
|
||||
</a>
|
||||
</li>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderAppointments(items: UpcomingAppointment[]): void {
|
||||
const list = document.getElementById("dashboard-appointments-list")!;
|
||||
const empty = document.getElementById("dashboard-appointments-empty")!;
|
||||
|
||||
if (!items.length) {
|
||||
list.innerHTML = "";
|
||||
list.style.display = "none";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
list.style.display = "";
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = items.map((a) => {
|
||||
const dot = a.type
|
||||
? `<span class="dashboard-termin-dot dashboard-termin-${esc(a.type)}" aria-hidden="true"></span>`
|
||||
: `<span class="dashboard-termin-dot" aria-hidden="true"></span>`;
|
||||
const href = a.akte_id ? `/akten/${esc(a.akte_id)}/termine` : "#";
|
||||
const tag = a.akte_id ? "a" : "div";
|
||||
const akteLine = a.akte_ref && a.akte_title
|
||||
? `<span class="dashboard-list-ref">${esc(a.akte_ref)} · ${esc(a.akte_title)}</span>`
|
||||
: "";
|
||||
return `<li class="dashboard-list-item">
|
||||
<${tag} href="${href}" class="dashboard-list-link">
|
||||
<div class="dashboard-list-main">
|
||||
<span class="dashboard-list-title">${dot}${esc(a.title)}</span>
|
||||
${akteLine}
|
||||
</div>
|
||||
<div class="dashboard-list-meta">
|
||||
<span class="dashboard-appt-time">${esc(formatDateTime(a.start_at))}</span>
|
||||
</div>
|
||||
</${tag}>
|
||||
</li>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderActivity(items: ActivityEntry[]): void {
|
||||
const list = document.getElementById("dashboard-activity-list")!;
|
||||
const empty = document.getElementById("dashboard-activity-empty")!;
|
||||
|
||||
if (!items.length) {
|
||||
list.innerHTML = "";
|
||||
list.style.display = "none";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
list.style.display = "";
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = items.map((e) => {
|
||||
const actor = e.actor_name || e.actor_email || t("dashboard.activity.system");
|
||||
const actionLabel = e.action
|
||||
? (t(`dashboard.action.${e.action}`) || e.action)
|
||||
: t("dashboard.activity.event");
|
||||
return `<li class="dashboard-activity-item">
|
||||
<span class="dashboard-activity-time">${esc(formatDateTime(e.timestamp))}</span>
|
||||
<span class="dashboard-activity-body">
|
||||
<span class="dashboard-activity-actor">${esc(actor)}</span>
|
||||
<span class="dashboard-activity-action">${esc(actionLabel)}</span>
|
||||
<a href="/akten/${esc(e.akte_id)}" class="dashboard-activity-akte">${esc(e.akte_ref)}</a>
|
||||
<span class="dashboard-activity-details">${esc(e.details)}</span>
|
||||
</span>
|
||||
</li>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function toggleOnboardingHint(user: DashboardUser | null): void {
|
||||
const onboarding = document.getElementById("dashboard-onboarding")!;
|
||||
onboarding.style.display = user ? "none" : "block";
|
||||
}
|
||||
|
||||
function setCount(id: string, n: number): void {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = String(n);
|
||||
}
|
||||
|
||||
function formatRelative(isoDate: string): string {
|
||||
const due = new Date(isoDate + "T00:00:00");
|
||||
if (isNaN(due.getTime())) return isoDate;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const diffDays = Math.round((due.getTime() - today.getTime()) / 86400000);
|
||||
const lang = getLang();
|
||||
if (diffDays < 0) {
|
||||
const n = Math.abs(diffDays);
|
||||
return lang === "de"
|
||||
? (n === 1 ? "vor 1 Tag" : `vor ${n} Tagen`)
|
||||
: (n === 1 ? "1 day ago" : `${n} days ago`);
|
||||
}
|
||||
if (diffDays === 0) return t("dashboard.when.today");
|
||||
if (diffDays === 1) return t("dashboard.when.tomorrow");
|
||||
return lang === "de" ? `in ${diffDays} Tagen` : `in ${diffDays} days`;
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const locale = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
return d.toLocaleString(locale, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s ?? "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return (s ?? "").replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function schedulePolling(): void {
|
||||
// Refresh the payload every minute so open dashboards stay current when
|
||||
// teammates create Akten/Fristen. Uses the JSON endpoint — no page reload.
|
||||
window.setInterval(() => {
|
||||
void loadDashboard();
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
onLangChange(render);
|
||||
|
||||
const inlined = window.__PALIAD_DASHBOARD__;
|
||||
if (inlined !== undefined) {
|
||||
// Server-side hydration path: the handler spliced the payload directly
|
||||
// into the HTML. Render synchronously, then start polling.
|
||||
if (inlined === null) {
|
||||
document.getElementById("dashboard-unavailable")!.style.display = "block";
|
||||
} else {
|
||||
data = inlined;
|
||||
render();
|
||||
}
|
||||
schedulePolling();
|
||||
return;
|
||||
}
|
||||
// Fallback for dev or when the handler couldn't splice (no DB, etc.).
|
||||
void loadDashboard().then(schedulePolling);
|
||||
});
|
||||
@@ -24,6 +24,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.akten": "Akten",
|
||||
"nav.fristen": "Fristen",
|
||||
"nav.termine": "Termine",
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.group.uebersicht": "\u00dcbersicht",
|
||||
"nav.group.arbeit": "Arbeit",
|
||||
"nav.group.werkzeuge": "Werkzeuge",
|
||||
"nav.group.wissen": "Wissen",
|
||||
@@ -582,6 +584,42 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"office.london": "London",
|
||||
"office.paris": "Paris",
|
||||
"office.milan": "Mailand",
|
||||
|
||||
// Dashboard (logged-in landing)
|
||||
"dashboard.title": "Dashboard \u2014 Paliad",
|
||||
"dashboard.greeting.prefix": "Guten Tag",
|
||||
"dashboard.unavailable": "Dashboard ben\u00f6tigt die Datenbank \u2014 bitte Administrator kontaktieren.",
|
||||
"dashboard.onboarding": "Bitte schlie\u00dfen Sie das Onboarding ab, damit Ihnen Fristen und Mandate angezeigt werden k\u00f6nnen.",
|
||||
"dashboard.summary.heading": "Fristen auf einen Blick",
|
||||
"dashboard.summary.overdue": "\u00dcberf\u00e4llig",
|
||||
"dashboard.summary.this_week": "Diese Woche",
|
||||
"dashboard.summary.upcoming": "Kommend",
|
||||
"dashboard.summary.completed": "Abgeschlossen (7\u202fT.)",
|
||||
"dashboard.matters.heading": "Meine Mandate",
|
||||
"dashboard.matters.active": "Aktiv",
|
||||
"dashboard.matters.archived": "Archiviert",
|
||||
"dashboard.matters.total": "Gesamt",
|
||||
"dashboard.deadlines.heading": "Kommende Fristen",
|
||||
"dashboard.deadlines.empty": "Keine Fristen in den n\u00e4chsten 7 Tagen.",
|
||||
"dashboard.appointments.heading": "Kommende Termine",
|
||||
"dashboard.appointments.empty": "Keine Termine in den n\u00e4chsten 7 Tagen.",
|
||||
"dashboard.activity.heading": "Letzte Aktivit\u00e4t",
|
||||
"dashboard.activity.empty": "Noch keine Aktivit\u00e4t erfasst.",
|
||||
"dashboard.activity.system": "System",
|
||||
"dashboard.activity.event": "Ereignis",
|
||||
"dashboard.urgency.overdue": "\u00dcberf\u00e4llig",
|
||||
"dashboard.urgency.today": "Heute",
|
||||
"dashboard.urgency.urgent": "Dringend",
|
||||
"dashboard.urgency.soon": "Bald",
|
||||
"dashboard.when.today": "heute",
|
||||
"dashboard.when.tomorrow": "morgen",
|
||||
"dashboard.action.akte_created": "legte Akte an",
|
||||
"dashboard.action.akte_archived": "archivierte Akte",
|
||||
"dashboard.action.status_changed": "\u00e4nderte Status",
|
||||
"dashboard.action.visibility_changed": "\u00e4nderte Sichtbarkeit",
|
||||
"dashboard.action.collaborators_updated": "aktualisierte Bearbeiter",
|
||||
"dashboard.action.partei_added": "f\u00fcgte Partei hinzu",
|
||||
"dashboard.action.partei_removed": "entfernte Partei",
|
||||
},
|
||||
|
||||
en: {
|
||||
@@ -599,6 +637,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.akten": "Matters",
|
||||
"nav.fristen": "Deadlines",
|
||||
"nav.termine": "Appointments",
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.group.uebersicht": "Overview",
|
||||
"nav.group.arbeit": "Work",
|
||||
"nav.group.werkzeuge": "Tools",
|
||||
"nav.group.wissen": "Knowledge",
|
||||
@@ -1157,6 +1197,42 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"office.london": "London",
|
||||
"office.paris": "Paris",
|
||||
"office.milan": "Milan",
|
||||
|
||||
// Dashboard (logged-in landing)
|
||||
"dashboard.title": "Dashboard \u2014 Paliad",
|
||||
"dashboard.greeting.prefix": "Good day",
|
||||
"dashboard.unavailable": "Dashboard requires the database \u2014 contact an administrator.",
|
||||
"dashboard.onboarding": "Please complete onboarding before deadlines and matters are shown.",
|
||||
"dashboard.summary.heading": "Deadlines at a glance",
|
||||
"dashboard.summary.overdue": "Overdue",
|
||||
"dashboard.summary.this_week": "This week",
|
||||
"dashboard.summary.upcoming": "Upcoming",
|
||||
"dashboard.summary.completed": "Completed (7d)",
|
||||
"dashboard.matters.heading": "My matters",
|
||||
"dashboard.matters.active": "Active",
|
||||
"dashboard.matters.archived": "Archived",
|
||||
"dashboard.matters.total": "Total",
|
||||
"dashboard.deadlines.heading": "Upcoming deadlines",
|
||||
"dashboard.deadlines.empty": "No deadlines in the next 7 days.",
|
||||
"dashboard.appointments.heading": "Upcoming appointments",
|
||||
"dashboard.appointments.empty": "No appointments in the next 7 days.",
|
||||
"dashboard.activity.heading": "Recent activity",
|
||||
"dashboard.activity.empty": "No activity recorded yet.",
|
||||
"dashboard.activity.system": "System",
|
||||
"dashboard.activity.event": "event",
|
||||
"dashboard.urgency.overdue": "Overdue",
|
||||
"dashboard.urgency.today": "Today",
|
||||
"dashboard.urgency.urgent": "Urgent",
|
||||
"dashboard.urgency.soon": "Soon",
|
||||
"dashboard.when.today": "today",
|
||||
"dashboard.when.tomorrow": "tomorrow",
|
||||
"dashboard.action.akte_created": "created matter",
|
||||
"dashboard.action.akte_archived": "archived matter",
|
||||
"dashboard.action.status_changed": "changed status",
|
||||
"dashboard.action.visibility_changed": "changed visibility",
|
||||
"dashboard.action.collaborators_updated": "updated collaborators",
|
||||
"dashboard.action.partei_added": "added party",
|
||||
"dashboard.action.partei_removed": "removed party",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ const ICON_PIN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" str
|
||||
const ICON_MENU = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>';
|
||||
const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
||||
const ICON_CALENDAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||
const ICON_GAUGE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 14l3.5-3.5"/><path d="M3 12a9 9 0 0 1 18 0"/><path d="M12 3v2"/><path d="M3 12H5"/><path d="M19 12h2"/><path d="M5.6 5.6l1.4 1.4"/><path d="M17 7l1.4-1.4"/></svg>';
|
||||
|
||||
interface SidebarProps {
|
||||
currentPath: string;
|
||||
@@ -69,6 +70,10 @@ export function Sidebar({ currentPath }: SidebarProps): string {
|
||||
<nav className="sidebar-nav">
|
||||
{navItem("/", ICON_HOME, "nav.home", "Home", currentPath)}
|
||||
|
||||
{group("nav.group.uebersicht", "\u00DCbersicht",
|
||||
navItem("/dashboard", ICON_GAUGE, "nav.dashboard", "Dashboard", currentPath),
|
||||
)}
|
||||
|
||||
{group("nav.group.arbeit", "Arbeit",
|
||||
navItem("/akten", ICON_FOLDER, "nav.akten", "Akten", currentPath) +
|
||||
navItem("/fristen", ICON_CLOCK, "nav.fristen", "Fristen", currentPath) +
|
||||
|
||||
138
frontend/src/dashboard.tsx
Normal file
138
frontend/src/dashboard.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
// The /* __PALIAD_DASHBOARD_DATA__ */ token below is replaced at request time
|
||||
// by the Go handler (internal/handlers/dashboard_shell.go) with a JSON blob
|
||||
// assigned to window.__PALIAD_DASHBOARD__. Keep the token intact and exactly
|
||||
// once in the output.
|
||||
const HYDRATION_SCRIPT =
|
||||
"/*__PALIAD_DASHBOARD_DATA__*/";
|
||||
|
||||
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.0" />
|
||||
<title data-i18n="dashboard.title">Dashboard — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
<script dangerouslySetInnerHTML={{ __html: HYDRATION_SCRIPT }} />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar 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>
|
||||
</div>
|
||||
|
||||
<div id="dashboard-unavailable" className="dashboard-unavailable" style="display:none">
|
||||
<p data-i18n="dashboard.unavailable">
|
||||
Dashboard benötigt die Datenbank — bitte Administrator kontaktieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="dashboard-onboarding" className="dashboard-unavailable" style="display:none">
|
||||
<p data-i18n="dashboard.onboarding">
|
||||
Bitte schließen Sie das Onboarding ab, damit Ihnen Fristen und Mandate angezeigt werden können.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Traffic-light deadline summary */}
|
||||
<section className="dashboard-summary" aria-labelledby="dashboard-summary-heading">
|
||||
<h2 id="dashboard-summary-heading" className="dashboard-section-heading" data-i18n="dashboard.summary.heading">
|
||||
Fristen auf einen Blick
|
||||
</h2>
|
||||
<div className="dashboard-summary-grid">
|
||||
<a href="/fristen?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">Überfällig</div>
|
||||
</a>
|
||||
<a href="/fristen?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="/fristen?status=upcoming" className="dashboard-card dashboard-card-green" id="dashboard-card-upcoming">
|
||||
<div className="dashboard-card-count" id="dashboard-count-upcoming">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.upcoming">Kommend</div>
|
||||
</a>
|
||||
<a href="/fristen?status=completed" className="dashboard-card dashboard-card-done" id="dashboard-card-completed">
|
||||
<div className="dashboard-card-count" id="dashboard-count-completed">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.completed">Abgeschlossen (7 T.)</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Matter summary card */}
|
||||
<section className="dashboard-matters">
|
||||
<a href="/akten" className="dashboard-matter-card">
|
||||
<div className="dashboard-matter-header">
|
||||
<h3 data-i18n="dashboard.matters.heading">Meine Mandate</h3>
|
||||
<span className="dashboard-matter-arrow" aria-hidden="true">→</span>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{/* Two-column lists */}
|
||||
<div className="dashboard-columns">
|
||||
<section className="dashboard-col">
|
||||
<h3 className="dashboard-section-heading" data-i18n="dashboard.deadlines.heading">Kommende Fristen</h3>
|
||||
<ul className="dashboard-list" id="dashboard-deadlines-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-deadlines-empty" style="display:none" data-i18n="dashboard.deadlines.empty">
|
||||
Keine Fristen in den nächsten 7 Tagen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="dashboard-col">
|
||||
<h3 className="dashboard-section-heading" data-i18n="dashboard.appointments.heading">Kommende Termine</h3>
|
||||
<ul className="dashboard-list" id="dashboard-appointments-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-appointments-empty" style="display:none" data-i18n="dashboard.appointments.empty">
|
||||
Keine Termine in den nächsten 7 Tagen.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Activity feed */}
|
||||
<section className="dashboard-activity">
|
||||
<h3 className="dashboard-section-heading" data-i18n="dashboard.activity.heading">Letzte Aktivität</h3>
|
||||
<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ät erfasst.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -4547,3 +4547,386 @@ input[type="range"]::-moz-range-thumb {
|
||||
min-height: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
Dashboard (Phase G — logged-in landing)
|
||||
======================================================================== */
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dashboard-greeting {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard-greeting-name {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.dashboard-subline {
|
||||
margin-top: 0.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.dashboard-office-chip {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.dashboard-date {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.dashboard-section-heading {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboard-unavailable {
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: #fff8e6;
|
||||
color: #70520b;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
/* --- Traffic-light summary cards --- */
|
||||
|
||||
.dashboard-summary {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.dashboard-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
display: block;
|
||||
padding: 1.25rem 1.4rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.dashboard-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.dashboard-card-count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.dashboard-card-label {
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dashboard-card-red .dashboard-card-count { color: #b91c1c; }
|
||||
.dashboard-card-red { border-left: 3px solid #b91c1c; }
|
||||
.dashboard-card-amber .dashboard-card-count { color: #b45309; }
|
||||
.dashboard-card-amber { border-left: 3px solid #f59e0b; }
|
||||
.dashboard-card-green .dashboard-card-count { color: #15803d; }
|
||||
.dashboard-card-green { border-left: 3px solid #65a30d; }
|
||||
.dashboard-card-done .dashboard-card-count { color: #475569; }
|
||||
.dashboard-card-done { border-left: 3px solid #94a3b8; }
|
||||
|
||||
.dashboard-card-quiet .dashboard-card-count { color: var(--color-text-muted); }
|
||||
.dashboard-card-quiet { border-left-color: var(--color-border); }
|
||||
|
||||
/* --- Matter summary card --- */
|
||||
|
||||
.dashboard-matters {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.dashboard-matter-card {
|
||||
display: block;
|
||||
padding: 1.25rem 1.5rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow);
|
||||
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.dashboard-matter-card:hover {
|
||||
border-color: var(--color-accent-light);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.dashboard-matter-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.dashboard-matter-header h3 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard-matter-arrow {
|
||||
color: var(--color-accent);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.dashboard-matter-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-matter-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.dashboard-matter-lbl {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* --- Two-column lists --- */
|
||||
|
||||
.dashboard-columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.dashboard-col {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem 1.4rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.dashboard-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dashboard-list-item {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.dashboard-list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dashboard-list-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 0;
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
transition: color 0.1s ease;
|
||||
}
|
||||
|
||||
.dashboard-list-link:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.dashboard-list-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-list-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-list-ref {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-list-meta {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dashboard-urgency-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-urgency-overdue { background: #fee2e2; color: #b91c1c; }
|
||||
.dashboard-urgency-today { background: #fef3c7; color: #92400e; }
|
||||
.dashboard-urgency-urgent { background: #fef3c7; color: #b45309; }
|
||||
.dashboard-urgency-soon { background: #ecfccb; color: #365314; }
|
||||
|
||||
.dashboard-appt-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-termin-dot {
|
||||
display: inline-block;
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: #94a3b8;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.dashboard-termin-hearing { background: #b91c1c; }
|
||||
.dashboard-termin-meeting { background: #2563eb; }
|
||||
.dashboard-termin-consultation { background: #65a30d; }
|
||||
.dashboard-termin-deadline_hearing { background: #b45309; }
|
||||
|
||||
.dashboard-empty {
|
||||
padding: 1.5rem 0.5rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.88rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* --- Activity feed --- */
|
||||
|
||||
.dashboard-activity {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem 1.4rem;
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.dashboard-activity-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dashboard-activity-item {
|
||||
display: grid;
|
||||
grid-template-columns: 8.5rem 1fr;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.dashboard-activity-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dashboard-activity-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-activity-body {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.dashboard-activity-actor {
|
||||
font-weight: 600;
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
.dashboard-activity-action {
|
||||
color: var(--color-text-muted);
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
.dashboard-activity-akte {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
.dashboard-activity-akte:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dashboard-activity-details {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* --- Responsive --- */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dashboard-summary-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.dashboard-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.dashboard-matter-stats {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.dashboard-activity-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type dbServices struct {
|
||||
calc *services.DeadlineCalculator
|
||||
users *services.UserService
|
||||
fristenrechner *services.FristenrechnerService
|
||||
dashboard *services.DashboardService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
70
internal/handlers/dashboard.go
Normal file
70
internal/handlers/dashboard.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/auth"
|
||||
)
|
||||
|
||||
// GET /api/dashboard — returns the DashboardData JSON for the logged-in user.
|
||||
// Returns 503 if DATABASE_URL is unset.
|
||||
func handleDashboardAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := dbSvc.dashboard.Get(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, data)
|
||||
}
|
||||
|
||||
// GET /dashboard — protected shell page. The client boots, reads the initial
|
||||
// payload inlined by the server into window.__PALIAD_DASHBOARD__, and renders
|
||||
// without a second round-trip (audit §2.3: no skeleton→fetch waterfall).
|
||||
func handleDashboardPage(w http.ResponseWriter, r *http.Request) {
|
||||
uid, hasUser := auth.UserIDFromContext(r.Context())
|
||||
var payload []byte
|
||||
if hasUser && dbSvc != nil {
|
||||
// Best-effort server-render. If the DB read fails we still serve the
|
||||
// shell; the client will show the inline error state instead of the
|
||||
// zero-count cards.
|
||||
if data, err := dbSvc.dashboard.Get(r.Context(), uid); err == nil {
|
||||
payload = mustJSON(data)
|
||||
}
|
||||
}
|
||||
serveDashboardShell(w, r, payload)
|
||||
}
|
||||
|
||||
// handleRootPage is the public `/` route. Unauthenticated visitors get the
|
||||
// marketing landing; authenticated users get a 302 to /dashboard so `/` feels
|
||||
// like a no-op they can bookmark.
|
||||
func handleRootPage(w http.ResponseWriter, r *http.Request) {
|
||||
if hasValidSession(r) {
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, "dist/index.html")
|
||||
}
|
||||
|
||||
// hasValidSession returns true when a session cookie is present, parses, and
|
||||
// hasn't expired. Kept intentionally loose: we don't reach out to Supabase
|
||||
// here. A near-expiry token will still be accepted and the downstream
|
||||
// Middleware handles refresh on the next protected request.
|
||||
func hasValidSession(r *http.Request) bool {
|
||||
cookie, err := r.Cookie(auth.SessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return false
|
||||
}
|
||||
exp, err := auth.DecodeJWTExpiry(cookie.Value)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().Before(exp)
|
||||
}
|
||||
90
internal/handlers/dashboard_shell.go
Normal file
90
internal/handlers/dashboard_shell.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// The dashboard shell is pre-rendered by bun (`renderDashboard()` → dist/dashboard.html)
|
||||
// and contains the placeholder token below. On each request we splice in a
|
||||
// JSON blob as `window.__PALIAD_DASHBOARD__` so the client can paint the real
|
||||
// data on first frame — no skeleton + /api/dashboard waterfall.
|
||||
const dashboardDataPlaceholder = "/*__PALIAD_DASHBOARD_DATA__*/"
|
||||
|
||||
var (
|
||||
dashboardShellOnce sync.Once
|
||||
dashboardShellBytes []byte
|
||||
dashboardShellErr error
|
||||
)
|
||||
|
||||
// loadDashboardShell reads dist/dashboard.html once. Server restarts on new
|
||||
// builds (container-level) so a lifetime cache is safe.
|
||||
func loadDashboardShell() ([]byte, error) {
|
||||
dashboardShellOnce.Do(func() {
|
||||
path := filepath.Join("dist", "dashboard.html")
|
||||
dashboardShellBytes, dashboardShellErr = os.ReadFile(path)
|
||||
if dashboardShellErr != nil {
|
||||
return
|
||||
}
|
||||
if !bytes.Contains(dashboardShellBytes, []byte(dashboardDataPlaceholder)) {
|
||||
log.Printf("warning: dashboard.html is missing the data placeholder — client will fall back to /api/dashboard")
|
||||
}
|
||||
})
|
||||
return dashboardShellBytes, dashboardShellErr
|
||||
}
|
||||
|
||||
// serveDashboardShell writes dist/dashboard.html with the JSON payload spliced
|
||||
// into the placeholder. A nil payload disables server-side hydration; the
|
||||
// client then falls back to fetching /api/dashboard on mount.
|
||||
func serveDashboardShell(w http.ResponseWriter, _ *http.Request, payload []byte) {
|
||||
shell, err := loadDashboardShell()
|
||||
if err != nil {
|
||||
http.Error(w, "dashboard shell unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var body []byte
|
||||
if len(payload) > 0 {
|
||||
// JSON is wrapped so the script block is self-contained even when the
|
||||
// payload contains `</script>` sequences (defensive: our data is
|
||||
// server-owned, but future event.description fields could contain
|
||||
// arbitrary text).
|
||||
inline := append([]byte("window.__PALIAD_DASHBOARD__="), escapeForScript(payload)...)
|
||||
inline = append(inline, ';')
|
||||
body = bytes.Replace(shell, []byte(dashboardDataPlaceholder), inline, 1)
|
||||
} else {
|
||||
body = bytes.Replace(shell, []byte(dashboardDataPlaceholder),
|
||||
[]byte("window.__PALIAD_DASHBOARD__=null;"), 1)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// escapeForScript makes a JSON blob safe to embed directly in an inline
|
||||
// <script>. JSON strings may contain `</script>` or U+2028/U+2029, both of
|
||||
// which terminate script blocks in some parsers.
|
||||
func escapeForScript(b []byte) []byte {
|
||||
b = bytes.ReplaceAll(b, []byte(`</`), []byte(`<\/`))
|
||||
b = bytes.ReplaceAll(b, []byte("\u2028"), []byte(`\u2028`))
|
||||
b = bytes.ReplaceAll(b, []byte("\u2029"), []byte(`\u2029`))
|
||||
return b
|
||||
}
|
||||
|
||||
// mustJSON encodes v, falling back to null on error (logged). Used only for
|
||||
// server-side hydration where a broken payload is non-fatal — the client will
|
||||
// fetch /api/dashboard as a fallback.
|
||||
func mustJSON(v any) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
log.Printf("dashboard hydration encode: %v", err)
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type Services struct {
|
||||
Calculator *services.DeadlineCalculator
|
||||
Users *services.UserService
|
||||
Fristenrechner *services.FristenrechnerService
|
||||
Dashboard *services.DashboardService
|
||||
}
|
||||
|
||||
func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) {
|
||||
@@ -35,6 +36,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
calc: svc.Calculator,
|
||||
users: svc.Users,
|
||||
fristenrechner: svc.Fristenrechner,
|
||||
dashboard: svc.Dashboard,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,12 +48,16 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
mux.HandleFunc("GET /login", handleLoginPage)
|
||||
mux.HandleFunc("GET /logout", handleLogout)
|
||||
|
||||
// Landing page: public to unauthenticated visitors, redirects logged-in
|
||||
// users to /dashboard. Handled on the outer mux so the auth Middleware
|
||||
// doesn't bounce unauthenticated visitors to /login.
|
||||
mux.HandleFunc("GET /{$}", handleRootPage)
|
||||
|
||||
// Static assets (public)
|
||||
mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets"))))
|
||||
|
||||
// Protected routes
|
||||
protected := http.NewServeMux()
|
||||
protected.HandleFunc("GET /{$}", handleIndex)
|
||||
protected.HandleFunc("GET /tools/kostenrechner", handleKostenrechnerPage)
|
||||
protected.HandleFunc("POST /api/tools/kostenrechner", handleKostenrechnerAPI)
|
||||
protected.HandleFunc("GET /tools/fristenrechner", handleFristenrechnerPage)
|
||||
@@ -108,6 +114,12 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
|
||||
protected.HandleFunc("GET /api/me", handleGetMe)
|
||||
protected.HandleFunc("GET /api/users", handleListUsers)
|
||||
protected.HandleFunc("GET /api/dashboard", handleDashboardAPI)
|
||||
|
||||
// Phase G — Dashboard (logged-in landing). Server-renders the data
|
||||
// payload inline; client boots from window.__PALIAD_DASHBOARD__ with no
|
||||
// waterfall fetch (design audit §2.3).
|
||||
protected.HandleFunc("GET /dashboard", handleDashboardPage)
|
||||
|
||||
// Phase D — server-rendered Akten pages (pre-built HTML; client TS calls
|
||||
// the JSON APIs above). Sub-routes share the same detail HTML; the client
|
||||
@@ -134,10 +146,6 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
mux.Handle("/", client.Middleware(client.WithUserID(protected)))
|
||||
}
|
||||
|
||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/index.html")
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
313
internal/services/dashboard_service.go
Normal file
313
internal/services/dashboard_service.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package services
|
||||
|
||||
// DashboardService aggregates the summary payload for the logged-in landing
|
||||
// page: deadline counts, matter counts, upcoming Fristen/Termine, and the
|
||||
// recent activity feed. Scoped to Akten the caller can see — same predicate
|
||||
// as AkteService.ListVisibleForUser (see migration 006 for the canonical SQL
|
||||
// version).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/models"
|
||||
)
|
||||
|
||||
// DashboardService reads paliad.akten/fristen/termine/akten_events to assemble
|
||||
// the Dashboard payload. Office-scoped through the standard visibility rule.
|
||||
type DashboardService struct {
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
}
|
||||
|
||||
// NewDashboardService wires the service to its deps.
|
||||
func NewDashboardService(db *sqlx.DB, users *UserService) *DashboardService {
|
||||
return &DashboardService{db: db, users: users}
|
||||
}
|
||||
|
||||
// DashboardData is the full payload returned to the frontend.
|
||||
type DashboardData struct {
|
||||
User *DashboardUser `json:"user"`
|
||||
DeadlineSummary DeadlineSummary `json:"deadline_summary"`
|
||||
MatterSummary MatterSummary `json:"matter_summary"`
|
||||
UpcomingDeadlines []UpcomingDeadline `json:"upcoming_deadlines"`
|
||||
UpcomingAppointments []UpcomingAppointment `json:"upcoming_appointments"`
|
||||
RecentActivity []ActivityEntry `json:"recent_activity"`
|
||||
}
|
||||
|
||||
// DashboardUser is the subset of paliad.users the dashboard header renders.
|
||||
type DashboardUser struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Office string `json:"office"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// DeadlineSummary is the four traffic-light counts.
|
||||
type DeadlineSummary struct {
|
||||
Overdue int `json:"overdue" db:"overdue"`
|
||||
ThisWeek int `json:"this_week" db:"this_week"`
|
||||
Upcoming int `json:"upcoming" db:"upcoming"`
|
||||
CompletedThisWeek int `json:"completed_this_week" db:"completed_this_week"`
|
||||
}
|
||||
|
||||
// MatterSummary counts of visible Akten by high-level status.
|
||||
type MatterSummary struct {
|
||||
Active int `json:"active" db:"active"`
|
||||
Archived int `json:"archived" db:"archived"`
|
||||
Total int `json:"total" db:"total"`
|
||||
}
|
||||
|
||||
// UpcomingDeadline is one row for the "Kommende Fristen" column.
|
||||
type UpcomingDeadline struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
Title string `json:"title" db:"title"`
|
||||
DueDate string `json:"due_date" db:"due_date"`
|
||||
AkteID uuid.UUID `json:"akte_id" db:"akte_id"`
|
||||
AkteTitle string `json:"akte_title" db:"akte_title"`
|
||||
AkteRef string `json:"akte_ref" db:"akte_ref"`
|
||||
Urgency string `json:"urgency"`
|
||||
}
|
||||
|
||||
// UpcomingAppointment is one row for the "Kommende Termine" column.
|
||||
// AkteID/Title/Ref are pointers because termine may be ad-hoc (no parent).
|
||||
type UpcomingAppointment struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
Title string `json:"title" db:"title"`
|
||||
StartAt time.Time `json:"start_at" db:"start_at"`
|
||||
EndAt *time.Time `json:"end_at" db:"end_at"`
|
||||
Type *string `json:"type" db:"termin_type"`
|
||||
AkteID *uuid.UUID `json:"akte_id" db:"akte_id"`
|
||||
AkteTitle *string `json:"akte_title" db:"akte_title"`
|
||||
AkteRef *string `json:"akte_ref" db:"akte_ref"`
|
||||
}
|
||||
|
||||
// ActivityEntry is one row in the "Letzte Aktivität" feed.
|
||||
type ActivityEntry struct {
|
||||
Timestamp time.Time `json:"timestamp" db:"timestamp"`
|
||||
ActorEmail *string `json:"actor_email" db:"actor_email"`
|
||||
ActorName *string `json:"actor_name" db:"actor_name"`
|
||||
AkteID uuid.UUID `json:"akte_id" db:"akte_id"`
|
||||
AkteTitle string `json:"akte_title" db:"akte_title"`
|
||||
AkteRef string `json:"akte_ref" db:"akte_ref"`
|
||||
Action *string `json:"action" db:"action"`
|
||||
Details string `json:"details" db:"details"`
|
||||
Description *string `json:"description" db:"description"`
|
||||
}
|
||||
|
||||
// Get builds the full dashboard payload for the given user.
|
||||
//
|
||||
// Returns zero-value summaries and empty lists if the user has no
|
||||
// paliad.users row yet — brand-new logins still get a valid response so the
|
||||
// page can render an onboarding hint instead of an error.
|
||||
func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*DashboardData, error) {
|
||||
user, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := &DashboardData{
|
||||
UpcomingDeadlines: []UpcomingDeadline{},
|
||||
UpcomingAppointments: []UpcomingAppointment{},
|
||||
RecentActivity: []ActivityEntry{},
|
||||
}
|
||||
if user == nil {
|
||||
return data, nil
|
||||
}
|
||||
data.User = &DashboardUser{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.DisplayName,
|
||||
Office: user.Office,
|
||||
Role: user.Role,
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
today := now.Format("2006-01-02")
|
||||
endOfWindow := now.AddDate(0, 0, 7).Format("2006-01-02")
|
||||
sevenDaysAgo := now.AddDate(0, 0, -7).UTC()
|
||||
|
||||
if err := s.loadSummary(ctx, data, user, today, endOfWindow, sevenDaysAgo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.loadUpcomingDeadlines(ctx, data, user, today, endOfWindow); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.loadUpcomingAppointments(ctx, data, user, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.loadRecentActivity(ctx, data, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
annotateUrgency(data.UpcomingDeadlines, now)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// loadSummary fills DeadlineSummary and MatterSummary in one round-trip using
|
||||
// CTEs that restrict to visible Akten.
|
||||
func (s *DashboardService) loadSummary(ctx context.Context, data *DashboardData, user *models.User, today, endOfWeek string, sevenDaysAgo time.Time) error {
|
||||
query := `
|
||||
WITH visible_akten AS (
|
||||
SELECT id, status
|
||||
FROM paliad.akten
|
||||
WHERE firm_wide_visible = true
|
||||
OR owning_office = $1
|
||||
OR $2::uuid = ANY (collaborators)
|
||||
OR $3 = 'admin'
|
||||
),
|
||||
deadline_stats AS (
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE f.due_date < $4::date AND f.status = 'pending') AS overdue,
|
||||
COUNT(*) FILTER (WHERE f.due_date >= $4::date AND f.due_date <= $5::date AND f.status = 'pending') AS this_week,
|
||||
COUNT(*) FILTER (WHERE f.due_date > $5::date AND f.status = 'pending') AS upcoming,
|
||||
COUNT(*) FILTER (WHERE f.status = 'completed' AND f.completed_at >= $6) AS completed_this_week
|
||||
FROM paliad.fristen f
|
||||
JOIN visible_akten v ON v.id = f.akte_id
|
||||
),
|
||||
matter_stats AS (
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'active') AS active,
|
||||
COUNT(*) FILTER (WHERE status = 'archived') AS archived,
|
||||
COUNT(*) AS total
|
||||
FROM visible_akten
|
||||
)
|
||||
SELECT ds.overdue, ds.this_week, ds.upcoming, ds.completed_this_week,
|
||||
ms.active, ms.archived, ms.total
|
||||
FROM deadline_stats ds, matter_stats ms`
|
||||
|
||||
var row struct {
|
||||
DeadlineSummary
|
||||
MatterSummary
|
||||
}
|
||||
err := s.db.GetContext(ctx, &row, query,
|
||||
user.Office, user.ID, user.Role, today, endOfWeek, sevenDaysAgo)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("dashboard summary: %w", err)
|
||||
}
|
||||
data.DeadlineSummary = row.DeadlineSummary
|
||||
data.MatterSummary = row.MatterSummary
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) loadUpcomingDeadlines(ctx context.Context, data *DashboardData, user *models.User, today, endOfWeek string) error {
|
||||
query := `
|
||||
SELECT f.id,
|
||||
f.title,
|
||||
to_char(f.due_date, 'YYYY-MM-DD') AS due_date,
|
||||
a.id AS akte_id,
|
||||
a.title AS akte_title,
|
||||
a.aktenzeichen AS akte_ref
|
||||
FROM paliad.fristen f
|
||||
JOIN paliad.akten a ON a.id = f.akte_id
|
||||
WHERE f.status = 'pending'
|
||||
AND f.due_date >= $4::date
|
||||
AND f.due_date <= $5::date
|
||||
AND (a.firm_wide_visible = true
|
||||
OR a.owning_office = $1
|
||||
OR $2::uuid = ANY (a.collaborators)
|
||||
OR $3 = 'admin')
|
||||
ORDER BY f.due_date ASC
|
||||
LIMIT 10`
|
||||
if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, query,
|
||||
user.Office, user.ID, user.Role, today, endOfWeek); err != nil {
|
||||
return fmt.Errorf("dashboard upcoming deadlines: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) loadUpcomingAppointments(ctx context.Context, data *DashboardData, user *models.User, now time.Time) error {
|
||||
// Termine may be ad-hoc (no parent Akte). Apply visibility to Termine that
|
||||
// reference an Akte; ad-hoc Termine are visible to any authenticated user.
|
||||
query := `
|
||||
SELECT t.id,
|
||||
t.title,
|
||||
t.start_at,
|
||||
t.end_at,
|
||||
t.termin_type,
|
||||
t.akte_id,
|
||||
a.title AS akte_title,
|
||||
a.aktenzeichen AS akte_ref
|
||||
FROM paliad.termine t
|
||||
LEFT JOIN paliad.akten a ON a.id = t.akte_id
|
||||
WHERE t.start_at >= $4
|
||||
AND t.start_at < ($4 + interval '7 days')
|
||||
AND (t.akte_id IS NULL
|
||||
OR a.firm_wide_visible = true
|
||||
OR a.owning_office = $1
|
||||
OR $2::uuid = ANY (a.collaborators)
|
||||
OR $3 = 'admin')
|
||||
ORDER BY t.start_at ASC
|
||||
LIMIT 10`
|
||||
if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, query,
|
||||
user.Office, user.ID, user.Role, now); err != nil {
|
||||
return fmt.Errorf("dashboard upcoming appointments: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) loadRecentActivity(ctx context.Context, data *DashboardData, user *models.User) error {
|
||||
// Timestamp preference: event_date (explicit) → created_at (fallback).
|
||||
// actor_email/name come from paliad.users — NULL if the actor never
|
||||
// onboarded; the UI then falls back to a "System" label.
|
||||
query := `
|
||||
SELECT COALESCE(e.event_date, e.created_at) AS timestamp,
|
||||
u.email AS actor_email,
|
||||
u.display_name AS actor_name,
|
||||
e.akte_id,
|
||||
a.title AS akte_title,
|
||||
a.aktenzeichen AS akte_ref,
|
||||
e.event_type AS action,
|
||||
e.title AS details,
|
||||
e.description
|
||||
FROM paliad.akten_events e
|
||||
JOIN paliad.akten a ON a.id = e.akte_id
|
||||
LEFT JOIN paliad.users u ON u.id = e.created_by
|
||||
WHERE a.firm_wide_visible = true
|
||||
OR a.owning_office = $1
|
||||
OR $2::uuid = ANY (a.collaborators)
|
||||
OR $3 = 'admin'
|
||||
ORDER BY COALESCE(e.event_date, e.created_at) DESC
|
||||
LIMIT 10`
|
||||
if err := s.db.SelectContext(ctx, &data.RecentActivity, query,
|
||||
user.Office, user.ID, user.Role); err != nil {
|
||||
return fmt.Errorf("dashboard recent activity: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// annotateUrgency sets the Urgency bucket on each UpcomingDeadline. Only
|
||||
// status=pending deadlines with due_date ∈ [today, today+7d] are in the slice,
|
||||
// but "overdue" is still emitted to be defensive across daylight-saving or
|
||||
// clock skew between DB and server.
|
||||
func annotateUrgency(deadlines []UpcomingDeadline, now time.Time) {
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
for i := range deadlines {
|
||||
due, err := time.ParseInLocation("2006-01-02", deadlines[i].DueDate, now.Location())
|
||||
if err != nil {
|
||||
deadlines[i].Urgency = "soon"
|
||||
continue
|
||||
}
|
||||
days := int(due.Sub(today).Hours() / 24)
|
||||
switch {
|
||||
case days < 0:
|
||||
deadlines[i].Urgency = "overdue"
|
||||
case days == 0:
|
||||
deadlines[i].Urgency = "today"
|
||||
case days <= 2:
|
||||
deadlines[i].Urgency = "urgent"
|
||||
default:
|
||||
deadlines[i].Urgency = "soon"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user