diff --git a/src/app/(app)/raporlar/alisveris/csv/route.ts b/src/app/(app)/raporlar/alisveris/csv/route.ts new file mode 100644 index 0000000..6914822 --- /dev/null +++ b/src/app/(app)/raporlar/alisveris/csv/route.ts @@ -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), + ); +} diff --git a/src/app/(app)/raporlar/alisveris/page.tsx b/src/app/(app)/raporlar/alisveris/page.tsx new file mode 100644 index 0000000..17ae2e3 --- /dev/null +++ b/src/app/(app)/raporlar/alisveris/page.tsx @@ -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 ( + + ({ + value: String(p), + label: tr.reports.periods[p]!, + }))} + /> + + + + + + } + > + + + ); +} diff --git a/src/app/(app)/raporlar/birikim/csv/route.ts b/src/app/(app)/raporlar/birikim/csv/route.ts new file mode 100644 index 0000000..075c41a --- /dev/null +++ b/src/app/(app)/raporlar/birikim/csv/route.ts @@ -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()), + ); +} diff --git a/src/app/(app)/raporlar/birikim/page.tsx b/src/app/(app)/raporlar/birikim/page.tsx new file mode 100644 index 0000000..2ef1736 --- /dev/null +++ b/src/app/(app)/raporlar/birikim/page.tsx @@ -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 ( + + ({ value: r, label: t.ranges[r]! }))} + /> + + + + + } + > + + + ); +} diff --git a/src/app/(app)/raporlar/birim-fiyat/csv/route.ts b/src/app/(app)/raporlar/birim-fiyat/csv/route.ts new file mode 100644 index 0000000..a1cfa14 --- /dev/null +++ b/src/app/(app)/raporlar/birim-fiyat/csv/route.ts @@ -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(), + ), + ); +} diff --git a/src/app/(app)/raporlar/birim-fiyat/page.tsx b/src/app/(app)/raporlar/birim-fiyat/page.tsx new file mode 100644 index 0000000..7240a95 --- /dev/null +++ b/src/app/(app)/raporlar/birim-fiyat/page.tsx @@ -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 ( + + + + ) : undefined + } + > + + + ); +} diff --git a/src/app/(app)/raporlar/cari/csv/route.ts b/src/app/(app)/raporlar/cari/csv/route.ts new file mode 100644 index 0000000..dd372e5 --- /dev/null +++ b/src/app/(app)/raporlar/cari/csv/route.ts @@ -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), + ); +} diff --git a/src/app/(app)/raporlar/cari/page.tsx b/src/app/(app)/raporlar/cari/page.tsx new file mode 100644 index 0000000..ccd3965 --- /dev/null +++ b/src/app/(app)/raporlar/cari/page.tsx @@ -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 ( + + + + + } + > + + + ); +} diff --git a/src/app/(app)/raporlar/fire/csv/route.ts b/src/app/(app)/raporlar/fire/csv/route.ts new file mode 100644 index 0000000..37920f3 --- /dev/null +++ b/src/app/(app)/raporlar/fire/csv/route.ts @@ -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()), + ); +} diff --git a/src/app/(app)/raporlar/fire/page.tsx b/src/app/(app)/raporlar/fire/page.tsx new file mode 100644 index 0000000..83eafe0 --- /dev/null +++ b/src/app/(app)/raporlar/fire/page.tsx @@ -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 ( + + ({ + value: String(p), + label: tr.reports.periods[p]!, + }))} + /> + + + + + } + > + + + ); +} diff --git a/src/app/(app)/raporlar/page.tsx b/src/app/(app)/raporlar/page.tsx index 6a06ea7..9bc4ae8 100644 --- a/src/app/(app)/raporlar/page.tsx +++ b/src/app/(app)/raporlar/page.tsx @@ -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. */ diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index fd2012e..88ecae5 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -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; diff --git a/src/features/reports/components/contacts-assets-view.tsx b/src/features/reports/components/contacts-assets-view.tsx new file mode 100644 index 0000000..020acc7 --- /dev/null +++ b/src/features/reports/components/contacts-assets-view.tsx @@ -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 ( +
+

{label}

+

{value}

+
+ ); +} + +export function ContactsReportView({ data }: { data: ContactsReport }) { + const t = tr.reports.contacts; + return ( +
+
+ + + + +
+ +
+

+ {t.contact} +

+ {data.contacts.length ? ( +
+ + + + + + + + + + + {data.contacts.map((c) => ( + + + + + + + ))} + +
{t.contact}{t.direction}{t.balance}{t.due}
+ + {c.name} + + {c.balanceMinor > 0 ? t.owesMe : t.iOwe} 0 ? "text-income" : "text-expense", + )} + > + {formatMoney(Math.abs(c.balanceMinor))} + + {c.dueDate ? dayLabel(c.dueDate) : "—"} + {t.dueStates[c.due] ? ( + + {t.dueStates[c.due]} + + ) : null} +
+
+ ) : ( +

+ {t.noContacts} +

+ )} +
+ +
+
+

