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:
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
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 }) {
|
||||
</Link>
|
||||
</Button>
|
||||
) : 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 className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,26rem)] xl:items-start">
|
||||
<div className="flex flex-col gap-4">
|
||||
{data.suggestions.length ? (
|
||||
<SuggestionsPanel listId={list.id} suggestions={data.suggestions} canWrite={canWrite} />
|
||||
) : null}
|
||||
{items.length ? (
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{items.map((i) => (
|
||||
<ItemRow
|
||||
key={i.id}
|
||||
item={i}
|
||||
canWrite={canWrite}
|
||||
onEdit={() => setEditing(i)}
|
||||
onMove={() => setMoving(i)}
|
||||
onRemove={() => remove(i)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.emptyList}
|
||||
</p>
|
||||
)}
|
||||
{data.report ? (
|
||||
<ShoppingReport completion={data.report.completion} summary={data.report.summary} />
|
||||
) : (
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,26rem)] xl:items-start">
|
||||
<div className="flex flex-col gap-4">
|
||||
{data.suggestions.length ? (
|
||||
<SuggestionsPanel
|
||||
listId={list.id}
|
||||
suggestions={data.suggestions}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
) : null}
|
||||
{items.length ? (
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{items.map((i) => (
|
||||
<ItemRow
|
||||
key={i.id}
|
||||
item={i}
|
||||
canWrite={canWrite}
|
||||
onEdit={() => setEditing(i)}
|
||||
onMove={() => setMoving(i)}
|
||||
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>
|
||||
<PlanPanel plan={data.plan} items={items} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adding || editing ? (
|
||||
<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";
|
||||
|
||||
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({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user