diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5f39b0..2ab2316 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,28 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
## [Yayınlanmamış]
+### Eklendi — Faz 11a: ev stoğu
+
+- Stok kategorileri (`stockCategories`): gider kategorilerinden ayrı, sınırsız derinlikte ağaç; varsayılan set (Mutfak › Manav, Şarküteri, Bakliyat; Genel Temizlik › Bulaşık, Çamaşır, Genel; Kişisel Temizlik › Şampuan, Tıraş, Kişisel Bakım) yeni hanede ve migration ile mevcut hanelere yüklenir. Her kategori bir gider kategorisine eşlenebilir (alt kategoriler üstten devralır; Faz 13'te alışveriş gideri için) ve sayım sıklığı tutar. Taşıma, arşiv (içinde aktif ürün yoksa) ve arşivden çıkarma; `/stok/kategoriler`.
+- Ürün tipleri (`productTypes`): kategori, temel ölçü birimi (kg, lt, adet … veya özel birim: "yıkama", "tablet"), hedef stok, kritik eşik yüzdesi (varsayılan %20), saklama yeri, "1 azalt" miktarı, not; ilk kurulumda evdeki başlangıç miktarı.
+- Stok hareketleri (`stockMovements`): alım (isteğe bağlı SKT), tüketim, fire/zarar (nedenli) ve sayım düzeltmesi (gerçek miktar girilir, fark işlenir). Mevcut miktar hareketlerden türetilir; stokta olandan fazla tüketim/fire reddedilir. Hareket silme denetim kaydına yazılır.
+- SKT takibi FIFO partileriyle: çıkışlar en eski girişten düşer; evde kalan miktarın SKT'si 7 gün içinde dolan veya geçmiş partiler uyarılır.
+- `/stok`: özet, "Alınması önerilenler" (eşiğin altındakiler ve hedefe tamamlayan miktar), SKT uyarıları, kategoriye göre ürün listesi (mevcut/hedef çubuğu ve eşik çizgisi, arama, "yalnızca azalanlar"), tek dokunuşla "1 azalt". `/stok/:id`: partiler ve hareket geçmişi.
+- Dashboard "Azalan stoklar" kartı.
+- Seed: Çakır Ailesi için 19 ürün tipi ve son 6 ayın haftalık alım/tüketim/fire/sayım simülasyonu.
+- Testler: mevcut miktar, FIFO partileri, SKT durumu, eşik ve öneri (birim); görünüm, izolasyon, hareket kuralları, sayım, kategori kuralları (entegrasyon).
+
+### Eklendi — Faz 11a: ev stoğu
+
+- Stok kategorileri (`stockCategories`): gider kategorilerinden ayrı, sınırsız derinlikte ağaç (en fazla 5 seviye). Varsayılan set (Mutfak › Manav, Şarküteri, Bakliyat; Genel Temizlik › Bulaşık, Çamaşır, Genel; Kişisel Temizlik › Şampuan, Tıraş, Kişisel Bakım) yeni hanelerde ve migration ile mevcut hanelerde kurulur. Her kategori bir gider kategorisine eşlenebilir (üstten devralınır; alışveriş tamamlama için), sayım sıklığı alanı (üstten devralınır). `/stok/kategoriler`: ekle, düzenle, taşı, arşivle (içinde aktif ürün varken arşivlenemez).
+- Ürün tipleri (`productTypes`): kategori, temel birim (kg, lt, adet … veya özel birim: "yıkama", "tablet"), hedef stok, kritik eşik (%20 varsayılan), saklama yeri, "1 azalt" miktarı, not; ilk kurulumda evdeki mevcut miktar. Arşivleme.
+- Stok hareketleri (`stockMovements`): alım (isteğe bağlı son kullanma tarihi), tüketim, fire/zarar (neden ile), sayım düzeltmesi (gerçek miktar girilir, fark otomatik). Mevcut miktar hareketlerden türetilir; stoktan fazla tüketim/fire reddedilir; hareket silme denetim kaydına yazılır.
+- SKT takibi FIFO: çıkışlar en eski alımdan düşülür; evde kalan partilerin SKT'si 7 gün içinde dolan veya geçmiş olanlar uyarılır.
+- `/stok`: özet (ürün, azalan, SKT yaklaşan/geçmiş), "Alınması önerilenler" (eşiğin altındakiler ve hedefe tamamlayan miktar), SKT listesi, kategoriye göre ürünler (mevcut/hedef çubuğu ve eşik çizgisi), arama, "yalnızca azalanlar", tek dokunuşla "1 azalt". `/stok/:id`: durum, partiler, hareketler, alım/tüketim/fire/sayım.
+- Dashboard "Azalan stoklar" kartı.
+- Seed: Çakır Ailesi için 19 ürün tipi ve son 6 ayın haftalık tüketim, alım, fire ve sayım hareketleri.
+- Testler: mevcut miktar, FIFO partileri, SKT durumu, eşik ve öneri (birim); görünüm, gider eşlemesi, izolasyon, hareket kuralları, sayım, arşiv ve kategori kuralları (entegrasyon).
+
## [0.10.0] - 2026-11-06
Faz 10: aylık bütçe, gelir planı ve günlük plan (10a ve 10b alt fazları).
diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx
index b36d3cc..ee2af1c 100644
--- a/src/app/(app)/dashboard/page.tsx
+++ b/src/app/(app)/dashboard/page.tsx
@@ -11,6 +11,7 @@ import { getBudget } from "@/features/budgets/service";
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
import { getNetWorth } from "@/features/net-worth/service";
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
+import { LowStockCard } from "@/features/stock/components/low-stock-card";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
import { cn } from "@/lib/utils";
@@ -105,6 +106,9 @@ export default async function DashboardPage() {
+
+
+
>
);
}
diff --git a/src/app/(app)/stok/[id]/page.tsx b/src/app/(app)/stok/[id]/page.tsx
new file mode 100644
index 0000000..dfcc8c5
--- /dev/null
+++ b/src/app/(app)/stok/[id]/page.tsx
@@ -0,0 +1,20 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { ProductDetailView } from "@/features/stock/components/product-detail-view";
+import { getProductType, getStockOptions } from "@/features/stock/service";
+import { tr } from "@/i18n/tr";
+import { requireActiveHousehold } from "@/lib/dal";
+
+export const metadata: Metadata = { title: tr.stock.detail.movements };
+
+/** /stok/:id — ürün tipi: mevcut durum, partiler ve hareketler (başka hanenin kaydı → 404). */
+export default async function ProductTypePage({ params }: PageProps<"/stok/[id]">) {
+ const { user, household } = await requireActiveHousehold("viewer");
+ const { id } = await params;
+ const [data, options] = await Promise.all([
+ getProductType(user.id, household.id, id),
+ getStockOptions(user.id, household.id),
+ ]);
+ if (!data || !options) notFound();
+ return ;
+}
diff --git a/src/app/(app)/stok/kategoriler/page.tsx b/src/app/(app)/stok/kategoriler/page.tsx
new file mode 100644
index 0000000..dd9b533
--- /dev/null
+++ b/src/app/(app)/stok/kategoriler/page.tsx
@@ -0,0 +1,29 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { PageHeader } from "@/components/page-header";
+import { StockCategoriesManager } from "@/features/stock/components/stock-categories-manager";
+import { getStockOptions, listStockCategories } from "@/features/stock/service";
+import { tr } from "@/i18n/tr";
+import { requireActiveHousehold } from "@/lib/dal";
+
+export const metadata: Metadata = { title: tr.stock.categories.title };
+
+/** /stok/kategoriler — stok kategori ağacı, gider eşlemesi ve sayım sıklığı. */
+export default async function StockCategoriesPage() {
+ const { user, household } = await requireActiveHousehold("viewer");
+ const [data, options] = await Promise.all([
+ listStockCategories(user.id, household.id),
+ getStockOptions(user.id, household.id),
+ ]);
+ if (!data || !options) notFound();
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/app/(app)/stok/page.tsx b/src/app/(app)/stok/page.tsx
new file mode 100644
index 0000000..982f939
--- /dev/null
+++ b/src/app/(app)/stok/page.tsx
@@ -0,0 +1,26 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { PageHeader } from "@/components/page-header";
+import { StockView } from "@/features/stock/components/stock-view";
+import { getStock, getStockOptions } from "@/features/stock/service";
+import { tr } from "@/i18n/tr";
+import { requireActiveHousehold } from "@/lib/dal";
+
+export const metadata: Metadata = { title: tr.stock.title };
+
+/** /stok — ev stoğu: özet, alınması önerilenler, SKT uyarıları, kategoriye göre ürünler. */
+export default async function StockPage() {
+ const { user, household } = await requireActiveHousehold("viewer");
+ const [data, options] = await Promise.all([
+ getStock(user.id, household.id),
+ getStockOptions(user.id, household.id),
+ ]);
+ if (!data || !options) notFound();
+ return (
+ <>
+
+ {!data.canWrite ?
{tr.stock.readOnly}
: null}
+
+ >
+ );
+}
diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts
index d5f42f2..3b712d8 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 = 10.2;
+export const CURRENT_PHASE = 11.1;
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
@@ -40,7 +40,7 @@ export const MAIN_NAV: NavItem[] = [
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 },
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 },
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.1 },
- { href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 },
+ { href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 },
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
diff --git a/src/features/stock/components/low-stock-card.tsx b/src/features/stock/components/low-stock-card.tsx
new file mode 100644
index 0000000..e00b39b
--- /dev/null
+++ b/src/features/stock/components/low-stock-card.tsx
@@ -0,0 +1,84 @@
+import { 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";
+import { tr } from "@/i18n/tr";
+import { formatStock } from "@/shared/stock";
+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). */
+export async function LowStockCard({
+ userId,
+ householdId,
+}: {
+ userId: string;
+ householdId: string;
+}) {
+ const data = await getStock(userId, householdId);
+ if (!data || (!data.suggestions.length && !data.expiring.length)) return null;
+ const low = data.suggestions.slice(0, LIMIT);
+ const expiring = data.expiring.slice(0, Math.max(0, LIMIT - low.length) || 2);
+ return (
+
+
+ {t.dashboard.title}
+
+ {t.dashboard.description(data.suggestions.length, data.expiring.length)}
+
+
+
+
+ {low.map((i) => (
+ -
+
+ {i.name}
+
+
+ {formatStock(Math.max(0, i.current), i.unit)}
+
+ {" · "}
+ {t.suggestions.suggested(formatStock(i.suggestedQuantity, i.unit))}
+
+
+
+ ))}
+ {expiring.map(({ item, lot }) => (
+ -
+
+ {item.name}
+
+
+
+ {t.expiring.on(
+ new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short" }).format(
+ new Date(`${lot.expiryDate}T12:00:00+03:00`),
+ ),
+ )}
+
+
+ ))}
+
+ {data.suggestions.length > low.length ? (
+
+ {t.dashboard.more(data.suggestions.length - low.length)}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/features/stock/components/product-detail-view.tsx b/src/features/stock/components/product-detail-view.tsx
new file mode 100644
index 0000000..ce9a43e
--- /dev/null
+++ b/src/features/stock/components/product-detail-view.tsx
@@ -0,0 +1,326 @@
+"use client";
+
+import {
+ Archive,
+ ArchiveRestore,
+ ArrowLeft,
+ ClipboardCheck,
+ EllipsisVertical,
+ Minus,
+ Pencil,
+ ShoppingBag,
+ Trash,
+ Trash2,
+} from "lucide-react";
+import Link from "next/link";
+import { useState } from "react";
+import { toast } from "sonner";
+import { PageHeader } from "@/components/page-header";
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { tr } from "@/i18n/tr";
+import { cn } from "@/lib/utils";
+import { formatStock } from "@/shared/stock";
+import { deleteStockMovementAction, setProductTypeArchivedAction } from "../actions";
+import type { ProductTypeDetail, StockMovementRow, StockOption } from "../service";
+import {
+ ProductTypeFormDialog,
+ StockCountDialog,
+ StockMovementDialog,
+ type MovementKindInput,
+} from "./stock-dialogs";
+import { ExpiryBadge, LevelBadge, QuickConsumeButton, StockBar, ymdLabel } from "./stock-view";
+
+const t = tr.stock;
+
+type Pending =
+ | { kind: "edit" }
+ | { kind: "movement"; movement: MovementKindInput }
+ | { kind: "count" }
+ | { kind: "archive" }
+ | { kind: "deleteMovement"; row: StockMovementRow }
+ | null;
+
+function Stat({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+/** /stok/:id — mevcut durum, partiler (SKT) ve hareketler. */
+export function ProductDetailView({
+ data,
+ categories,
+}: {
+ data: ProductTypeDetail;
+ categories: StockOption[];
+}) {
+ const [pending, setPending] = useState(null);
+ const close = () => setPending(null);
+ const q = (x: number) => formatStock(x, data.unit);
+ const active = data.canWrite && !data.archived;
+
+ return (
+
+
+
+
+
+
+
+ setPending({ kind: "edit" })}
+ >
+
+ {tr.common.edit}
+
+
+ {data.archived ? (
+ {
+ const r = await setProductTypeArchivedAction(data.id, false);
+ if (r.ok) toast.success(t.toast.unarchived);
+ else toast.error(r.error);
+ }}
+ >
+
+ {t.detail.unarchive}
+
+ ) : (
+ setPending({ kind: "archive" })}
+ >
+
+ {t.detail.archive}
+
+ )}
+
+
+ ) : null
+ }
+ />
+
+ {data.archived ? {t.detail.archived} : null}
+
+
+
+
+
+
+
+
+ {q(Math.max(0, data.current))}
+
+
+ {q(data.targetQuantity)}
+
+ {q(data.level.thresholdQuantity)}{" "}
+
+ (%{data.thresholdPct})
+
+
+ {q(data.suggestedQuantity)}
+
+
+ {data.location ? (
+
+ {t.detail.location}: {data.location}
+
+ ) : null}
+ {data.note ? {data.note}
: null}
+ {active ? (
+
+
+
+
+
+
+
+ ) : null}
+
+
+
+
+
+ {t.detail.lots}
+ {t.detail.lotsHint}
+
+
+ {data.lots.length === 0 ? (
+ {t.detail.noLots}
+ ) : (
+
+ {data.lots.map((lot, i) => (
+ -
+
+ {q(lot.remaining)}
+ · {ymdLabel(lot.date)}
+
+ {lot.expiryDate && lot.status && lot.status !== "ok" ? (
+
+ ) : (
+
+ {lot.expiryDate ? t.expiring.on(ymdLabel(lot.expiryDate)) : t.detail.noExpiry}
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+
+
+ {t.detail.movements}
+
+ {data.movements.length === 0 ? (
+
+ {t.detail.noMovements}
+
+ ) : (
+
+ {data.movements.map((row) => (
+ -
+
+
+ {row.initial ? t.initial : t.kinds[row.kind]}
+ 0 ? "text-income" : "text-expense")}
+ >
+ {row.delta > 0 ? "+" : "−"}
+ {q(Math.abs(row.delta))}
+
+
+
+ {ymdLabel(row.date)}
+ {row.wasteReason ? ` · ${t.wasteReasons[row.wasteReason]}` : ""}
+ {row.expiryDate ? ` · ${t.expiring.on(ymdLabel(row.expiryDate))}` : ""}
+ {row.note ? ` · ${row.note}` : ""}
+
+
+
+ {t.detail.after(q(row.quantityAfter))}
+
+ {data.canWrite ? (
+
+ ) : null}
+
+ ))}
+
+ )}
+
+
+ {pending?.kind === "edit" ? (
+
+ ) : null}
+ {pending?.kind === "movement" ? (
+
+ ) : null}
+ {pending?.kind === "count" ? : null}
+ !o && close()}
+ >
+
+
+
+ {pending?.kind === "archive" ? t.detail.archiveTitle : t.detail.deleteMovementTitle}
+
+
+ {pending?.kind === "archive"
+ ? t.detail.archiveDescription(data.name)
+ : t.detail.deleteMovementDescription}
+
+
+
+ {tr.common.cancel}
+
+
+
+
+
+ );
+}
diff --git a/src/features/stock/components/stock-categories-manager.tsx b/src/features/stock/components/stock-categories-manager.tsx
new file mode 100644
index 0000000..97a82fa
--- /dev/null
+++ b/src/features/stock/components/stock-categories-manager.tsx
@@ -0,0 +1,353 @@
+"use client";
+
+import {
+ Archive,
+ ArchiveRestore,
+ ArrowLeft,
+ EllipsisVertical,
+ FolderPlus,
+ Pencil,
+ Plus,
+} from "lucide-react";
+import Link from "next/link";
+import { useMemo, useState } from "react";
+import { toast } from "sonner";
+import { ResponsiveDialog } from "@/components/responsive-dialog";
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
+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 { saveStockCategoryAction, setStockCategoryArchivedAction } from "../actions";
+import type { StockCategoryView, StockOption } from "../service";
+
+const t = tr.stock.categories;
+const NONE = "__none__";
+type Errors = Record;
+
+type Pending =
+ | { kind: "create"; parentId: string | null }
+ | { kind: "edit"; category: StockCategoryView }
+ | { kind: "archive"; category: StockCategoryView }
+ | null;
+
+/** /stok/kategoriler: ağaç, gider eşlemesi, sayım sıklığı, arşiv. */
+export function StockCategoriesManager({
+ categories,
+ expenseCategories,
+ canWrite,
+}: {
+ categories: StockCategoryView[];
+ expenseCategories: StockOption[];
+ canWrite: boolean;
+}) {
+ const [pending, setPending] = useState(null);
+ const [showArchived, setShowArchived] = useState(false);
+ const close = () => setPending(null);
+ const visible = categories.filter((c) => showArchived || !c.archived);
+
+ async function unarchive(c: StockCategoryView) {
+ const r = await setStockCategoryArchivedAction(c.id, false);
+ if (r.ok) toast.success(tr.stock.toast.unarchived);
+ else toast.error(r.error);
+ }
+
+ return (
+
+
+
+
+ {canWrite ? (
+
+ ) : null}
+
+
+ {visible.length === 0 ? (
+
+ {t.empty}
+
+ ) : (
+
+ {visible.map((c) => (
+ -
+
+
+ {c.name}
+ {c.isDefault ? {t.defaultBadge} : null}
+ {c.archived ? {t.archived} : null}
+
+
+ {[
+ c.expense
+ ? c.expense.inherited
+ ? t.inherited(c.expense.path)
+ : c.expense.path
+ : t.noExpense,
+ c.effectiveCountDays ? t.everyDays(c.effectiveCountDays) : null,
+ c.productCount ? t.productCount(c.productCount) : null,
+ ]
+ .filter(Boolean)
+ .join(" · ")}
+
+
+ {canWrite ? (
+
+
+
+
+
+ {c.archived ? (
+ unarchive(c)}>
+
+ {t.unarchive}
+
+ ) : (
+ <>
+ setPending({ kind: "create", parentId: c.id })}
+ >
+
+ {t.addChild}
+
+ setPending({ kind: "edit", category: c })}
+ >
+
+ {t.edit}
+
+
+ setPending({ kind: "archive", category: c })}
+ >
+
+ {t.archive}
+
+ >
+ )}
+
+
+ ) : null}
+
+ ))}
+
+ )}
+
+ {pending?.kind === "create" || pending?.kind === "edit" ? (
+
+ ) : null}
+
!o && close()}>
+
+
+ {t.archiveTitle}
+
+ {pending?.kind === "archive" ? t.archiveDescription(pending.category.name) : null}
+
+
+
+ {tr.common.cancel}
+
+
+
+
+
+ );
+}
+
+function StockCategoryDialog({
+ category,
+ parentId,
+ categories,
+ expenseCategories,
+ onClose,
+}: {
+ category: StockCategoryView | null;
+ parentId: string | null;
+ categories: StockCategoryView[];
+ expenseCategories: StockOption[];
+ onClose: () => void;
+}) {
+ const [v, setV] = useState({
+ name: category?.name ?? "",
+ parentId: parentId ?? "",
+ expenseCategoryId: category?.expenseCategoryId ?? "",
+ countEveryDays: category?.countEveryDays ? String(category.countEveryDays) : "",
+ });
+ const [errors, setErrors] = useState({});
+ const [busy, setBusy] = useState(false);
+ const set = (k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
+
+ // Kendisi ve alt ağacı üst olarak seçilemez.
+ const parents = useMemo(() => {
+ if (!category) return categories.filter((c) => !c.archived);
+ const blocked = new Set([category.id]);
+ for (const c of categories) if (c.parentId && blocked.has(c.parentId)) blocked.add(c.id);
+ return categories.filter((c) => !c.archived && !blocked.has(c.id));
+ }, [categories, category]);
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setBusy(true);
+ const r = await saveStockCategoryAction(category?.id ?? null, v);
+ setBusy(false);
+ if (r.ok) {
+ toast.success(tr.stock.toast.saved);
+ onClose();
+ } else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
+ }
+
+ return (
+ !o && onClose()}
+ title={category ? t.editTitle : t.createTitle}
+ footer={
+ <>
+
+
+ >
+ }
+ >
+
+
+ );
+}
diff --git a/src/features/stock/components/stock-dialogs.tsx b/src/features/stock/components/stock-dialogs.tsx
new file mode 100644
index 0000000..32b78bc
--- /dev/null
+++ b/src/features/stock/components/stock-dialogs.tsx
@@ -0,0 +1,523 @@
+"use client";
+
+import { useState } from "react";
+import { toast } from "sonner";
+import { ResponsiveDialog } from "@/components/responsive-dialog";
+import { Button } from "@/components/ui/button";
+import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { tr } from "@/i18n/tr";
+import { todayIstanbul } from "@/shared/dates";
+import {
+ formatStock,
+ quantityInput,
+ STOCK_UNITS,
+ WASTE_REASONS,
+ type WasteReason,
+} from "@/shared/stock";
+import { addStockMovementAction, countStockAction, saveProductTypeAction } from "../actions";
+import type { ProductTypeView, StockOption } from "../service";
+
+const t = tr.stock;
+type Errors = Record;
+const CUSTOM = "__custom__";
+
+function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+function errorsOf(r: { fieldErrors?: Partial>; error: string }): Errors {
+ return { ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error };
+}
+
+/** Yeni ürün tipi / düzenle. */
+export function ProductTypeFormDialog({
+ item,
+ categories,
+ defaultCategoryId,
+ onClose,
+ onCreated,
+}: {
+ item: ProductTypeView | null;
+ categories: StockOption[];
+ defaultCategoryId?: string;
+ onClose: () => void;
+ onCreated?: (id: string) => void;
+}) {
+ const standard = (STOCK_UNITS as readonly string[]).includes(item?.unit ?? "adet");
+ const [v, setV] = useState({
+ name: item?.name ?? "",
+ categoryId: item?.categoryId ?? defaultCategoryId ?? "",
+ unitChoice: standard ? (item?.unit ?? "adet") : CUSTOM,
+ customUnit: standard ? "" : (item?.unit ?? ""),
+ targetQuantity: item ? quantityInput(item.targetQuantity) : "",
+ thresholdPct: String(item?.thresholdPct ?? 20),
+ location: item?.location ?? "",
+ step: item ? quantityInput(item.step) : "1",
+ countEveryDays: item?.countEveryDays ? String(item.countEveryDays) : "",
+ note: item?.note ?? "",
+ initialQuantity: "",
+ });
+ const [errors, setErrors] = useState({});
+ const [busy, setBusy] = useState(false);
+ const set = (k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
+ const unit = v.unitChoice === CUSTOM ? v.customUnit.trim() : v.unitChoice;
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setBusy(true);
+ const r = await saveProductTypeAction(item?.id ?? null, {
+ name: v.name,
+ categoryId: v.categoryId,
+ unit,
+ targetQuantity: v.targetQuantity,
+ thresholdPct: v.thresholdPct,
+ location: v.location,
+ step: v.step,
+ countEveryDays: v.countEveryDays,
+ note: v.note,
+ initialQuantity: item ? "" : v.initialQuantity,
+ });
+ setBusy(false);
+ if (r.ok) {
+ toast.success(t.toast.saved);
+ onClose();
+ if (!item) onCreated?.((r.data as { id: string }).id);
+ } else setErrors(errorsOf(r));
+ }
+
+ const f = t.fields;
+ const unitSuffix = unit ? ` (${unit})` : "";
+ return (
+ !o && onClose()}
+ title={item ? t.editTitle : t.createTitle}
+ footer={}
+ >
+
+
+ );
+}
+
+export type MovementKindInput = "purchase" | "consume" | "waste";
+
+/** Alım, tüketim veya fire. */
+export function StockMovementDialog({
+ item,
+ initialKind,
+ onClose,
+}: {
+ item: ProductTypeView;
+ initialKind: MovementKindInput;
+ onClose: () => void;
+}) {
+ const [kind, setKind] = useState(initialKind);
+ const [v, setV] = useState({
+ date: todayIstanbul(),
+ quantity:
+ initialKind === "purchase" && item.suggestedQuantity
+ ? quantityInput(item.suggestedQuantity)
+ : "",
+ wasteReason: "" as WasteReason | "",
+ expiryDate: "",
+ note: "",
+ });
+ const [errors, setErrors] = useState({});
+ const [busy, setBusy] = useState(false);
+ const set = (k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setBusy(true);
+ const r = await addStockMovementAction(item.id, { kind, ...v });
+ setBusy(false);
+ if (r.ok) {
+ toast.success(t.toast.movementSaved);
+ onClose();
+ } else setErrors(errorsOf(r));
+ }
+
+ const f = t.fields;
+ return (
+ !o && onClose()}
+ title={`${t.movementTitles[kind]} · ${item.name}`}
+ description={`${t.detail.current}: ${formatStock(item.current, item.unit)}`}
+ footer={}
+ >
+
+
+ );
+}
+
+/** Tek ürün sayımı: gerçek miktar → fark sayım düzeltmesi. */
+export function StockCountDialog({
+ item,
+ onClose,
+}: {
+ item: ProductTypeView;
+ onClose: () => void;
+}) {
+ const [v, setV] = useState({ date: todayIstanbul(), actualQuantity: "", note: "" });
+ const [errors, setErrors] = useState({});
+ const [busy, setBusy] = useState(false);
+ const set = (k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setBusy(true);
+ const r = await countStockAction(item.id, v);
+ setBusy(false);
+ if (r.ok) {
+ const diff = (r.data as { diff: number }).diff;
+ if (diff === 0) toast.success(t.toast.noChange);
+ else
+ toast.success(
+ t.toast.counted(`${diff > 0 ? "+" : "−"}${formatStock(Math.abs(diff), item.unit)}`),
+ );
+ onClose();
+ } else setErrors(errorsOf(r));
+ }
+
+ const f = t.fields;
+ return (
+ !o && onClose()}
+ title={`${t.countTitle} · ${item.name}`}
+ description={t.countDescription}
+ footer={}
+ >
+
+
+ );
+}
diff --git a/src/features/stock/components/stock-view.tsx b/src/features/stock/components/stock-view.tsx
new file mode 100644
index 0000000..52a9640
--- /dev/null
+++ b/src/features/stock/components/stock-view.tsx
@@ -0,0 +1,315 @@
+"use client";
+
+import { AlertTriangle, ChevronRight, Clock, FolderTree, Plus, Search } from "lucide-react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useMemo, useState, useTransition } from "react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { tr } from "@/i18n/tr";
+import { cn } from "@/lib/utils";
+import { formatDate } from "@/shared/format";
+import { foldTr } from "@/shared/search";
+import { formatStock, stepLabel, type ExpiringLot } from "@/shared/stock";
+import { quickConsumeAction } from "../actions";
+import type { ProductTypeView, StockOption, StockView as Data } from "../service";
+import { ProductTypeFormDialog } from "./stock-dialogs";
+
+const t = tr.stock;
+export const ymdLabel = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
+
+/** Mevcut/hedef çubuğu; eşik çizgiyle işaretli. */
+export function StockBar({ item }: { item: ProductTypeView }) {
+ const pct = Math.min(100, item.level.pct ?? 0);
+ return (
+
+
+ {item.thresholdPct > 0 && item.thresholdPct < 100 ? (
+
+ ) : null}
+
+ );
+}
+
+export function LevelBadge({ item }: { item: ProductTypeView }) {
+ if (item.current <= 0 && item.targetQuantity > 0)
+ return {t.list.outBadge};
+ if (item.level.low)
+ return (
+
+
+ {t.list.lowBadge}
+
+ );
+ return null;
+}
+
+export function ExpiryBadge({ lot }: { lot: Pick }) {
+ return (
+
+
+ {t.expiring.on(ymdLabel(lot.expiryDate))}
+
+ );
+}
+
+/** "1 azalt" düğmesi. */
+export function QuickConsumeButton({ item }: { item: ProductTypeView }) {
+ const [pending, start] = useTransition();
+ return (
+
+ );
+}
+
+function Stat({ label, value, tone }: { label: string; value: number; tone?: string }) {
+ return (
+
+
{label}
+
0 && tone)}>{value}
+
+ );
+}
+
+/** /stok: özet, öneriler, SKT uyarıları ve kategoriye göre ürün listesi. */
+export function StockView({ data, options }: { data: Data; options: StockOption[] }) {
+ const router = useRouter();
+ const [creating, setCreating] = useState(false);
+ const [query, setQuery] = useState("");
+ const [onlyLow, setOnlyLow] = useState(false);
+
+ const expired = data.expiring.filter((e) => e.lot.status === "expired").length;
+ const groups = useMemo(() => {
+ const q = foldTr(query.trim());
+ const match = (i: ProductTypeView) =>
+ (!onlyLow || i.level.low) &&
+ (!q || foldTr(i.name).includes(q) || foldTr(i.location).includes(q));
+ return data.categories
+ .map((c) => ({
+ category: c,
+ items: data.items.filter((i) => i.categoryId === c.id && match(i)),
+ }))
+ .filter((g) => g.items.length > 0);
+ }, [data, query, onlyLow]);
+
+ return (
+
+
+
+ {data.canWrite ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t.suggestions.title}
+ {t.suggestions.description}
+
+
+ {data.suggestions.length === 0 ? (
+ {t.suggestions.empty}
+ ) : (
+
+ {data.suggestions.map((i) => (
+ -
+
+ {i.name}
+
+ {t.list.of(
+ formatStock(Math.max(0, i.current), i.unit),
+ formatStock(i.targetQuantity, i.unit),
+ )}
+
+
+
+ {t.suggestions.suggested(formatStock(i.suggestedQuantity, i.unit))}
+
+
+ ))}
+
+ )}
+
+
+ {data.expiring.length ? (
+
+
+ {t.expiring.title}
+ {t.expiring.description}
+
+
+
+ {data.expiring.map(({ item, lot }) => (
+ -
+
+ {item.name}
+
+ {formatStock(lot.remaining, item.unit)}
+
+
+
+
+ ))}
+
+
+
+ ) : null}
+
+
+ {data.items.length === 0 ? (
+
+ {t.list.empty}
+
+ ) : (
+ <>
+
+
+
+ setQuery(e.target.value)}
+ placeholder={t.list.search}
+ aria-label={t.list.search}
+ className="pl-8"
+ />
+
+
+
+ {groups.length === 0 ? (
+
{t.list.noMatch}
+ ) : null}
+ {groups.map(({ category, items }) => (
+
+ {category.path}
+
+ {items.map((item) => (
+
+ ))}
+
+
+ ))}
+ >
+ )}
+
+ {creating ? (
+
setCreating(false)}
+ onCreated={(id) => router.push(`/stok/${id}`)}
+ />
+ ) : null}
+
+ );
+}
+
+function StockRow({ item, canWrite }: { item: ProductTypeView; canWrite: boolean }) {
+ const lot = item.expiring[0];
+ return (
+
+
+
+
+
+ {item.name}
+
+ {lot ? : null}
+
+
+
+ {formatStock(Math.max(0, item.current), item.unit)}
+
+
+ {" "}
+ / {formatStock(item.targetQuantity, item.unit)}
+
+
+
+
+ {item.location ? (
+
{item.location}
+ ) : null}
+
+
+
+ {canWrite ? : null}
+
+ );
+}