diff --git a/src/app/(app)/stok/analiz/page.tsx b/src/app/(app)/stok/analiz/page.tsx new file mode 100644 index 0000000..127e09e --- /dev/null +++ b/src/app/(app)/stok/analiz/page.tsx @@ -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 ; +} diff --git a/src/app/(app)/stok/sayim/page.tsx b/src/app/(app)/stok/sayim/page.tsx new file mode 100644 index 0000000..be5075f --- /dev/null +++ b/src/app/(app)/stok/sayim/page.tsx @@ -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= — 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 ; +} diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index 3b712d8..e6ee9d1 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -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; diff --git a/src/features/stock/components/count-sheet.tsx b/src/features/stock/components/count-sheet.tsx new file mode 100644 index 0000000..b5c65f3 --- /dev/null +++ b/src/features/stock/components/count-sheet.tsx @@ -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>({}); + const [errors, setErrors] = useState>({}); + 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(); + 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) => + 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 = {}; + 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 ( +
+ + + +
+ + {dueTotal > 0 ? ( + + ) : null} + {data.canWrite && rows.length ? ( +
+ + {filled ? ( + + ) : null} +
+ ) : null} +
+ + {rows.length === 0 ? ( +

+ {t.count.empty} +

+ ) : null} + + {groups.map((g) => ( +
+

{g.path}

+
    + {g.rows.map((r) => ( + set(r.id, patch)} + /> + ))} +
+
+ ))} + + {data.canWrite && data.rows.length ? ( +
+ setNote(e.target.value)} + placeholder={t.count.note} + aria-label={t.count.note} + maxLength={300} + className="min-w-40 flex-1" + /> + + {t.count.counted(filled)} + + +
+ ) : null} +
+ ); +} + +function CountRow({ + row, + entry, + errors, + disabled, + onChange, +}: { + row: CountSheetRow; + entry: Entry; + errors: Record; + disabled: boolean; + onChange: (patch: Partial) => 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 ( +
  • +
    +
    + +

    + {[ + row.location, + row.count + ? row.count.lastDate + ? t.count.lastCount(ymdLabel(row.count.lastDate)) + : t.count.neverCounted + : null, + ] + .filter(Boolean) + .join(" · ")} +

    +
    +
    +
    +

    {t.count.expected}

    + {formatStock(Math.max(0, row.expected), row.unit)} +
    +
    + + {t.count.actual} ({row.unit}) + + onChange({ actual: e.target.value })} + disabled={disabled} + aria-invalid={Boolean(actualError) || (entry.actual.trim() !== "" && actual === null)} + className="h-8 tabular-nums" + /> +
    +
    +

    {t.count.diff}

    + 0 + ? "text-income" + : diff < 0 + ? "text-expense" + : "text-muted-foreground", + )} + > + {diff === null ? "—" : signed(diff, row.unit)} + +
    +
    +
    + {actualError ?

    {actualError}

    : null} + {shortage > 0 ? ( +
    + {t.count.markWaste}: + 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" + /> + + {wasteError ?

    {wasteError}

    : null} +
    + ) : null} +
  • + ); +} diff --git a/src/features/stock/components/low-stock-card.tsx b/src/features/stock/components/low-stock-card.tsx index e00b39b..67cb422 100644 --- a/src/features/stock/components/low-stock-card.tsx +++ b/src/features/stock/components/low-stock-card.tsx @@ -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({ {t.dashboard.title} - {t.dashboard.description(data.suggestions.length, data.expiring.length)} + {t.dashboard.description( + data.suggestions.length, + data.expiring.length, + data.countDue.length, + )} @@ -69,6 +74,23 @@ export async function LowStockCard({ ))} + {data.countDue.slice(0, 3).map((g) => ( +
  • + + {g.path} + + + + {t.count.dueBadge(g.overdueDays)} + +
  • + ))} {data.suggestions.length > low.length ? (

    diff --git a/src/features/stock/components/product-detail-view.tsx b/src/features/stock/components/product-detail-view.tsx index ce9a43e..a957398 100644 --- a/src/features/stock/components/product-detail-view.tsx +++ b/src/features/stock/components/product-detail-view.tsx @@ -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({ {q(data.suggestedQuantity)} + {suggestionReasons(data).length ? ( +

    + {suggestionReasons(data).join(" ")} +

    + ) : null} +
    + + {data.suggestion.usage ? ( + t.detail.perWeek(q(roundForDisplay(data.suggestion.usage.perDay * 7))) + ) : ( + {t.detail.noUsage} + )} + + + {data.count ? ( + + {t.detail.countEvery(data.count.everyDays)} + + {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))}` + : ""} + + + ) : ( + + {t.detail.noCountSchedule} + + )} + +
    {data.location ? (

    {t.detail.location}: {data.location} diff --git a/src/features/stock/components/stock-analysis-view.tsx b/src/features/stock/components/stock-analysis-view.tsx new file mode 100644 index 0000000..448ef5d --- /dev/null +++ b/src/features/stock/components/stock-analysis-view.tsx @@ -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 ( +

    + {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 ( +
    + {m.wasted > 0 ? ( +
    + ) : null} + {m.used > 0 ? ( +
    0 ? "" : "rounded-t-[3px]")} + style={{ height: `${(m.used / max) * 100}%`, minHeight: 2 }} + /> + ) : null} + {total === 0 ? {label} : null} +
    + ); + })} +
    + ); +} + +function Num({ value, unit, tone }: { value: number | null; unit: string; tone?: string }) { + return ( + + {value === null ? t.noData : formatStock(value, unit)} + + ); +} + +/** /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(); + 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 ( +
    + + + + {data.rows.length === 0 ? ( +

    + {t.empty} +

    + ) : ( + <> +
    + + {t.trend(data.months.length)}: {monthShort(first)} – {monthShort(last)} + + + + {tr.stock.kinds.consume} + + + + {tr.stock.kinds.waste} + +
    + +
    +
    + {t.product} + {t.trend(data.months.length)} + {t.avgUsed} + {t.avgWasted} + {t.wastePct} + {t.thisMonth} +
    + {[...groups.entries()].map(([path, rows]) => ( +
    +

    + {path} +

    +
      + {rows.map((r) => { + const high = r.wastePct !== null && r.wastePct >= HIGH_WASTE_PCT; + return ( +
    • + + {r.name} + + +

      + + {t.avgUsed} + + +

      +

      + + {t.avgWasted} + + +

      +

      + + {t.wastePct} + + + {r.wastePct === null ? t.noData : `%${r.wastePct}`} + +

      +

      + + {t.thisMonth} + + +

      +
    • + ); + })} +
    +
    + ))} +
    + + )} +
    + ); +} diff --git a/src/features/stock/components/stock-view.tsx b/src/features/stock/components/stock-view.tsx index 52a9640..915f4ff 100644 --- a/src/features/stock/components/stock-view.tsx +++ b/src/features/stock/components/stock-view.tsx @@ -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 (
    @@ -144,6 +164,18 @@ export function StockView({ data, options }: { data: Data; options: StockOption[ return (
    + + + ) : null} + + ))} + + + + ) : null} {data.expiring.length ? (