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,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} />;
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ export type NavItem = {
|
|||||||
|
|
||||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
/** 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;
|
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 Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -9,7 +9,7 @@ import { getStock } from "../service";
|
|||||||
const t = tr.stock;
|
const t = tr.stock;
|
||||||
const LIMIT = 5;
|
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({
|
export async function LowStockCard({
|
||||||
userId,
|
userId,
|
||||||
householdId,
|
householdId,
|
||||||
@@ -18,7 +18,8 @@ export async function LowStockCard({
|
|||||||
householdId: string;
|
householdId: string;
|
||||||
}) {
|
}) {
|
||||||
const data = await getStock(userId, householdId);
|
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 low = data.suggestions.slice(0, LIMIT);
|
||||||
const expiring = data.expiring.slice(0, Math.max(0, LIMIT - low.length) || 2);
|
const expiring = data.expiring.slice(0, Math.max(0, LIMIT - low.length) || 2);
|
||||||
return (
|
return (
|
||||||
@@ -26,7 +27,11 @@ export async function LowStockCard({
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">{t.dashboard.title}</CardTitle>
|
<CardTitle className="text-base">{t.dashboard.title}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t.dashboard.description(data.suggestions.length, data.expiring.length)}
|
{t.dashboard.description(
|
||||||
|
data.suggestions.length,
|
||||||
|
data.expiring.length,
|
||||||
|
data.countDue.length,
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-3">
|
<CardContent className="flex flex-col gap-3">
|
||||||
@@ -69,6 +74,23 @@ export async function LowStockCard({
|
|||||||
</span>
|
</span>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
{data.suggestions.length > low.length ? (
|
{data.suggestions.length > low.length ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatStock } from "@/shared/stock";
|
import { formatStock, roundForDisplay } from "@/shared/stock";
|
||||||
import { deleteStockMovementAction, setProductTypeArchivedAction } from "../actions";
|
import { deleteStockMovementAction, setProductTypeArchivedAction } from "../actions";
|
||||||
import type { ProductTypeDetail, StockMovementRow, StockOption } from "../service";
|
import type { ProductTypeDetail, StockMovementRow, StockOption } from "../service";
|
||||||
import {
|
import {
|
||||||
@@ -46,7 +46,14 @@ import {
|
|||||||
StockMovementDialog,
|
StockMovementDialog,
|
||||||
type MovementKindInput,
|
type MovementKindInput,
|
||||||
} from "./stock-dialogs";
|
} 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;
|
const t = tr.stock;
|
||||||
|
|
||||||
@@ -156,6 +163,53 @@ export function ProductDetailView({
|
|||||||
<Stat label={t.detail.suggested}>{q(data.suggestedQuantity)}</Stat>
|
<Stat label={t.detail.suggested}>{q(data.suggestedQuantity)}</Stat>
|
||||||
</div>
|
</div>
|
||||||
<StockBar item={data} />
|
<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 ? (
|
{data.location ? (
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
<span className="text-muted-foreground">{t.detail.location}:</span> {data.location}
|
<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";
|
"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 Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
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 }) {
|
function Stat({ label, value, tone }: { label: string; value: number; tone?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -144,6 +164,18 @@ export function StockView({ data, options }: { data: Data; options: StockOption[
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<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>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href="/stok/kategoriler">
|
<Link href="/stok/kategoriler">
|
||||||
<FolderTree />
|
<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>
|
<p className="text-sm text-muted-foreground">{t.suggestions.empty}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y text-sm">
|
<ul className="divide-y text-sm">
|
||||||
{data.suggestions.map((i) => (
|
{data.suggestions.map((i) => {
|
||||||
<li key={i.id} className="flex items-center justify-between gap-3 py-2">
|
const reasons = suggestionReasons(i);
|
||||||
<Link href={`/stok/${i.id}`} className="min-w-0 hover:underline">
|
return (
|
||||||
<span className="block truncate font-medium">{i.name}</span>
|
<li key={i.id} className="flex flex-col gap-0.5 py-2">
|
||||||
<span className="block text-xs text-muted-foreground tabular-nums">
|
<div className="flex items-center justify-between gap-3">
|
||||||
{t.list.of(
|
<Link href={`/stok/${i.id}`} className="min-w-0 hover:underline">
|
||||||
formatStock(Math.max(0, i.current), i.unit),
|
<span className="block truncate font-medium">{i.name}</span>
|
||||||
formatStock(i.targetQuantity, i.unit),
|
<span className="block text-xs text-muted-foreground tabular-nums">
|
||||||
)}
|
{t.list.of(
|
||||||
</span>
|
formatStock(Math.max(0, i.current), i.unit),
|
||||||
</Link>
|
formatStock(i.targetQuantity, i.unit),
|
||||||
<span className="shrink-0 text-right font-medium tabular-nums">
|
)}
|
||||||
{t.suggestions.suggested(formatStock(i.suggestedQuantity, i.unit))}
|
</span>
|
||||||
</span>
|
</Link>
|
||||||
</li>
|
<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>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 ? (
|
{data.expiring.length ? (
|
||||||
<Card size="sm">
|
<Card size="sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
Reference in New Issue
Block a user