62023ef35d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
481 lines
16 KiB
TypeScript
481 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
AlertTriangle,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Copy,
|
||
EllipsisVertical,
|
||
History,
|
||
Pencil,
|
||
} from "lucide-react";
|
||
import Link from "next/link";
|
||
import { useState } from "react";
|
||
import { toast } from "sonner";
|
||
import { IconBadge } from "@/components/appearance/swatch";
|
||
import { MoneyInput } from "@/components/money-input";
|
||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from "@/components/ui/alert-dialog";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import { tr } from "@/i18n/tr";
|
||
import { cn } from "@/lib/utils";
|
||
import { shiftMonth, type BudgetStatus } from "@/shared/budget";
|
||
import { formatDate, formatMoney } from "@/shared/format";
|
||
import { formatMoneyInput, parseMoney } from "@/shared/money";
|
||
import { budgetFromActualsAction, copyPreviousBudgetAction, saveBudgetAction } from "../actions";
|
||
import type { BudgetLineView, BudgetView as Data } from "../service";
|
||
|
||
const t = tr.budget;
|
||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||
const monthFmt = new Intl.DateTimeFormat("tr-TR", {
|
||
month: "long",
|
||
year: "numeric",
|
||
timeZone: "Europe/Istanbul",
|
||
});
|
||
const monthLabel = (m: string) => monthFmt.format(new Date(`${m}-15T12:00:00+03:00`));
|
||
|
||
const BAR: Record<BudgetStatus, string> = {
|
||
ok: "bg-budget",
|
||
warning: "bg-amber-500",
|
||
over: "bg-expense",
|
||
none: "bg-muted-foreground/40",
|
||
};
|
||
|
||
/** Yüzde çubuğu; `marker` ayın geçen kısmını (yüzde) gösterir. */
|
||
function Bar({
|
||
ratioBp,
|
||
status,
|
||
marker,
|
||
label,
|
||
}: {
|
||
ratioBp: number | null;
|
||
status: BudgetStatus;
|
||
marker?: number;
|
||
label: string;
|
||
}) {
|
||
const pct = ratioBp === null ? 0 : Math.min(100, ratioBp / 100);
|
||
return (
|
||
<div
|
||
className="relative h-2 w-full overflow-hidden rounded-full bg-muted"
|
||
role="progressbar"
|
||
aria-label={label}
|
||
aria-valuemin={0}
|
||
aria-valuemax={100}
|
||
aria-valuenow={Math.round(ratioBp === null ? 0 : ratioBp / 100)}
|
||
>
|
||
<div className={cn("h-full rounded-full", BAR[status])} style={{ width: `${pct}%` }} />
|
||
{marker !== undefined && marker > 0 && marker < 100 ? (
|
||
<span
|
||
aria-hidden
|
||
className="absolute top-0 h-full w-0.5 bg-foreground/60"
|
||
style={{ left: `${marker}%` }}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatusBadge({
|
||
status,
|
||
forecastOver,
|
||
exceeded,
|
||
}: {
|
||
status: BudgetStatus;
|
||
forecastOver: boolean;
|
||
/** Limit aşıldı mı (tam %100'de "doldu"). */
|
||
exceeded: boolean;
|
||
}) {
|
||
if (status === "over")
|
||
return (
|
||
<Badge variant="destructive" className="gap-1">
|
||
<AlertTriangle className="size-3" />
|
||
{exceeded ? t.status.over : t.status.full}
|
||
</Badge>
|
||
);
|
||
if (status === "warning")
|
||
return (
|
||
<Badge
|
||
variant="outline"
|
||
className="gap-1 border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||
>
|
||
<AlertTriangle className="size-3" />
|
||
{t.status.warning}
|
||
</Badge>
|
||
);
|
||
if (forecastOver)
|
||
return (
|
||
<Badge variant="outline" className="text-muted-foreground">
|
||
{t.status.forecastOver}
|
||
</Badge>
|
||
);
|
||
return null;
|
||
}
|
||
|
||
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
|
||
return (
|
||
<div className="min-w-0">
|
||
<p className="text-xs text-muted-foreground">{label}</p>
|
||
<p className={cn("text-lg font-semibold tabular-nums", tone)}>{value}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type Pending = { kind: "edit" } | { kind: "replace"; source: "copy" | "actuals" } | null;
|
||
|
||
export function BudgetView({ data }: { data: Data }) {
|
||
const [pending, setPending] = useState<Pending>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const close = () => setPending(null);
|
||
const tt = data.totals;
|
||
const monthPct = Math.round((data.elapsedDays * 100) / data.totalDays);
|
||
const warnings = data.lines.filter((l) => l.status === "warning" || l.status === "over");
|
||
|
||
async function generate(source: "copy" | "actuals") {
|
||
setBusy(true);
|
||
const r =
|
||
source === "copy"
|
||
? await copyPreviousBudgetAction(data.month)
|
||
: await budgetFromActualsAction(data.month);
|
||
setBusy(false);
|
||
close();
|
||
if (r.ok) toast.success(source === "copy" ? t.toast.copied : t.toast.created);
|
||
else toast.error(r.error);
|
||
}
|
||
const askOrRun = (source: "copy" | "actuals") =>
|
||
data.hasBudget ? setPending({ kind: "replace", source }) : generate(source);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<div className="flex items-center gap-1">
|
||
<Button variant="ghost" size="icon" asChild>
|
||
<Link href={`/butce?ay=${shiftMonth(data.month, -1)}`} aria-label={t.prevMonth}>
|
||
<ChevronLeft />
|
||
</Link>
|
||
</Button>
|
||
<div className="min-w-36 text-center">
|
||
<p className="font-medium capitalize">{monthLabel(data.month)}</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t.period(d(data.period.from), d(data.period.to))}
|
||
</p>
|
||
</div>
|
||
<Button variant="ghost" size="icon" asChild>
|
||
<Link href={`/butce?ay=${shiftMonth(data.month, 1)}`} aria-label={t.nextMonth}>
|
||
<ChevronRight />
|
||
</Link>
|
||
</Button>
|
||
{data.month !== data.currentMonth ? (
|
||
<Button variant="link" size="sm" asChild>
|
||
<Link href="/butce">{t.thisMonth}</Link>
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
{data.canWrite ? (
|
||
<div className="flex gap-2">
|
||
<Button size="sm" onClick={() => setPending({ kind: "edit" })}>
|
||
<Pencil />
|
||
{t.edit}
|
||
</Button>
|
||
<DropdownMenu modal={false}>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button size="icon-sm" variant="outline" aria-label={tr.recurring.actions.more}>
|
||
<EllipsisVertical />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem
|
||
disabled={!data.hasPrevious || busy}
|
||
onSelect={() => askOrRun("copy")}
|
||
>
|
||
<Copy />
|
||
{t.copyPrevious}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem disabled={busy} onSelect={() => askOrRun("actuals")}>
|
||
<History />
|
||
{t.fromActuals}
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<Card size="sm">
|
||
<CardContent className="flex flex-col gap-3">
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||
<Stat
|
||
label={t.totals.limit}
|
||
value={data.hasBudget ? formatMoney(tt.limitMinor) : "—"}
|
||
/>
|
||
<Stat label={t.totals.actual} value={formatMoney(tt.actualMinor)} tone="text-expense" />
|
||
<Stat
|
||
label={tt.remainingMinor < 0 ? t.totals.over : t.totals.remaining}
|
||
value={data.hasBudget ? formatMoney(Math.abs(tt.remainingMinor)) : "—"}
|
||
tone={tt.remainingMinor < 0 ? "text-expense" : "text-budget"}
|
||
/>
|
||
<Stat
|
||
label={t.totals.forecast}
|
||
value={formatMoney(tt.forecastMinor)}
|
||
tone={tt.forecastOver ? "text-expense" : undefined}
|
||
/>
|
||
</div>
|
||
{data.hasBudget ? (
|
||
<Bar
|
||
ratioBp={tt.ratioBp}
|
||
status={tt.status}
|
||
marker={monthPct}
|
||
label={`${t.totals.actual} / ${t.totals.limit}`}
|
||
/>
|
||
) : null}
|
||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||
<span>
|
||
{t.totals.monthProgress(data.elapsedDays, data.totalDays, String(monthPct))}
|
||
{tt.ratioBp !== null ? ` · %${Math.round(tt.ratioBp / 100)}` : ""}
|
||
</span>
|
||
{tt.plannedMinor ? <span>{t.totals.planned(formatMoney(tt.plannedMinor))}</span> : null}
|
||
{data.hasBudget && tt.unbudgetedMinor ? (
|
||
<span>
|
||
{t.totals.unbudgeted}: {formatMoney(tt.unbudgetedMinor)}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
{data.includesHidden ? (
|
||
<p className="text-xs text-muted-foreground">{t.includesHidden}</p>
|
||
) : null}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{warnings.length ? (
|
||
<div
|
||
role="status"
|
||
className="flex items-center gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm"
|
||
>
|
||
<AlertTriangle className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||
{t.warnings(warnings.length)}
|
||
</div>
|
||
) : null}
|
||
|
||
{!data.hasBudget ? (
|
||
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed p-6 text-center">
|
||
<p className="font-medium">{t.empty}</p>
|
||
<p className="max-w-md text-sm text-muted-foreground">{t.emptyHint}</p>
|
||
{data.canWrite ? (
|
||
<div className="flex flex-wrap justify-center gap-2">
|
||
<Button size="sm" onClick={() => setPending({ kind: "edit" })}>
|
||
<Pencil />
|
||
{t.edit}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={!data.hasPrevious || busy}
|
||
onClick={() => generate("copy")}
|
||
>
|
||
<Copy />
|
||
{t.copyPrevious}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={busy}
|
||
onClick={() => generate("actuals")}
|
||
>
|
||
<History />
|
||
{t.fromActuals}
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{data.lines.length ? (
|
||
<ul className="flex flex-col divide-y rounded-xl border bg-card">
|
||
{data.lines.map((l) => (
|
||
<Line key={l.categoryId} line={l} marker={monthPct} />
|
||
))}
|
||
</ul>
|
||
) : data.hasBudget ? (
|
||
<p className="text-sm text-muted-foreground">{t.noSpending}</p>
|
||
) : null}
|
||
|
||
{pending?.kind === "edit" ? <EditDialog data={data} onClose={close} /> : null}
|
||
<AlertDialog open={pending?.kind === "replace"} onOpenChange={(o) => !o && close()}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>{t.replaceTitle}</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
{t.replaceDescription}
|
||
{pending?.kind === "replace" && pending.source === "actuals"
|
||
? ` ${t.fromActualsHint}`
|
||
: ""}
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||
<Button
|
||
disabled={busy}
|
||
onClick={() => pending?.kind === "replace" && generate(pending.source)}
|
||
>
|
||
{t.replace}
|
||
</Button>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Line({ line: l, marker }: { line: BudgetLineView; marker: number }) {
|
||
const hasLimit = l.limitMinor !== null;
|
||
return (
|
||
<li
|
||
className="flex items-start gap-3 p-3"
|
||
style={{ paddingLeft: `${0.75 + l.depth * 1.25}rem` }}
|
||
>
|
||
<IconBadge icon={l.icon} color={l.color} />
|
||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
|
||
<p className="flex min-w-0 items-center gap-2 font-medium">
|
||
<span className="truncate">{l.name}</span>
|
||
<StatusBadge
|
||
status={l.status}
|
||
forecastOver={l.forecastOver}
|
||
exceeded={(l.remainingMinor ?? 0) < 0}
|
||
/>
|
||
</p>
|
||
<p className="text-sm tabular-nums">
|
||
{hasLimit
|
||
? t.line.ofLimit(formatMoney(l.actualMinor), formatMoney(l.limitMinor!))
|
||
: formatMoney(l.actualMinor)}
|
||
</p>
|
||
</div>
|
||
{hasLimit ? (
|
||
<Bar
|
||
ratioBp={l.ratioBp}
|
||
status={l.status}
|
||
marker={marker}
|
||
label={`${l.name}: ${t.line.ofLimit(formatMoney(l.actualMinor), formatMoney(l.limitMinor!))}`}
|
||
/>
|
||
) : null}
|
||
<p className="flex flex-wrap gap-x-3 text-xs text-muted-foreground">
|
||
{hasLimit ? (
|
||
<span className={cn(l.remainingMinor! < 0 && "text-expense")}>
|
||
{l.remainingMinor! < 0
|
||
? t.line.over(formatMoney(-l.remainingMinor!))
|
||
: t.line.remaining(formatMoney(l.remainingMinor!))}
|
||
</span>
|
||
) : (
|
||
<span>{l.covered ? t.status.covered : t.status.none}</span>
|
||
)}
|
||
{l.plannedMinor ? <span>{t.line.planned(formatMoney(l.plannedMinor))}</span> : null}
|
||
{l.forecastMinor !== l.actualMinor ? (
|
||
<span className={cn(l.forecastOver && "text-expense")}>
|
||
{t.line.forecast(formatMoney(l.forecastMinor))}
|
||
</span>
|
||
) : null}
|
||
</p>
|
||
</div>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
function EditDialog({ data, onClose }: { data: Data; onClose: () => void }) {
|
||
const [values, setValues] = useState<Record<string, string>>(() =>
|
||
Object.fromEntries(
|
||
data.editor.categories.map((c) => {
|
||
const v = data.editor.limits[c.id];
|
||
return [c.id, v ? formatMoneyInput(v) : ""];
|
||
}),
|
||
),
|
||
);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
// Toplam: limiti olan ve üstünde limit olmayan kategoriler (alt limitler iki kez sayılmaz).
|
||
let total = 0;
|
||
const stack: { depth: number; limited: boolean }[] = [];
|
||
for (const c of data.editor.categories) {
|
||
while (stack.length && stack.at(-1)!.depth >= c.depth) stack.pop();
|
||
const covered = stack.some((s) => s.limited);
|
||
const v = parseMoney(values[c.id] ?? "") ?? 0;
|
||
if (v > 0 && !covered) total += v;
|
||
stack.push({ depth: c.depth, limited: v > 0 });
|
||
}
|
||
|
||
async function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setBusy(true);
|
||
const r = await saveBudgetAction({
|
||
month: data.month,
|
||
limits: Object.entries(values).map(([categoryId, amount]) => ({ categoryId, amount })),
|
||
});
|
||
setBusy(false);
|
||
if (r.ok) {
|
||
toast.success(t.toast.saved);
|
||
onClose();
|
||
} else setError(r.error);
|
||
}
|
||
|
||
return (
|
||
<ResponsiveDialog
|
||
open
|
||
onOpenChange={(o) => !o && onClose()}
|
||
title={t.editTitle(monthLabel(data.month))}
|
||
description={t.editHint}
|
||
footer={
|
||
<>
|
||
<span className="mr-auto self-center text-sm font-medium tabular-nums">
|
||
{t.editTotal(formatMoney(total))}
|
||
</span>
|
||
<Button variant="outline" type="button" onClick={onClose}>
|
||
{tr.common.cancel}
|
||
</Button>
|
||
<Button type="submit" form="budget-form" disabled={busy}>
|
||
{tr.common.save}
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
<form id="budget-form" onSubmit={submit} noValidate className="flex flex-col">
|
||
{data.editor.categories.map((c) => (
|
||
<label
|
||
key={c.id}
|
||
className="flex items-center justify-between gap-3 border-b py-1.5 last:border-b-0"
|
||
style={{ paddingLeft: `${c.depth * 1.25}rem` }}
|
||
>
|
||
<span className={cn("min-w-0 truncate text-sm", c.depth === 0 && "font-medium")}>
|
||
{c.name}
|
||
</span>
|
||
<div className="w-36 shrink-0">
|
||
<MoneyInput
|
||
id={`limit-${c.id}`}
|
||
value={values[c.id] ?? ""}
|
||
onChange={(x) => setValues((s) => ({ ...s, [c.id]: x }))}
|
||
aria-label={c.name}
|
||
/>
|
||
</div>
|
||
</label>
|
||
))}
|
||
{error ? <p className="pt-2 text-sm text-destructive">{error}</p> : null}
|
||
</form>
|
||
</ResponsiveDialog>
|
||
);
|
||
}
|