From c8678da7b5801ad48d103acc0437787886a1d60b Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 6 Nov 2026 23:57:50 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20al=C4=B1=C5=9Fveri=C5=9F=20sonras?= =?UTF-8?q?=C4=B1=20giri=C5=9F=20ekran=C4=B1=20ve=20kar=C5=9F=C4=B1la?= =?UTF-8?q?=C5=9Ft=C4=B1rma=20raporu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /alisveris/:id/tamamla: alınan ürün/muadil, paket, tutar, mağaza başına hesap ve fiş, alınmayanlar - tamamlanan listede planlanan vs. gerçekleşen raporu ve mağaza giderleri - liste sayfası ve alışveriş modunda "Alışverişi tamamla" Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/alisveris/[id]/tamamla/page.tsx | 19 + src/components/layout/nav-config.ts | 2 +- .../shopping/components/completion-form.tsx | 660 ++++++++++++++++++ .../shopping/components/list-detail.tsx | 68 +- .../shopping/components/report-view.tsx | 184 +++++ .../shopping/components/shopping-mode.tsx | 10 +- 6 files changed, 916 insertions(+), 27 deletions(-) create mode 100644 src/app/(app)/alisveris/[id]/tamamla/page.tsx create mode 100644 src/features/shopping/components/completion-form.tsx create mode 100644 src/features/shopping/components/report-view.tsx diff --git a/src/app/(app)/alisveris/[id]/tamamla/page.tsx b/src/app/(app)/alisveris/[id]/tamamla/page.tsx new file mode 100644 index 0000000..6627d1e --- /dev/null +++ b/src/app/(app)/alisveris/[id]/tamamla/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { CompletionForm } from "@/features/shopping/components/completion-form"; +import { getCompletionSheet } from "@/features/shopping/completion"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.shopping.complete.title }; + +/** /alisveris/:id/tamamla — alışveriş sonrası giriş: alınanlar, ödeme, fiş, alınmayanlar. */ +export default async function CompleteShoppingPage({ + params, +}: PageProps<"/alisveris/[id]/tamamla">) { + const { user, household } = await requireActiveHousehold("member"); + const { id } = await params; + const sheet = await getCompletionSheet(user.id, household.id, id); + if (!sheet) notFound(); + return ; +} diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index 1a1e2a4..d5d5d74 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 = 13.1; +export const CURRENT_PHASE = 13.2; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; diff --git a/src/features/shopping/components/completion-form.tsx b/src/features/shopping/components/completion-form.tsx new file mode 100644 index 0000000..ea5f4d2 --- /dev/null +++ b/src/features/shopping/components/completion-form.tsx @@ -0,0 +1,660 @@ +"use client"; + +import { ArrowLeft, Plus, Trash2 } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { AttachmentPicker } from "@/features/attachments/components/attachment-picker"; +import type { AttachmentInfo } from "@/features/attachments/service"; +import { StoreDot } from "@/features/prices/components/offers-list"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { formatMoney } from "@/shared/format"; +import { formatMoneyInput, parseMoney } from "@/shared/money"; +import { parseQuantity } from "@/shared/quantity"; +import type { LeftoverAction } from "@/shared/shopping"; +import { quantityInput } from "@/shared/stock"; +import { completeShoppingAction } from "../actions"; +import type { CompletionSheet } from "../completion"; +import type { ShoppingItemView } from "../service"; + +const t = tr.shopping.complete; +/** Ürün tipi kaleminde marka belirtmeden alım (Radix Select boş değer kabul etmez). */ +const GENERIC = "__generic__"; + +type Line = { + productId: string; + storeId: string; + packages: string; + amount: string; + /** Kullanıcı tutarı elle değiştirdi: fiyattan yeniden hesaplanmaz. */ + touched: boolean; +}; +type ItemLine = Line & { bought: boolean }; +type Extra = Line & { key: number }; + +/** Paket fiyatı biliniyorsa tutar = fiyat × paket. */ +function priceAmount(sheet: CompletionSheet, l: Pick) { + const price = sheet.prices[`${l.productId}:${l.storeId}`]; + const q = parseQuantity(l.packages); + return price && q ? formatMoneyInput(Math.round((price * q) / 1000)) : ""; +} + +function withPrice(sheet: CompletionSheet, l: Line, patch: Partial): Line { + const next = { ...l, ...patch }; + if ("amount" in patch) return { ...next, touched: true }; + if (next.touched) return next; + return { ...next, amount: priceAmount(sheet, next) || next.amount }; +} + +function initialLine(sheet: CompletionSheet, item: ShoppingItemView): Line { + const plan = sheet.planned[item.id]; + const typeOf = (id: string | null) => sheet.products.find((p) => p.id === id)?.productTypeId; + const typeId = item.kind === "product" ? typeOf(item.productId) : item.productTypeId; + const productId = + item.kind === "text" + ? "" + : (plan?.productId ?? + item.productId ?? + sheet.products.find((p) => p.productTypeId === typeId)?.id ?? + GENERIC); + const packages = plan + ? String(plan.packages) + : item.quantity && (item.kind === "product" || productId === GENERIC) + ? quantityInput(item.quantity) + : "1"; + const base = { + productId, + storeId: plan?.storeId ?? defaultStore(sheet), + packages, + amount: "", + touched: false, + }; + return { ...base, amount: priceAmount(sheet, base) }; +} + +/** Planda en çok kalemin gittiği mağaza; plan yoksa ilk mağaza. */ +function defaultStore(sheet: CompletionSheet) { + const count = new Map(); + for (const p of Object.values(sheet.planned)) + count.set(p.storeId, (count.get(p.storeId) ?? 0) + 1); + return [...count.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? sheet.stores[0]?.id ?? ""; +} + +/** Mağazaya uygun varsayılan hesap: geçerli yemek kartı, yoksa banka/nakit, yoksa ilk hesap. */ +function defaultAccount(sheet: CompletionSheet, storeId: string) { + const store = sheet.stores.find((s) => s.id === storeId); + const meal = sheet.accounts.find( + (a) => + a.type === "meal_card" && a.mealCardProvider && store?.providers.includes(a.mealCardProvider), + ); + return ( + meal?.id ?? + sheet.accounts.find((a) => a.type === "bank" || a.type === "cash")?.id ?? + sheet.accounts[0]?.id ?? + "" + ); +} + +function LineFields({ + sheet, + line, + products, + text, + onChange, + errors, + idPrefix, + genericUnit, +}: { + sheet: CompletionSheet; + line: Line; + products: CompletionSheet["products"]; + text: boolean; + onChange: (patch: Partial) => void; + errors: Record; + idPrefix: string; + /** Ürün tipi kalemi: markasız seçeneği ve tipin birimi. */ + genericUnit?: string; +}) { + const generic = line.productId === GENERIC; + return ( +
+ {text ? ( +

+ {tr.shopping.complete.textItem} +

+ ) : ( + + + {t.product} + + + {errors.productId ? {errors.productId} : null} + + )} + + + {t.store} + + + + {text ? ( + + ) : ( + + + {generic && genericUnit ? `${t.quantity} (${genericUnit})` : t.packages} + + onChange({ packages: e.target.value })} + className="text-right tabular-nums" + aria-invalid={Boolean(errors.packages)} + /> + + )} + + + {t.amount} + + onChange({ amount: e.target.value })} + className="text-right tabular-nums" + aria-invalid={Boolean(errors.amount)} + /> + {errors.amount ? {errors.amount} : null} + +
+ ); +} + +/** /alisveris/:id/tamamla — alınanları gir, mağaza başına hesap ve fiş, alınmayanlar. */ +export function CompletionForm({ sheet }: { sheet: CompletionSheet }) { + const router = useRouter(); + const anyChecked = sheet.items.some((i) => i.checked); + const [date, setDate] = useState(sheet.today); + const [rows, setRows] = useState>(() => + Object.fromEntries( + sheet.items.map((i) => [ + i.id, + { ...initialLine(sheet, i), bought: anyChecked ? i.checked : !i.archived }, + ]), + ), + ); + const [extras, setExtras] = useState([]); + const [accounts, setAccounts] = useState>({}); + const [receipts, setReceipts] = useState>({}); + const [uploading, setUploading] = useState(0); + const [fallback, setFallback] = useState(sheet.defaultFallbackCategoryId ?? ""); + const [leftover, setLeftover] = useState<{ + action: LeftoverAction; + target: string; + name: string; + }>({ + action: sheet.otherLists.length ? "move" : "new", + target: sheet.otherLists[0]?.id ?? "", + name: t.newListDefault(sheet.list.name), + }); + const [errors, setErrors] = useState>({}); + const [busy, setBusy] = useState(false); + + const typeProducts = (item: ShoppingItemView) => { + const typeId = + item.kind === "product" + ? sheet.products.find((p) => p.id === item.productId)?.productTypeId + : item.productTypeId; + return sheet.products.filter((p) => p.productTypeId === typeId); + }; + + const bought = sheet.items.filter((i) => rows[i.id]!.bought); + const unbought = sheet.items.filter((i) => !rows[i.id]!.bought); + const allLines: (Line & { itemId: string; text: boolean })[] = [ + ...bought.map((i) => ({ ...rows[i.id]!, itemId: i.id, text: i.kind === "text" })), + ...extras.map((e) => ({ ...e, itemId: "", text: false })), + ]; + const storeIds = [...new Set(allLines.map((l) => l.storeId).filter(Boolean))]; + const unmapped = new Set(sheet.unmappedTypeIds); + const needsFallback = allLines.some( + (l) => + l.text || unmapped.has(sheet.products.find((p) => p.id === l.productId)?.productTypeId ?? ""), + ); + const total = allLines.reduce((a, l) => a + (parseMoney(l.amount) ?? 0), 0); + const plannedTotal = useMemo( + () => Object.values(sheet.planned).reduce((a, p) => a + p.priceMinor * p.packages, 0), + [sheet.planned], + ); + + function setRow(id: string, patch: Partial) { + setRows((s) => { + const cur = s[id]!; + const { bought: b, ...rest } = patch; + const next = Object.keys(rest).length ? { ...cur, ...withPrice(sheet, cur, rest) } : cur; + return { ...s, [id]: { ...next, bought: b ?? cur.bought } }; + }); + } + + async function submit() { + setBusy(true); + const lineKeys = allLines.map((l, i) => + l.itemId ? `item:${l.itemId}` : `extra:${extras[i - bought.length]?.key}`, + ); + const r = await completeShoppingAction(sheet.list.id, { + date, + lines: allLines.map((l) => ({ + itemId: l.itemId, + productId: l.text || l.productId === GENERIC ? "" : l.productId, + storeId: l.storeId, + packages: l.text ? "1" : l.packages, + amount: l.amount, + })), + stores: storeIds.map((id) => ({ + storeId: id, + accountId: accounts[id] ?? defaultAccount(sheet, id), + attachmentIds: (receipts[id] ?? []).map((a) => a.id), + })), + fallbackCategoryId: needsFallback ? fallback : "", + leftover: unbought.length + ? { action: leftover.action, targetListId: leftover.target, newListName: leftover.name } + : { action: "drop" }, + }); + setBusy(false); + if (r.ok) { + toast.success(t.saved); + router.push(`/alisveris/${sheet.list.id}`); + return; + } + // Satır hatalarını satır anahtarına çevir. + const mapped: Record = {}; + for (const [k, msg] of Object.entries(r.fieldErrors ?? {})) { + const m = /^lines\.(\d+)\.(\w+)$/.exec(k); + if (m) mapped[`${lineKeys[Number(m[1])]}.${m[2]}`] = msg; + else { + const s = /^stores\.(\d+)\.(\w+)$/.exec(k); + mapped[s ? `store:${storeIds[Number(s[1])]}` : k] = msg; + } + } + setErrors({ ...mapped, root: r.error }); + toast.error(r.error); + } + + const errs = (key: string) => ({ + productId: errors[`${key}.productId`], + storeId: errors[`${key}.storeId`], + packages: errors[`${key}.packages`], + amount: errors[`${key}.amount`], + }); + + return ( +
+ +
+

+ {t.title}: {sheet.list.name} +

+

{t.description}

+
+ {sheet.stores.length === 0 ? ( +

{t.noStores}

+ ) : null} + + {t.date} + setDate(e.target.value)} + /> + {errors.date ? {errors.date} : null} + + +
+

+ {t.itemsTitle} +

+
    + {sheet.items.map((item) => { + const row = rows[item.id]!; + const plan = sheet.planned[item.id]; + const planProduct = plan ? sheet.products.find((p) => p.id === plan.productId) : null; + const planStore = plan ? sheet.stores.find((s) => s.id === plan.storeId) : null; + const substitute = + row.bought && + item.kind !== "text" && + (item.kind === "product" + ? row.productId !== item.productId + : plan && row.productId !== plan.productId); + const key = `item:${item.id}`; + return ( +
  • +
    + setRow(item.id, { bought: x === true })} + className="mt-0.5 size-5" + /> + + {substitute ? {t.substitute} : null} +
    + {row.bought ? ( + setRow(item.id, patch)} + errors={errs(key)} + idPrefix={`l-${item.id}`} + /> + ) : null} +
  • + ); + })} +
