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
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { StockAnalysisView } from "@/features/stock/components/stock-analysis-view";
import { getStockAnalysis } from "@/features/stock/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.stock.analysis.title };
/** /stok/analiz — ürün tipi başına aylık ortalama tüketim ve fire. */
export default async function StockAnalysisPage() {
const { user, household } = await requireActiveHousehold("viewer");
const data = await getStockAnalysis(user.id, household.id);
if (!data) notFound();
return <StockAnalysisView data={data} />;
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { CountSheet } from "@/features/stock/components/count-sheet";
import { getCountSheet } from "@/features/stock/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.stock.count.title };
/** /stok/sayim?kategori=<id> — periyodik sayım: beklenen → sayılan (kategori alt ağacıyla). */
export default async function StockCountPage({ searchParams }: PageProps<"/stok/sayim">) {
const { user, household } = await requireActiveHousehold("viewer");
const sp = await searchParams;
const kategori =
typeof sp.kategori === "string" && /^[a-f0-9]{24}$/i.test(sp.kategori) ? sp.kategori : null;
const data = await getCountSheet(user.id, household.id, kategori);
if (!data) notFound();
return <CountSheet key={`${kategori ?? "all"}-${data.today}`} data={data} />;
}
+1 -1
View File
@@ -27,7 +27,7 @@ export type NavItem = {
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
export const CURRENT_PHASE = 11.1;
export const CURRENT_PHASE = 11.2;
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
@@ -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>
);
}
+99 -17
View File
@@ -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>