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:
@@ -0,0 +1,47 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getShoppingReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { parsePeriod } from "@/shared/reports";
|
||||
|
||||
/** GET /raporlar/alisveris/csv?donem=3|6|12&tablo=listeler|magazalar */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getShoppingReport(c.userId, c.householdId, parsePeriod(c.params.get("donem")));
|
||||
if (!r) return forbidden();
|
||||
const h = tr.reports.csvHeaders;
|
||||
const today = todayIstanbul();
|
||||
if (c.params.get("tablo") === "magazalar")
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.stores,
|
||||
r.stores.map((s) => [
|
||||
s.name,
|
||||
csvMoney(s.amountMinor),
|
||||
s.count,
|
||||
csvPct(s.sharePct),
|
||||
csvMoney(s.fromListsMinor),
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.stores(today),
|
||||
);
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.lists,
|
||||
r.lists.map((l) => [
|
||||
l.date,
|
||||
l.name,
|
||||
csvMoney(l.summary.plannedMinor),
|
||||
csvMoney(l.summary.actualMinor),
|
||||
csvMoney(l.summary.diffMinor),
|
||||
l.summary.counts.same,
|
||||
l.summary.counts.substitute,
|
||||
l.summary.counts.skipped,
|
||||
l.summary.counts.extra,
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.lists(today),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
CsvButton,
|
||||
RangeNav,
|
||||
ReportFrame,
|
||||
ToolsEnd,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { ShoppingReportView } from "@/features/reports/components/shopping-waste-view";
|
||||
import { getShoppingReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { parsePeriod, REPORT_PERIODS } from "@/shared/reports";
|
||||
|
||||
const t = tr.reports.shopping;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/alisveris?donem=3|6|12 — liste vs. gerçekleşen, mağaza bazlı harcama. */
|
||||
export default async function ShoppingReportPage({
|
||||
searchParams,
|
||||
}: PageProps<"/raporlar/alisveris">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const period = parsePeriod((await searchParams).donem);
|
||||
const data = await getShoppingReport(user.id, household.id, period);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
<>
|
||||
<RangeNav
|
||||
path="/raporlar/alisveris"
|
||||
label={tr.reports.periodLabel}
|
||||
value={String(period)}
|
||||
options={REPORT_PERIODS.map((p) => ({
|
||||
value: String(p),
|
||||
label: tr.reports.periods[p]!,
|
||||
}))}
|
||||
/>
|
||||
<ToolsEnd>
|
||||
<CsvButton
|
||||
href={`/raporlar/alisveris/csv?donem=${period}&tablo=listeler`}
|
||||
label={t.csvLists}
|
||||
/>
|
||||
<CsvButton
|
||||
href={`/raporlar/alisveris/csv?donem=${period}&tablo=magazalar`}
|
||||
label={t.csvStores}
|
||||
/>
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ShoppingReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { REPORT_RANGES, type ReportRange } from "@/features/assets/service";
|
||||
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getAssetsReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { formatAssetQuantity } from "@/shared/assets";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
|
||||
/** GET /raporlar/birikim/csv — varlık başına maliyet, değer ve kâr/zarar. */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const q = c.params.get("donem");
|
||||
const range: ReportRange = REPORT_RANGES.includes(q as ReportRange) ? (q as ReportRange) : "6";
|
||||
const r = await getAssetsReport(c.userId, c.householdId, range);
|
||||
if (!r) return forbidden();
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
tr.reports.csvHeaders.assets,
|
||||
r.items.map((a) => [
|
||||
a.name || a.label,
|
||||
formatAssetQuantity(a.assetKey, a.summary.quantity),
|
||||
csvMoney(a.summary.costMinor),
|
||||
a.summary.currentValueMinor === null ? "" : csvMoney(a.summary.currentValueMinor),
|
||||
a.summary.unrealizedMinor === null ? "" : csvMoney(a.summary.unrealizedMinor),
|
||||
csvMoney(a.summary.realizedMinor),
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.assets(todayIstanbul()),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { AssetsReportView } from "@/features/reports/components/contacts-assets-view";
|
||||
import {
|
||||
CsvButton,
|
||||
RangeNav,
|
||||
ReportFrame,
|
||||
ToolsEnd,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { getAssetsReport } from "@/features/reports/extra";
|
||||
import { REPORT_RANGES, type ReportRange } from "@/features/assets/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
const t = tr.reports.assets;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/birikim?donem=3|6|12|tum — varlık kâr/zarar ve dönem özeti. */
|
||||
export default async function AssetsReportPage({ searchParams }: PageProps<"/raporlar/birikim">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const q = (await searchParams).donem;
|
||||
const range: ReportRange = REPORT_RANGES.includes(q as ReportRange) ? (q as ReportRange) : "6";
|
||||
const data = await getAssetsReport(user.id, household.id, range);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
<>
|
||||
<RangeNav
|
||||
path="/raporlar/birikim"
|
||||
label={tr.reports.periodLabel}
|
||||
value={range}
|
||||
options={REPORT_RANGES.map((r) => ({ value: r, label: t.ranges[r]! }))}
|
||||
/>
|
||||
<ToolsEnd>
|
||||
<CsvButton href="/raporlar/birikim/csv" />
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AssetsReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getUnitPriceReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { unitPriceBase } from "@/shared/prices";
|
||||
|
||||
/** GET /raporlar/birim-fiyat/csv?tip=<ürün tipi> — ay başına en düşük ve ortalama (gösterim biriminde). */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getUnitPriceReport(c.userId, c.householdId, c.params.get("tip") ?? undefined);
|
||||
if (!r?.selected) return forbidden();
|
||||
const sel = r.selected;
|
||||
const base = unitPriceBase(sel.unit);
|
||||
const money = (x: number | null) => (x === null ? "" : csvMoney(Math.round(x * base.factor)));
|
||||
const header = tr.reports.csvHeaders.unitPrice.map((h, i) =>
|
||||
i === 1 || i === 2 ? `${h} (TL/${base.unit})` : h,
|
||||
);
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
header,
|
||||
sel.points.map((p) => [p.month, money(p.minMinor), money(p.avgMinor), p.count]),
|
||||
),
|
||||
tr.reports.csvFiles.unitPrice(
|
||||
sel.name.toLocaleLowerCase("tr-TR").replace(/\s+/g, "-"),
|
||||
todayIstanbul(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CsvButton, ReportFrame, ToolsEnd } from "@/features/reports/components/report-frame";
|
||||
import { UnitPriceView } from "@/features/reports/components/unit-price-view";
|
||||
import { getUnitPriceReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
const t = tr.reports.unitPrice;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/birim-fiyat?tip=<ürün tipi> — aylık en düşük ve ortalama birim fiyat. */
|
||||
export default async function UnitPriceReportPage({
|
||||
searchParams,
|
||||
}: PageProps<"/raporlar/birim-fiyat">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const tip = (await searchParams).tip;
|
||||
const data = await getUnitPriceReport(
|
||||
user.id,
|
||||
household.id,
|
||||
typeof tip === "string" ? tip : undefined,
|
||||
);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
data.selected ? (
|
||||
<ToolsEnd>
|
||||
<CsvButton href={`/raporlar/birim-fiyat/csv?tip=${data.selected.id}`} />
|
||||
</ToolsEnd>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<UnitPriceView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getContactsReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
|
||||
/** GET /raporlar/cari/csv?tablo=cari|ayni */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getContactsReport(c.userId, c.householdId);
|
||||
if (!r) return forbidden();
|
||||
const h = tr.reports.csvHeaders;
|
||||
const today = todayIstanbul();
|
||||
if (c.params.get("tablo") === "ayni")
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.inKind,
|
||||
r.inKind.map((d) => [
|
||||
d.contactName,
|
||||
d.direction === "receivable" ? tr.reports.contacts.receivable : tr.reports.contacts.debt,
|
||||
d.remainingLabel,
|
||||
d.valueMinor === null ? "" : csvMoney(d.valueMinor),
|
||||
d.date,
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.inKind(today),
|
||||
);
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.contacts,
|
||||
r.contacts.map((x) => [x.name, csvMoney(x.balanceMinor), x.dueDate ?? ""]),
|
||||
),
|
||||
tr.reports.csvFiles.contacts(today),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ContactsReportView } from "@/features/reports/components/contacts-assets-view";
|
||||
import { CsvButton, ReportFrame, ToolsEnd } from "@/features/reports/components/report-frame";
|
||||
import { getContactsReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
const t = tr.reports.contacts;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/cari — cari bakiyeler ve açık aynî borç/alacaklar. */
|
||||
export default async function ContactsReportPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await getContactsReport(user.id, household.id);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
<ToolsEnd>
|
||||
<CsvButton href="/raporlar/cari/csv?tablo=cari" label={t.csvContacts} />
|
||||
<CsvButton href="/raporlar/cari/csv?tablo=ayni" label={t.csvInKind} />
|
||||
</ToolsEnd>
|
||||
}
|
||||
>
|
||||
<ContactsReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getWasteReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { quantityInput } from "@/shared/stock";
|
||||
import { parsePeriod } from "@/shared/reports";
|
||||
|
||||
/** GET /raporlar/fire/csv?donem=3|6|12 */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getWasteReport(c.userId, c.householdId, parsePeriod(c.params.get("donem")));
|
||||
if (!r) return forbidden();
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
tr.reports.csvHeaders.waste,
|
||||
r.rows.map((x) => [
|
||||
x.name,
|
||||
x.unit,
|
||||
quantityInput(x.wasted),
|
||||
quantityInput(x.purchased),
|
||||
csvPct(x.wastePct),
|
||||
x.costMinor === null ? "" : csvMoney(x.costMinor),
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.waste(todayIstanbul()),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
CsvButton,
|
||||
RangeNav,
|
||||
ReportFrame,
|
||||
ToolsEnd,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { WasteReportView } from "@/features/reports/components/shopping-waste-view";
|
||||
import { getWasteReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { parsePeriod, REPORT_PERIODS } from "@/shared/reports";
|
||||
|
||||
const t = tr.reports.waste;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/fire?donem=3|6|12 — fire/zarar raporu. */
|
||||
export default async function WasteReportPage({ searchParams }: PageProps<"/raporlar/fire">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const period = parsePeriod((await searchParams).donem);
|
||||
const data = await getWasteReport(user.id, household.id, period);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
<>
|
||||
<RangeNav
|
||||
path="/raporlar/fire"
|
||||
label={tr.reports.periodLabel}
|
||||
value={String(period)}
|
||||
options={REPORT_PERIODS.map((p) => ({
|
||||
value: String(p),
|
||||
label: tr.reports.periods[p]!,
|
||||
}))}
|
||||
/>
|
||||
<ToolsEnd>
|
||||
<CsvButton href={`/raporlar/fire/csv?donem=${period}`} />
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<WasteReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
import { ChartPie, ChevronRight, Target, TrendingUp, Users } from "lucide-react";
|
||||
import {
|
||||
ChartPie,
|
||||
ChevronRight,
|
||||
Coins,
|
||||
HandCoins,
|
||||
ShoppingCart,
|
||||
Tags,
|
||||
Target,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
@@ -18,6 +29,11 @@ const REPORTS = [
|
||||
{ href: "/raporlar/trend", key: "trend", icon: TrendingUp },
|
||||
{ href: "/raporlar/kisi-hesap", key: "people", icon: Users },
|
||||
{ href: "/raporlar/butce", key: "budget", icon: Target },
|
||||
{ href: "/raporlar/cari", key: "contacts", icon: HandCoins },
|
||||
{ href: "/raporlar/birikim", key: "assets", icon: Coins },
|
||||
{ href: "/raporlar/alisveris", key: "shopping", icon: ShoppingCart },
|
||||
{ href: "/raporlar/birim-fiyat", key: "unitPrice", icon: Tags },
|
||||
{ href: "/raporlar/fire", key: "waste", icon: Trash2 },
|
||||
] as const;
|
||||
|
||||
/** /raporlar — bu bütçe ayının özeti ve raporlar. */
|
||||
|
||||
@@ -28,7 +28,7 @@ export type NavItem = {
|
||||
|
||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||
export const CURRENT_PHASE = 14.1;
|
||||
export const CURRENT_PHASE = 14.2;
|
||||
|
||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -125,3 +125,44 @@ export function Change({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dönem seçici (?donem=…): seçenekler bağlantı, seçili olan vurgulu. */
|
||||
export function RangeNav({
|
||||
path,
|
||||
param = "donem",
|
||||
options,
|
||||
value,
|
||||
label,
|
||||
}: {
|
||||
path: string;
|
||||
param?: string;
|
||||
options: { value: string; label: string }[];
|
||||
value: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<nav aria-label={label} className="flex rounded-lg bg-muted p-0.5">
|
||||
{options.map((o) => (
|
||||
<Link
|
||||
key={o.value}
|
||||
href={`${path}?${param}=${o.value}`}
|
||||
aria-current={o.value === value ? "page" : undefined}
|
||||
className={
|
||||
o.value === value
|
||||
? "rounded-md bg-background px-2.5 py-1 text-sm font-medium shadow-sm"
|
||||
: "rounded-md px-2.5 py-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
}
|
||||
>
|
||||
{o.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
const dayFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
export const dayLabel = (ymd: string) => dayFmt.format(new Date(`${ymd}T12:00:00+03:00`));
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import Link from "next/link";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
import { WASTE_REASONS } from "@/shared/stock";
|
||||
import { formatStock } from "@/shared/stock";
|
||||
import type { ShoppingReport, WasteReport } from "../extra";
|
||||
import { dayLabel, formatPct } from "./report-frame";
|
||||
|
||||
const signed = (n: number) => `${n > 0 ? "+" : n < 0 ? "−" : ""}${formatMoney(Math.abs(n))}`;
|
||||
const empty = (text: string) => (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
|
||||
export function ShoppingReportView({ data }: { data: ShoppingReport }) {
|
||||
const t = tr.reports.shopping;
|
||||
const reasons = tr.shopping.report.reasons;
|
||||
const reasonRows = (
|
||||
["price", "quantity", "substitute", "skipped", "extra", "unplanned"] as const
|
||||
).filter((k) => data.listTotals[k] !== 0);
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<section aria-labelledby="lists-h" className="flex flex-col gap-2">
|
||||
<h2 id="lists-h" className="font-medium">
|
||||
{t.listsTitle}
|
||||
</h2>
|
||||
{data.lists.length ? (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[36rem] 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.date}</th>
|
||||
<th className="px-3 py-2 text-left font-normal">{t.list}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.planned}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.actual}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.diff}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.lists.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td className="px-3 py-1.5 whitespace-nowrap">{dayLabel(l.date)}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<Link href={`/alisveris/${l.id}`} className="font-medium hover:underline">
|
||||
{l.name}
|
||||
</Link>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t.counts(
|
||||
l.summary.counts.same,
|
||||
l.summary.counts.substitute,
|
||||
l.summary.counts.skipped,
|
||||
l.summary.counts.extra,
|
||||
)}
|
||||
{l.stores.length ? ` · ${l.stores.join(", ")}` : ""}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{formatMoney(l.summary.plannedMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{formatMoney(l.summary.actualMinor)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-right",
|
||||
l.summary.diffMinor > 0
|
||||
? "text-expense"
|
||||
: l.summary.diffMinor < 0
|
||||
? "text-income"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{signed(l.summary.diffMinor)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t font-medium">
|
||||
<tr>
|
||||
<td className="px-3 py-2" colSpan={2}>
|
||||
{t.total}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{formatMoney(data.listTotals.plannedMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{formatMoney(data.listTotals.actualMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{signed(data.listTotals.diffMinor)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{reasonRows.length ? (
|
||||
<ul className="flex flex-col gap-1 rounded-xl border bg-card p-3 text-sm">
|
||||
{reasonRows.map((k) => (
|
||||
<li key={k} className="flex justify-between gap-3">
|
||||
<span>{reasons[k]}</span>
|
||||
<span className="tabular-nums">{signed(data.listTotals[k])}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
empty(t.listsEmpty)
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="stores-h" className="flex flex-col gap-2">
|
||||
<div>
|
||||
<h2 id="stores-h" className="font-medium">
|
||||
{t.storesTitle}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{t.storesHint}</p>
|
||||
</div>
|
||||
{data.stores.length ? (
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{data.stores.map((s) => (
|
||||
<li key={s.name} className="flex flex-col gap-1.5 p-3 text-sm">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{s.name}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{s.count} {t.count.toLocaleLowerCase("tr-TR")}
|
||||
{s.fromListsMinor ? ` · ${t.fromLists} ${formatMoney(s.fromListsMinor)}` : ""}
|
||||
</span>
|
||||
<span className="w-28 text-right tabular-nums">{formatMoney(s.amountMinor)}</span>
|
||||
<span className="w-14 text-right text-muted-foreground tabular-nums">
|
||||
{formatPct(s.sharePct)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden>
|
||||
<div
|
||||
className="h-full rounded-full bg-budget"
|
||||
style={{ width: `${s.sharePct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
<li className="flex justify-between p-3 text-sm font-medium">
|
||||
<span>{t.total}</span>
|
||||
<span className="tabular-nums">{formatMoney(data.storesTotalMinor)}</span>
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
empty(t.storesEmpty)
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WasteReportView({ data }: { data: WasteReport }) {
|
||||
const t = tr.reports.waste;
|
||||
const reasonText = tr.stock.wasteReasons;
|
||||
if (!data.rows.length) return empty(t.empty);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,14rem)_minmax(0,1fr)]">
|
||||
<div className="rounded-xl border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">{t.total}</p>
|
||||
<p className="text-lg font-semibold text-expense tabular-nums">
|
||||
{formatMoney(data.totalCostMinor)}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">{t.costHint}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-card p-3">
|
||||
<h2 className="mb-1 text-xs text-muted-foreground">{t.reasonsTitle}</h2>
|
||||
<ul className="flex flex-col gap-1 text-sm">
|
||||
{WASTE_REASONS.filter((r) => data.reasons[r].count).map((r) => (
|
||||
<li key={r} className="flex justify-between gap-3">
|
||||
<span>
|
||||
{reasonText[r]}{" "}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({t.count(data.reasons[r].count)})
|
||||
</span>
|
||||
</span>
|
||||
<span className="tabular-nums">{formatMoney(data.reasons[r].costMinor)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[36rem] 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.product}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.wasted}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.purchased}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.ratio}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.cost}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.rows.map((r) => (
|
||||
<tr key={r.productTypeId}>
|
||||
<td className="px-3 py-1.5">
|
||||
<Link href={`/stok/${r.productTypeId}`} className="hover:underline">
|
||||
{r.name}
|
||||
</Link>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{WASTE_REASONS.filter((x) => r.byReason[x])
|
||||
.map((x) => `${reasonText[x]}: ${formatStock(r.byReason[x], r.unit)}`)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">{formatStock(r.wasted, r.unit)}</td>
|
||||
<td className="px-3 py-1.5 text-right">{formatStock(r.purchased, r.unit)}</td>
|
||||
<td
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-right",
|
||||
(r.wastePct ?? 0) >= 15 && "font-medium text-expense",
|
||||
)}
|
||||
>
|
||||
{formatPct(r.wastePct)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{r.costMinor === null ? "—" : formatMoney(r.costMinor)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{data.unpriced ? (
|
||||
<p className="text-xs text-muted-foreground">{t.unpriced(data.unpriced)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user