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,178 @@
"use client";
import { useRouter } from "next/navigation";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { tr } from "@/i18n/tr";
import { formatUnitPrice, unitPriceBase } from "@/shared/prices";
import type { UnitPriceReport } from "../extra";
import { dayLabel, monthLabel, monthShort } from "./report-frame";
const t = tr.reports.unitPrice;
const config = {
min: { label: t.min, color: "var(--income)" },
avg: { label: t.avg, color: "var(--budget)" },
} satisfies ChartConfig;
/** Ürün tipi seçimi (URL'de) ve aylık en düşük / ortalama birim fiyat. */
export function UnitPriceView({ data }: { data: UnitPriceReport }) {
const router = useRouter();
const sel = data.selected;
if (!sel)
return (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.noTypes}
</p>
);
const base = unitPriceBase(sel.unit);
// Gösterim biriminde TL (g → kg, ml → lt).
const tl = (minor: number | null) => (minor === null ? null : (minor * base.factor) / 100);
const chart = sel.points.map((p) => ({
month: p.month,
label: monthShort(p.month),
min: tl(p.minMinor),
avg: tl(p.avgMinor),
}));
const fmt = (minor: number | null) => (minor === null ? "—" : formatUnitPrice(minor, sel.unit));
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<label htmlFor="up-type" className="text-sm text-muted-foreground">
{t.type}
</label>
<Select
value={sel.id}
onValueChange={(id) => router.replace(`/raporlar/birim-fiyat?tip=${id}`)}
>
<SelectTrigger id="up-type" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-72">
{data.types.map((x) => (
<SelectItem key={x.id} value={x.id}>
{x.name}{" "}
<span className="text-xs text-muted-foreground">({t.records(x.records)})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-xl border bg-card p-4">
<ChartContainer
config={config}
className="aspect-auto h-60 w-full"
role="img"
aria-label={t.chartLabel(sel.name)}
>
<LineChart data={chart} margin={{ left: 4, right: 8, top: 8 }}>
<CartesianGrid vertical={false} />
<XAxis dataKey="label" tickLine={false} axisLine={false} tickMargin={8} />
<YAxis
tickLine={false}
axisLine={false}
width={56}
tickFormatter={(v: number) =>
`₺${v.toLocaleString("tr-TR", { maximumFractionDigits: 2 })}`
}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const m = payload?.[0]?.payload?.month as string | undefined;
return m ? monthLabel(m) : "";
}}
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">
{formatUnitPrice((Number(value) * 100) / base.factor, sel.unit)}
</span>
</div>
)}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{(["min", "avg"] as const).map((k) => (
<Line
key={k}
dataKey={k}
stroke={`var(--color-${k})`}
strokeWidth={2}
strokeDasharray={k === "avg" ? "6 3" : undefined}
dot={{ r: 3 }}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
</ChartContainer>
<table className="mt-3 w-full text-sm tabular-nums">
<thead className="text-xs text-muted-foreground">
<tr>
<th className="py-1 text-left font-normal">{t.month}</th>
<th className="py-1 text-right font-normal">{t.min}</th>
<th className="py-1 text-right font-normal">{t.avg}</th>
<th className="py-1 text-right font-normal">{t.count}</th>
</tr>
</thead>
<tbody className="divide-y">
{[...sel.points].reverse().map((p) => (
<tr key={p.month}>
<td className="py-1 capitalize">{monthLabel(p.month)}</td>
<td className="py-1 text-right">{fmt(p.minMinor)}</td>
<td className="py-1 text-right">{fmt(p.avgMinor)}</td>
<td className="py-1 text-right">{p.count}</td>
</tr>
))}
</tbody>
</table>
</div>
{sel.stores.length ? (
<section className="rounded-xl border bg-card p-4" aria-labelledby="up-stores">
<h2 id="up-stores" className="mb-2 text-sm font-medium">
{t.storesTitle}
</h2>
<table className="w-full text-sm tabular-nums">
<thead className="text-xs text-muted-foreground">
<tr>
<th className="py-1 text-left font-normal">{t.store}</th>
<th className="py-1 text-right font-normal">{t.min}</th>
<th className="py-1 text-right font-normal">{t.last}</th>
</tr>
</thead>
<tbody className="divide-y">
{sel.stores.map((s) => (
<tr key={s.id}>
<td className="py-1">{s.label}</td>
<td className="py-1 text-right">{fmt(s.minMinor)}</td>
<td className="py-1 text-right">{dayLabel(s.lastDate)}</td>
</tr>
))}
</tbody>
</table>
</section>
) : null}
</div>
);
}