223 lines
8.0 KiB
TypeScript
223 lines
8.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { api } from "@/lib/api-client";
|
|
import type { PhaseInput, TransitionDecision } from "@/lib/types";
|
|
import type { PhaseComputed } from "@/lib/calculations";
|
|
|
|
interface ItemDraft {
|
|
positionType: "SECURITY" | "REAL_ESTATE";
|
|
id: string;
|
|
name: string;
|
|
decision: TransitionDecision;
|
|
salePrice: number | null;
|
|
// Referenzwerte fuer die Anzeige/Berechnung des verfuegbaren Startkapitals
|
|
carryOverValue: number;
|
|
originalValue: number;
|
|
saleTaxRate: number;
|
|
}
|
|
|
|
export function TransitionPanel({
|
|
phase,
|
|
computed,
|
|
nextPhaseName,
|
|
onChanged,
|
|
}: {
|
|
phase: PhaseInput;
|
|
computed: PhaseComputed;
|
|
nextPhaseName: string;
|
|
onChanged: () => void;
|
|
}) {
|
|
const [items, setItems] = useState<ItemDraft[]>([]);
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [saved, setSaved] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
async function load() {
|
|
const initial: ItemDraft[] = [
|
|
...phase.securities.map((s) => {
|
|
const c = computed.securities.find((cs) => cs.id === s.id);
|
|
return {
|
|
positionType: "SECURITY" as const,
|
|
id: s.id,
|
|
name: s.name,
|
|
decision: "CARRY_OVER" as TransitionDecision,
|
|
salePrice: null,
|
|
carryOverValue: c?.endValue ?? 0,
|
|
originalValue: c?.startValue ?? 0,
|
|
saleTaxRate: s.saleTaxRate,
|
|
};
|
|
}),
|
|
...phase.realEstates.map((re) => {
|
|
const c = computed.realEstates.find((cr) => cr.id === re.id);
|
|
return {
|
|
positionType: "REAL_ESTATE" as const,
|
|
id: re.id,
|
|
name: re.name,
|
|
decision: "CARRY_OVER" as TransitionDecision,
|
|
salePrice: re.marketValue,
|
|
carryOverValue: c?.endNetIfKept ?? 0,
|
|
originalValue: re.marketValue,
|
|
saleTaxRate: re.saleTaxRate,
|
|
};
|
|
}),
|
|
];
|
|
|
|
try {
|
|
const data = await api.get<{ transition: { items: { positionType: string; securityId: string | null; realEstateId: string | null; decision: TransitionDecision; salePrice: number | null }[] } | null }>(
|
|
`/api/phases/${phase.id}/transition`
|
|
);
|
|
if (cancelled) return;
|
|
if (data.transition) {
|
|
for (const savedItem of data.transition.items) {
|
|
const target = initial.find(
|
|
(it) => it.id === (savedItem.securityId ?? savedItem.realEstateId)
|
|
);
|
|
if (target) {
|
|
target.decision = savedItem.decision;
|
|
if (savedItem.salePrice != null) target.salePrice = savedItem.salePrice;
|
|
}
|
|
}
|
|
}
|
|
setItems(initial);
|
|
setLoaded(true);
|
|
} catch {
|
|
setItems(initial);
|
|
setLoaded(true);
|
|
}
|
|
}
|
|
load();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [phase.id]);
|
|
|
|
if (!loaded) {
|
|
return (
|
|
<div className="mx-2 rounded-md bg-zinc-100 px-4 py-3 text-xs text-zinc-500 dark:bg-zinc-800">
|
|
Uebergang wird geladen…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const totalAvailableCapital = items.reduce((sum, it) => {
|
|
if (it.positionType === "SECURITY") {
|
|
if (it.decision === "CARRY_OVER") return sum;
|
|
const gain = Math.max(0, it.carryOverValue - it.originalValue);
|
|
const tax = gain * (it.saleTaxRate / 100);
|
|
return sum + (it.carryOverValue - tax);
|
|
}
|
|
if (it.decision === "CARRY_OVER") return sum;
|
|
const salePrice = it.salePrice ?? 0;
|
|
const gain = Math.max(0, salePrice - it.originalValue);
|
|
const tax = gain * (it.saleTaxRate / 100);
|
|
return sum + (salePrice - tax);
|
|
}, 0);
|
|
|
|
async function handleSave() {
|
|
setSaving(true);
|
|
setError(null);
|
|
setSaved(false);
|
|
try {
|
|
await api.put(`/api/phases/${phase.id}/transition`, {
|
|
items: items.map((it) => ({
|
|
positionType: it.positionType,
|
|
securityId: it.positionType === "SECURITY" ? it.id : null,
|
|
realEstateId: it.positionType === "REAL_ESTATE" ? it.id : null,
|
|
decision: it.decision,
|
|
salePrice: it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? it.salePrice : null,
|
|
})),
|
|
});
|
|
setSaved(true);
|
|
onChanged();
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
if (items.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="mx-2 flex flex-col gap-3 rounded-md border border-dashed border-zinc-300 bg-zinc-50 p-4 dark:border-zinc-600 dark:bg-zinc-800/40">
|
|
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
|
|
Uebergang → {nextPhaseName}
|
|
</div>
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-xs text-zinc-500">
|
|
<th className="pb-1 font-normal">Position</th>
|
|
<th className="pb-1 font-normal">Entscheidung</th>
|
|
<th className="pb-1 font-normal">Verkaufspreis / Wert</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((it, i) => (
|
|
<tr key={`${it.positionType}-${it.id}`} className="border-t border-zinc-200 dark:border-zinc-700">
|
|
<td className="py-2 pr-2">{it.name}</td>
|
|
<td className="py-2 pr-2">
|
|
<select
|
|
className="rounded-md border border-zinc-300 bg-white px-2 py-1 text-xs dark:border-zinc-600 dark:bg-zinc-900"
|
|
value={it.decision}
|
|
onChange={(e) =>
|
|
setItems((prev) =>
|
|
prev.map((x, idx) => (idx === i ? { ...x, decision: e.target.value as TransitionDecision } : x))
|
|
)
|
|
}
|
|
>
|
|
<option value="CARRY_OVER">Uebernehmen</option>
|
|
<option value="SELL">Verkaufen</option>
|
|
</select>
|
|
</td>
|
|
<td className="py-2">
|
|
{it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? (
|
|
<input
|
|
type="number"
|
|
className="w-32 rounded-md border border-zinc-300 bg-white px-2 py-1 text-xs dark:border-zinc-600 dark:bg-zinc-900"
|
|
value={it.salePrice ?? 0}
|
|
onChange={(e) =>
|
|
setItems((prev) =>
|
|
prev.map((x, idx) => (idx === i ? { ...x, salePrice: e.target.valueAsNumber || 0 } : x))
|
|
)
|
|
}
|
|
/>
|
|
) : (
|
|
<span className="text-xs text-zinc-500">
|
|
{it.decision === "CARRY_OVER" ? it.carryOverValue.toLocaleString("de-CH") : it.carryOverValue.toLocaleString("de-CH")} CHF
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
<div className="rounded-md bg-white px-3 py-2 text-sm dark:bg-zinc-900">
|
|
Verfuegbares Startkapital fuer neue Phase (aus Verkaeufen): <strong>{totalAvailableCapital.toLocaleString("de-CH")} CHF</strong>
|
|
<p className="mt-1 text-xs text-zinc-500">
|
|
Wird beim Speichern automatisch in "{nextPhaseName}" als verfuegbares Startkapital hinterlegt.
|
|
Uebernommene Wertschriften erscheinen dort automatisch mit ihrem Endwert als neuer Startwert.
|
|
</p>
|
|
</div>
|
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
disabled={saving}
|
|
onClick={handleSave}
|
|
className="self-start rounded-md bg-zinc-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-zinc-700 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900"
|
|
>
|
|
{saving ? "Speichern..." : "Uebergang speichern"}
|
|
</button>
|
|
{saved && <span className="text-xs text-emerald-600 dark:text-emerald-400">Gespeichert.</span>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|