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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Clock } from "lucide-react";
|
||||
import { ClipboardCheck, Clock } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -9,7 +9,7 @@ import { getStock } from "../service";
|
||||
const t = tr.stock;
|
||||
const LIMIT = 5;
|
||||
|
||||
/** Dashboard: eşiğin altındaki ürünler ve SKT'si yaklaşan partiler (yoksa görünmez). */
|
||||
/** Dashboard: eşiğin altındaki ürünler, SKT'si yaklaşan partiler, sayımı gelen kategoriler. */
|
||||
export async function LowStockCard({
|
||||
userId,
|
||||
householdId,
|
||||
@@ -18,7 +18,8 @@ export async function LowStockCard({
|
||||
householdId: string;
|
||||
}) {
|
||||
const data = await getStock(userId, householdId);
|
||||
if (!data || (!data.suggestions.length && !data.expiring.length)) return null;
|
||||
if (!data || (!data.suggestions.length && !data.expiring.length && !data.countDue.length))
|
||||
return null;
|
||||
const low = data.suggestions.slice(0, LIMIT);
|
||||
const expiring = data.expiring.slice(0, Math.max(0, LIMIT - low.length) || 2);
|
||||
return (
|
||||
@@ -26,7 +27,11 @@ export async function LowStockCard({
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t.dashboard.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{t.dashboard.description(data.suggestions.length, data.expiring.length)}
|
||||
{t.dashboard.description(
|
||||
data.suggestions.length,
|
||||
data.expiring.length,
|
||||
data.countDue.length,
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
@@ -69,6 +74,23 @@ export async function LowStockCard({
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{data.countDue.slice(0, 3).map((g) => (
|
||||
<li
|
||||
key={`count-${g.categoryId}`}
|
||||
className="flex items-center justify-between gap-3 py-1.5"
|
||||
>
|
||||
<Link
|
||||
href={`/stok/sayim?kategori=${g.categoryId}`}
|
||||
className="min-w-0 truncate hover:underline"
|
||||
>
|
||||
{g.path}
|
||||
</Link>
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs text-amber-700 dark:text-amber-400">
|
||||
<ClipboardCheck className="size-3" />
|
||||
{t.count.dueBadge(g.overdueDays)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{data.suggestions.length > low.length ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatStock } from "@/shared/stock";
|
||||
import { formatStock, roundForDisplay } from "@/shared/stock";
|
||||
import { deleteStockMovementAction, setProductTypeArchivedAction } from "../actions";
|
||||
import type { ProductTypeDetail, StockMovementRow, StockOption } from "../service";
|
||||
import {
|
||||
@@ -46,7 +46,14 @@ import {
|
||||
StockMovementDialog,
|
||||
type MovementKindInput,
|
||||
} from "./stock-dialogs";
|
||||
import { ExpiryBadge, LevelBadge, QuickConsumeButton, StockBar, ymdLabel } from "./stock-view";
|
||||
import {
|
||||
ExpiryBadge,
|
||||
LevelBadge,
|
||||
QuickConsumeButton,
|
||||
StockBar,
|
||||
suggestionReasons,
|
||||
ymdLabel,
|
||||
} from "./stock-view";
|
||||
|
||||
const t = tr.stock;
|
||||
|
||||
@@ -156,6 +163,53 @@ export function ProductDetailView({
|
||||
<Stat label={t.detail.suggested}>{q(data.suggestedQuantity)}</Stat>
|
||||
</div>
|
||||
<StockBar item={data} />
|
||||
{suggestionReasons(data).length ? (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs",
|
||||
data.suggestion.waste
|
||||
? "text-amber-700 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{suggestionReasons(data).join(" ")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2">
|
||||
<Stat label={t.detail.usage}>
|
||||
{data.suggestion.usage ? (
|
||||
t.detail.perWeek(q(roundForDisplay(data.suggestion.usage.perDay * 7)))
|
||||
) : (
|
||||
<span className="font-normal text-muted-foreground">{t.detail.noUsage}</span>
|
||||
)}
|
||||
</Stat>
|
||||
<Stat label={t.detail.countSchedule}>
|
||||
{data.count ? (
|
||||
<span className="flex flex-wrap items-center gap-x-2">
|
||||
<span>{t.detail.countEvery(data.count.everyDays)}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-normal",
|
||||
data.count.due
|
||||
? "text-amber-700 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{data.count.due
|
||||
? tr.stock.count.dueBadge(data.count.overdueDays)
|
||||
: t.detail.nextCount(ymdLabel(data.count.nextDate))}
|
||||
{data.count.lastDate
|
||||
? ` · ${tr.stock.count.lastCount(ymdLabel(data.count.lastDate))}`
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{t.detail.noCountSchedule}
|
||||
</span>
|
||||
)}
|
||||
</Stat>
|
||||
</div>
|
||||
{data.location ? (
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">{t.detail.location}:</span> {data.location}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatStock } from "@/shared/stock";
|
||||
import type { StockAnalysis, StockAnalysisRow } from "../service";
|
||||
|
||||
const t = tr.stock.analysis;
|
||||
/** Bu oranın (%) üstündeki fire vurgulanır. */
|
||||
const HIGH_WASTE_PCT = 15;
|
||||
|
||||
const monthShort = (month: string) =>
|
||||
new Intl.DateTimeFormat("tr-TR", { month: "short" }).format(new Date(`${month}-15T12:00:00Z`));
|
||||
const monthLong = (month: string) =>
|
||||
new Intl.DateTimeFormat("tr-TR", { month: "long", year: "numeric" }).format(
|
||||
new Date(`${month}-15T12:00:00Z`),
|
||||
);
|
||||
|
||||
/** Ürünün kendi ölçeğinde son aylar: tüketim (alt) + fire (üst), taban çizgisine oturur. */
|
||||
function Trend({ row }: { row: StockAnalysisRow }) {
|
||||
const max = Math.max(1, ...row.months.map((m) => m.used + m.wasted));
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label={t.chartLabel(row.name)}
|
||||
className="flex h-8 w-full items-end gap-1 border-b border-border"
|
||||
>
|
||||
{row.months.map((m) => {
|
||||
const total = m.used + m.wasted;
|
||||
const label = t.monthValue(
|
||||
monthLong(m.month),
|
||||
formatStock(m.used, row.unit),
|
||||
formatStock(m.wasted, row.unit),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={m.month}
|
||||
title={label}
|
||||
className="flex h-full min-w-0 flex-1 flex-col justify-end gap-0.5 hover:opacity-80"
|
||||
>
|
||||
{m.wasted > 0 ? (
|
||||
<div
|
||||
className="w-full rounded-t-[3px] bg-expense"
|
||||
style={{ height: `${(m.wasted / max) * 100}%`, minHeight: 2 }}
|
||||
/>
|
||||
) : null}
|
||||
{m.used > 0 ? (
|
||||
<div
|
||||
className={cn("w-full bg-budget", m.wasted > 0 ? "" : "rounded-t-[3px]")}
|
||||
style={{ height: `${(m.used / max) * 100}%`, minHeight: 2 }}
|
||||
/>
|
||||
) : null}
|
||||
{total === 0 ? <span className="sr-only">{label}</span> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Num({ value, unit, tone }: { value: number | null; unit: string; tone?: string }) {
|
||||
return (
|
||||
<span className={cn("tabular-nums", value === null && "text-muted-foreground", tone)}>
|
||||
{value === null ? t.noData : formatStock(value, unit)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** /stok/analiz: ürün tipi başına aylık ortalama tüketim, fire ve son ayların eğilimi. */
|
||||
export function StockAnalysisView({ data }: { data: StockAnalysis }) {
|
||||
const groups = new Map<string, StockAnalysisRow[]>();
|
||||
for (const r of data.rows) groups.set(r.categoryPath, [...(groups.get(r.categoryPath) ?? []), r]);
|
||||
const first = data.months[0]!;
|
||||
const last = data.months.at(-1)!;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href="/stok">
|
||||
<ArrowLeft />
|
||||
{t.back}
|
||||
</Link>
|
||||
</Button>
|
||||
<PageHeader title={t.title} description={t.description} />
|
||||
|
||||
{data.rows.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.empty}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t.trend(data.months.length)}: {monthShort(first)} – {monthShort(last)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span aria-hidden className="size-2.5 rounded-sm bg-budget" />
|
||||
{tr.stock.kinds.consume}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span aria-hidden className="size-2.5 rounded-sm bg-expense" />
|
||||
{tr.stock.kinds.waste}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border bg-card">
|
||||
<div className="hidden grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_repeat(4,minmax(0,0.7fr))] gap-3 border-b px-3 py-2 text-xs text-muted-foreground md:grid">
|
||||
<span>{t.product}</span>
|
||||
<span>{t.trend(data.months.length)}</span>
|
||||
<span className="text-right">{t.avgUsed}</span>
|
||||
<span className="text-right">{t.avgWasted}</span>
|
||||
<span className="text-right">{t.wastePct}</span>
|
||||
<span className="text-right">{t.thisMonth}</span>
|
||||
</div>
|
||||
{[...groups.entries()].map(([path, rows]) => (
|
||||
<section key={path}>
|
||||
<h2 className="border-b bg-muted/40 px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{path}
|
||||
</h2>
|
||||
<ul className="divide-y">
|
||||
{rows.map((r) => {
|
||||
const high = r.wastePct !== null && r.wastePct >= HIGH_WASTE_PCT;
|
||||
return (
|
||||
<li
|
||||
key={r.id}
|
||||
className="grid grid-cols-2 items-center gap-x-3 gap-y-2 px-3 py-2.5 text-sm md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_repeat(4,minmax(0,0.7fr))]"
|
||||
>
|
||||
<Link
|
||||
href={`/stok/${r.id}`}
|
||||
className="min-w-0 truncate font-medium hover:underline"
|
||||
>
|
||||
{r.name}
|
||||
</Link>
|
||||
<Trend row={r} />
|
||||
<p className="flex justify-between gap-2 md:block md:text-right">
|
||||
<span className="text-xs text-muted-foreground md:hidden">
|
||||
{t.avgUsed}
|
||||
</span>
|
||||
<Num value={r.avgMonthlyUsed} unit={r.unit} />
|
||||
</p>
|
||||
<p className="flex justify-between gap-2 md:block md:text-right">
|
||||
<span className="text-xs text-muted-foreground md:hidden">
|
||||
{t.avgWasted}
|
||||
</span>
|
||||
<Num value={r.avgMonthlyWasted} unit={r.unit} />
|
||||
</p>
|
||||
<p className="flex justify-between gap-2 md:block md:text-right">
|
||||
<span className="text-xs text-muted-foreground md:hidden">
|
||||
{t.wastePct}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
high ? "font-medium text-expense" : "",
|
||||
r.wastePct === null && "text-muted-foreground",
|
||||
)}
|
||||
title={high ? t.highWaste : undefined}
|
||||
>
|
||||
{r.wastePct === null ? t.noData : `%${r.wastePct}`}
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex justify-between gap-2 md:block md:text-right">
|
||||
<span className="text-xs text-muted-foreground md:hidden">
|
||||
{t.thisMonth}
|
||||
</span>
|
||||
<Num value={r.thisMonth.used} unit={r.unit} />
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, ChevronRight, Clock, FolderTree, Plus, Search } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChartColumn,
|
||||
ChevronRight,
|
||||
ClipboardCheck,
|
||||
Clock,
|
||||
FolderTree,
|
||||
Plus,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
@@ -111,6 +120,17 @@ export function QuickConsumeButton({ item }: { item: ProductTypeView }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Önerilen miktarın gerekçesi: fire indirimi, tüketim hızı yuvarlaması, kaç gün yeter. */
|
||||
export function suggestionReasons(item: ProductTypeView): string[] {
|
||||
const s = item.suggestion;
|
||||
if (s.quantity === 0) return [];
|
||||
const out: string[] = [];
|
||||
if (s.waste) out.push(t.suggestions.wasteReduced(s.waste.purchases, s.waste.pct));
|
||||
if (s.roundedByUsage) out.push(t.suggestions.roundedByUsage(formatStock(s.roundTo, item.unit)));
|
||||
if (s.coverDays !== null) out.push(t.suggestions.covers(s.coverDays));
|
||||
return out;
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: number; tone?: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
@@ -144,6 +164,18 @@ export function StockView({ data, options }: { data: Data; options: StockOption[
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/stok/analiz">
|
||||
<ChartColumn />
|
||||
{t.analysisLink}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/stok/sayim">
|
||||
<ClipboardCheck />
|
||||
{t.countLink}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/stok/kategoriler">
|
||||
<FolderTree />
|
||||
@@ -182,26 +214,76 @@ export function StockView({ data, options }: { data: Data; options: StockOption[
|
||||
<p className="text-sm text-muted-foreground">{t.suggestions.empty}</p>
|
||||
) : (
|
||||
<ul className="divide-y text-sm">
|
||||
{data.suggestions.map((i) => (
|
||||
<li key={i.id} className="flex items-center justify-between gap-3 py-2">
|
||||
<Link href={`/stok/${i.id}`} className="min-w-0 hover:underline">
|
||||
<span className="block truncate font-medium">{i.name}</span>
|
||||
<span className="block text-xs text-muted-foreground tabular-nums">
|
||||
{t.list.of(
|
||||
formatStock(Math.max(0, i.current), i.unit),
|
||||
formatStock(i.targetQuantity, i.unit),
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
<span className="shrink-0 text-right font-medium tabular-nums">
|
||||
{t.suggestions.suggested(formatStock(i.suggestedQuantity, i.unit))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{data.suggestions.map((i) => {
|
||||
const reasons = suggestionReasons(i);
|
||||
return (
|
||||
<li key={i.id} className="flex flex-col gap-0.5 py-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Link href={`/stok/${i.id}`} className="min-w-0 hover:underline">
|
||||
<span className="block truncate font-medium">{i.name}</span>
|
||||
<span className="block text-xs text-muted-foreground tabular-nums">
|
||||
{t.list.of(
|
||||
formatStock(Math.max(0, i.current), i.unit),
|
||||
formatStock(i.targetQuantity, i.unit),
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
<span className="shrink-0 text-right font-medium tabular-nums">
|
||||
{t.suggestions.suggested(formatStock(i.suggestedQuantity, i.unit))}
|
||||
</span>
|
||||
</div>
|
||||
{reasons.length ? (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs",
|
||||
i.suggestion.waste
|
||||
? "text-amber-700 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{reasons.join(" ")}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{data.countDue.length ? (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t.countDue.title}</CardTitle>
|
||||
<CardDescription>{t.countDue.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y text-sm">
|
||||
{data.countDue.map((g) => (
|
||||
<li key={g.categoryId} className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<span className="block truncate font-medium">{g.path}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t.countDue.products(g.products)} ·{" "}
|
||||
<span className="text-amber-700 dark:text-amber-400">
|
||||
{t.countDue.overdue(g.overdueDays)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{data.canWrite ? (
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href={`/stok/sayim?kategori=${g.categoryId}`}>
|
||||
<ClipboardCheck />
|
||||
{t.countDue.start}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{data.expiring.length ? (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
|
||||
Reference in New Issue
Block a user