+ {t.inKindTitle} +

+

{t.inKindHint}

+
+ {data.inKind.length ? ( +
+ + + + + + + + + + + + {data.inKind.map((d) => ( + + + + + + + + ))} + +
{t.contact}{t.direction}{t.remaining}{t.value}{t.since}
+ + {d.contactName} + + + {d.direction === "receivable" ? t.receivable : t.debt} + {d.remainingLabel} + {d.valueMinor === null ? "—" : formatMoney(d.valueMinor)} + {dayLabel(d.date)}
+
+ ) : ( +

+ {t.noInKind} +

+ )} + {data.inKindTotals.unpriced ? ( +

{t.unpriced(data.inKindTotals.unpriced)}

+ ) : null} +
+
+ ); +} + +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 ( +
+
+

+ {t.periodTitle}: {dayLabel(p.from)} – {dayLabel(p.to)} +

+
+ + + + + +
+ {p.excluded ? ( +

{t.excluded(p.excluded)}

+ ) : null} +
+ + {data.items.length ? ( +
+ + + + + + + + + + + + + {data.items.map((a) => ( + + + + + + + + + ))} + + + + + + + + + + +
{t.asset}{t.quantity}{t.cost}{t.value}{t.unrealized}{t.realized}
+ + {a.name || a.label} + + + {formatAssetQuantity(a.assetKey, a.summary.quantity)} + {formatMoney(a.summary.costMinor)} + {a.summary.currentValueMinor === null + ? t.noPrice + : formatMoney(a.summary.currentValueMinor)} + + {a.summary.unrealizedMinor === null + ? "—" + : `${signed(a.summary.unrealizedMinor)}${bp(a.summary.unrealizedBp)}`} + + {signed(a.summary.realizedMinor)} +
{t.total} + {formatMoney(data.totals.costMinor)}{formatMoney(data.totals.valueMinor)} + {signed(data.totals.unrealizedMinor)} + + {signed(data.totals.realizedMinor)} +
+
+ ) : ( +

+ {t.empty} +

+ )} +
+ ); +} diff --git a/src/features/reports/components/report-frame.tsx b/src/features/reports/components/report-frame.tsx index 8eaf5c3..ac10999 100644 --- a/src/features/reports/components/report-frame.tsx +++ b/src/features/reports/components/report-frame.tsx @@ -125,3 +125,44 @@ export function Change({ ); } + +/** 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 ( + + ); +} + +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`)); diff --git a/src/features/reports/components/shopping-waste-view.tsx b/src/features/reports/components/shopping-waste-view.tsx new file mode 100644 index 0000000..ac953e6 --- /dev/null +++ b/src/features/reports/components/shopping-waste-view.tsx @@ -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) => ( +

+ {text} +

+); + +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 ( +
+
+

+ {t.listsTitle} +

+ {data.lists.length ? ( + <> +
+ + + + + + + + + + + + {data.lists.map((l) => ( + + + + + + + + ))} + + + + + + + + + +
{t.date}{t.list}{t.planned}{t.actual}{t.diff}
{dayLabel(l.date)} + + {l.name} + + + {t.counts( + l.summary.counts.same, + l.summary.counts.substitute, + l.summary.counts.skipped, + l.summary.counts.extra, + )} + {l.stores.length ? ` · ${l.stores.join(", ")}` : ""} + + + {formatMoney(l.summary.plannedMinor)} + + {formatMoney(l.summary.actualMinor)} + 0 + ? "text-expense" + : l.summary.diffMinor < 0 + ? "text-income" + : "", + )} + > + {signed(l.summary.diffMinor)} +
+ {t.total} + + {formatMoney(data.listTotals.plannedMinor)} + + {formatMoney(data.listTotals.actualMinor)} + {signed(data.listTotals.diffMinor)}
+
+ {reasonRows.length ? ( + + ) : null} + + ) : ( + empty(t.listsEmpty) + )} +
+ +
+
+

+ {t.storesTitle} +

+

{t.storesHint}

+
+ {data.stores.length ? ( + + ) : ( + empty(t.storesEmpty) + )} +
+
+ ); +} + +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 ( +
+
+
+

{t.total}

+

+ {formatMoney(data.totalCostMinor)} +

+

{t.costHint}

+
+
+

{t.reasonsTitle}

+
    + {WASTE_REASONS.filter((r) => data.reasons[r].count).map((r) => ( +
  • + + {reasonText[r]}{" "} + + ({t.count(data.reasons[r].count)}) + + + {formatMoney(data.reasons[r].costMinor)} +
  • + ))} +
+
+
+
+ + + + + + + + + + + + {data.rows.map((r) => ( + + + + + + + + ))} + +
{t.product}{t.wasted}{t.purchased}{t.ratio}{t.cost}
+ + {r.name} + + + {WASTE_REASONS.filter((x) => r.byReason[x]) + .map((x) => `${reasonText[x]}: ${formatStock(r.byReason[x], r.unit)}`) + .join(" · ")} + + {formatStock(r.wasted, r.unit)}{formatStock(r.purchased, r.unit)}= 15 && "font-medium text-expense", + )} + > + {formatPct(r.wastePct)} + + {r.costMinor === null ? "—" : formatMoney(r.costMinor)} +
+
+ {data.unpriced ? ( +

{t.unpriced(data.unpriced)}

+ ) : null} +
+ ); +} diff --git a/src/features/reports/components/unit-price-view.tsx b/src/features/reports/components/unit-price-view.tsx new file mode 100644 index 0000000..728af98 --- /dev/null +++ b/src/features/reports/components/unit-price-view.tsx @@ -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 ( +

+ {t.noTypes} +

+ ); + 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 ( +
+
+ + +
+ +
+ + + + + + `₺${v.toLocaleString("tr-TR", { maximumFractionDigits: 2 })}` + } + /> + { + const m = payload?.[0]?.payload?.month as string | undefined; + return m ? monthLabel(m) : ""; + }} + formatter={(value, name) => ( +
+ + {config[name as keyof typeof config]?.label} + + + {formatUnitPrice((Number(value) * 100) / base.factor, sel.unit)} + +
+ )} + /> + } + /> + } /> + {(["min", "avg"] as const).map((k) => ( + + ))} +
+
+ + + + + + + + + + + {[...sel.points].reverse().map((p) => ( + + + + + + + ))} + +
{t.month}{t.min}{t.avg}{t.count}
{monthLabel(p.month)}{fmt(p.minMinor)}{fmt(p.avgMinor)}{p.count}
+
+ + {sel.stores.length ? ( +
+

+ {t.storesTitle} +

+ + + + + + + + + + {sel.stores.map((s) => ( + + + + + + ))} + +
{t.store}{t.min}{t.last}
{s.label}{fmt(s.minMinor)}{dayLabel(s.lastDate)}
+
+ ) : null} +
+ ); +}