feat(ui): sayım ekranı, tüketim analizi ve sayım hatırlatmaları

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 10:58:12 +03:00
parent 814bdbbc2a
commit 07ba531d31
8 changed files with 749 additions and 24 deletions
@@ -0,0 +1,351 @@
"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>
);
}