Files
butcem/src/features/shopping/components/shopping-mode.tsx
T
suphicakir c8678da7b5 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>
2026-11-06 23:57:50 +03:00

258 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { ArrowLeft, Check, ClipboardCheck, Globe } from "lucide-react";
import Link from "next/link";
import { startTransition, useOptimistic } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { StoreDot } from "@/features/prices/components/offers-list";
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatMoney } from "@/shared/format";
import { formatQuantity } from "@/shared/quantity";
import { SHOPPING_REFRESH_MS } from "@/shared/shopping";
import type { PlanLine } from "@/shared/shopping-plan";
import { setShoppingItemCheckedAction } from "../actions";
import type { ShoppingItemView, ShoppingListDetail } from "../service";
import { planHeadline, type PlanChoice } from "./plan-panel";
const t = tr.shopping;
const m = t.mode;
type Group = {
key: string;
title: string;
slot: number | null;
online: boolean;
hint?: string;
rows: { item: ShoppingItemView; line: PlanLine | null }[];
};
function Row({
item,
line,
product,
canWrite,
onToggle,
}: {
item: ShoppingItemView;
line: PlanLine | null;
product: { label: string; packageLabel: string } | undefined;
canWrite: boolean;
onToggle: () => void;
}) {
const sub = [
line
? line.packages > 1
? t.plan.line(formatQuantity(line.packages * 1000), formatMoney(line.priceMinor))
: formatMoney(line.priceMinor)
: null,
line && product && item.kind === "type" ? `${product.label} · ${product.packageLabel}` : null,
item.note || null,
].filter(Boolean);
return (
<li>
<button
type="button"
role="checkbox"
aria-checked={item.checked}
aria-label={m.check(item.label)}
disabled={!canWrite}
onClick={onToggle}
className={cn(
"flex min-h-16 w-full items-center gap-4 px-4 py-3 text-left transition-colors hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:outline-none disabled:cursor-default",
item.checked && "bg-muted/40",
)}
>
<span
aria-hidden
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md border-2",
item.checked ? "border-income bg-income text-white" : "border-muted-foreground/40",
)}
>
{item.checked ? <Check className="size-4.5" strokeWidth={3} /> : null}
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block text-base font-medium",
item.checked && "text-muted-foreground line-through",
)}
>
{item.label}
</span>
{sub.length ? (
<span className="line-clamp-2 text-sm text-muted-foreground">{sub.join(" · ")}</span>
) : null}
</span>
<span className="shrink-0 text-base tabular-nums">{item.quantityLabel}</span>
</button>
</li>
);
}
/** /alisveris/:id/mod — mağazaya göre gruplanmış, büyük dokunma alanlı kontrol listesi. */
export function ShoppingModeView({
data,
choice,
}: {
data: ShoppingListDetail;
choice: PlanChoice;
}) {
useAutoRefresh(SHOPPING_REFRESH_MS);
const { list, plan, canWrite } = data;
const [items, setOptimistic] = useOptimistic(
data.items,
(state, change: { id: string; checked: boolean }) =>
state.map((i) => (i.id === change.id ? { ...i, checked: change.checked } : i)),
);
const option = (choice === "single" ? plan.single : null) ?? plan.recommended;
const byId = new Map(items.map((i) => [i.id, i]));
const groups: Group[] = [];
const placed = new Set<string>();
for (const g of option?.stores ?? []) {
const store = plan.stores[g.storeId];
const rows = g.lines.flatMap((line) => {
const item = byId.get(line.itemId);
if (!item) return [];
placed.add(item.id);
return [{ item, line }];
});
groups.push({
key: g.storeId,
title: store?.label ?? "—",
slot: store?.slot ?? null,
online: store?.kind === "online",
rows,
});
}
const rest = items.filter((i) => !placed.has(i.id));
if (rest.length)
groups.push({
key: "other",
title: m.other,
slot: null,
online: false,
hint: m.otherHint,
rows: rest.map((item) => ({ item, line: null })),
});
const done = items.filter((i) => i.checked).length;
const pct = items.length ? Math.round((done / items.length) * 100) : 0;
function toggle(item: ShoppingItemView) {
const checked = !item.checked;
startTransition(async () => {
setOptimistic({ id: item.id, checked });
const r = await setShoppingItemCheckedAction(list.id, item.id, checked);
if (!r.ok) toast.error(r.error);
});
}
const headline = planHeadline(plan);
const differs =
plan.single &&
plan.single.stores.map((s) => s.storeId).join() !==
plan.recommended?.stores.map((s) => s.storeId).join();
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-4">
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
<Link href={`/alisveris/${list.id}`}>
<ArrowLeft />
{t.actions.backToList}
</Link>
</Button>
<div className="flex flex-col gap-2">
<h1 className="text-xl font-semibold">
{m.title}: {list.name}
</h1>
{headline ? <p className="text-sm text-muted-foreground">{headline}</p> : null}
{differs ? (
<nav aria-label={m.planChoice} className="flex gap-2">
{(["recommended", "single"] as const).map((c) => (
<Button
key={c}
size="sm"
variant={c === choice ? "secondary" : "ghost"}
asChild
aria-current={c === choice ? "page" : undefined}
>
<Link href={c === "single" ? "?plan=single" : "?"} replace scroll={false}>
{c === "single" ? t.plan.single : t.plan.recommended}
</Link>
</Button>
))}
</nav>
) : null}
</div>
<div className="sticky top-14 z-10 -mx-4 bg-background/95 px-4 py-2 backdrop-blur">
<div className="flex items-center justify-between text-sm">
<span className="font-medium tabular-nums">{m.progress(done, items.length)}</span>
{done === items.length && items.length ? (
<span className="text-income">{m.allDone}</span>
) : null}
</div>
<div
className="mt-1.5 h-2 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-valuemin={0}
aria-valuemax={items.length}
aria-valuenow={done}
aria-label={m.progress(done, items.length)}
>
<div
className="h-full rounded-full bg-income transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
{groups.map((g) => {
const groupDone = g.rows.filter((r) => r.item.checked).length;
return (
<section key={g.key} className="overflow-hidden rounded-xl border bg-card">
<header className="flex items-center gap-2 border-b bg-muted/40 px-4 py-2.5">
<h2 className="flex min-w-0 items-center font-medium">
{g.slot ? <StoreDot slot={g.slot} /> : null}
<span className="truncate">{g.title}</span>
{g.online ? (
<Globe className="ml-1.5 size-3.5 text-muted-foreground" aria-hidden />
) : null}
</h2>
<span className="ml-auto text-sm text-muted-foreground tabular-nums">
{groupDone}/{g.rows.length}
</span>
</header>
{g.hint ? <p className="px-4 pt-2 text-xs text-muted-foreground">{g.hint}</p> : null}
<ul className="divide-y">
{g.rows.map(({ item, line }) => (
<Row
key={item.id}
item={item}
line={line}
product={line ? plan.products[line.productId] : undefined}
canWrite={canWrite}
onToggle={() => toggle(item)}
/>
))}
</ul>
</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>
);
}