868bb23fd9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
524 lines
19 KiB
TypeScript
524 lines
19 KiB
TypeScript
"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,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { tr } from "@/i18n/tr";
|
||
import { todayIstanbul } from "@/shared/dates";
|
||
import {
|
||
formatStock,
|
||
quantityInput,
|
||
STOCK_UNITS,
|
||
WASTE_REASONS,
|
||
type WasteReason,
|
||
} from "@/shared/stock";
|
||
import { addStockMovementAction, countStockAction, saveProductTypeAction } from "../actions";
|
||
import type { ProductTypeView, StockOption } from "../service";
|
||
|
||
const t = tr.stock;
|
||
type Errors = Record<string, string | undefined>;
|
||
const CUSTOM = "__custom__";
|
||
|
||
function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) {
|
||
return (
|
||
<>
|
||
<Button variant="outline" type="button" onClick={onClose}>
|
||
{tr.common.cancel}
|
||
</Button>
|
||
<Button type="submit" form={formId} disabled={busy}>
|
||
{tr.common.save}
|
||
</Button>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function errorsOf(r: { fieldErrors?: Partial<Record<string, string>>; error: string }): Errors {
|
||
return { ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error };
|
||
}
|
||
|
||
/** Yeni ürün tipi / düzenle. */
|
||
export function ProductTypeFormDialog({
|
||
item,
|
||
categories,
|
||
defaultCategoryId,
|
||
onClose,
|
||
onCreated,
|
||
}: {
|
||
item: ProductTypeView | null;
|
||
categories: StockOption[];
|
||
defaultCategoryId?: string;
|
||
onClose: () => void;
|
||
onCreated?: (id: string) => void;
|
||
}) {
|
||
const standard = (STOCK_UNITS as readonly string[]).includes(item?.unit ?? "adet");
|
||
const [v, setV] = useState({
|
||
name: item?.name ?? "",
|
||
categoryId: item?.categoryId ?? defaultCategoryId ?? "",
|
||
unitChoice: standard ? (item?.unit ?? "adet") : CUSTOM,
|
||
customUnit: standard ? "" : (item?.unit ?? ""),
|
||
targetQuantity: item ? quantityInput(item.targetQuantity) : "",
|
||
thresholdPct: String(item?.thresholdPct ?? 20),
|
||
location: item?.location ?? "",
|
||
step: item ? quantityInput(item.step) : "1",
|
||
countEveryDays: item?.countEveryDays ? String(item.countEveryDays) : "",
|
||
note: item?.note ?? "",
|
||
initialQuantity: "",
|
||
});
|
||
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 unit = v.unitChoice === CUSTOM ? v.customUnit.trim() : v.unitChoice;
|
||
|
||
async function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setBusy(true);
|
||
const r = await saveProductTypeAction(item?.id ?? null, {
|
||
name: v.name,
|
||
categoryId: v.categoryId,
|
||
unit,
|
||
targetQuantity: v.targetQuantity,
|
||
thresholdPct: v.thresholdPct,
|
||
location: v.location,
|
||
step: v.step,
|
||
countEveryDays: v.countEveryDays,
|
||
note: v.note,
|
||
initialQuantity: item ? "" : v.initialQuantity,
|
||
});
|
||
setBusy(false);
|
||
if (r.ok) {
|
||
toast.success(t.toast.saved);
|
||
onClose();
|
||
if (!item) onCreated?.((r.data as { id: string }).id);
|
||
} else setErrors(errorsOf(r));
|
||
}
|
||
|
||
const f = t.fields;
|
||
const unitSuffix = unit ? ` (${unit})` : "";
|
||
return (
|
||
<ResponsiveDialog
|
||
open
|
||
onOpenChange={(o) => !o && onClose()}
|
||
title={item ? t.editTitle : t.createTitle}
|
||
footer={<Footer busy={busy} formId="product-form" onClose={onClose} />}
|
||
>
|
||
<form id="product-form" onSubmit={submit} noValidate>
|
||
<FieldGroup>
|
||
<Field data-invalid={Boolean(errors.name)}>
|
||
<FieldLabel htmlFor="pt-name">{f.name}</FieldLabel>
|
||
<Input
|
||
id="pt-name"
|
||
value={v.name}
|
||
placeholder={f.namePlaceholder}
|
||
onChange={(e) => set("name", e.target.value)}
|
||
aria-invalid={Boolean(errors.name)}
|
||
autoFocus={!item}
|
||
/>
|
||
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
|
||
</Field>
|
||
<Field data-invalid={Boolean(errors.categoryId)}>
|
||
<FieldLabel htmlFor="pt-cat">{f.category}</FieldLabel>
|
||
<Select value={v.categoryId} onValueChange={(x) => set("categoryId", x)}>
|
||
<SelectTrigger
|
||
id="pt-cat"
|
||
className="w-full"
|
||
aria-invalid={Boolean(errors.categoryId)}
|
||
>
|
||
<SelectValue placeholder={t.validation.selectCategory}>
|
||
{categories.find((c) => c.id === v.categoryId)?.path}
|
||
</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent className="max-h-72">
|
||
{categories.map((c) => (
|
||
<SelectItem key={c.id} value={c.id}>
|
||
<span style={{ paddingLeft: `${c.depth * 12}px` }}>{c.label}</span>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{errors.categoryId ? <FieldError>{errors.categoryId}</FieldError> : null}
|
||
</Field>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field data-invalid={Boolean(errors.unit)}>
|
||
<FieldLabel htmlFor="pt-unit">{f.unit}</FieldLabel>
|
||
<Select value={v.unitChoice} onValueChange={(x) => set("unitChoice", x)}>
|
||
<SelectTrigger id="pt-unit" className="w-full">
|
||
<SelectValue>{v.unitChoice === CUSTOM ? f.customUnit : v.unitChoice}</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{STOCK_UNITS.map((u) => (
|
||
<SelectItem key={u} value={u}>
|
||
{u}
|
||
</SelectItem>
|
||
))}
|
||
<SelectItem value={CUSTOM}>{f.customUnitOption}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
{errors.unit && v.unitChoice !== CUSTOM ? (
|
||
<FieldError>{errors.unit}</FieldError>
|
||
) : null}
|
||
</Field>
|
||
{v.unitChoice === CUSTOM ? (
|
||
<Field data-invalid={Boolean(errors.unit)}>
|
||
<FieldLabel htmlFor="pt-custom">{f.customUnit}</FieldLabel>
|
||
<Input
|
||
id="pt-custom"
|
||
value={v.customUnit}
|
||
placeholder={f.customUnitPlaceholder}
|
||
onChange={(e) => set("customUnit", e.target.value)}
|
||
aria-invalid={Boolean(errors.unit)}
|
||
/>
|
||
{errors.unit ? <FieldError>{errors.unit}</FieldError> : null}
|
||
</Field>
|
||
) : (
|
||
<Field>
|
||
<FieldLabel htmlFor="pt-location">{f.location}</FieldLabel>
|
||
<Input
|
||
id="pt-location"
|
||
value={v.location}
|
||
placeholder={f.locationPlaceholder}
|
||
onChange={(e) => set("location", e.target.value)}
|
||
/>
|
||
</Field>
|
||
)}
|
||
</div>
|
||
{v.unitChoice === CUSTOM ? (
|
||
<Field>
|
||
<FieldLabel htmlFor="pt-location">{f.location}</FieldLabel>
|
||
<Input
|
||
id="pt-location"
|
||
value={v.location}
|
||
placeholder={f.locationPlaceholder}
|
||
onChange={(e) => set("location", e.target.value)}
|
||
/>
|
||
</Field>
|
||
) : null}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field data-invalid={Boolean(errors.targetQuantity)}>
|
||
<FieldLabel htmlFor="pt-target">
|
||
{f.target}
|
||
{unitSuffix}
|
||
</FieldLabel>
|
||
<Input
|
||
id="pt-target"
|
||
inputMode="decimal"
|
||
value={v.targetQuantity}
|
||
onChange={(e) => set("targetQuantity", e.target.value)}
|
||
className="text-right tabular-nums"
|
||
aria-invalid={Boolean(errors.targetQuantity)}
|
||
/>
|
||
{errors.targetQuantity ? <FieldError>{errors.targetQuantity}</FieldError> : null}
|
||
</Field>
|
||
<Field data-invalid={Boolean(errors.thresholdPct)}>
|
||
<FieldLabel htmlFor="pt-threshold">{f.thresholdPct}</FieldLabel>
|
||
<Input
|
||
id="pt-threshold"
|
||
inputMode="numeric"
|
||
value={v.thresholdPct}
|
||
onChange={(e) => set("thresholdPct", e.target.value)}
|
||
className="text-right tabular-nums"
|
||
aria-invalid={Boolean(errors.thresholdPct)}
|
||
/>
|
||
{errors.thresholdPct ? <FieldError>{errors.thresholdPct}</FieldError> : null}
|
||
</Field>
|
||
</div>
|
||
<FieldDescription className="-mt-2">
|
||
{f.targetHint} {f.thresholdHint}
|
||
</FieldDescription>
|
||
{!item ? (
|
||
<Field data-invalid={Boolean(errors.initialQuantity)}>
|
||
<FieldLabel htmlFor="pt-initial">
|
||
{f.initialQuantity}
|
||
{unitSuffix}
|
||
</FieldLabel>
|
||
<Input
|
||
id="pt-initial"
|
||
inputMode="decimal"
|
||
value={v.initialQuantity}
|
||
onChange={(e) => set("initialQuantity", e.target.value)}
|
||
className="text-right tabular-nums"
|
||
aria-invalid={Boolean(errors.initialQuantity)}
|
||
/>
|
||
<FieldDescription>{f.initialHint}</FieldDescription>
|
||
{errors.initialQuantity ? <FieldError>{errors.initialQuantity}</FieldError> : null}
|
||
</Field>
|
||
) : null}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field data-invalid={Boolean(errors.step)}>
|
||
<FieldLabel htmlFor="pt-step">
|
||
{f.step}
|
||
{unitSuffix}
|
||
</FieldLabel>
|
||
<Input
|
||
id="pt-step"
|
||
inputMode="decimal"
|
||
value={v.step}
|
||
onChange={(e) => set("step", e.target.value)}
|
||
className="text-right tabular-nums"
|
||
aria-invalid={Boolean(errors.step)}
|
||
/>
|
||
{errors.step ? <FieldError>{errors.step}</FieldError> : null}
|
||
</Field>
|
||
<Field data-invalid={Boolean(errors.countEveryDays)}>
|
||
<FieldLabel htmlFor="pt-count">{f.countEveryDays}</FieldLabel>
|
||
<Input
|
||
id="pt-count"
|
||
inputMode="numeric"
|
||
value={v.countEveryDays}
|
||
onChange={(e) => set("countEveryDays", e.target.value)}
|
||
className="text-right tabular-nums"
|
||
aria-invalid={Boolean(errors.countEveryDays)}
|
||
/>
|
||
{errors.countEveryDays ? <FieldError>{errors.countEveryDays}</FieldError> : null}
|
||
</Field>
|
||
</div>
|
||
<FieldDescription className="-mt-2">
|
||
{f.stepHint} {f.countEveryDaysHint}
|
||
</FieldDescription>
|
||
<Field>
|
||
<FieldLabel htmlFor="pt-note">{f.note}</FieldLabel>
|
||
<Input id="pt-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||
</Field>
|
||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||
</FieldGroup>
|
||
</form>
|
||
</ResponsiveDialog>
|
||
);
|
||
}
|
||
|
||
export type MovementKindInput = "purchase" | "consume" | "waste";
|
||
|
||
/** Alım, tüketim veya fire. */
|
||
export function StockMovementDialog({
|
||
item,
|
||
initialKind,
|
||
onClose,
|
||
}: {
|
||
item: ProductTypeView;
|
||
initialKind: MovementKindInput;
|
||
onClose: () => void;
|
||
}) {
|
||
const [kind, setKind] = useState<MovementKindInput>(initialKind);
|
||
const [v, setV] = useState({
|
||
date: todayIstanbul(),
|
||
quantity:
|
||
initialKind === "purchase" && item.suggestedQuantity
|
||
? quantityInput(item.suggestedQuantity)
|
||
: "",
|
||
wasteReason: "" as WasteReason | "",
|
||
expiryDate: "",
|
||
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 }));
|
||
|
||
async function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setBusy(true);
|
||
const r = await addStockMovementAction(item.id, { kind, ...v });
|
||
setBusy(false);
|
||
if (r.ok) {
|
||
toast.success(t.toast.movementSaved);
|
||
onClose();
|
||
} else setErrors(errorsOf(r));
|
||
}
|
||
|
||
const f = t.fields;
|
||
return (
|
||
<ResponsiveDialog
|
||
open
|
||
onOpenChange={(o) => !o && onClose()}
|
||
title={`${t.movementTitles[kind]} · ${item.name}`}
|
||
description={`${t.detail.current}: ${formatStock(item.current, item.unit)}`}
|
||
footer={<Footer busy={busy} formId="stock-movement-form" onClose={onClose} />}
|
||
>
|
||
<form id="stock-movement-form" onSubmit={submit} noValidate>
|
||
<FieldGroup>
|
||
<Tabs
|
||
value={kind}
|
||
onValueChange={(x) => {
|
||
setKind(x as MovementKindInput);
|
||
setErrors({});
|
||
}}
|
||
>
|
||
<TabsList className="grid w-full grid-cols-3">
|
||
{(["purchase", "consume", "waste"] as const).map((k) => (
|
||
<TabsTrigger key={k} value={k}>
|
||
{t.kinds[k]}
|
||
</TabsTrigger>
|
||
))}
|
||
</TabsList>
|
||
</Tabs>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field data-invalid={Boolean(errors.quantity)}>
|
||
<FieldLabel htmlFor="sm-qty">
|
||
{f.quantity} ({item.unit})
|
||
</FieldLabel>
|
||
<Input
|
||
id="sm-qty"
|
||
inputMode="decimal"
|
||
value={v.quantity}
|
||
onChange={(e) => set("quantity", e.target.value)}
|
||
className="text-right tabular-nums"
|
||
aria-invalid={Boolean(errors.quantity)}
|
||
autoFocus
|
||
/>
|
||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||
</Field>
|
||
<Field data-invalid={Boolean(errors.date)}>
|
||
<FieldLabel htmlFor="sm-date">{f.date}</FieldLabel>
|
||
<Input
|
||
id="sm-date"
|
||
type="date"
|
||
value={v.date}
|
||
max={todayIstanbul()}
|
||
onChange={(e) => set("date", e.target.value)}
|
||
/>
|
||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||
</Field>
|
||
</div>
|
||
{kind === "purchase" ? (
|
||
<Field data-invalid={Boolean(errors.expiryDate)}>
|
||
<FieldLabel htmlFor="sm-expiry">{f.expiryDate}</FieldLabel>
|
||
<Input
|
||
id="sm-expiry"
|
||
type="date"
|
||
value={v.expiryDate}
|
||
min={v.date}
|
||
onChange={(e) => set("expiryDate", e.target.value)}
|
||
/>
|
||
<FieldDescription>{f.expiryHint}</FieldDescription>
|
||
{errors.expiryDate ? <FieldError>{errors.expiryDate}</FieldError> : null}
|
||
</Field>
|
||
) : null}
|
||
{kind === "waste" ? (
|
||
<Field data-invalid={Boolean(errors.wasteReason)}>
|
||
<FieldLabel htmlFor="sm-reason">{f.wasteReason}</FieldLabel>
|
||
<Select
|
||
value={v.wasteReason}
|
||
onValueChange={(x) => set("wasteReason", x as WasteReason)}
|
||
>
|
||
<SelectTrigger
|
||
id="sm-reason"
|
||
className="w-full"
|
||
aria-invalid={Boolean(errors.wasteReason)}
|
||
>
|
||
<SelectValue placeholder={t.validation.wasteReason}>
|
||
{v.wasteReason ? t.wasteReasons[v.wasteReason] : null}
|
||
</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{WASTE_REASONS.map((r) => (
|
||
<SelectItem key={r} value={r}>
|
||
{t.wasteReasons[r]}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{errors.wasteReason ? <FieldError>{errors.wasteReason}</FieldError> : null}
|
||
</Field>
|
||
) : null}
|
||
<Field>
|
||
<FieldLabel htmlFor="sm-note">{f.note}</FieldLabel>
|
||
<Input id="sm-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||
</Field>
|
||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||
</FieldGroup>
|
||
</form>
|
||
</ResponsiveDialog>
|
||
);
|
||
}
|
||
|
||
/** Tek ürün sayımı: gerçek miktar → fark sayım düzeltmesi. */
|
||
export function StockCountDialog({
|
||
item,
|
||
onClose,
|
||
}: {
|
||
item: ProductTypeView;
|
||
onClose: () => void;
|
||
}) {
|
||
const [v, setV] = useState({ date: todayIstanbul(), actualQuantity: "", 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 }));
|
||
|
||
async function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setBusy(true);
|
||
const r = await countStockAction(item.id, v);
|
||
setBusy(false);
|
||
if (r.ok) {
|
||
const diff = (r.data as { diff: number }).diff;
|
||
if (diff === 0) toast.success(t.toast.noChange);
|
||
else
|
||
toast.success(
|
||
t.toast.counted(`${diff > 0 ? "+" : "−"}${formatStock(Math.abs(diff), item.unit)}`),
|
||
);
|
||
onClose();
|
||
} else setErrors(errorsOf(r));
|
||
}
|
||
|
||
const f = t.fields;
|
||
return (
|
||
<ResponsiveDialog
|
||
open
|
||
onOpenChange={(o) => !o && onClose()}
|
||
title={`${t.countTitle} · ${item.name}`}
|
||
description={t.countDescription}
|
||
footer={<Footer busy={busy} formId="stock-count-form" onClose={onClose} />}
|
||
>
|
||
<form id="stock-count-form" onSubmit={submit} noValidate>
|
||
<FieldGroup>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field data-invalid={Boolean(errors.actualQuantity)}>
|
||
<FieldLabel htmlFor="sc-qty">
|
||
{f.actualQuantity} ({item.unit})
|
||
</FieldLabel>
|
||
<Input
|
||
id="sc-qty"
|
||
inputMode="decimal"
|
||
value={v.actualQuantity}
|
||
onChange={(e) => set("actualQuantity", e.target.value)}
|
||
className="text-right tabular-nums"
|
||
aria-invalid={Boolean(errors.actualQuantity)}
|
||
autoFocus
|
||
/>
|
||
{errors.actualQuantity ? <FieldError>{errors.actualQuantity}</FieldError> : null}
|
||
</Field>
|
||
<Field data-invalid={Boolean(errors.date)}>
|
||
<FieldLabel htmlFor="sc-date">{f.date}</FieldLabel>
|
||
<Input
|
||
id="sc-date"
|
||
type="date"
|
||
value={v.date}
|
||
max={todayIstanbul()}
|
||
onChange={(e) => set("date", e.target.value)}
|
||
/>
|
||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||
</Field>
|
||
</div>
|
||
<FieldDescription className="-mt-2">
|
||
{f.expected(formatStock(Math.max(0, item.current), item.unit))}
|
||
</FieldDescription>
|
||
<Field>
|
||
<FieldLabel htmlFor="sc-note">{f.note}</FieldLabel>
|
||
<Input id="sc-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||
</Field>
|
||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||
</FieldGroup>
|
||
</form>
|
||
</ResponsiveDialog>
|
||
);
|
||
}
|