Merge Phase G: Dashboard landing
# Conflicts: # frontend/build.ts # frontend/src/styles/global.css
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user