feat(ui): alışveriş listeleri, akıllı öneri paneli ve alışveriş modu
- /alisveris, /alisveris/:id ve /alisveris/:id/mod sayfaları; odak ve 30 sn yenileme - stok önerileri paneli, kalem ekle/düzenle/taşı, mağazaya gruplu kontrol listesi - dashboard Alışveriş kartı, kenar menü, ürün tipi formunda hacimli işareti Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { SHOPPING_ITEM_KINDS, type ShoppingItemKind } from "@/shared/shopping";
|
||||
import { quantityInput } from "@/shared/stock";
|
||||
import { addShoppingItemAction, updateShoppingItemAction } from "../actions";
|
||||
import type { ItemOptions, ShoppingItemView } from "../service";
|
||||
|
||||
const t = tr.shopping;
|
||||
const f = t.fields;
|
||||
type Errors = Record<string, string | undefined>;
|
||||
|
||||
/** Kalem ekle (ürün tipi / belirli marka / serbest metin) ya da düzenle (miktar, not). */
|
||||
export function ShoppingItemDialog({
|
||||
listId,
|
||||
item,
|
||||
options,
|
||||
onClose,
|
||||
}: {
|
||||
listId: string;
|
||||
item: ShoppingItemView | null;
|
||||
options: ItemOptions;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [v, setV] = useState({
|
||||
kind: (item?.kind ?? "type") as ShoppingItemKind,
|
||||
productTypeId: item?.productTypeId ?? "",
|
||||
productId: item?.productId ?? "",
|
||||
text: item?.text ?? "",
|
||||
quantity: item?.quantity != null ? quantityInput(item.quantity) : "",
|
||||
unit: item?.kind === "text" ? item.unit : "",
|
||||
note: item?.note ?? "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||
|
||||
const typeUnit = options.types.find((x) => x.id === v.productTypeId)?.unit ?? "";
|
||||
const unitSuffix =
|
||||
v.kind === "type" ? (item?.unit ?? typeUnit) : v.kind === "product" ? t.packageUnit : "";
|
||||
const productGroups = new Map<string, ItemOptions["products"]>();
|
||||
for (const p of options.products)
|
||||
productGroups.set(p.typeName, [...(productGroups.get(p.typeName) ?? []), p]);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = item
|
||||
? await updateShoppingItemAction(listId, item.id, {
|
||||
quantity: v.quantity,
|
||||
text: v.text,
|
||||
unit: v.unit,
|
||||
note: v.note,
|
||||
})
|
||||
: await addShoppingItemAction(listId, v);
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(item ? t.toast.itemSaved : t.toast.itemAdded);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={item ? t.editItemTitle : t.addItemTitle}
|
||||
description={item && item.kind !== "text" ? item.label : undefined}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="shopping-item-form" disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="shopping-item-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
{item ? null : (
|
||||
<Field>
|
||||
<FieldLabel>{f.kind}</FieldLabel>
|
||||
<Tabs value={v.kind} onValueChange={(x) => set("kind", x as ShoppingItemKind)}>
|
||||
<TabsList className="w-full">
|
||||
{SHOPPING_ITEM_KINDS.map((k) => (
|
||||
<TabsTrigger key={k} value={k}>
|
||||
{t.kinds[k]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<FieldDescription>{t.kindHints[v.kind]}</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
{!item && v.kind === "type" ? (
|
||||
<Field data-invalid={Boolean(errors.productTypeId)}>
|
||||
<FieldLabel htmlFor="si-type">{f.productType}</FieldLabel>
|
||||
<Select value={v.productTypeId} onValueChange={(x) => set("productTypeId", x)}>
|
||||
<SelectTrigger
|
||||
id="si-type"
|
||||
className="w-full"
|
||||
aria-invalid={Boolean(errors.productTypeId)}
|
||||
>
|
||||
<SelectValue placeholder={t.validation.productType} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{options.types.map((x) => (
|
||||
<SelectItem key={x.id} value={x.id}>
|
||||
{x.name} <span className="text-xs text-muted-foreground">({x.unit})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.productTypeId ? <FieldError>{errors.productTypeId}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
{!item && v.kind === "product" ? (
|
||||
<Field data-invalid={Boolean(errors.productId)}>
|
||||
<FieldLabel htmlFor="si-product">{f.product}</FieldLabel>
|
||||
<Select value={v.productId} onValueChange={(x) => set("productId", x)}>
|
||||
<SelectTrigger
|
||||
id="si-product"
|
||||
className="w-full"
|
||||
aria-invalid={Boolean(errors.productId)}
|
||||
>
|
||||
<SelectValue placeholder={t.validation.product} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{[...productGroups.entries()].map(([group, rows]) => (
|
||||
<SelectGroup key={group}>
|
||||
<SelectLabel>{group}</SelectLabel>
|
||||
{rows.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.label}{" "}
|
||||
<span className="text-xs text-muted-foreground">({p.packageLabel})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.productId ? <FieldError>{errors.productId}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
{v.kind === "text" ? (
|
||||
<Field data-invalid={Boolean(errors.text)}>
|
||||
<FieldLabel htmlFor="si-text">{f.text}</FieldLabel>
|
||||
<Input
|
||||
id="si-text"
|
||||
value={v.text}
|
||||
placeholder={f.textPlaceholder}
|
||||
onChange={(e) => set("text", e.target.value)}
|
||||
aria-invalid={Boolean(errors.text)}
|
||||
autoFocus={!item}
|
||||
/>
|
||||
{errors.text ? <FieldError>{errors.text}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.quantity)}>
|
||||
<FieldLabel htmlFor="si-qty">
|
||||
{v.kind === "product" ? f.packages : f.quantity}
|
||||
{unitSuffix ? ` (${unitSuffix})` : ""}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="si-qty"
|
||||
inputMode="decimal"
|
||||
value={v.quantity}
|
||||
onChange={(e) => set("quantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.quantity)}
|
||||
/>
|
||||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||
</Field>
|
||||
{v.kind === "text" ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="si-unit">{f.unit}</FieldLabel>
|
||||
<Input
|
||||
id="si-unit"
|
||||
value={v.unit}
|
||||
placeholder={f.unitPlaceholder}
|
||||
onChange={(e) => set("unit", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
{v.kind !== "text" ? (
|
||||
<FieldDescription className="-mt-2">{f.quantityHint}</FieldDescription>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="si-note">{f.itemNote}</FieldLabel>
|
||||
<Input
|
||||
id="si-note"
|
||||
value={v.note}
|
||||
placeholder={f.itemNotePlaceholder}
|
||||
onChange={(e) => set("note", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user