feat(ui): cari, birikim, alışveriş, birim fiyat ve fire rapor sayfaları

- dönem seçici, tablolar, birim fiyat grafiği ve CSV indirme
- rapor ana sayfasında yeni kartlar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 00:53:02 +03:00
parent 8623c5a02e
commit c3c3c43bd1
16 changed files with 1115 additions and 2 deletions
@@ -0,0 +1,250 @@
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatMoney } from "@/shared/format";
import { formatAssetQuantity } from "@/shared/assets";
import type { AssetsReport, ContactsReport } from "../extra";
import { dayLabel } from "./report-frame";
const signed = (n: number) => `${n > 0 ? "+" : n < 0 ? "−" : ""}${formatMoney(Math.abs(n))}`;
const bp = (n: number | null) =>
n === null
? ""
: ` (${n < 0 ? "−" : "+"}%${(Math.abs(n) / 100).toLocaleString("tr-TR", { maximumFractionDigits: 1 })})`;
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
return (
<div className="rounded-xl border bg-card p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className={cn("text-lg font-semibold tabular-nums", tone)}>{value}</p>
</div>
);
}
export function ContactsReportView({ data }: { data: ContactsReport }) {
const t = tr.reports.contacts;
return (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<Stat label={t.owesMe} value={formatMoney(data.totals.owesMeMinor)} tone="text-income" />
<Stat label={t.iOwe} value={formatMoney(data.totals.iOweMinor)} tone="text-expense" />
<Stat
label={`${t.receivable} (${t.value.toLocaleLowerCase("tr-TR")})`}
value={formatMoney(data.inKindTotals.receivableMinor)}
/>
<Stat
label={`${t.debt} (${t.value.toLocaleLowerCase("tr-TR")})`}
value={formatMoney(data.inKindTotals.debtMinor)}
/>
</div>
<section aria-labelledby="cari-h" className="flex flex-col gap-2">
<h2 id="cari-h" className="font-medium">
{t.contact}
</h2>
{data.contacts.length ? (
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[28rem] text-sm tabular-nums">
<thead className="border-b text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-normal">{t.contact}</th>
<th className="px-3 py-2 text-left font-normal">{t.direction}</th>
<th className="px-3 py-2 text-right font-normal">{t.balance}</th>
<th className="px-3 py-2 text-right font-normal">{t.due}</th>
</tr>
</thead>
<tbody className="divide-y">
{data.contacts.map((c) => (
<tr key={c.id}>
<td className="px-3 py-1.5">
<Link href={`/cari/${c.id}`} className="hover:underline">
{c.name}
</Link>
</td>
<td className="px-3 py-1.5">{c.balanceMinor > 0 ? t.owesMe : t.iOwe}</td>
<td
className={cn(
"px-3 py-1.5 text-right font-medium",
c.balanceMinor > 0 ? "text-income" : "text-expense",
)}
>
{formatMoney(Math.abs(c.balanceMinor))}
</td>
<td className="px-3 py-1.5 text-right">
{c.dueDate ? dayLabel(c.dueDate) : "—"}
{t.dueStates[c.due] ? (
<Badge
variant="outline"
className={cn(
"ml-2",
c.due === "overdue" && "border-expense/50 text-expense",
)}
>
{t.dueStates[c.due]}
</Badge>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.noContacts}
</p>
)}
</section>
<section aria-labelledby="ayni-h" className="flex flex-col gap-2">
<div>
<h2 id="ayni-h" className="font-medium">
{t.inKindTitle}
</h2>
<p className="text-xs text-muted-foreground">{t.inKindHint}</p>
</div>
{data.inKind.length ? (
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[32rem] text-sm tabular-nums">
<thead className="border-b text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-normal">{t.contact}</th>
<th className="px-3 py-2 text-left font-normal">{t.direction}</th>
<th className="px-3 py-2 text-right font-normal">{t.remaining}</th>
<th className="px-3 py-2 text-right font-normal">{t.value}</th>
<th className="px-3 py-2 text-right font-normal">{t.since}</th>
</tr>
</thead>
<tbody className="divide-y">
{data.inKind.map((d) => (
<tr key={d.id}>
<td className="px-3 py-1.5">
<Link href={`/cari/${d.contactId}`} className="hover:underline">
{d.contactName}
</Link>
</td>
<td className="px-3 py-1.5">
{d.direction === "receivable" ? t.receivable : t.debt}
</td>
<td className="px-3 py-1.5 text-right">{d.remainingLabel}</td>
<td className="px-3 py-1.5 text-right">
{d.valueMinor === null ? "—" : formatMoney(d.valueMinor)}
</td>
<td className="px-3 py-1.5 text-right">{dayLabel(d.date)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.noInKind}
</p>
)}
{data.inKindTotals.unpriced ? (
<p className="text-xs text-muted-foreground">{t.unpriced(data.inKindTotals.unpriced)}</p>
) : null}
</section>
</div>
);
}
export function AssetsReportView({ data }: { data: AssetsReport }) {
const t = tr.reports.assets;
const p = data.period;
const tone = (n: number | null) =>
n === null ? "" : n < 0 ? "text-expense" : n > 0 ? "text-income" : "";
return (
<div className="flex flex-col gap-6">
<section aria-labelledby="period-h" className="flex flex-col gap-2">
<h2 id="period-h" className="font-medium">
{t.periodTitle}: {dayLabel(p.from)} – {dayLabel(p.to)}
</h2>
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<Stat
label={t.startValue}
value={p.startValueMinor === null ? "—" : formatMoney(p.startValueMinor)}
/>
<Stat label={t.invested} value={formatMoney(p.investedMinor)} />
<Stat label={t.proceeds} value={formatMoney(p.proceedsMinor)} />
<Stat
label={t.endValue}
value={p.endValueMinor === null ? "—" : formatMoney(p.endValueMinor)}
/>
<Stat
label={t.profit}
value={p.profitMinor === null ? "—" : `${signed(p.profitMinor)}${bp(p.profitBp)}`}
tone={tone(p.profitMinor)}
/>
</div>
{p.excluded ? (
<p className="text-xs text-muted-foreground">{t.excluded(p.excluded)}</p>
) : null}
</section>
{data.items.length ? (
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[40rem] text-sm tabular-nums">
<thead className="border-b text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-normal">{t.asset}</th>
<th className="px-3 py-2 text-right font-normal">{t.quantity}</th>
<th className="px-3 py-2 text-right font-normal">{t.cost}</th>
<th className="px-3 py-2 text-right font-normal">{t.value}</th>
<th className="px-3 py-2 text-right font-normal">{t.unrealized}</th>
<th className="px-3 py-2 text-right font-normal">{t.realized}</th>
</tr>
</thead>
<tbody className="divide-y">
{data.items.map((a) => (
<tr key={a.id}>
<td className="px-3 py-1.5">
<Link href={`/birikim/${a.id}`} className="hover:underline">
{a.name || a.label}
</Link>
</td>
<td className="px-3 py-1.5 text-right">
{formatAssetQuantity(a.assetKey, a.summary.quantity)}
</td>
<td className="px-3 py-1.5 text-right">{formatMoney(a.summary.costMinor)}</td>
<td className="px-3 py-1.5 text-right">
{a.summary.currentValueMinor === null
? t.noPrice
: formatMoney(a.summary.currentValueMinor)}
</td>
<td className={cn("px-3 py-1.5 text-right", tone(a.summary.unrealizedMinor))}>
{a.summary.unrealizedMinor === null
? "—"
: `${signed(a.summary.unrealizedMinor)}${bp(a.summary.unrealizedBp)}`}
</td>
<td className={cn("px-3 py-1.5 text-right", tone(a.summary.realizedMinor))}>
{signed(a.summary.realizedMinor)}
</td>
</tr>
))}
</tbody>
<tfoot className="border-t font-medium">
<tr>
<td className="px-3 py-2">{t.total}</td>
<td />
<td className="px-3 py-2 text-right">{formatMoney(data.totals.costMinor)}</td>
<td className="px-3 py-2 text-right">{formatMoney(data.totals.valueMinor)}</td>
<td className={cn("px-3 py-2 text-right", tone(data.totals.unrealizedMinor))}>
{signed(data.totals.unrealizedMinor)}
</td>
<td className={cn("px-3 py-2 text-right", tone(data.totals.realizedMinor))}>
{signed(data.totals.realizedMinor)}
</td>
</tr>
</tfoot>
</table>
</div>
) : (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.empty}
</p>
)}
</div>
);
}