feat(ui): değer grafiği, net varlık kartı, dashboard kartları ve ödeme diyaloğunda varlık bağı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 00:19:05 +03:00
parent 9a60085d14
commit b022c1c3b0
10 changed files with 825 additions and 36 deletions
@@ -54,7 +54,14 @@ type Pending =
| null;
/** /birikim/:id — özet, hareket ekleme ve hareket geçmişi. */
export function AssetDetailView({ data }: { data: AssetDetail }) {
export function AssetDetailView({
data,
report,
}: {
data: AssetDetail;
/** Dönemsel rapor (sunucuda hazırlanır). */
report?: React.ReactNode;
}) {
const router = useRouter();
const [pending, setPending] = useState<Pending>(null);
const close = () => setPending(null);
@@ -171,6 +178,8 @@ export function AssetDetailView({ data }: { data: AssetDetail }) {
</CardContent>
</Card>
{report}
<section className="flex flex-col gap-2" aria-labelledby="movements-title">
<h2 id="movements-title" className="text-base font-medium">
{t.history}
@@ -0,0 +1,204 @@
"use client";
import Link from "next/link";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatDate, formatMoney } from "@/shared/format";
import type { PeriodReport } from "@/shared/portfolio";
import type { ReportRange } from "../service";
import { ProfitLoss, Stat } from "./assets-view";
const t = tr.assets.report;
const RANGES: ReportRange[] = ["3", "6", "12", "tum"];
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
const tickFmt = new Intl.DateTimeFormat("tr-TR", {
day: "numeric",
month: "short",
timeZone: "Europe/Istanbul",
});
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
const config = {
value: { label: t.value, color: "var(--budget)" },
cost: { label: t.cost, color: "var(--muted-foreground)" },
} satisfies ChartConfig;
/** Dönem seçici + özet + aylık değer/maliyet grafiği + tablo görünümü. */
export function PeriodReportView({
report,
range,
basePath,
}: {
report: PeriodReport & { excluded: number };
range: ReportRange;
basePath: string;
}) {
const data = report.points.map((p) => ({
date: p.date,
label: tickFmt.format(new Date(`${p.date}T12:00:00+03:00`)),
// Kuruş → TL (eksen ve ipucu TL gösterir).
value: p.valueMinor === null ? null : p.valueMinor / 100,
cost: p.costMinor / 100,
}));
const hasData = report.points.some((p) => p.costMinor > 0 || (p.valueMinor ?? 0) > 0);
return (
<Card size="sm">
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<CardTitle className="text-base">{t.title}</CardTitle>
<CardDescription>{t.period(d(report.from), d(report.to))}</CardDescription>
</div>
<nav aria-label={t.rangeLabel} className="flex rounded-lg bg-muted p-0.5 text-sm">
{RANGES.map((r) => (
<Link
key={r}
href={`${basePath}?donem=${r}`}
scroll={false}
aria-current={r === range ? "page" : undefined}
className={cn(
"rounded-md px-2.5 py-1 text-muted-foreground transition-colors",
r === range && "bg-background font-medium text-foreground shadow-sm",
)}
>
{t.ranges[r]}
</Link>
))}
</nav>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label={t.startValue}>
{report.startValueMinor !== null ? formatMoney(report.startValueMinor) : t.noPrice}
</Stat>
<Stat label={t.invested}>{formatMoney(report.investedMinor)}</Stat>
<Stat label={t.endValue}>
{report.endValueMinor !== null ? formatMoney(report.endValueMinor) : t.noPrice}
</Stat>
<Stat label={t.profit}>
<ProfitLoss minor={report.profitMinor} bp={report.profitBp} />
</Stat>
{report.proceedsMinor ? (
<Stat label={t.proceeds}>{formatMoney(report.proceedsMinor)}</Stat>
) : null}
{report.giftInMinor ? (
<Stat label={t.giftIn}>{formatMoney(report.giftInMinor)}</Stat>
) : null}
{report.giftOutMinor ? (
<Stat label={t.giftOut}>{formatMoney(report.giftOutMinor)}</Stat>
) : null}
</div>
<p className="text-xs text-muted-foreground">{t.formula}</p>
{report.excluded ? (
<p className="text-xs text-muted-foreground">{t.excluded(report.excluded)}</p>
) : null}
{hasData ? (
<>
<ChartContainer
config={config}
className="aspect-auto h-56 w-full sm:h-64"
aria-label={t.chartLabel}
role="img"
>
<LineChart data={data} margin={{ left: 4, right: 12, top: 8 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={16}
/>
<YAxis
tickLine={false}
axisLine={false}
width={48}
tickFormatter={(v: number) => compact.format(v)}
/>
<ChartTooltip
cursor
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const date = payload?.[0]?.payload?.date as string | undefined;
return date ? d(date) : "";
}}
formatter={(value, name) => (
<div className="flex w-full items-center justify-between gap-3">
<span className="text-muted-foreground">
{config[name as keyof typeof config]?.label}
</span>
<span className="font-medium text-foreground tabular-nums">
{formatMoney(Math.round(Number(value) * 100))}
</span>
</div>
)}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
<Line
dataKey="cost"
type="stepAfter"
stroke="var(--color-cost)"
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
isAnimationActive={false}
/>
<Line
dataKey="value"
type="linear"
stroke="var(--color-value)"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
connectNulls={false}
isAnimationActive={false}
/>
</LineChart>
</ChartContainer>
<details className="text-sm">
<summary className="cursor-pointer text-xs text-muted-foreground">{t.table}</summary>
<table className="mt-2 w-full text-right tabular-nums">
<thead className="text-xs text-muted-foreground">
<tr>
<th className="py-1 text-left font-normal">{t.date}</th>
<th className="py-1 font-normal">{t.value}</th>
<th className="py-1 font-normal">{t.cost}</th>
</tr>
</thead>
<tbody className="divide-y">
{report.points.map((p) => (
<tr key={p.date}>
<td className="py-1 text-left">{d(p.date)}</td>
<td className="py-1">
{p.valueMinor !== null ? formatMoney(p.valueMinor) : t.noPrice}
</td>
<td className="py-1">{formatMoney(p.costMinor)}</td>
</tr>
))}
</tbody>
</table>
</details>
</>
) : (
<p className="text-sm text-muted-foreground">{t.empty}</p>
)}
</CardContent>
</Card>
);
}
@@ -16,6 +16,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
@@ -35,6 +36,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getAssetOptionsAction, type AssetOption } from "@/features/assets/actions";
import { getTransactionFormOptionsAction } from "@/features/transactions/actions";
import { AccountSelect } from "@/features/transactions/components/transaction-form";
import type { TransactionFormOptions } from "@/features/transactions/service";
@@ -79,6 +81,21 @@ export function useAccounts() {
return options?.accounts ?? null;
}
/** Kaydın varlık türündeki birikim varlıkları (ödeme diyaloğu açıldığında). */
function useAssetOptions(assetKey: AssetKey) {
const [options, setOptions] = useState<AssetOption[] | null>(null);
useEffect(() => {
let cancelled = false;
getAssetOptionsAction().then((r) => {
if (!cancelled && r.ok) setOptions(r.data.filter((a) => a.assetKey === assetKey));
});
return () => {
cancelled = true;
};
}, [assetKey]);
return options;
}
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
return (
<div className="min-w-0">
@@ -602,16 +619,23 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
});
const [errors, setErrors] = useState<Errors>({});
const [busy, setBusy] = useState(false);
const assets = useAssetOptions(item.assetKey);
const [link, setLink] = useState(false);
const [assetId, setAssetId] = useState("");
const linked = method === "asset" && link;
const linkAssetId = assetId || assets?.[0]?.id || "";
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const amount = linked ? "" : v.amount;
const r = await recordPaymentAction(item.id, {
method,
date: v.date,
quantity: method === "cash" ? "" : v.quantity,
amount: v.amount,
accountId: v.amount ? v.accountId || accounts?.[0]?.id || "" : "",
amount,
accountId: amount ? v.accountId || accounts?.[0]?.id || "" : "",
assetId: linked ? linkAssetId : "",
note: v.note,
});
setBusy(false);
@@ -659,17 +683,19 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
</Field>
) : null}
<Field data-invalid={Boolean(errors.amount)}>
<FieldLabel htmlFor="pay-amount">
{method === "cash" ? f.amount : f.assetAmount}
</FieldLabel>
<MoneyInput
id="pay-amount"
value={v.amount}
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
/>
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
</Field>
{linked || (method === "asset" && !debt) ? null : (
<Field data-invalid={Boolean(errors.amount)}>
<FieldLabel htmlFor="pay-amount">
{method === "cash" ? f.amount : f.assetAmount}
</FieldLabel>
<MoneyInput
id="pay-amount"
value={v.amount}
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
/>
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
</Field>
)}
<Field>
<FieldLabel htmlFor="pay-date">{tr.transactions.fields.date}</FieldLabel>
<Input
@@ -684,7 +710,49 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
{method === "cash" ? (
<FieldDescription className="-mt-2">{f.quantityHint}</FieldDescription>
) : null}
{method === "cash" || v.amount ? (
{method === "asset" ? (
<div className="flex flex-col gap-2 rounded-lg border p-3">
<label className="flex items-start gap-2 text-sm">
<Checkbox
checked={link}
onCheckedChange={(x) => setLink(x === true)}
disabled={!assets?.length}
className="mt-0.5"
/>
<span>
{debt ? t.link.debt : t.link.receivable}
<span className="block text-xs text-muted-foreground">
{assets && !assets.length
? t.link.none
: debt
? t.link.debtHint
: t.link.receivableHint}
</span>
</span>
</label>
{link && assets?.length ? (
<Field data-invalid={Boolean(errors.assetId)}>
<FieldLabel htmlFor="pay-asset" className="sr-only">
{t.link.asset}
</FieldLabel>
<Select value={linkAssetId} onValueChange={setAssetId}>
<SelectTrigger id="pay-asset" className="w-full">
<SelectValue>{assets.find((a) => a.id === linkAssetId)?.name}</SelectValue>
</SelectTrigger>
<SelectContent>
{assets.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name} · {formatAssetQuantity(item.assetKey, a.quantity)}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.assetId ? <FieldError>{errors.assetId}</FieldError> : null}
</Field>
) : null}
</div>
) : null}
{(method === "cash" || v.amount) && !linked ? (
<Field data-invalid={Boolean(errors.accountId)}>
<FieldLabel htmlFor="pay-account">{f.account}</FieldLabel>
<AccountSelect
@@ -0,0 +1,62 @@
import { Card, CardContent } from "@/components/ui/card";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatMoney } from "@/shared/format";
import type { NetWorth } from "../service";
const t = tr.assets.netWorth;
/** Net varlık dökümü: artılar ve eksiler, altta toplam. */
export function NetWorthCard({ data }: { data: NetWorth }) {
const rows: { label: string; minor: number; sign: 1 | -1 }[] = [
{ label: t.accounts, minor: data.accountsMinor, sign: 1 },
{ label: t.assets, minor: data.assetsMinor, sign: 1 },
{ label: t.contactReceivable, minor: data.contactReceivableMinor, sign: 1 },
{ label: t.inKindReceivable, minor: data.inKindReceivableMinor, sign: 1 },
{ label: t.cardDebt, minor: data.cardDebtMinor, sign: -1 },
{ label: t.contactPayable, minor: data.contactPayableMinor, sign: -1 },
{ label: t.inKindPayable, minor: data.inKindPayableMinor, sign: -1 },
];
const plus = rows.filter((r) => r.sign === 1);
const minus = rows.filter((r) => r.sign === -1);
const list = (items: typeof rows) => (
<dl className="flex flex-col text-sm">
{items.map((r) => (
<div key={r.label} className="flex items-center justify-between gap-2 py-1">
<dt className="text-muted-foreground">{r.label}</dt>
<dd className="tabular-nums">
{r.sign < 0 && r.minor ? "−" : ""}
{formatMoney(r.minor)}
</dd>
</div>
))}
</dl>
);
return (
<Card size="sm">
<CardContent className="flex flex-col gap-3">
<div>
<p
className={cn(
"text-3xl font-semibold tracking-tight tabular-nums",
data.totalMinor < 0 && "text-expense",
)}
>
{formatMoney(data.totalMinor)}
</p>
<p className="text-sm text-muted-foreground">{t.description}</p>
</div>
<div className="grid gap-x-8 gap-y-1 border-t pt-2 sm:grid-cols-2">
{list(plus)}
{list(minus)}
</div>
{data.missingPrice ? (
<p className="text-xs text-muted-foreground">{t.missingPrice(data.missingPrice)}</p>
) : null}
{data.includesHidden ? (
<p className="text-xs text-muted-foreground">{t.includesHidden}</p>
) : null}
</CardContent>
</Card>
);
}