feat(ui): alışveriş sonrası giriş ekranı ve karşılaştırma raporu

- /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 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 23:57:50 +03:00
parent bd5a20ce64
commit c8678da7b5
6 changed files with 916 additions and 27 deletions
@@ -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 <CompletionForm sheet={sheet} />;
}
+1 -1
View File
@@ -28,7 +28,7 @@ export type NavItem = {
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */ /** 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; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
@@ -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<Line, "productId" | "storeId" | "packages">) {
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>): 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<string, number>();
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<Line>) => void;
errors: Record<string, string | undefined>;
idPrefix: string;
/** Ürün tipi kalemi: markasız seçeneği ve tipin birimi. */
genericUnit?: string;
}) {
const generic = line.productId === GENERIC;
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)_5rem_7rem]">
{text ? (
<p className="col-span-2 self-center text-xs text-muted-foreground sm:col-span-1">
{tr.shopping.complete.textItem}
</p>
) : (
<Field data-invalid={Boolean(errors.productId)} className="col-span-2 sm:col-span-1">
<FieldLabel htmlFor={`${idPrefix}-p`} className="text-xs">
{t.product}
</FieldLabel>
<Select value={line.productId} onValueChange={(x) => onChange({ productId: x })}>
<SelectTrigger id={`${idPrefix}-p`} className="w-full">
<SelectValue placeholder={tr.shopping.validation.product} />
</SelectTrigger>
<SelectContent className="max-h-72">
{genericUnit ? (
<SelectItem value={GENERIC}>{t.generic(genericUnit)}</SelectItem>
) : null}
{products.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.label}{" "}
<span className="text-xs text-muted-foreground">({p.packageLabel})</span>
</SelectItem>
))}
</SelectContent>
</Select>
{errors.productId ? <FieldError>{errors.productId}</FieldError> : null}
</Field>
)}
<Field data-invalid={Boolean(errors.storeId)} className="col-span-2 sm:col-span-1">
<FieldLabel htmlFor={`${idPrefix}-s`} className="text-xs">
{t.store}
</FieldLabel>
<Select value={line.storeId} onValueChange={(x) => onChange({ storeId: x })}>
<SelectTrigger id={`${idPrefix}-s`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-72">
{sheet.stores.map((s) => (
<SelectItem key={s.id} value={s.id}>
<StoreDot slot={s.slot} />
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{text ? (
<span className="hidden sm:block" />
) : (
<Field data-invalid={Boolean(errors.packages)}>
<FieldLabel htmlFor={`${idPrefix}-q`} className="text-xs">
{generic && genericUnit ? `${t.quantity} (${genericUnit})` : t.packages}
</FieldLabel>
<Input
id={`${idPrefix}-q`}
inputMode="decimal"
value={line.packages}
onChange={(e) => onChange({ packages: e.target.value })}
className="text-right tabular-nums"
aria-invalid={Boolean(errors.packages)}
/>
</Field>
)}
<Field
data-invalid={Boolean(errors.amount)}
className={text ? "col-span-2 sm:col-span-1" : ""}
>
<FieldLabel htmlFor={`${idPrefix}-a`} className="text-xs">
{t.amount}
</FieldLabel>
<Input
id={`${idPrefix}-a`}
inputMode="decimal"
value={line.amount}
onChange={(e) => onChange({ amount: e.target.value })}
className="text-right tabular-nums"
aria-invalid={Boolean(errors.amount)}
/>
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
</Field>
</div>
);
}
/** /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<Record<string, ItemLine>>(() =>
Object.fromEntries(
sheet.items.map((i) => [
i.id,
{ ...initialLine(sheet, i), bought: anyChecked ? i.checked : !i.archived },
]),
),
);
const [extras, setExtras] = useState<Extra[]>([]);
const [accounts, setAccounts] = useState<Record<string, string>>({});
const [receipts, setReceipts] = useState<Record<string, AttachmentInfo[]>>({});
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<Record<string, string | undefined>>({});
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<ItemLine>) {
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<string, string | undefined> = {};
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 (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
<Link href={`/alisveris/${sheet.list.id}`}>
<ArrowLeft />
{tr.shopping.actions.backToList}
</Link>
</Button>
<div>
<h1 className="text-xl font-semibold">
{t.title}: {sheet.list.name}
</h1>
<p className="text-sm text-muted-foreground">{t.description}</p>
</div>
{sheet.stores.length === 0 ? (
<p className="rounded-lg border border-dashed p-6 text-center text-sm">{t.noStores}</p>
) : null}
<Field className="max-w-48" data-invalid={Boolean(errors.date)}>
<FieldLabel htmlFor="sc-date">{t.date}</FieldLabel>
<Input
id="sc-date"
type="date"
value={date}
max={sheet.today}
onChange={(e) => setDate(e.target.value)}
/>
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
</Field>
<section aria-labelledby="sc-items" className="flex flex-col gap-2">
<h2 id="sc-items" className="font-medium">
{t.itemsTitle}
</h2>
<ul className="divide-y rounded-xl border bg-card">
{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 (
<li
key={item.id}
className={cn("flex flex-col gap-3 p-3", !row.bought && "bg-muted/30")}
>
<div className="flex items-start gap-3">
<Checkbox
id={`b-${item.id}`}
checked={row.bought}
onCheckedChange={(x) => setRow(item.id, { bought: x === true })}
className="mt-0.5 size-5"
/>
<label htmlFor={`b-${item.id}`} className="min-w-0 flex-1 cursor-pointer">
<span
className={cn("block font-medium", !row.bought && "text-muted-foreground")}
>
{item.label}{" "}
{item.quantityLabel ? (
<span className="text-sm font-normal text-muted-foreground tabular-nums">
· {item.quantityLabel}
</span>
) : null}
</span>
<span className="block text-xs text-muted-foreground">
{plan && planProduct && planStore
? t.planned(
`${planProduct.label} · ${planStore.label} · ${formatMoney(plan.priceMinor * plan.packages)}`,
)
: item.kind === "text"
? null
: t.noPlan}
</span>
</label>
{substitute ? <Badge variant="outline">{t.substitute}</Badge> : null}
</div>
{row.bought ? (
<LineFields
sheet={sheet}
line={row}
products={typeProducts(item)}
text={item.kind === "text"}
genericUnit={item.kind === "type" ? item.unit : undefined}
onChange={(patch) => setRow(item.id, patch)}
errors={errs(key)}
idPrefix={`l-${item.id}`}
/>
) : null}
</li>
);
})}
</ul>
</section>
<section aria-labelledby="sc-extras" className="flex flex-col gap-2">
<h2 id="sc-extras" className="font-medium">
{t.extrasTitle}
</h2>
{extras.length ? (
<ul className="divide-y rounded-xl border bg-card">
{extras.map((e) => (
<li key={e.key} className="flex items-start gap-2 p-3">
<div className="min-w-0 flex-1">
<LineFields
sheet={sheet}
line={e}
products={sheet.products}
text={false}
onChange={(patch) =>
setExtras((s) =>
s.map((x) =>
x.key === e.key ? { ...x, ...withPrice(sheet, x, patch) } : x,
),
)
}
errors={errs(`extra:${e.key}`)}
idPrefix={`e-${e.key}`}
/>
</div>
<Button
size="icon-sm"
variant="ghost"
className="mt-6"
aria-label={t.removeExtra}
onClick={() => setExtras((s) => s.filter((x) => x.key !== e.key))}
>
<Trash2 />
</Button>
</li>
))}
</ul>
) : null}
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={() =>
setExtras((s) => [
...s,
{
key: Date.now(),
productId: "",
storeId: storeIds[0] ?? defaultStore(sheet),
packages: "1",
amount: "",
touched: false,
},
])
}
>
<Plus />
{t.addExtra}
</Button>
</section>
{storeIds.length ? (
<section aria-labelledby="sc-stores" className="flex flex-col gap-2">
<div>
<h2 id="sc-stores" className="font-medium">
{t.storesTitle}
</h2>
<p className="text-sm text-muted-foreground">{t.storesDescription}</p>
</div>
<ul className="grid gap-3 md:grid-cols-2">
{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 (
<li key={id} className="flex flex-col gap-3 rounded-xl border bg-card p-3">
<div className="flex items-center gap-2">
<span className="flex min-w-0 items-center font-medium">
<StoreDot slot={store?.slot ?? 1} />
<span className="truncate">{store?.label}</span>
</span>
<span className="ml-auto text-sm tabular-nums">
{t.subtotal(lines.length)} · {formatMoney(sum)}
</span>
</div>
<Field data-invalid={Boolean(errors[`store:${id}`])}>
<FieldLabel htmlFor={`acc-${id}`}>{t.account}</FieldLabel>
<Select
value={accounts[id] ?? defaultAccount(sheet, id)}
onValueChange={(x) => setAccounts((s) => ({ ...s, [id]: x }))}
>
<SelectTrigger id={`acc-${id}`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sheet.accounts.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
{errors[`store:${id}`] ? (
<FieldError>{errors[`store:${id}`]}</FieldError>
) : null}
</Field>
<Field>
<FieldLabel>{t.receipts}</FieldLabel>
<AttachmentPicker
value={receipts[id] ?? []}
onChange={(items) => setReceipts((s) => ({ ...s, [id]: items }))}
allowedKinds={["image"]}
onBusyChange={(b) => setUploading((n) => n + (b ? 1 : -1))}
addLabel={t.addReceipt}
hint={t.receiptHint}
/>
</Field>
</li>
);
})}
</ul>
</section>
) : null}
{needsFallback ? (
<Field data-invalid={Boolean(errors.fallbackCategoryId)} className="max-w-md">
<FieldLabel htmlFor="sc-fallback">{t.fallbackCategory}</FieldLabel>
<Select value={fallback} onValueChange={setFallback}>
<SelectTrigger id="sc-fallback" className="w-full">
<SelectValue placeholder={t.fallbackCategory} />
</SelectTrigger>
<SelectContent className="max-h-72">
{sheet.expenseCategories.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.path}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldDescription>{t.fallbackHint}</FieldDescription>
{errors.fallbackCategoryId ? <FieldError>{errors.fallbackCategoryId}</FieldError> : null}
</Field>
) : null}
{unbought.length ? (
<section aria-labelledby="sc-left" className="flex flex-col gap-2">
<div>
<h2 id="sc-left" className="font-medium">
{t.leftoverTitle}
</h2>
<p className="text-sm text-muted-foreground">
{t.leftoverDescription(unbought.length)} {unbought.map((i) => i.label).join(", ")}
</p>
</div>
<div role="radiogroup" aria-labelledby="sc-left" className="flex flex-col gap-2">
{(["move", "new", "drop"] as const)
.filter((a) => a !== "move" || sheet.otherLists.length)
.map((a) => (
<label key={a} className="flex items-center gap-2 text-sm">
<input
type="radio"
name="leftover"
value={a}
checked={leftover.action === a}
onChange={() => setLeftover((s) => ({ ...s, action: a }))}
className="size-4 accent-primary"
/>
{t.leftoverActions[a]}
</label>
))}
</div>
{leftover.action === "move" ? (
<Select
value={leftover.target}
onValueChange={(x) => setLeftover((s) => ({ ...s, target: x }))}
>
<SelectTrigger className="w-full max-w-md" aria-label={tr.shopping.moveTarget}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{sheet.otherLists.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : leftover.action === "new" ? (
<Input
aria-label={t.newListName}
value={leftover.name}
onChange={(e) => setLeftover((s) => ({ ...s, name: e.target.value }))}
className="max-w-md"
/>
) : null}
{errors["leftover.targetListId"] || errors["leftover.newListName"] ? (
<FieldError>
{errors["leftover.targetListId"] ?? errors["leftover.newListName"]}
</FieldError>
) : null}
</section>
) : null}
<div className="sticky bottom-[calc(4rem+env(safe-area-inset-bottom))] z-10 -mx-4 border-t bg-background/95 backdrop-blur md:bottom-0 md:mx-0 md:rounded-xl md:border">
<div className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1 text-sm">
<span className="font-medium tabular-nums">
{t.total}: {formatMoney(total)}
</span>
{plannedTotal ? (
<span className="text-muted-foreground tabular-nums">
{" · "}
{t.plannedTotal}: {formatMoney(plannedTotal)}
</span>
) : null}
{errors.root ? (
<span className="block text-xs text-destructive">{errors.root}</span>
) : null}
</div>
<Button onClick={submit} disabled={busy || uploading > 0 || allLines.length === 0}>
{t.submit}
</Button>
</div>
</div>
</div>
);
}
@@ -4,6 +4,7 @@ import {
ArrowLeft, ArrowLeft,
ArrowRightLeft, ArrowRightLeft,
Check, Check,
ClipboardCheck,
EllipsisVertical, EllipsisVertical,
Package, Package,
Pencil, Pencil,
@@ -62,6 +63,7 @@ import type { ShoppingItemView, ShoppingListDetail, SuggestionView } from "../se
import { ShoppingItemDialog } from "./item-dialog"; import { ShoppingItemDialog } from "./item-dialog";
import { ShoppingListDialog } from "./list-dialog"; import { ShoppingListDialog } from "./list-dialog";
import { PlanPanel } from "./plan-panel"; import { PlanPanel } from "./plan-panel";
import { ShoppingReport } from "./report-view";
const t = tr.shopping; const t = tr.shopping;
@@ -411,34 +413,50 @@ export function ShoppingListDetailView({ data }: { data: ShoppingListDetail }) {
</Link> </Link>
</Button> </Button>
) : null} ) : null}
{items.length && canWrite ? (
<Button size="sm" variant="outline" asChild>
<Link href={`/alisveris/${list.id}/tamamla`}>
<ClipboardCheck />
{tr.shopping.complete.action}
</Link>
</Button>
) : null}
</div> </div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,26rem)] xl:items-start"> {data.report ? (
<div className="flex flex-col gap-4"> <ShoppingReport completion={data.report.completion} summary={data.report.summary} />
{data.suggestions.length ? ( ) : (
<SuggestionsPanel listId={list.id} suggestions={data.suggestions} canWrite={canWrite} /> <div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,26rem)] xl:items-start">
) : null} <div className="flex flex-col gap-4">
{items.length ? ( {data.suggestions.length ? (
<ul className="divide-y rounded-xl border bg-card"> <SuggestionsPanel
{items.map((i) => ( listId={list.id}
<ItemRow suggestions={data.suggestions}
key={i.id} canWrite={canWrite}
item={i} />
canWrite={canWrite} ) : null}
onEdit={() => setEditing(i)} {items.length ? (
onMove={() => setMoving(i)} <ul className="divide-y rounded-xl border bg-card">
onRemove={() => remove(i)} {items.map((i) => (
/> <ItemRow
))} key={i.id}
</ul> item={i}
) : ( canWrite={canWrite}
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground"> onEdit={() => setEditing(i)}
{t.emptyList} onMove={() => setMoving(i)}
</p> onRemove={() => remove(i)}
)} />
))}
</ul>
) : (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.emptyList}
</p>
)}
</div>
<PlanPanel plan={data.plan} items={items} />
</div> </div>
<PlanPanel plan={data.plan} items={items} /> )}
</div>
{adding || editing ? ( {adding || editing ? (
<ShoppingItemDialog <ShoppingItemDialog
@@ -0,0 +1,184 @@
import { ArrowRight, Receipt } from "lucide-react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatDate, formatMoney } from "@/shared/format";
import { formatQuantity } from "@/shared/quantity";
import type { CompletionRow, ShoppingCompletion } from "@/shared/shopping";
import type { ComparisonReasons, ComparisonSummary, MatchKind } from "@/shared/shopping-compare";
const t = tr.shopping.report;
const MATCH_STYLE: Record<MatchKind, string> = {
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 (
<span
className={cn(
"tabular-nums",
value > 0 ? "text-expense" : value < 0 ? "text-income" : "text-muted-foreground",
className,
)}
>
{value > 0 ? "+" : value < 0 ? "−" : ""}
{formatMoney(Math.abs(value))}
</span>
);
}
function Cell({ label, sub, amount }: { label: string; sub: string; amount: number | null }) {
return (
<span className="flex min-w-0 flex-col">
<span className="truncate">{label || "—"}</span>
{sub ? <span className="truncate text-xs text-muted-foreground">{sub}</span> : null}
{amount !== null ? <span className="text-xs tabular-nums">{formatMoney(amount)}</span> : null}
</span>
);
}
const pkgs = (packages: number) => `${formatQuantity(packages)} ${tr.shopping.packageUnit}`;
function Row({ row }: { row: CompletionRow }) {
return (
<li className="grid grid-cols-2 gap-x-3 gap-y-2 p-3 text-sm md:grid-cols-[minmax(0,1.2fr)_7rem_minmax(0,1.5fr)_minmax(0,1.5fr)] md:items-start">
<span className="font-medium">{row.label}</span>
<span className="justify-self-end md:justify-self-start">
<Badge variant="outline" className={MATCH_STYLE[row.match]}>
{t.matches[row.match]}
</Badge>
</span>
<span className="flex gap-1.5 md:block">
<span className="text-xs text-muted-foreground md:hidden">{t.plan}:</span>
{row.plan ? (
<Cell
label={row.plan.productLabel}
sub={`${row.plan.storeLabel} · ${pkgs(row.plan.packages)}`}
amount={row.plan.costMinor}
/>
) : (
<span className="text-muted-foreground">{t.noPlanCell}</span>
)}
</span>
<span className="flex gap-1.5 md:block">
<span className="text-xs text-muted-foreground md:hidden">{t.bought}:</span>
{row.amountMinor !== null ? (
<Cell
label={row.productLabel || row.label}
sub={[row.storeLabel, row.quantityLabel ?? (row.productId ? pkgs(row.packages) : "")]
.filter(Boolean)
.join(" · ")}
amount={row.amountMinor}
/>
) : (
<span className="text-muted-foreground">{t.noPlanCell}</span>
)}
</span>
</li>
);
}
/** 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 (
<section aria-labelledby="report-h" className="flex flex-col gap-4">
<div>
<h2 id="report-h" className="font-medium">
{t.title}
</h2>
<p className="text-sm text-muted-foreground">
{t.description(formatDate(`${completion.date}T12:00:00+03:00`))}
</p>
</div>
<dl className="grid grid-cols-3 gap-3">
{[
{ label: t.planned, node: formatMoney(summary.plannedMinor) },
{ label: t.actual, node: formatMoney(summary.actualMinor) },
{ label: t.diff, node: <Signed value={summary.diffMinor} /> },
].map((x) => (
<div key={x.label} className="rounded-xl border bg-card p-3">
<dt className="text-xs text-muted-foreground">{x.label}</dt>
<dd className="text-lg font-semibold tabular-nums">{x.node}</dd>
</div>
))}
</dl>
{reasons.length ? (
<div className="rounded-xl border bg-card p-3">
<h3 className="mb-2 text-sm font-medium">{t.reasonsTitle}</h3>
<ul className="flex flex-col gap-1 text-sm">
{reasons.map(([k, v]) => (
<li key={k} className="flex justify-between gap-3">
<span>{t.reasons[k]}</span>
<Signed value={v} />
</li>
))}
</ul>
</div>
) : null}
<div className="overflow-hidden rounded-xl border bg-card">
<div className="hidden grid-cols-[minmax(0,1.2fr)_7rem_minmax(0,1.5fr)_minmax(0,1.5fr)] gap-3 border-b px-3 py-2 text-xs text-muted-foreground md:grid">
<span>{t.item}</span>
<span />
<span>{t.plan}</span>
<span>{t.bought}</span>
</div>
<ul className="divide-y">
{completion.rows.map((r, i) => (
<Row key={r.itemId ?? `extra-${i}`} row={r} />
))}
</ul>
</div>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium">{t.expenses}</h3>
<ul className="grid gap-2 md:grid-cols-2">
{completion.stores.map((s) => (
<li
key={s.transactionId}
className="flex items-center gap-3 rounded-lg border bg-card p-3 text-sm"
>
<Receipt className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{s.label}</span>
<span className="block truncate text-xs text-muted-foreground">
{t.expenseLine(s.accountName, s.attachmentCount)}
</span>
</span>
<Link
href={`/islemler?hesap=${s.accountId}`}
className="shrink-0 font-medium tabular-nums hover:underline"
>
{formatMoney(s.amountMinor)}
</Link>
</li>
))}
</ul>
</div>
{completion.movedTo ? (
<Link
href={`/alisveris/${completion.movedTo.listId}`}
className="flex w-fit items-center gap-1 text-sm text-primary hover:underline"
>
{t.movedTo(completion.movedTo.name)}
<ArrowRight className="size-3.5" aria-hidden />
</Link>
) : null}
</section>
);
}
@@ -1,6 +1,6 @@
"use client"; "use client";
import { ArrowLeft, Check, Globe } from "lucide-react"; import { ArrowLeft, Check, ClipboardCheck, Globe } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { startTransition, useOptimistic } from "react"; import { startTransition, useOptimistic } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -244,6 +244,14 @@ export function ShoppingModeView({
</section> </section>
); );
})} })}
{canWrite && done > 0 ? (
<Button asChild size="lg" className="w-full">
<Link href={`/alisveris/${list.id}/tamamla`}>
<ClipboardCheck />
{t.complete.action}
</Link>
</Button>
) : null}
</div> </div>
); );
} }