Files
butcem/src/features/stock/components/stock-dialogs.tsx
T

524 lines
19 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 { 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>
);
}