0761f6b3e2
Deploy App / deploy (push) Successful in 1m4s
Immobilien-Bugfix (gravierend): - Der Ist-Wizard belegte den Immobilienwert mit dem EIGENKAPITAL vor (ElementYearPoint.value), waehrend Erfassung und Rechenkern den VERKEHRSWERT erwarten (propertyValue). Der Kern setzte die Zahl als Verkehrswert ein und liess die Hypothek stehen -> das Eigenkapital brach im Ist-Jahr um genau die Hypothek ein, meist ins Negative. - Sichtbar als negative Gesamt-Abweichung trotz reiner Lohnerhoehung und als "wegbrechendes" Wohneigentum in der Vermoegensaufteilung. - Feld ist neu als "Verkehrswert + Restschuld" beschriftet; 3 Regressionstests. Ist-Datensaetze bearbeitbar: - Klick auf die Zeile (oder "Bearbeiten") oeffnet den Satz erneut - neuer Endpunkt PUT /api/plans/<id>/actuals/<setId> - beim Bearbeiten ueberschreiben die Planwerte die erfassten Zahlen nicht Ring-Klick in der Vermoegensaufteilung repariert: - Recharts 3 reicht kein activePayload mehr durch (nur activeIndex) -- der Handler feuerte nie, der Ring zeigte immer das Planende Seitenleiste sauber dreistufig: - Ebene 1 Plaene, Ebene 2 die vier Bereiche mit buendigen Symbolen, Ebene 3 nur die Szenarien (verschachtelt nach Herkunft) Szenario-Liste zeigt neben der Version deren Kommentar. SPEZIFIKATION 0.32 (neue Kapitel 3.9.6, 3.9.7). 275 -> 278 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
607 lines
23 KiB
TypeScript
607 lines
23 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import {
|
||
BarChart3,
|
||
CalendarClock,
|
||
Check,
|
||
Dices,
|
||
Eye,
|
||
FileSpreadsheet,
|
||
GitBranch,
|
||
History,
|
||
Layers,
|
||
LineChart as LineChartIcon,
|
||
Pencil,
|
||
Plus,
|
||
SlidersHorizontal,
|
||
Tornado,
|
||
Trash2,
|
||
X,
|
||
} from "lucide-react";
|
||
import { Button, useConfirm, useToast } from "@/components/ui";
|
||
import { api } from "@/lib/api-client";
|
||
import { formatChf } from "@/lib/format";
|
||
import { ANALYSIS_TYPE_LABEL, sourceLabel, type AnalysisType, type SavedAnalysisMeta } from "@/lib/analyses";
|
||
import type { PersonRole } from "@/lib/types";
|
||
|
||
export type PlanTab = "dashboard" | "scenarios" | "actuals" | "analyses" | "reports";
|
||
|
||
// --- Typen der Dashboard-Antwort ---
|
||
interface DashboardResponse {
|
||
plan: {
|
||
id: string;
|
||
name: string;
|
||
householdType: "SINGLE" | "COUPLE";
|
||
startYear: number | null;
|
||
persons: { role: PersonRole; name: string | null; age: number }[];
|
||
};
|
||
counts: { scenarios: number; actuals: number; analyses: number };
|
||
base: {
|
||
scenarioId: string;
|
||
name: string;
|
||
phaseCount: number;
|
||
endNominal: number;
|
||
endReal: number;
|
||
ruinAge: number | null;
|
||
actualEndNominal: number | null;
|
||
actualYears: number[];
|
||
} | null;
|
||
scenarios: {
|
||
id: string;
|
||
name: string;
|
||
isBase: boolean;
|
||
parentScenarioId: string | null;
|
||
version: string | null;
|
||
versionComment: string | null;
|
||
elementCount: number;
|
||
phaseCount: number;
|
||
versionCount: number;
|
||
endNominal: number;
|
||
endReal: number;
|
||
ruinAge: number | null;
|
||
}[];
|
||
}
|
||
|
||
function useDashboard(planId: string, reloadKey: number) {
|
||
const [data, setData] = useState<DashboardResponse | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const d = await api.get<DashboardResponse>(`/api/plans/${planId}/dashboard`);
|
||
if (!cancelled) setData(d);
|
||
} catch (e) {
|
||
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [planId, reloadKey]);
|
||
return { data, error };
|
||
}
|
||
|
||
// Anklickbare Kennzahl-Kachel. Ohne `onClick` bleibt sie eine reine Anzeige.
|
||
function Stat({
|
||
label,
|
||
value,
|
||
hint,
|
||
tone,
|
||
icon: Icon,
|
||
onClick,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
hint?: string;
|
||
tone?: "danger" | "success";
|
||
icon?: typeof Layers;
|
||
onClick?: () => void;
|
||
}) {
|
||
const toneClass = tone === "danger" ? "text-danger" : tone === "success" ? "text-success" : "text-fg";
|
||
const body = (
|
||
<>
|
||
<div className="flex items-center gap-1.5 text-xs text-muted">
|
||
{Icon && <Icon className="h-3.5 w-3.5 text-accent" />}
|
||
{label}
|
||
</div>
|
||
<div className={`mt-1 text-2xl font-semibold tabular-nums ${toneClass}`}>{value}</div>
|
||
{hint && <div className="mt-0.5 text-[11px] text-faint">{hint}</div>}
|
||
</>
|
||
);
|
||
if (!onClick) {
|
||
return <div className="rounded-2xl border border-border bg-surface p-4 shadow-sm">{body}</div>;
|
||
}
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className="group rounded-2xl border border-border bg-surface p-4 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:border-accent hover:shadow-md"
|
||
>
|
||
{body}
|
||
<div className="mt-1 text-[11px] font-medium text-accent opacity-0 transition-opacity group-hover:opacity-100">
|
||
Öffnen →
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// =========================================================================================
|
||
// Plan-Dashboard
|
||
// =========================================================================================
|
||
export function PlanDashboardView({
|
||
planId,
|
||
onNavigate,
|
||
onRenamed,
|
||
}: {
|
||
planId: string;
|
||
onNavigate: (tab: PlanTab) => void;
|
||
onRenamed: () => void;
|
||
}) {
|
||
const [reloadKey, setReloadKey] = useState(0);
|
||
const { data, error } = useDashboard(planId, reloadKey);
|
||
const [editing, setEditing] = useState(false);
|
||
const [draftName, setDraftName] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
const toast = useToast();
|
||
|
||
if (error) return <p className="text-sm text-danger">{error}</p>;
|
||
if (!data) return <p className="text-sm text-muted">Wird geladen…</p>;
|
||
|
||
const { plan, counts, base } = data;
|
||
const persons = plan.persons
|
||
.map((p) => `${p.name || (p.role === "PERSON_A" ? "Person A" : "Person B")} (${p.age})`)
|
||
.join(" · ");
|
||
|
||
// Jüngster erfasster Ist-Datensatz -- er bestimmt, gegen welchen Stand verglichen wird.
|
||
const latestActualYear = base && base.actualYears.length > 0 ? Math.max(...base.actualYears) : null;
|
||
const delta = base && base.actualEndNominal !== null ? base.actualEndNominal - base.endNominal : null;
|
||
|
||
async function saveName() {
|
||
const name = draftName.trim();
|
||
if (!name) return;
|
||
setSaving(true);
|
||
try {
|
||
await api.patch(`/api/plans/${planId}`, { name });
|
||
setEditing(false);
|
||
setReloadKey((k) => k + 1);
|
||
onRenamed();
|
||
toast("success", "Plan umbenannt.");
|
||
} catch (e) {
|
||
toast("error", e instanceof Error ? e.message : "Umbenennen fehlgeschlagen.");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-6">
|
||
<div>
|
||
{editing ? (
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<input
|
||
autoFocus
|
||
value={draftName}
|
||
onChange={(e) => setDraftName(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") void saveName();
|
||
if (e.key === "Escape") setEditing(false);
|
||
}}
|
||
maxLength={120}
|
||
className="rounded-lg border border-border bg-input px-3 py-1.5 text-lg font-semibold text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||
/>
|
||
<Button size="sm" onClick={saveName} disabled={saving}>
|
||
<Check className="h-4 w-4" /> Speichern
|
||
</Button>
|
||
<Button size="sm" variant="secondary" onClick={() => setEditing(false)}>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="group flex items-center gap-2">
|
||
<h2 className="text-lg font-semibold text-fg">{plan.name}</h2>
|
||
<button
|
||
type="button"
|
||
aria-label="Plan umbenennen"
|
||
title="Plan umbenennen"
|
||
onClick={() => {
|
||
setDraftName(plan.name);
|
||
setEditing(true);
|
||
}}
|
||
className="rounded-md p-1 text-faint opacity-60 transition-opacity hover:bg-surface-2 hover:text-fg group-hover:opacity-100"
|
||
>
|
||
<Pencil className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
<p className="text-sm text-muted">
|
||
{plan.householdType === "COUPLE" ? "Paar" : "Einzelperson"} · {persons}
|
||
{plan.startYear ? ` · Planstart ${plan.startYear}` : ""}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Anklickbare Kacheln -- jede führt in ihren Bereich. */}
|
||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||
<Stat
|
||
label="Szenarien"
|
||
value={String(counts.scenarios)}
|
||
hint="Varianten dieser Planung"
|
||
icon={Layers}
|
||
onClick={() => onNavigate("scenarios")}
|
||
/>
|
||
<Stat
|
||
label="Effektive Werte"
|
||
value={String(counts.actuals)}
|
||
hint="erfasste Ist-Datensätze"
|
||
icon={CalendarClock}
|
||
onClick={() => onNavigate("actuals")}
|
||
/>
|
||
<Stat
|
||
label="Gespeicherte Analysen"
|
||
value={String(counts.analyses)}
|
||
hint="Grafiken, Simulationen, Tornados"
|
||
icon={BarChart3}
|
||
onClick={() => onNavigate("analyses")}
|
||
/>
|
||
<Stat
|
||
label="Berichte"
|
||
value="PDF"
|
||
hint="erzeugen und wieder herunterladen"
|
||
icon={FileSpreadsheet}
|
||
onClick={() => onNavigate("reports")}
|
||
/>
|
||
</div>
|
||
|
||
{base && (
|
||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-sm">
|
||
<h3 className="mb-3 flex flex-wrap items-baseline gap-2 text-sm font-semibold text-fg">
|
||
Basisszenario «{base.name}»
|
||
<span className="text-[11px] font-normal text-faint">alle Kennzahlen beziehen sich hierauf</span>
|
||
</h3>
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||
<Stat label="Endvermögen (nominal)" value={formatChf(base.endNominal)} />
|
||
<Stat label="Endvermögen (real)" value={formatChf(base.endReal)} />
|
||
<Stat label="Lebensphasen" value={String(base.phaseCount)} />
|
||
{base.ruinAge !== null ? (
|
||
<Stat label="Kapital reicht" value={`bis Alter ${base.ruinAge}`} tone="danger" hint="danach aufgebraucht" />
|
||
) : (
|
||
<Stat label="Kapital reicht" value="bis Planende" tone="success" />
|
||
)}
|
||
</div>
|
||
{base.actualEndNominal !== null && delta !== null && (
|
||
<p
|
||
className={`mt-4 rounded-xl border px-3 py-2 text-xs ${
|
||
delta >= 0 ? "border-success bg-success/10 text-fg" : "border-danger bg-danger-soft text-fg"
|
||
}`}
|
||
>
|
||
Mit den effektiven Werten{latestActualYear ? ` von ${latestActualYear}` : ""} liegt das Endvermögen
|
||
(nominal) bei <strong>{formatChf(base.actualEndNominal)}</strong> – eine Abweichung von{" "}
|
||
<strong className={delta >= 0 ? "text-success" : "text-danger"}>
|
||
{delta >= 0 ? "+" : ""}
|
||
{formatChf(delta)}
|
||
</strong>{" "}
|
||
gegenüber dem Plan.
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// =========================================================================================
|
||
// Szenario-Liste
|
||
// =========================================================================================
|
||
export function ScenarioListView({
|
||
planId,
|
||
reloadKey = 0,
|
||
onOpenMatrix,
|
||
onOpenHistory,
|
||
onCopyFrom,
|
||
onDelete,
|
||
}: {
|
||
planId: string;
|
||
reloadKey?: number;
|
||
onOpenMatrix: (scenarioId: string) => void;
|
||
onOpenHistory: (scenarioId: string) => void;
|
||
// Kopiervorlage ist frei wählbar -- nicht mehr zwingend das Basisszenario.
|
||
onCopyFrom: (scenarioId: string) => void;
|
||
onDelete: (scenarioId: string) => void;
|
||
}) {
|
||
const { data, error } = useDashboard(planId, reloadKey);
|
||
if (error) return <p className="text-sm text-danger">{error}</p>;
|
||
if (!data) return <p className="text-sm text-muted">Wird geladen…</p>;
|
||
|
||
const nameById = new Map(data.scenarios.map((s) => [s.id, s.name]));
|
||
const baseId = data.scenarios.find((s) => s.isBase)?.id ?? data.scenarios[0]?.id;
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold text-fg">Szenarien</h2>
|
||
{baseId && (
|
||
<Button onClick={() => onCopyFrom(baseId)}>
|
||
<Plus className="h-4 w-4" /> Neues Szenario
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="overflow-x-auto rounded-xl border border-border">
|
||
<table className="w-full border-collapse text-sm">
|
||
<thead>
|
||
<tr className="bg-surface-2 text-xs text-faint">
|
||
<th className="px-3 py-2 text-left font-semibold">Szenario</th>
|
||
<th className="px-3 py-2 text-right font-semibold">Version</th>
|
||
<th className="px-3 py-2 text-right font-semibold">Phasen</th>
|
||
<th className="px-3 py-2 text-right font-semibold">Elemente</th>
|
||
<th className="px-3 py-2 text-right font-semibold">Endvermögen (nom.)</th>
|
||
<th className="px-3 py-2 text-right font-semibold">Kapital reicht</th>
|
||
<th className="px-3 py-2 text-right font-semibold">Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.scenarios.map((s) => (
|
||
// Die ganze Zeile öffnet die Matrix -- der frühere «Matrix»-Knopf entfällt.
|
||
<tr
|
||
key={s.id}
|
||
onClick={() => onOpenMatrix(s.id)}
|
||
className={`cursor-pointer border-t border-border transition-colors hover:bg-accent-soft/40 ${
|
||
s.isBase ? "bg-accent-soft/20" : ""
|
||
}`}
|
||
>
|
||
<td className="px-3 py-2">
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium text-fg">{s.name}</span>
|
||
{s.isBase && (
|
||
<span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">Basis</span>
|
||
)}
|
||
</div>
|
||
{s.parentScenarioId && (
|
||
<div className="flex items-center gap-1 text-[11px] text-faint">
|
||
<GitBranch className="h-3 w-3" /> aus {nameById.get(s.parentScenarioId) ?? "…"}
|
||
</div>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-2 text-right">
|
||
<div className="tabular-nums text-muted">{s.version ?? "–"}</div>
|
||
{s.versionComment && (
|
||
<div className="max-w-48 truncate text-[11px] text-faint" title={s.versionComment}>
|
||
«{s.versionComment}»
|
||
</div>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.phaseCount}</td>
|
||
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.elementCount}</td>
|
||
<td className="px-3 py-2 text-right tabular-nums text-fg">{formatChf(s.endNominal)}</td>
|
||
<td className="px-3 py-2 text-right">
|
||
{s.ruinAge !== null ? (
|
||
<span className="text-danger">Alter {s.ruinAge}</span>
|
||
) : (
|
||
<span className="text-success">Planende</span>
|
||
)}
|
||
</td>
|
||
{/* Aktionen: stopPropagation, sonst öffnet der Zeilenklick die Matrix. */}
|
||
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
||
<div className="flex items-center justify-end gap-1.5">
|
||
<button
|
||
type="button"
|
||
title="Änderungshistorie"
|
||
onClick={() => onOpenHistory(s.id)}
|
||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface-2 hover:text-fg"
|
||
>
|
||
<History className="h-3.5 w-3.5" /> Historie
|
||
</button>
|
||
<button
|
||
type="button"
|
||
title="Neues Szenario aus diesem"
|
||
onClick={() => onCopyFrom(s.id)}
|
||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface-2 hover:text-fg"
|
||
>
|
||
<Plus className="h-3.5 w-3.5" /> Kopie
|
||
</button>
|
||
{!s.isBase && (
|
||
<button
|
||
type="button"
|
||
aria-label="Szenario löschen"
|
||
title="Szenario löschen"
|
||
onClick={() => onDelete(s.id)}
|
||
className="rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// =========================================================================================
|
||
// Analysen: vier Kacheln + Liste der gespeicherten
|
||
// =========================================================================================
|
||
export type ToolKind = "CHART" | "MONTE_CARLO" | "SENSITIVITY" | "LIVESIM";
|
||
|
||
const TILES: { kind: ToolKind; title: string; icon: typeof BarChart3; blurb: string }[] = [
|
||
{
|
||
kind: "CHART",
|
||
title: "Grafiken",
|
||
icon: LineChartIcon,
|
||
blurb:
|
||
"Vermögensverlauf, Einkommen vs. Ausgaben oder Vermögensaufteilung – wähle eine Grafik und ob nominal/real und Plan/effektiv gezeigt werden.",
|
||
},
|
||
{
|
||
kind: "LIVESIM",
|
||
title: "Live-Simulation",
|
||
icon: SlidersHorizontal,
|
||
blurb: "Dreh an Reglern (Rendite, Ausgaben, Inflation …) und sieh die Wirkung sofort – ohne etwas zu speichern.",
|
||
},
|
||
{
|
||
kind: "MONTE_CARLO",
|
||
title: "Monte-Carlo",
|
||
icon: Dices,
|
||
blurb: "Tausende Zufallspfade zeigen, wie wahrscheinlich deine Planung hält und dein Ziel erreicht wird.",
|
||
},
|
||
{
|
||
kind: "SENSITIVITY",
|
||
title: "Einflussfaktoren",
|
||
icon: Tornado,
|
||
blurb: "Welche deiner Annahmen entscheidet überhaupt über das Ergebnis? Ein Tornado ordnet sie nach Wirkung.",
|
||
},
|
||
];
|
||
|
||
function typeIcon(t: AnalysisType) {
|
||
return t === "MONTE_CARLO" ? Dices : t === "SENSITIVITY" ? Tornado : BarChart3;
|
||
}
|
||
|
||
export function AnalysesView({
|
||
planId,
|
||
onLaunch,
|
||
onOpenSaved,
|
||
}: {
|
||
planId: string;
|
||
onLaunch: (kind: ToolKind) => void;
|
||
onOpenSaved: (id: string) => void;
|
||
}) {
|
||
const [saved, setSaved] = useState<SavedAnalysisMeta[] | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const confirm = useConfirm();
|
||
const toast = useToast();
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
const d = await api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`);
|
||
setSaved(d.analyses);
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
|
||
}
|
||
}, [planId]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const d = await api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`);
|
||
if (!cancelled) setSaved(d.analyses);
|
||
} catch (e) {
|
||
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [planId]);
|
||
|
||
async function remove(a: SavedAnalysisMeta) {
|
||
const ok = await confirm({ title: "Analyse löschen?", message: `«${a.name}» wird entfernt.`, confirmLabel: "Löschen", danger: true });
|
||
if (!ok) return;
|
||
try {
|
||
await api.delete(`/api/plans/${planId}/analyses/${a.id}`);
|
||
await load();
|
||
toast("success", "Analyse gelöscht.");
|
||
} catch (e) {
|
||
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||
}
|
||
}
|
||
|
||
const dt = (iso: string) =>
|
||
new Date(iso).toLocaleDateString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||
|
||
return (
|
||
<div className="flex flex-col gap-6">
|
||
<h2 className="text-lg font-semibold text-fg">Analysen</h2>
|
||
|
||
{/* Vier Kacheln, die beim Darüberfahren (oder Antippen) umklappen. */}
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||
{TILES.map((t) => (
|
||
<FlipTile key={t.kind} tile={t} onClick={() => onLaunch(t.kind)} />
|
||
))}
|
||
</div>
|
||
|
||
<div>
|
||
<h3 className="mb-2 text-sm font-semibold text-fg">Gespeicherte Analysen</h3>
|
||
{error && <p className="text-xs text-danger">{error}</p>}
|
||
{!saved && !error && <p className="text-xs text-muted">Wird geladen…</p>}
|
||
{saved && saved.length === 0 && (
|
||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
|
||
Noch nichts gespeichert. In jedem Werkzeug oben kannst du ein Ergebnis festhalten – es erscheint dann hier.
|
||
</p>
|
||
)}
|
||
{saved && saved.length > 0 && (
|
||
<div className="flex flex-col gap-2">
|
||
{saved.map((a) => {
|
||
const Icon = typeIcon(a.type);
|
||
return (
|
||
<div key={a.id} className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface-2 p-3">
|
||
<Icon className="h-4 w-4 shrink-0 text-accent" />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-sm font-medium text-fg">{a.name}</div>
|
||
<div className="text-[11px] text-muted">
|
||
{ANALYSIS_TYPE_LABEL[a.type]} · {dt(a.createdAt)}
|
||
{a.scenarioName ? ` · ${a.scenarioName}${a.versionLabel ? ` ${a.versionLabel}` : ""}` : ""} ·{" "}
|
||
{a.metric === "real" ? "real" : "nominal"} · {sourceLabel(a.source)}
|
||
{a.summary ? ` · ${a.summary}` : ""}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => onOpenSaved(a.id)}
|
||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface-2 hover:text-fg"
|
||
>
|
||
<Eye className="h-3.5 w-3.5" /> Anzeigen
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => remove(a)}
|
||
className="rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FlipTile({ tile, onClick }: { tile: (typeof TILES)[number]; onClick: () => void }) {
|
||
const [flipped, setFlipped] = useState(false);
|
||
const Icon = tile.icon;
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
onMouseEnter={() => setFlipped(true)}
|
||
onMouseLeave={() => setFlipped(false)}
|
||
// Touch hat kein Hover -- ein Antippen des Info-Bereichs klappt um, statt gleich zu starten.
|
||
className="group relative flex h-40 flex-col items-start justify-between overflow-hidden rounded-2xl border border-border bg-surface p-4 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:border-accent hover:shadow-md"
|
||
>
|
||
{!flipped ? (
|
||
<>
|
||
<Icon className="h-8 w-8 text-accent" />
|
||
<div>
|
||
<div className="text-sm font-semibold text-fg">{tile.title}</div>
|
||
<div className="text-[11px] text-faint">Zum Starten klicken</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="flex h-full flex-col">
|
||
<div className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-accent">
|
||
<Icon className="h-4 w-4" /> {tile.title}
|
||
</div>
|
||
<p className="text-[11px] leading-relaxed text-muted">{tile.blurb}</p>
|
||
<span className="mt-auto text-[11px] font-medium text-accent">Starten →</span>
|
||
</div>
|
||
)}
|
||
</button>
|
||
);
|
||
}
|