07ba531d31
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { ArrowLeft, ClipboardCheck, Eraser, ListChecks } from "lucide-react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import { useMemo, useState, useTransition } from "react";
|
||
import { toast } from "sonner";
|
||
import { PageHeader } from "@/components/page-header";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { tr } from "@/i18n/tr";
|
||
import { cn } from "@/lib/utils";
|
||
import { parseQuantity } from "@/shared/quantity";
|
||
import { formatStock, quantityInput, WASTE_REASONS, type WasteReason } from "@/shared/stock";
|
||
import { submitCountAction } from "../actions";
|
||
import type { CountSheet as Data, CountSheetRow } from "../service";
|
||
import { ymdLabel } from "./stock-view";
|
||
|
||
const t = tr.stock;
|
||
const ALL = "__all__";
|
||
|
||
type Entry = { actual: string; waste: string; reason: WasteReason | "" };
|
||
const EMPTY: Entry = { actual: "", waste: "", reason: "" };
|
||
|
||
function signed(q: number, unit: string) {
|
||
if (q === 0) return t.count.same;
|
||
return `${q > 0 ? "+" : "−"}${formatStock(Math.abs(q), unit)}`;
|
||
}
|
||
|
||
/** /stok/sayim — liste hâlinde beklenen → sayılan; fark sayım düzeltmesi, bir kısmı fire. */
|
||
export function CountSheet({ data }: { data: Data }) {
|
||
const router = useRouter();
|
||
const [entries, setEntries] = useState<Record<string, Entry>>({});
|
||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||
const [onlyDue, setOnlyDue] = useState(data.rows.some((r) => r.count?.due));
|
||
const [note, setNote] = useState("");
|
||
const [pending, start] = useTransition();
|
||
|
||
const rows = useMemo(
|
||
() => (onlyDue ? data.rows.filter((r) => r.count?.due) : data.rows),
|
||
[data.rows, onlyDue],
|
||
);
|
||
const groups = useMemo(() => {
|
||
const by = new Map<string, { path: string; rows: CountSheetRow[] }>();
|
||
for (const r of rows) {
|
||
const g = by.get(r.categoryId) ?? { path: r.categoryPath, rows: [] };
|
||
g.rows.push(r);
|
||
by.set(r.categoryId, g);
|
||
}
|
||
return [...by.values()];
|
||
}, [rows]);
|
||
const dueTotal = data.rows.filter((r) => r.count?.due).length;
|
||
const filled = data.rows.filter((r) => entries[r.id]?.actual.trim()).length;
|
||
|
||
const set = (id: string, patch: Partial<Entry>) =>
|
||
setEntries((e) => ({ ...e, [id]: { ...(e[id] ?? EMPTY), ...patch } }));
|
||
|
||
const fillExpected = () =>
|
||
setEntries((e) => {
|
||
const next = { ...e };
|
||
for (const r of rows)
|
||
if (!next[r.id]?.actual.trim())
|
||
next[r.id] = { ...(next[r.id] ?? EMPTY), actual: quantityInput(Math.max(0, r.expected)) };
|
||
return next;
|
||
});
|
||
|
||
const submit = () =>
|
||
start(async () => {
|
||
const sent = data.rows.filter((r) => entries[r.id]?.actual.trim());
|
||
const r = await submitCountAction({
|
||
date: data.today,
|
||
categoryId: data.category?.id ?? "",
|
||
note,
|
||
rows: sent.map((row) => {
|
||
const e = entries[row.id]!;
|
||
// Fire alanı yalnızca sayımda eksik çıkınca geçerlidir.
|
||
const actual = parseQuantity(e.actual);
|
||
const short = actual !== null && actual < row.expected;
|
||
return {
|
||
productTypeId: row.id,
|
||
actualQuantity: e.actual,
|
||
wasteQuantity: short ? e.waste : "",
|
||
wasteReason: short ? e.reason : "",
|
||
};
|
||
}),
|
||
});
|
||
if (r.ok) {
|
||
const d = r.data as { counted: number; adjusted: number };
|
||
toast.success(t.count.done(d.counted, d.adjusted));
|
||
setEntries({});
|
||
setErrors({});
|
||
setNote("");
|
||
router.refresh();
|
||
return;
|
||
}
|
||
const next: Record<string, string> = {};
|
||
for (const [key, msg] of Object.entries(r.fieldErrors ?? {})) {
|
||
const m = /^rows\.(\d+)\.(\w+)$/.exec(key);
|
||
const row = m ? sent[Number(m[1])] : undefined;
|
||
if (row && msg) next[`${row.id}.${m![2]}`] = msg;
|
||
}
|
||
setErrors(next);
|
||
toast.error(r.fieldErrors?.rows ?? r.error);
|
||
});
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||
<Link href="/stok">
|
||
<ArrowLeft />
|
||
{t.count.back}
|
||
</Link>
|
||
</Button>
|
||
<PageHeader
|
||
title={data.category ? `${t.count.title}: ${data.category.path}` : t.count.title}
|
||
description={t.count.description}
|
||
/>
|
||
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<Select
|
||
value={data.category?.id ?? ALL}
|
||
onValueChange={(v) =>
|
||
router.push(v === ALL ? "/stok/sayim" : `/stok/sayim?kategori=${v}`)
|
||
}
|
||
>
|
||
<SelectTrigger className="w-full sm:w-72" aria-label={t.count.category}>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value={ALL}>{t.count.allCategories}</SelectItem>
|
||
{data.categories.map((c) => (
|
||
<SelectItem key={c.id} value={c.id}>
|
||
<span style={{ paddingLeft: c.depth * 12 }}>{c.path.split(" › ").at(-1)}</span>
|
||
{c.due ? (
|
||
<span className="text-xs text-amber-700 dark:text-amber-400">
|
||
{" "}
|
||
· {t.count.dueCount(c.due)}
|
||
</span>
|
||
) : null}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{dueTotal > 0 ? (
|
||
<label className="flex items-center gap-2 text-sm">
|
||
<Checkbox checked={onlyDue} onCheckedChange={(x) => setOnlyDue(x === true)} />
|
||
{t.count.onlyDue} ({dueTotal})
|
||
</label>
|
||
) : null}
|
||
{data.canWrite && rows.length ? (
|
||
<div className="flex gap-2 sm:ml-auto">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={fillExpected}
|
||
title={t.count.fillExpectedHint}
|
||
>
|
||
<ListChecks />
|
||
{t.count.fillExpected}
|
||
</Button>
|
||
{filled ? (
|
||
<Button size="sm" variant="ghost" onClick={() => setEntries({})}>
|
||
<Eraser />
|
||
{t.count.clear}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{rows.length === 0 ? (
|
||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||
{t.count.empty}
|
||
</p>
|
||
) : null}
|
||
|
||
{groups.map((g) => (
|
||
<section key={g.path} className="flex flex-col gap-2">
|
||
<h2 className="text-sm font-medium text-muted-foreground">{g.path}</h2>
|
||
<ul className="divide-y rounded-xl border bg-card">
|
||
{g.rows.map((r) => (
|
||
<CountRow
|
||
key={r.id}
|
||
row={r}
|
||
entry={entries[r.id] ?? EMPTY}
|
||
errors={errors}
|
||
disabled={!data.canWrite || pending}
|
||
onChange={(patch) => set(r.id, patch)}
|
||
/>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
))}
|
||
|
||
{data.canWrite && data.rows.length ? (
|
||
<div className="sticky bottom-[calc(4rem+env(safe-area-inset-bottom))] z-10 -mx-4 flex flex-wrap items-center gap-3 border-t bg-background/95 px-4 py-3 backdrop-blur md:bottom-0 md:mx-0 md:rounded-xl md:border">
|
||
<Input
|
||
value={note}
|
||
onChange={(e) => setNote(e.target.value)}
|
||
placeholder={t.count.note}
|
||
aria-label={t.count.note}
|
||
maxLength={300}
|
||
className="min-w-40 flex-1"
|
||
/>
|
||
<span className="text-sm text-muted-foreground tabular-nums">
|
||
{t.count.counted(filled)}
|
||
</span>
|
||
<Button onClick={submit} disabled={pending || filled === 0}>
|
||
<ClipboardCheck />
|
||
{t.count.submit}
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CountRow({
|
||
row,
|
||
entry,
|
||
errors,
|
||
disabled,
|
||
onChange,
|
||
}: {
|
||
row: CountSheetRow;
|
||
entry: Entry;
|
||
errors: Record<string, string>;
|
||
disabled: boolean;
|
||
onChange: (patch: Partial<Entry>) => void;
|
||
}) {
|
||
const actual = entry.actual.trim() ? parseQuantity(entry.actual) : null;
|
||
const diff = actual === null ? null : actual - row.expected;
|
||
const shortage = diff !== null && diff < 0 ? -diff : 0;
|
||
const actualError = errors[`${row.id}.actualQuantity`];
|
||
const wasteError = errors[`${row.id}.wasteQuantity`] ?? errors[`${row.id}.wasteReason`];
|
||
const inputId = `count-${row.id}`;
|
||
return (
|
||
<li className="flex flex-col gap-2 p-3">
|
||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||
<div className="min-w-0 flex-1 basis-40">
|
||
<label htmlFor={inputId} className="flex flex-wrap items-center gap-1.5 font-medium">
|
||
<span className="truncate">{row.name}</span>
|
||
{row.count?.due ? (
|
||
<Badge
|
||
variant="outline"
|
||
className="border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||
>
|
||
{t.count.dueBadge(row.count.overdueDays)}
|
||
</Badge>
|
||
) : null}
|
||
</label>
|
||
<p className="truncate text-xs text-muted-foreground">
|
||
{[
|
||
row.location,
|
||
row.count
|
||
? row.count.lastDate
|
||
? t.count.lastCount(ymdLabel(row.count.lastDate))
|
||
: t.count.neverCounted
|
||
: null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" · ")}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-20 text-right text-sm tabular-nums">
|
||
<p className="text-xs text-muted-foreground">{t.count.expected}</p>
|
||
{formatStock(Math.max(0, row.expected), row.unit)}
|
||
</div>
|
||
<div className="w-28">
|
||
<span className="text-xs text-muted-foreground">
|
||
{t.count.actual} ({row.unit})
|
||
</span>
|
||
<Input
|
||
id={inputId}
|
||
inputMode="decimal"
|
||
value={entry.actual}
|
||
onChange={(e) => onChange({ actual: e.target.value })}
|
||
disabled={disabled}
|
||
aria-invalid={Boolean(actualError) || (entry.actual.trim() !== "" && actual === null)}
|
||
className="h-8 tabular-nums"
|
||
/>
|
||
</div>
|
||
<div className="w-20 text-right text-sm tabular-nums">
|
||
<p className="text-xs text-muted-foreground">{t.count.diff}</p>
|
||
<span
|
||
className={cn(
|
||
diff === null
|
||
? "text-muted-foreground"
|
||
: diff > 0
|
||
? "text-income"
|
||
: diff < 0
|
||
? "text-expense"
|
||
: "text-muted-foreground",
|
||
)}
|
||
>
|
||
{diff === null ? "—" : signed(diff, row.unit)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{actualError ? <p className="text-xs text-destructive">{actualError}</p> : null}
|
||
{shortage > 0 ? (
|
||
<div className="flex flex-wrap items-center gap-2 rounded-lg bg-muted/50 p-2 text-sm">
|
||
<span className="text-xs text-muted-foreground">{t.count.markWaste}:</span>
|
||
<Input
|
||
inputMode="decimal"
|
||
value={entry.waste}
|
||
onChange={(e) => onChange({ waste: e.target.value })}
|
||
disabled={disabled}
|
||
placeholder={t.count.wasteMax(formatStock(shortage, row.unit))}
|
||
aria-label={`${row.name}: ${t.count.wasteQuantity}`}
|
||
aria-invalid={Boolean(errors[`${row.id}.wasteQuantity`])}
|
||
className="h-8 w-36 tabular-nums"
|
||
/>
|
||
<Select
|
||
value={entry.reason}
|
||
onValueChange={(v) => onChange({ reason: v as WasteReason })}
|
||
disabled={disabled || !entry.waste.trim()}
|
||
>
|
||
<SelectTrigger
|
||
size="sm"
|
||
className="w-48"
|
||
aria-label={`${row.name}: ${t.fields.wasteReason}`}
|
||
aria-invalid={Boolean(errors[`${row.id}.wasteReason`])}
|
||
>
|
||
<SelectValue placeholder={t.fields.wasteReason} />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{WASTE_REASONS.map((w) => (
|
||
<SelectItem key={w} value={w}>
|
||
{t.wasteReasons[w]}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{wasteError ? <p className="w-full text-xs text-destructive">{wasteError}</p> : null}
|
||
</div>
|
||
) : null}
|
||
</li>
|
||
);
|
||
}
|