c8678da7b5
- /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>
661 lines
25 KiB
TypeScript
661 lines
25 KiB
TypeScript
"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>
|
||
);
|
||
}
|