+
+ +
+

+ {t.extrasTitle} +

+ {extras.length ? ( +
    + {extras.map((e) => ( +
  • +
    + + setExtras((s) => + s.map((x) => + x.key === e.key ? { ...x, ...withPrice(sheet, x, patch) } : x, + ), + ) + } + errors={errs(`extra:${e.key}`)} + idPrefix={`e-${e.key}`} + /> +
    + +
  • + ))} +
+ ) : null} + +
+ + {storeIds.length ? ( +
+
+

+ {t.storesTitle} +

+

{t.storesDescription}

+
+
    + {storeIds.map((id) => { + const store = sheet.stores.find((s) => s.id === id); + const lines = allLines.filter((l) => l.storeId === id); + const sum = lines.reduce((a, l) => a + (parseMoney(l.amount) ?? 0), 0); + return ( +
  • +
    + + + {store?.label} + + + {t.subtotal(lines.length)} · {formatMoney(sum)} + +
    + + {t.account} + + {errors[`store:${id}`] ? ( + {errors[`store:${id}`]} + ) : null} + + + {t.receipts} + setReceipts((s) => ({ ...s, [id]: items }))} + allowedKinds={["image"]} + onBusyChange={(b) => setUploading((n) => n + (b ? 1 : -1))} + addLabel={t.addReceipt} + hint={t.receiptHint} + /> + +
  • + ); + })} +
+
+ ) : null} + + {needsFallback ? ( + + {t.fallbackCategory} + + {t.fallbackHint} + {errors.fallbackCategoryId ? {errors.fallbackCategoryId} : null} + + ) : null} + + {unbought.length ? ( +
+
+

+ {t.leftoverTitle} +

+

+ {t.leftoverDescription(unbought.length)} {unbought.map((i) => i.label).join(", ")} +

+
+
+ {(["move", "new", "drop"] as const) + .filter((a) => a !== "move" || sheet.otherLists.length) + .map((a) => ( + + ))} +
+ {leftover.action === "move" ? ( + + ) : leftover.action === "new" ? ( + setLeftover((s) => ({ ...s, name: e.target.value }))} + className="max-w-md" + /> + ) : null} + {errors["leftover.targetListId"] || errors["leftover.newListName"] ? ( + + {errors["leftover.targetListId"] ?? errors["leftover.newListName"]} + + ) : null} +
+ ) : null} + +
+
+
+ + {t.total}: {formatMoney(total)} + + {plannedTotal ? ( + + {" · "} + {t.plannedTotal}: {formatMoney(plannedTotal)} + + ) : null} + {errors.root ? ( + {errors.root} + ) : null} +
+ +
+
+
+ ); +} diff --git a/src/features/shopping/components/list-detail.tsx b/src/features/shopping/components/list-detail.tsx index f3c8bdb..f6bbcb7 100644 --- a/src/features/shopping/components/list-detail.tsx +++ b/src/features/shopping/components/list-detail.tsx @@ -4,6 +4,7 @@ import { ArrowLeft, ArrowRightLeft, Check, + ClipboardCheck, EllipsisVertical, Package, Pencil, @@ -62,6 +63,7 @@ import type { ShoppingItemView, ShoppingListDetail, SuggestionView } from "../se import { ShoppingItemDialog } from "./item-dialog"; import { ShoppingListDialog } from "./list-dialog"; import { PlanPanel } from "./plan-panel"; +import { ShoppingReport } from "./report-view"; const t = tr.shopping; @@ -411,34 +413,50 @@ export function ShoppingListDetailView({ data }: { data: ShoppingListDetail }) { ) : null} + {items.length && canWrite ? ( + + ) : null} -
-
- {data.suggestions.length ? ( - - ) : null} - {items.length ? ( -
    - {items.map((i) => ( - setEditing(i)} - onMove={() => setMoving(i)} - onRemove={() => remove(i)} - /> - ))} -
- ) : ( -

- {t.emptyList} -

- )} + {data.report ? ( + + ) : ( +
+
+ {data.suggestions.length ? ( + + ) : null} + {items.length ? ( +
    + {items.map((i) => ( + setEditing(i)} + onMove={() => setMoving(i)} + onRemove={() => remove(i)} + /> + ))} +
+ ) : ( +

+ {t.emptyList} +

+ )} +
+
- -
+ )} {adding || editing ? ( = { + same: "border-income/40 text-income", + substitute: "border-amber-500/50 text-amber-700 dark:text-amber-400", + skipped: "text-muted-foreground", + extra: "border-budget/40 text-budget", +}; + +/** İşaretli tutar: "+₺12,00" / "−₺5,00". Renk tek başına anlam taşımaz. */ +function Signed({ value, className }: { value: number; className?: string }) { + return ( + 0 ? "text-expense" : value < 0 ? "text-income" : "text-muted-foreground", + className, + )} + > + {value > 0 ? "+" : value < 0 ? "−" : ""} + {formatMoney(Math.abs(value))} + + ); +} + +function Cell({ label, sub, amount }: { label: string; sub: string; amount: number | null }) { + return ( + + {label || "—"} + {sub ? {sub} : null} + {amount !== null ? {formatMoney(amount)} : null} + + ); +} + +const pkgs = (packages: number) => `${formatQuantity(packages)} ${tr.shopping.packageUnit}`; + +function Row({ row }: { row: CompletionRow }) { + return ( +
  • + {row.label} + + + {t.matches[row.match]} + + + + {t.plan}: + {row.plan ? ( + + ) : ( + {t.noPlanCell} + )} + + + {t.bought}: + {row.amountMinor !== null ? ( + + ) : ( + {t.noPlanCell} + )} + +
  • + ); +} + +/** Tamamlanan liste: planlanan vs. gerçek, farkın nedenleri, kalem bazında eşleşme, giderler. */ +export function ShoppingReport({ + completion, + summary, +}: { + completion: ShoppingCompletion; + summary: ComparisonSummary; +}) { + const reasons = (Object.entries(summary.reasons) as [keyof ComparisonReasons, number][]).filter( + ([, v]) => v !== 0, + ); + return ( +
    +
    +

    + {t.title} +

    +

    + {t.description(formatDate(`${completion.date}T12:00:00+03:00`))} +

    +
    +
    + {[ + { label: t.planned, node: formatMoney(summary.plannedMinor) }, + { label: t.actual, node: formatMoney(summary.actualMinor) }, + { label: t.diff, node: }, + ].map((x) => ( +
    +
    {x.label}
    +
    {x.node}
    +
    + ))} +
    + {reasons.length ? ( +
    +

    {t.reasonsTitle}

    +
      + {reasons.map(([k, v]) => ( +
    • + {t.reasons[k]} + +
    • + ))} +
    +
    + ) : null} + +
    +
    + {t.item} + + {t.plan} + {t.bought} +
    +
      + {completion.rows.map((r, i) => ( + + ))} +
    +
    + +
    +

    {t.expenses}

    +
      + {completion.stores.map((s) => ( +
    • + + + {s.label} + + {t.expenseLine(s.accountName, s.attachmentCount)} + + + + {formatMoney(s.amountMinor)} + +
    • + ))} +
    +
    + {completion.movedTo ? ( + + {t.movedTo(completion.movedTo.name)} + + + ) : null} +
    + ); +} diff --git a/src/features/shopping/components/shopping-mode.tsx b/src/features/shopping/components/shopping-mode.tsx index 7ec8827..a3da0d9 100644 --- a/src/features/shopping/components/shopping-mode.tsx +++ b/src/features/shopping/components/shopping-mode.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowLeft, Check, Globe } from "lucide-react"; +import { ArrowLeft, Check, ClipboardCheck, Globe } from "lucide-react"; import Link from "next/link"; import { startTransition, useOptimistic } from "react"; import { toast } from "sonner"; @@ -244,6 +244,14 @@ export function ShoppingModeView({ ); })} + {canWrite && done > 0 ? ( + + ) : null}
    ); }