7 Commits

Author SHA1 Message Date
suphicakir 8e7e374778 feat: Faz 11b - periyodik sayım, fire ayarlı öneri ve tüketim analizi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:58:18 +03:00
suphicakir 6cc698f70b chore(release): 0.11.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:58:12 +03:00
suphicakir 07ba531d31 feat(ui): sayım ekranı, tüketim analizi ve sayım hatırlatmaları
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:58:12 +03:00
suphicakir 814bdbbc2a feat(stock): sayım oturumları, fire ayarlı öneri motoru ve tüketim analizi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:58:12 +03:00
suphicakir 5e0f1b5901 feat: Faz 11a - ev stoğu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:10:10 +03:00
suphicakir 868bb23fd9 feat(ui): ev stoğu sayfaları ve dashboard azalan stoklar kartı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:10:10 +03:00
suphicakir d9de29c18d feat(stock): stok kategorileri, ürün tipleri, hareketler ve FIFO SKT
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:10:10 +03:00
38 changed files with 6139 additions and 7 deletions
+34 -1
View File
@@ -6,6 +6,38 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
## [Yayınlanmamış] ## [Yayınlanmamış]
## [0.11.0] - 2026-11-06
Faz 11: ev stoğu (11a ve 11b alt fazları).
### Eklendi — Faz 11b: periyodik sayım, fire ayarlı öneri, tüketim analizi
- Sayım oturumları (`stockCounts`): her sayımda ürün başına beklenen, sayılan ve fireye ayrılan miktar; fark sıfır olsa da kayıt tutulur ve oluşan hareketler oturuma bağlanır (`countId`). Ürün detayındaki tek ürün sayımı da oturum oluşturur.
- Periyodik sayım ekranı (`/stok/sayim?kategori=…`): kategori (alt ağacıyla) veya tüm stok; liste hâlinde beklenen → sayılan, anlık fark; eksiğin bir kısmı nedenle fire olarak işaretlenebilir (fire, eksikten fazla olamaz), kalan fark sayım düzeltmesi olur. "Yalnızca sayımı gelenler", "Beklenenle doldur", tek transaction'da kayıt.
- Sayım takvimi: sıklık ürün tipinden, yoksa stok kategorisinden (üstten devralınır); son sayımdan (hiç sayılmadıysa ilk hareketten) n gün sonra sayım gelir. `/stok`'ta "Sayımı gelenler" kartı (kategori, ürün sayısı, gecikme) ve dashboard hatırlatması.
- Önerilen alım miktarı motoru: hedef − mevcut; son 3 alımda fire varsa (fireler FIFO ile hangi alımdan düştüyse ona yazılır, ≥ %5) fire oranı kadar düşürülür; haftalık tüketim adımdan büyükse haftalık tüketimin katına, değilse adıma yuvarlanır; hedefi aşmaz. Gerekçe gösterilir ("Son 2 alımda %30 fire, miktar azaltıldı.", "Haftalık tüketime (0,3 kg) göre yuvarlandı.", "Mevcutla birlikte yaklaşık 46 gün yeter.").
- Tüketim hızı (son 90 gün; tüketim + sayımda çıkan eksik, fire hariç) ve periyodik sayım durumu ürün detayında.
- Tüketim analizi (`/stok/analiz`): ürün tipi başına son 6 ayın tüketim/fire çubukları, aylık ortalama tüketim ve fire, fire oranı (≥ %15 vurgulu), bu ayın tüketimi. Maliyet ürün fiyatlarıyla (Faz 12) eklenecek.
- Migration `012-stock-counts`: index'ler; Faz 11a'da oturumsuz kaydedilen sayım düzeltmeleri, beklenen miktar yeniden hesaplanarak sayım oturumlarına bağlanır.
- Seed: Çakır Ailesi'nde Mutfak 15, Genel Temizlik 30 günde bir sayılır; sayım oturumları ve ara sıra sayım farkı; Mutfak'ın sayımı gecikmiş.
- Testler: FIFO fire dağılımı, son alımların fire oranı, öneri (fire indirimi, tüketim hızına yuvarlama, hedef sınırı), sayım takvimi, aylık kullanım (birim); seed sayım takvimi, toplu sayım (fire + düzeltme, oturum, sıfır fark, yetki), tüketim analizi (entegrasyon).
### Değişti — Faz 11b
- Dashboard stok kartı "Stok uyarıları" oldu: azalan ürünler, SKT yaklaşan partiler ve sayımı gelen kategoriler.
- Seed'deki sayım farkları artık periyodik sayım oturumlarında oluşur.
### 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).
## [0.10.0] - 2026-11-06 ## [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ı). Faz 10: aylık bütçe, gelir planı ve günlük plan (10a ve 10b alt fazları).
@@ -318,7 +350,8 @@ Faz 1: Framework, klasör yapısı ve altyapı.
- `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri. - `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri.
- `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`. - `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`.
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.10.0...HEAD [Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.11.0...HEAD
[0.11.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.10.0...v0.11.0
[0.10.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.9.0...v0.10.0 [0.10.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.9.0...v0.10.0
[0.9.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.8.0...v0.9.0 [0.9.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.8.0...v0.9.0
[0.8.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.7.0...v0.8.0 [0.8.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.7.0...v0.8.0
+1
View File
@@ -322,6 +322,7 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
- **Faz 8 kararları (2026-11-05):** 8a cari hesaplar, 8b aynî borç/alacak; `v0.8.0` 8b sonunda. Varlık türleri ve elle fiyat tablosu (Bölüm 6.6) 8b'ye çekilir, Faz 9 yeniden kullanır. Cariler hane ortaktır; "yalnızca ben" işaretli cari yalnızca sahibine görünür. Vade hatırlatması uygulama içi uyarıdır (dashboard + cari sayfası); push/e-posta Faz 16'da değerlendirilir. - **Faz 8 kararları (2026-11-05):** 8a cari hesaplar, 8b aynî borç/alacak; `v0.8.0` 8b sonunda. Varlık türleri ve elle fiyat tablosu (Bölüm 6.6) 8b'ye çekilir, Faz 9 yeniden kullanır. Cariler hane ortaktır; "yalnızca ben" işaretli cari yalnızca sahibine görünür. Vade hatırlatması uygulama içi uyarıdır (dashboard + cari sayfası); push/e-posta Faz 16'da değerlendirilir.
- **Faz 9 kararları (2026-11-05):** 9a varlık lotları ve kâr/zarar, 9b dönemsel rapor ve net varlık; `v0.9.0` 9b sonunda. Varlıklar hane ortaktır; "yalnızca ben" işaretli varlık ve hareketleri yalnızca sahibine görünür. Varlık alım/satışı hesaba bağlıysa transfer olarak kaydedilir (gelir/gider raporlarına girmez). Aynî kayıt "aynı varlıkla" kapanırken isteğe bağlı olarak varlıktan düşülür / varlığa eklenir. - **Faz 9 kararları (2026-11-05):** 9a varlık lotları ve kâr/zarar, 9b dönemsel rapor ve net varlık; `v0.9.0` 9b sonunda. Varlıklar hane ortaktır; "yalnızca ben" işaretli varlık ve hareketleri yalnızca sahibine görünür. Varlık alım/satışı hesaba bağlıysa transfer olarak kaydedilir (gelir/gider raporlarına girmez). Aynî kayıt "aynı varlıkla" kapanırken isteğe bağlı olarak varlıktan düşülür / varlığa eklenir.
- **Faz 10 kararları (2026-11-06):** 10a aylık bütçe, 10b gelir planı ve günlük plan; `v0.10.0` 10b sonunda. Limit her gider kategorisine verilebilir; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır (toplamda iki kez sayılmaz). Başkasının "yalnızca ben" hesabından yapılan harcama hane ayarına (gizli hesapları toplamlara kat) göre yalnızca tutar olarak bütçeye girer. - **Faz 10 kararları (2026-11-06):** 10a aylık bütçe, 10b gelir planı ve günlük plan; `v0.10.0` 10b sonunda. Limit her gider kategorisine verilebilir; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır (toplamda iki kez sayılmaz). Başkasının "yalnızca ben" hesabından yapılan harcama hane ayarına (gizli hesapları toplamlara kat) göre yalnızca tutar olarak bütçeye girer.
- **Faz 11 kararları (2026-11-06):** 11a stok kategorileri, ürün tipleri, hareketler, SKT ve %20 eşik; 11b periyodik sayım, fire ayarlı öneri ve tüketim analizi; `v0.11.0` 11b sonunda. Eşiğin altındaki ürünler Faz 11'de "Alınması önerilenler" listesinde gösterilir (koleksiyon yok); Faz 13'te aktif alışveriş listesine onayla/reddet önerisi olarak bağlanır. SKT alım hareketinde (isteğe bağlı) girilir; tüketim ve fire FIFO ile en eski alımdan düşülür.
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir. - **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
--- ---
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "butcem", "name": "butcem",
"version": "0.10.0", "version": "0.11.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "butcem", "name": "butcem",
"version": "0.10.0", "version": "0.11.0",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^7.0.2", "@hookform/resolvers": "^7.0.2",
"@tanstack/react-table": "^9.3.1", "@tanstack/react-table": "^9.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "butcem", "name": "butcem",
"version": "0.10.0", "version": "0.11.0",
"private": true, "private": true,
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
"engines": { "engines": {
+20
View File
@@ -0,0 +1,20 @@
import type { Types } from "mongoose";
import { installDefaultStockCategories } from "@/features/stock/service";
import { HouseholdModel } from "@/models/household";
import { ProductTypeModel } from "@/models/product-type";
import { StockCategoryModel } from "@/models/stock-category";
import { StockMovementModel } from "@/models/stock-movement";
import type { Migration } from "./types";
export const migration: Migration = {
name: "011-stock",
description: "stok koleksiyonları index'leri; mevcut hanelere varsayılan stok kategorileri",
async up() {
await StockCategoryModel.createIndexes();
await ProductTypeModel.createIndexes();
await StockMovementModel.createIndexes();
const households = await HouseholdModel.find({}).select({ createdBy: 1 }).lean();
for (const h of households)
await installDefaultStockCategories(h._id as Types.ObjectId, h.createdBy as Types.ObjectId);
},
};
+92
View File
@@ -0,0 +1,92 @@
import { Types } from "mongoose";
import { StockCountModel } from "@/models/stock-count";
import { StockMovementModel } from "@/models/stock-movement";
import { signedDelta, type StockMovementKind } from "@/shared/stock";
import type { Migration } from "./types";
type Move = {
_id: Types.ObjectId;
householdId: Types.ObjectId;
productTypeId: Types.ObjectId;
kind: StockMovementKind;
date: string;
quantity: number;
initial: boolean;
countId: Types.ObjectId | null;
createdBy: Types.ObjectId;
createdAt: Date;
};
/**
* Faz 11b: sayım oturumları. Faz 11a'da tek ürün sayımıyla oluşan (oturumsuz) sayım
* düzeltmeleri, o ana kadarki kayıtlardan beklenen miktar hesaplanarak hane + tarih + kişi
* başına bir sayım oturumuna bağlanır. Başlangıç miktarları sayım sayılmaz.
*/
export const migration: Migration = {
name: "012-stock-counts",
description: "stockCounts index'leri; oturumsuz sayım düzeltmelerini sayım oturumuna bağla",
async up() {
await StockCountModel.createIndexes();
const orphans = await StockMovementModel.find({
kind: "count_adjust",
initial: { $ne: true },
countId: null,
deletedAt: null,
}).lean<Move[]>();
if (orphans.length === 0) return;
const products = [...new Set(orphans.map((o) => String(o.productTypeId)))];
const all = await StockMovementModel.find({
productTypeId: { $in: products.map((id) => new Types.ObjectId(id)) },
deletedAt: null,
}).lean<Move[]>();
const byProduct = new Map<string, Move[]>();
for (const m of all) {
const list = byProduct.get(String(m.productTypeId)) ?? [];
list.push(m);
byProduct.set(String(m.productTypeId), list);
}
// Her düzeltme için beklenen = önceki hareketlerin toplamı (tarih, oluşturulma sırası).
const expectedOf = new Map<string, number>();
for (const list of byProduct.values()) {
list.sort((a, b) =>
a.date === b.date
? a.createdAt.getTime() - b.createdAt.getTime()
: a.date < b.date
? -1
: 1,
);
let running = 0;
for (const m of list) {
expectedOf.set(String(m._id), running);
running += signedDelta(m);
}
}
const sessions = new Map<string, Move[]>();
for (const o of orphans) {
const key = `${o.householdId}:${o.date}:${o.createdBy}`;
sessions.set(key, [...(sessions.get(key) ?? []), o]);
}
for (const moves of sessions.values()) {
const first = moves[0]!;
const count = await StockCountModel.create({
householdId: first.householdId,
date: first.date,
categoryId: null,
items: moves.map((m) => {
const expected = expectedOf.get(String(m._id)) ?? 0;
return {
productTypeId: m.productTypeId,
expected,
actual: expected + m.quantity,
wasted: 0,
};
}),
createdBy: first.createdBy,
});
await StockMovementModel.updateMany(
{ _id: { $in: moves.map((m) => m._id) } },
{ $set: { countId: count._id } },
);
}
},
};
+16 -1
View File
@@ -10,10 +10,25 @@ import { migration as m007 } from "./007-contacts";
import { migration as m008 } from "./008-in-kind"; import { migration as m008 } from "./008-in-kind";
import { migration as m009 } from "./009-assets"; import { migration as m009 } from "./009-assets";
import { migration as m010 } from "./010-budgets"; import { migration as m010 } from "./010-budgets";
import { migration as m011 } from "./011-stock";
import { migration as m012 } from "./012-stock-counts";
import type { Migration } from "./types"; import type { Migration } from "./types";
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008, m009, m010]; export const MIGRATIONS: Migration[] = [
m001,
m002,
m003,
m004,
m005,
m006,
m007,
m008,
m009,
m010,
m011,
m012,
];
const COLLECTION = "migrations"; const COLLECTION = "migrations";
+2
View File
@@ -34,6 +34,7 @@ describe("seed", () => {
expect(summary.created.transactions).toBeGreaterThan(150); expect(summary.created.transactions).toBeGreaterThan(150);
expect(summary.created.assets).toBe(4); expect(summary.created.assets).toBe(4);
expect(summary.created.budgets).toBe(2); expect(summary.created.budgets).toBe(2);
expect(summary.created.stock).toBe(19);
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount); expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
}); });
@@ -53,6 +54,7 @@ describe("seed", () => {
inKind: 0, inKind: 0,
assets: 0, assets: 0,
budgets: 0, budgets: 0,
stock: 0,
}); });
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length); expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
+9
View File
@@ -13,6 +13,7 @@ import { runMigrations } from "../migrations";
import { seedContacts } from "./contacts"; import { seedContacts } from "./contacts";
import { seedAssets } from "./assets"; import { seedAssets } from "./assets";
import { seedBudgets } from "./budgets"; import { seedBudgets } from "./budgets";
import { seedStock } from "./stock";
import { seedInKind } from "./in-kind"; import { seedInKind } from "./in-kind";
import { seedInstallments } from "./installments"; import { seedInstallments } from "./installments";
import { seedRecurring } from "./recurring"; import { seedRecurring } from "./recurring";
@@ -55,6 +56,7 @@ export type SeedSummary = {
inKind: number; inKind: number;
assets: number; assets: number;
budgets: number; budgets: number;
stock: number;
}; };
accounts: SeedAccountRow[]; accounts: SeedAccountRow[];
}; };
@@ -78,6 +80,10 @@ const DOMAIN_COLLECTIONS = [
"assets", "assets",
"assetMovements", "assetMovements",
"budgets", "budgets",
"stockCategories",
"productTypes",
"stockMovements",
"stockCounts",
]; ];
type AuthApi = ReturnType<typeof getAuth>["api"]; type AuthApi = ReturnType<typeof getAuth>["api"];
@@ -133,6 +139,7 @@ export async function runSeed({
inKind: 0, inKind: 0,
assets: 0, assets: 0,
budgets: 0, budgets: 0,
stock: 0,
}, },
accounts: [], accounts: [],
}; };
@@ -315,6 +322,8 @@ export async function runSeed({
if (summary.created.assets) log(`✓ Örnek varlıklar: ${summary.created.assets}`); if (summary.created.assets) log(`✓ Örnek varlıklar: ${summary.created.assets}`);
summary.created.budgets = await seedBudgets(householdIds, userIdMap); summary.created.budgets = await seedBudgets(householdIds, userIdMap);
if (summary.created.budgets) log(`✓ Örnek bütçeler: ${summary.created.budgets}`); if (summary.created.budgets) log(`✓ Örnek bütçeler: ${summary.created.budgets}`);
summary.created.stock = await seedStock(householdIds, userIdMap);
if (summary.created.stock) log(`✓ Örnek stok ürün tipleri: ${summary.created.stock}`);
// 7) Geliştirme hesapları tablosu // 7) Geliştirme hesapları tablosu
const all = [ const all = [
+428
View File
@@ -0,0 +1,428 @@
import { Types } from "mongoose";
import { installDefaultStockCategories } from "@/features/stock/service";
import { ProductTypeModel } from "@/models/product-type";
import { StockCategoryModel } from "@/models/stock-category";
import { StockCountModel } from "@/models/stock-count";
import { StockMovementModel } from "@/models/stock-movement";
import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
import { foldTr } from "@/shared/search";
/**
* Örnek ev stoğu (Faz 11). Varsayılan stok kategorileri her haneye; Çakır Ailesi'ne ürün tipleri
* ve son 6 ayın haftalık tüketim simülasyonu (alım, tüketim, fire, periyodik sayım, SKT).
* Sayım: Mutfak 15 günde bir (son sayım 21 gün önce → gecikmiş), Genel Temizlik 30 günde bir
* (son sayım 14 gün önce). Deterministik; idempotent: ürün tipi `seedRef` ile bir kez
* oluşturulur, sayım oturumları `seedRef` ile paylaşılır. Miktarlar binde bir.
*/
type SeedProduct = {
ref: string;
category: string;
name: string;
unit: string;
target: number;
/** Alımda paket miktarı (alım = hedefe tamamlayan paket sayısı × paket). */
pack: number;
perDay: number;
location: string;
step?: number;
threshold?: number;
shelfDays?: number;
/** Her n. alımdan sonra fire (oran %). */
waste?: { every: number; pct: number; reason: "spoiled" | "expired" | "damaged" };
/** Bu günden (gün önce) sonra yeniden alınmaz: bugün azalmış görünür. */
stopRefillDaysAgo?: number;
/** Her n. sayımda küçük eksik (−%, hedefin yüzdesi). */
countDrift?: { everyCounts: number; pct: number };
};
const K = 1000;
export const PRODUCTS: SeedProduct[] = [
// Mutfak › Bakliyat
{
ref: "kirmizi-mercimek",
category: "mutfak.bakliyat",
name: "Kırmızı Mercimek",
unit: "kg",
target: 2 * K,
pack: 1 * K,
perDay: 40,
location: "Kiler",
step: 100,
stopRefillDaysAgo: 40,
},
{
ref: "kuru-fasulye",
category: "mutfak.bakliyat",
name: "Kuru Fasulye",
unit: "kg",
target: 2 * K,
pack: 1 * K,
perDay: 25,
location: "Kiler",
step: 250,
},
{
ref: "nohut",
category: "mutfak.bakliyat",
name: "Nohut",
unit: "kg",
target: 2 * K,
pack: 1 * K,
perDay: 20,
location: "Kiler",
step: 250,
},
{
ref: "pirinc",
category: "mutfak.bakliyat",
name: "Baldo Pirinç",
unit: "kg",
target: 5 * K,
pack: 2_500,
perDay: 90,
location: "Kiler",
step: 500,
},
// Mutfak › Manav
{
ref: "domates",
category: "mutfak.manav",
name: "Domates",
unit: "kg",
target: 3 * K,
pack: 1 * K,
perDay: 300,
location: "Buzdolabı",
step: 500,
shelfDays: 8,
waste: { every: 2, pct: 30, reason: "spoiled" },
},
{
ref: "patates",
category: "mutfak.manav",
name: "Patates",
unit: "kg",
target: 5 * K,
pack: 1 * K,
perDay: 250,
location: "Kiler",
step: 500,
waste: { every: 4, pct: 10, reason: "spoiled" },
},
{
ref: "sogan",
category: "mutfak.manav",
name: "Kuru Soğan",
unit: "kg",
target: 3 * K,
pack: 1 * K,
perDay: 120,
location: "Kiler",
step: 500,
},
// Mutfak › Şarküteri
{
ref: "beyaz-peynir",
category: "mutfak.sarkuteri",
name: "Beyaz Peynir",
unit: "kg",
target: 1 * K,
pack: 500,
perDay: 30,
location: "Buzdolabı",
step: 100,
shelfDays: 45,
},
{
ref: "yumurta",
category: "mutfak.sarkuteri",
name: "Yumurta",
unit: "adet",
target: 30 * K,
pack: 15 * K,
perDay: 3 * K,
location: "Buzdolabı",
shelfDays: 25,
},
{
ref: "sut",
category: "mutfak.sarkuteri",
name: "Süt",
unit: "lt",
target: 6 * K,
pack: 1 * K,
perDay: 700,
location: "Buzdolabı",
shelfDays: 12,
waste: { every: 6, pct: 15, reason: "expired" },
},
// Genel Temizlik
{
ref: "tablet-deterjan",
category: "genel-temizlik.bulasik",
name: "Tablet Deterjan",
unit: "tablet",
target: 60 * K,
pack: 40 * K,
perDay: 720,
location: "Lavabo altı",
stopRefillDaysAgo: 40,
},
{
ref: "elde-yikama",
category: "genel-temizlik.bulasik",
name: "Elde Yıkama Deterjanı",
unit: "lt",
target: 1_500,
pack: 750,
perDay: 15,
location: "Lavabo altı",
step: 100,
},
{
ref: "toz-deterjan",
category: "genel-temizlik.camasir",
name: "Toz Deterjan",
unit: "yıkama",
target: 80 * K,
pack: 50 * K,
perDay: 700,
location: "Banyo dolabı",
countDrift: { everyCounts: 2, pct: 4 },
},
{
ref: "yumusatici",
category: "genel-temizlik.camasir",
name: "Yumuşatıcı",
unit: "lt",
target: 3 * K,
pack: 1_440,
perDay: 40,
location: "Banyo dolabı",
step: 100,
},
{
ref: "camasir-suyu",
category: "genel-temizlik.genel",
name: "Çamaşır Suyu",
unit: "lt",
target: 3 * K,
pack: 1 * K,
perDay: 25,
location: "Balkon",
step: 250,
},
{
ref: "tuvalet-kagidi",
category: "genel-temizlik.genel",
name: "Tuvalet Kağıdı",
unit: "rulo",
target: 32 * K,
pack: 16 * K,
perDay: 600,
location: "Banyo dolabı",
countDrift: { everyCounts: 3, pct: 6 },
},
// Kişisel Temizlik
{
ref: "sampuan",
category: "kisisel-temizlik.sampuan",
name: "Şampuan",
unit: "adet",
target: 3 * K,
pack: 1 * K,
perDay: 50,
location: "Banyo dolabı",
},
{
ref: "tiras-bicagi",
category: "kisisel-temizlik.tiras",
name: "Tıraş Bıçağı",
unit: "adet",
target: 8 * K,
pack: 4 * K,
perDay: 150,
location: "Banyo dolabı",
},
{
ref: "dis-macunu",
category: "kisisel-temizlik.kisisel-bakim",
name: "Diş Macunu",
unit: "adet",
target: 4 * K,
pack: 2 * K,
perDay: 70,
location: "Banyo dolabı",
stopRefillDaysAgo: 60,
},
];
const START_DAYS_AGO = 182;
/** Sayım sıklığı (gün) ve sayım günü kuralı (gün önce), ana stok kategorisine göre. */
const COUNT_PLANS: Record<string, { everyDays: number; on: (daysAgo: number) => boolean }> = {
mutfak: { everyDays: 15, on: (d) => d >= 21 && d % 14 === 7 },
"genel-temizlik": { everyDays: 30, on: (d) => d % 28 === 14 },
};
type Row = {
kind: "purchase" | "consume" | "waste" | "count_adjust";
daysAgo: number;
quantity: number;
expiryDate?: string | null;
wasteReason?: string | null;
note?: string;
initial?: boolean;
/** Bu satır hangi sayıma ait (gün önce). */
countDaysAgo?: number;
};
export type SeedCount = { daysAgo: number; expected: number; actual: number };
/**
* Haftalık simülasyon: tüketim, periyodik sayım (farkı nadiren), eşikte alım ve fire. Stok asla
* eksiye düşmez.
*/
export function simulate(p: SeedProduct, today: string): { rows: Row[]; counts: SeedCount[] } {
const rows: Row[] = [];
const counts: SeedCount[] = [];
const plan = COUNT_PLANS[p.category.split(".")[0]!];
let stock = p.target;
let purchases = 0;
rows.push({ kind: "count_adjust", daysAgo: START_DAYS_AGO, quantity: stock, initial: true });
for (let d = START_DAYS_AGO - 7; d >= 0; d -= 7) {
// Haftalık tüketim azaltma adımının katı (ör. tam tablet, 0,25 kg).
const step = p.step ?? K;
const consume = Math.min(stock, Math.max(step, Math.round((p.perDay * 7) / step) * step));
if (consume > 0) {
rows.push({ kind: "consume", daysAgo: d, quantity: consume });
stock -= consume;
}
if (plan?.on(d)) {
const expected = stock;
const nth = counts.length + 1;
if (p.countDrift && nth % p.countDrift.everyCounts === 0 && stock > 0) {
const drift = Math.min(stock, Math.round((p.target * p.countDrift.pct) / 100));
rows.push({
kind: "count_adjust",
daysAgo: d,
quantity: -drift,
note: "Sayım",
countDaysAgo: d,
});
stock -= drift;
}
counts.push({ daysAgo: d, expected, actual: stock });
}
const canRefill = p.stopRefillDaysAgo === undefined || d > p.stopRefillDaysAgo;
if (canRefill && stock < (p.target * 35) / 100) {
const packs = Math.max(1, Math.ceil((p.target - stock) / p.pack));
const quantity = packs * p.pack;
purchases++;
rows.push({
kind: "purchase",
daysAgo: d,
quantity,
expiryDate: p.shelfDays ? addDays(addDays(today, -d), p.shelfDays) : null,
});
stock += quantity;
if (p.waste && purchases % p.waste.every === 0 && d >= 3) {
const w = Math.min(stock, Math.round((quantity * p.waste.pct) / 100));
rows.push({ kind: "waste", daysAgo: d - 3, quantity: w, wasteReason: p.waste.reason });
stock -= w;
}
}
}
return { rows, counts };
}
export async function seedStock(
householdIds: Map<string, Types.ObjectId>,
users: Map<string, string>,
): Promise<number> {
const actor = new Types.ObjectId(users.get("ayse")!);
for (const [, id] of householdIds) await installDefaultStockCategories(id, actor);
const householdId = householdIds.get("Çakır Ailesi")!;
// Periyodik sayım sıklığı (kullanıcı ayarladıysa dokunulmaz).
for (const [key, plan] of Object.entries(COUNT_PLANS))
await StockCategoryModel.updateOne(
{ householdId, defaultKey: key, countEveryDays: null },
{ $set: { countEveryDays: plan.everyDays } },
);
const cats = await StockCategoryModel.find({ householdId, defaultKey: { $type: "string" } })
.select({ defaultKey: 1 })
.lean<{ _id: Types.ObjectId; defaultKey: string }[]>();
const catByKey = new Map(cats.map((c) => [c.defaultKey, c._id]));
const today = todayIstanbul();
let created = 0;
for (const p of PRODUCTS) {
if (await ProductTypeModel.exists({ householdId, seedRef: p.ref })) continue;
const doc = await ProductTypeModel.create({
householdId,
categoryId: catByKey.get(p.category)!,
name: p.name,
nameKey: foldTr(p.name),
unit: p.unit,
targetQuantity: p.target,
thresholdPct: p.threshold ?? 20,
location: p.location,
step: p.step ?? K,
seedRef: p.ref,
createdBy: actor,
updatedBy: actor,
});
const base = Date.now() - START_DAYS_AGO * 86_400_000;
const { rows, counts } = simulate(p, today);
// Sayım oturumu kategori başına ortak: ürün satırı oturuma eklenir.
const root = p.category.split(".")[0]!;
const countIds = new Map<number, Types.ObjectId>();
for (const c of counts) {
const session = await StockCountModel.findOneAndUpdate(
{ householdId, seedRef: `${root}:${c.daysAgo}` },
{
$setOnInsert: {
householdId,
date: addDays(today, -c.daysAgo),
categoryId: catByKey.get(root) ?? null,
note: "",
seedRef: `${root}:${c.daysAgo}`,
createdBy: actor,
},
$push: {
items: { productTypeId: doc._id, expected: c.expected, actual: c.actual, wasted: 0 },
},
},
{ upsert: true, returnDocument: "after" },
).lean<{ _id: Types.ObjectId }>();
countIds.set(c.daysAgo, session!._id);
}
// Ham ekleme: aynı tarihli hareketlerin sırası (createdAt) korunur.
await StockMovementModel.collection.insertMany(
rows.map((r, i) => {
const at = new Date(base + i * 1000);
return {
householdId,
productTypeId: doc._id,
kind: r.kind,
date: addDays(today, -r.daysAgo),
quantity: r.quantity,
wasteReason: r.wasteReason ?? null,
expiryDate: r.expiryDate ?? null,
note: r.note ?? "",
initial: r.initial ?? false,
countId: r.countDaysAgo === undefined ? null : (countIds.get(r.countDaysAgo) ?? null),
deletedAt: null,
seedRef: `${p.ref}:${i}`,
createdBy: actor,
createdAt: at,
updatedAt: at,
};
}),
);
created++;
}
return created;
}
+4
View File
@@ -11,6 +11,7 @@ import { getBudget } from "@/features/budgets/service";
import { ContactsDueBanner } from "@/features/contacts/components/due-banner"; import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
import { getNetWorth } from "@/features/net-worth/service"; import { getNetWorth } from "@/features/net-worth/service";
import { OverdueBanner } from "@/features/recurring/components/overdue-banner"; import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
import { LowStockCard } from "@/features/stock/components/low-stock-card";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal"; import { requireActiveHousehold } from "@/lib/dal";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -105,6 +106,9 @@ export default async function DashboardPage() {
<Suspense fallback={null}> <Suspense fallback={null}>
<DailyPlanCard userId={user.id} householdId={household.id} /> <DailyPlanCard userId={user.id} householdId={household.id} />
</Suspense> </Suspense>
<Suspense fallback={null}>
<LowStockCard userId={user.id} householdId={household.id} />
</Suspense>
</> </>
); );
} }
+20
View File
@@ -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 <ProductDetailView data={data} categories={options.categories} />;
}
+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} />;
}
+29
View File
@@ -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 (
<>
<PageHeader title={tr.stock.categories.title} description={tr.stock.categories.description} />
<StockCategoriesManager
categories={data.categories}
expenseCategories={options.expenseCategories}
canWrite={data.canWrite}
/>
</>
);
}
+26
View File
@@ -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 (
<>
<PageHeader title={tr.stock.title} description={tr.stock.description} />
{!data.canWrite ? <p className="text-sm text-muted-foreground">{tr.stock.readOnly}</p> : null}
<StockView data={data} options={options.categories} />
</>
);
}
+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} />;
}
+2 -2
View File
@@ -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 = 10.2; export const CURRENT_PHASE = 11.2;
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; 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: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 },
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 }, { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 },
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.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: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 }, { href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 }, { href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
+3
View File
@@ -7,6 +7,7 @@ import { getDb } from "@/lib/db/mongo";
import { connectMongoose } from "@/lib/db/mongoose"; import { connectMongoose } from "@/lib/db/mongoose";
import { toObjectId } from "@/lib/object-id"; import { toObjectId } from "@/lib/object-id";
import { installDefaultCategories } from "@/features/categories/service"; import { installDefaultCategories } from "@/features/categories/service";
import { installDefaultStockCategories } from "@/features/stock/service";
import { HouseholdModel } from "@/models/household"; import { HouseholdModel } from "@/models/household";
import { HouseholdMemberModel } from "@/models/household-member"; import { HouseholdMemberModel } from "@/models/household-member";
import type { HouseholdSettings } from "@/shared/schemas/household"; import type { HouseholdSettings } from "@/shared/schemas/household";
@@ -324,6 +325,8 @@ export async function adminCreateHousehold(
); );
// Varsayılan Türkçe kategori seti aynı transaction'da (Bölüm 6.2). // Varsayılan Türkçe kategori seti aynı transaction'da (Bölüm 6.2).
await installDefaultCategories(household!._id, actor, session); await installDefaultCategories(household!._id, actor, session);
// Varsayılan stok kategorileri (gider eşlemesi yukarıdaki kategorilerden).
await installDefaultStockCategories(household!._id, actor, session);
}); });
return ok({ id: createdId }); return ok({ id: createdId });
} }
+130
View File
@@ -0,0 +1,130 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal";
import {
productTypeFormSchema,
stockCategoryFormSchema,
stockCountFormSchema,
stockCountSheetSchema,
stockMovementFormSchema,
} from "@/shared/schemas/stock";
import {
addStockMovement,
countStock,
createProductType,
createStockCategory,
deleteStockMovement,
getStockOptions,
quickConsume,
setProductTypeArchived,
setStockCategoryArchived,
submitCount,
updateProductType,
updateStockCategory,
type StockOption,
} from "./service";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
function fieldErrors(error: z.ZodError) {
const out: Record<string, string> = {};
for (const issue of error.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
async function run<T, R>(
schema: z.ZodType<T>,
input: unknown,
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
): Promise<ActionResult<R>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const parsed = schema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
function withId<R>(
raw: unknown,
fn: (id: string) => Promise<ActionResult<R>>,
): Promise<ActionResult<R>> {
const id = idSchema.safeParse(raw);
if (!id.success) return Promise.resolve(fail(tr.common.notFound));
return fn(id.data);
}
/** Yeni (id boş) veya mevcut stok kategorisi. */
export async function saveStockCategoryAction(categoryId: unknown, input: unknown) {
if (categoryId)
return withId(categoryId, (id) =>
run(stockCategoryFormSchema, input, (u, h, d) => updateStockCategory(u, h, id, d)),
);
return run(stockCategoryFormSchema, input, createStockCategory);
}
export async function setStockCategoryArchivedAction(categoryId: unknown, archived: unknown) {
return withId(categoryId, (id) =>
run(z.boolean(), archived, (u, h, a) => setStockCategoryArchived(u, h, id, a)),
);
}
/** Yeni (id boş) veya mevcut ürün tipi. */
export async function saveProductTypeAction(productTypeId: unknown, input: unknown) {
if (productTypeId)
return withId(productTypeId, (id) =>
run(productTypeFormSchema, input, (u, h, d) => updateProductType(u, h, id, d)),
);
return run(productTypeFormSchema, input, createProductType);
}
export async function setProductTypeArchivedAction(productTypeId: unknown, archived: unknown) {
return withId(productTypeId, (id) =>
run(z.boolean(), archived, (u, h, a) => setProductTypeArchived(u, h, id, a)),
);
}
export async function addStockMovementAction(productTypeId: unknown, input: unknown) {
return withId(productTypeId, (id) =>
run(stockMovementFormSchema, input, (u, h, d) => addStockMovement(u, h, id, d)),
);
}
export async function quickConsumeAction(productTypeId: unknown) {
return withId(productTypeId, (id) => run(z.unknown(), null, (u, h) => quickConsume(u, h, id)));
}
export async function countStockAction(productTypeId: unknown, input: unknown) {
return withId(productTypeId, (id) =>
run(stockCountFormSchema, input, (u, h, d) => countStock(u, h, id, d)),
);
}
/** Toplu sayım (/stok/sayim). */
export async function submitCountAction(input: unknown) {
return run(stockCountSheetSchema, input, submitCount);
}
export async function deleteStockMovementAction(movementId: unknown) {
return withId(movementId, (id) =>
run(z.unknown(), null, (u, h) => deleteStockMovement(u, h, id)),
);
}
/** Ürün tipi ve stok kategorisi formları için seçenekler. */
export async function getStockOptionsAction(): Promise<
ActionResult<{ categories: StockOption[]; expenseCategories: StockOption[] }>
> {
const ctx = await getHouseholdActionContext("viewer");
if (!ctx) return fail(tr.common.unauthorized);
const data = await getStockOptions(ctx.user.id, ctx.household.id);
return data ? ok(data) : fail(tr.common.unauthorized);
}
@@ -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>
);
}
@@ -0,0 +1,106 @@
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";
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, SKT'si yaklaşan partiler, sayımı gelen kategoriler. */
export async function LowStockCard({
userId,
householdId,
}: {
userId: string;
householdId: string;
}) {
const data = await getStock(userId, householdId);
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 (
<Card size="sm">
<CardHeader>
<CardTitle className="text-base">{t.dashboard.title}</CardTitle>
<CardDescription>
{t.dashboard.description(
data.suggestions.length,
data.expiring.length,
data.countDue.length,
)}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<ul className="divide-y text-sm">
{low.map((i) => (
<li key={i.id} className="flex items-center justify-between gap-3 py-1.5">
<Link href={`/stok/${i.id}`} className="min-w-0 truncate hover:underline">
{i.name}
</Link>
<span className="shrink-0 text-xs tabular-nums">
<span className="text-expense">{formatStock(Math.max(0, i.current), i.unit)}</span>
<span className="text-muted-foreground">
{" · "}
{t.suggestions.suggested(formatStock(i.suggestedQuantity, i.unit))}
</span>
</span>
</li>
))}
{expiring.map(({ item, lot }) => (
<li
key={`${item.id}-${lot.index}`}
className="flex items-center justify-between gap-3 py-1.5"
>
<Link href={`/stok/${item.id}`} className="min-w-0 truncate hover:underline">
{item.name}
</Link>
<span
className={
lot.status === "expired"
? "flex shrink-0 items-center gap-1 text-xs text-expense"
: "flex shrink-0 items-center gap-1 text-xs text-amber-700 dark:text-amber-400"
}
>
<Clock className="size-3" />
{t.expiring.on(
new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short" }).format(
new Date(`${lot.expiryDate}T12:00:00+03:00`),
),
)}
</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">
{t.dashboard.more(data.suggestions.length - low.length)}
</p>
) : null}
<Button size="sm" variant="outline" className="w-fit" asChild>
<Link href="/stok">{t.dashboard.open}</Link>
</Button>
</CardContent>
</Card>
);
}
@@ -0,0 +1,380 @@
"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, roundForDisplay } 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,
suggestionReasons,
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 (
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<div className="font-semibold tabular-nums">{children}</div>
</div>
);
}
/** /stok/:id — mevcut durum, partiler (SKT) ve hareketler. */
export function ProductDetailView({
data,
categories,
}: {
data: ProductTypeDetail;
categories: StockOption[];
}) {
const [pending, setPending] = useState<Pending>(null);
const close = () => setPending(null);
const q = (x: number) => formatStock(x, data.unit);
const active = data.canWrite && !data.archived;
return (
<div className="flex flex-col gap-4">
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
<Link href="/stok">
<ArrowLeft />
{t.detail.back}
</Link>
</Button>
<PageHeader
title={data.name}
description={data.categoryPath}
actions={
data.canWrite ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="outline" aria-label={tr.recurring.actions.more}>
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={data.archived}
onSelect={() => setPending({ kind: "edit" })}
>
<Pencil />
{tr.common.edit}
</DropdownMenuItem>
<DropdownMenuSeparator />
{data.archived ? (
<DropdownMenuItem
onSelect={async () => {
const r = await setProductTypeArchivedAction(data.id, false);
if (r.ok) toast.success(t.toast.unarchived);
else toast.error(r.error);
}}
>
<ArchiveRestore />
{t.detail.unarchive}
</DropdownMenuItem>
) : (
<DropdownMenuItem
variant="destructive"
onSelect={() => setPending({ kind: "archive" })}
>
<Archive />
{t.detail.archive}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null
}
/>
<div className="-mt-2 flex flex-wrap gap-1.5">
{data.archived ? <Badge variant="secondary">{t.detail.archived}</Badge> : null}
<LevelBadge item={data} />
</div>
<Card size="sm">
<CardContent className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label={t.detail.current}>
<span className={cn(data.level.low && "text-expense")}>
{q(Math.max(0, data.current))}
</span>
</Stat>
<Stat label={t.detail.target}>{q(data.targetQuantity)}</Stat>
<Stat label={t.detail.threshold}>
{q(data.level.thresholdQuantity)}{" "}
<span className="text-xs font-normal text-muted-foreground">
(%{data.thresholdPct})
</span>
</Stat>
<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}
</p>
) : null}
{data.note ? <p className="text-sm text-muted-foreground">{data.note}</p> : null}
{active ? (
<div className="flex flex-wrap justify-end gap-2">
<QuickConsumeButton item={data} />
<Button
size="sm"
variant="outline"
onClick={() => setPending({ kind: "movement", movement: "consume" })}
>
<Minus />
{t.actions.consume}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setPending({ kind: "movement", movement: "waste" })}
>
<Trash />
{t.actions.waste}
</Button>
<Button size="sm" variant="outline" onClick={() => setPending({ kind: "count" })}>
<ClipboardCheck />
{t.actions.count}
</Button>
<Button
size="sm"
onClick={() => setPending({ kind: "movement", movement: "purchase" })}
>
<ShoppingBag />
{t.actions.purchase}
</Button>
</div>
) : null}
</CardContent>
</Card>
<Card size="sm">
<CardHeader>
<CardTitle className="text-base">{t.detail.lots}</CardTitle>
<p className="text-xs text-muted-foreground">{t.detail.lotsHint}</p>
</CardHeader>
<CardContent>
{data.lots.length === 0 ? (
<p className="text-sm text-muted-foreground">{t.detail.noLots}</p>
) : (
<ul className="divide-y text-sm">
{data.lots.map((lot, i) => (
<li key={i} className="flex items-center justify-between gap-3 py-2">
<span className="min-w-0">
<span className="font-medium tabular-nums">{q(lot.remaining)}</span>
<span className="text-xs text-muted-foreground"> · {ymdLabel(lot.date)}</span>
</span>
{lot.expiryDate && lot.status && lot.status !== "ok" ? (
<ExpiryBadge lot={{ expiryDate: lot.expiryDate, status: lot.status }} />
) : (
<span className="text-xs text-muted-foreground">
{lot.expiryDate ? t.expiring.on(ymdLabel(lot.expiryDate)) : t.detail.noExpiry}
</span>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
<section className="flex flex-col gap-2" aria-labelledby="stock-movements-title">
<h2 id="stock-movements-title" className="text-base font-medium">
{t.detail.movements}
</h2>
{data.movements.length === 0 ? (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.detail.noMovements}
</p>
) : (
<ul className="divide-y rounded-xl border bg-card">
{data.movements.map((row) => (
<li key={row.id} className="flex items-start gap-3 p-3">
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-x-2 text-sm font-medium">
<span>{row.initial ? t.initial : t.kinds[row.kind]}</span>
<span
className={cn("tabular-nums", row.delta > 0 ? "text-income" : "text-expense")}
>
{row.delta > 0 ? "+" : "−"}
{q(Math.abs(row.delta))}
</span>
</p>
<p className="text-xs text-muted-foreground">
{ymdLabel(row.date)}
{row.wasteReason ? ` · ${t.wasteReasons[row.wasteReason]}` : ""}
{row.expiryDate ? ` · ${t.expiring.on(ymdLabel(row.expiryDate))}` : ""}
{row.note ? ` · ${row.note}` : ""}
</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{t.detail.after(q(row.quantityAfter))}
</span>
{data.canWrite ? (
<Button
size="icon-sm"
variant="ghost"
aria-label={t.detail.deleteMovement}
onClick={() => setPending({ kind: "deleteMovement", row })}
className="shrink-0"
>
<Trash2 />
</Button>
) : null}
</li>
))}
</ul>
)}
</section>
{pending?.kind === "edit" ? (
<ProductTypeFormDialog item={data} categories={categories} onClose={close} />
) : null}
{pending?.kind === "movement" ? (
<StockMovementDialog item={data} initialKind={pending.movement} onClose={close} />
) : null}
{pending?.kind === "count" ? <StockCountDialog item={data} onClose={close} /> : null}
<AlertDialog
open={pending?.kind === "archive" || pending?.kind === "deleteMovement"}
onOpenChange={(o) => !o && close()}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{pending?.kind === "archive" ? t.detail.archiveTitle : t.detail.deleteMovementTitle}
</AlertDialogTitle>
<AlertDialogDescription>
{pending?.kind === "archive"
? t.detail.archiveDescription(data.name)
: t.detail.deleteMovementDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
<Button
variant="destructive"
onClick={async () => {
const r =
pending?.kind === "archive"
? await setProductTypeArchivedAction(data.id, true)
: pending?.kind === "deleteMovement"
? await deleteStockMovementAction(pending.row.id)
: null;
const wasArchive = pending?.kind === "archive";
close();
if (!r) return;
if (r.ok) toast.success(wasArchive ? t.toast.archived : t.toast.deleted);
else toast.error(r.error);
}}
>
{pending?.kind === "archive" ? t.detail.archive : t.detail.deleteMovement}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -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>
);
}
@@ -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<string, string | undefined>;
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<Pending>(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 (
<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>
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={showArchived} onCheckedChange={(x) => setShowArchived(x === true)} />
{t.showArchived}
</label>
{canWrite ? (
<Button size="sm" onClick={() => setPending({ kind: "create", parentId: null })}>
<Plus />
{t.new}
</Button>
) : null}
</div>
{visible.length === 0 ? (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.empty}
</p>
) : (
<ul className="divide-y rounded-xl border bg-card">
{visible.map((c) => (
<li
key={c.id}
className={cn("flex items-center gap-2 p-3", c.archived && "opacity-60")}
style={{ paddingLeft: `${12 + c.depth * 20}px` }}
>
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-1.5 text-sm font-medium">
<span className="truncate">{c.name}</span>
{c.isDefault ? <Badge variant="secondary">{t.defaultBadge}</Badge> : null}
{c.archived ? <Badge variant="outline">{t.archived}</Badge> : null}
</p>
<p className="truncate text-xs text-muted-foreground">
{[
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(" · ")}
</p>
</div>
{canWrite ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="icon-sm" variant="ghost" aria-label={tr.common.actions}>
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{c.archived ? (
<DropdownMenuItem onSelect={() => unarchive(c)}>
<ArchiveRestore />
{t.unarchive}
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem
onSelect={() => setPending({ kind: "create", parentId: c.id })}
>
<FolderPlus />
{t.addChild}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setPending({ kind: "edit", category: c })}
>
<Pencil />
{t.edit}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => setPending({ kind: "archive", category: c })}
>
<Archive />
{t.archive}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</li>
))}
</ul>
)}
{pending?.kind === "create" || pending?.kind === "edit" ? (
<StockCategoryDialog
category={pending.kind === "edit" ? pending.category : null}
parentId={
pending.kind === "create" ? pending.parentId : (pending.category.parentId ?? null)
}
categories={categories}
expenseCategories={expenseCategories}
onClose={close}
/>
) : null}
<AlertDialog open={pending?.kind === "archive"} onOpenChange={(o) => !o && close()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.archiveTitle}</AlertDialogTitle>
<AlertDialogDescription>
{pending?.kind === "archive" ? t.archiveDescription(pending.category.name) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
<Button
variant="destructive"
onClick={async () => {
if (pending?.kind !== "archive") return;
const r = await setStockCategoryArchivedAction(pending.category.id, true);
close();
if (r.ok) toast.success(tr.stock.toast.archived);
else toast.error(r.error);
}}
>
{t.archive}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
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<Errors>({});
const [busy, setBusy] = useState(false);
const set = <K extends keyof typeof v>(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 (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={category ? t.editTitle : t.createTitle}
footer={
<>
<Button variant="outline" type="button" onClick={onClose}>
{tr.common.cancel}
</Button>
<Button type="submit" form="stock-category-form" disabled={busy}>
{tr.common.save}
</Button>
</>
}
>
<form id="stock-category-form" onSubmit={submit} noValidate>
<FieldGroup>
<Field data-invalid={Boolean(errors.name)}>
<FieldLabel htmlFor="sc-name">{t.name}</FieldLabel>
<Input
id="sc-name"
value={v.name}
onChange={(e) => set("name", e.target.value)}
aria-invalid={Boolean(errors.name)}
autoFocus
/>
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.parentId)}>
<FieldLabel htmlFor="sc-parent">{t.parent}</FieldLabel>
<Select
value={v.parentId || NONE}
onValueChange={(x) => set("parentId", x === NONE ? "" : x)}
>
<SelectTrigger id="sc-parent" className="w-full">
<SelectValue>
{categories.find((c) => c.id === v.parentId)?.path ?? t.noParent}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value={NONE}>{t.noParent}</SelectItem>
{parents.map((c) => (
<SelectItem key={c.id} value={c.id}>
<span style={{ paddingLeft: `${c.depth * 12}px` }}>{c.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{errors.parentId ? <FieldError>{errors.parentId}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.expenseCategoryId)}>
<FieldLabel htmlFor="sc-expense">{t.expenseCategory}</FieldLabel>
<Select
value={v.expenseCategoryId || NONE}
onValueChange={(x) => set("expenseCategoryId", x === NONE ? "" : x)}
>
<SelectTrigger id="sc-expense" className="w-full">
<SelectValue>
{expenseCategories.find((c) => c.id === v.expenseCategoryId)?.path ??
t.expenseInherit}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value={NONE}>{t.expenseInherit}</SelectItem>
{expenseCategories.map((c) => (
<SelectItem key={c.id} value={c.id}>
<span style={{ paddingLeft: `${c.depth * 12}px` }}>{c.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<FieldDescription>{t.expenseHint}</FieldDescription>
{errors.expenseCategoryId ? <FieldError>{errors.expenseCategoryId}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.countEveryDays)}>
<FieldLabel htmlFor="sc-count">{t.countEveryDays}</FieldLabel>
<Input
id="sc-count"
inputMode="numeric"
value={v.countEveryDays}
onChange={(e) => set("countEveryDays", e.target.value)}
className="w-32 text-right tabular-nums"
aria-invalid={Boolean(errors.countEveryDays)}
/>
<FieldDescription>{t.countEveryDaysHint}</FieldDescription>
{errors.countEveryDays ? <FieldError>{errors.countEveryDays}</FieldError> : null}
</Field>
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
@@ -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<string, string | undefined>;
const CUSTOM = "__custom__";
function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) {
return (
<>
<Button variant="outline" type="button" onClick={onClose}>
{tr.common.cancel}
</Button>
<Button type="submit" form={formId} disabled={busy}>
{tr.common.save}
</Button>
</>
);
}
function errorsOf(r: { fieldErrors?: Partial<Record<string, string>>; 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<Errors>({});
const [busy, setBusy] = useState(false);
const set = <K extends keyof typeof v>(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 (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={item ? t.editTitle : t.createTitle}
footer={<Footer busy={busy} formId="product-form" onClose={onClose} />}
>
<form id="product-form" onSubmit={submit} noValidate>
<FieldGroup>
<Field data-invalid={Boolean(errors.name)}>
<FieldLabel htmlFor="pt-name">{f.name}</FieldLabel>
<Input
id="pt-name"
value={v.name}
placeholder={f.namePlaceholder}
onChange={(e) => set("name", e.target.value)}
aria-invalid={Boolean(errors.name)}
autoFocus={!item}
/>
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.categoryId)}>
<FieldLabel htmlFor="pt-cat">{f.category}</FieldLabel>
<Select value={v.categoryId} onValueChange={(x) => set("categoryId", x)}>
<SelectTrigger
id="pt-cat"
className="w-full"
aria-invalid={Boolean(errors.categoryId)}
>
<SelectValue placeholder={t.validation.selectCategory}>
{categories.find((c) => c.id === v.categoryId)?.path}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
{categories.map((c) => (
<SelectItem key={c.id} value={c.id}>
<span style={{ paddingLeft: `${c.depth * 12}px` }}>{c.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{errors.categoryId ? <FieldError>{errors.categoryId}</FieldError> : null}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.unit)}>
<FieldLabel htmlFor="pt-unit">{f.unit}</FieldLabel>
<Select value={v.unitChoice} onValueChange={(x) => set("unitChoice", x)}>
<SelectTrigger id="pt-unit" className="w-full">
<SelectValue>{v.unitChoice === CUSTOM ? f.customUnit : v.unitChoice}</SelectValue>
</SelectTrigger>
<SelectContent>
{STOCK_UNITS.map((u) => (
<SelectItem key={u} value={u}>
{u}
</SelectItem>
))}
<SelectItem value={CUSTOM}>{f.customUnitOption}</SelectItem>
</SelectContent>
</Select>
{errors.unit && v.unitChoice !== CUSTOM ? (
<FieldError>{errors.unit}</FieldError>
) : null}
</Field>
{v.unitChoice === CUSTOM ? (
<Field data-invalid={Boolean(errors.unit)}>
<FieldLabel htmlFor="pt-custom">{f.customUnit}</FieldLabel>
<Input
id="pt-custom"
value={v.customUnit}
placeholder={f.customUnitPlaceholder}
onChange={(e) => set("customUnit", e.target.value)}
aria-invalid={Boolean(errors.unit)}
/>
{errors.unit ? <FieldError>{errors.unit}</FieldError> : null}
</Field>
) : (
<Field>
<FieldLabel htmlFor="pt-location">{f.location}</FieldLabel>
<Input
id="pt-location"
value={v.location}
placeholder={f.locationPlaceholder}
onChange={(e) => set("location", e.target.value)}
/>
</Field>
)}
</div>
{v.unitChoice === CUSTOM ? (
<Field>
<FieldLabel htmlFor="pt-location">{f.location}</FieldLabel>
<Input
id="pt-location"
value={v.location}
placeholder={f.locationPlaceholder}
onChange={(e) => set("location", e.target.value)}
/>
</Field>
) : null}
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.targetQuantity)}>
<FieldLabel htmlFor="pt-target">
{f.target}
{unitSuffix}
</FieldLabel>
<Input
id="pt-target"
inputMode="decimal"
value={v.targetQuantity}
onChange={(e) => set("targetQuantity", e.target.value)}
className="text-right tabular-nums"
aria-invalid={Boolean(errors.targetQuantity)}
/>
{errors.targetQuantity ? <FieldError>{errors.targetQuantity}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.thresholdPct)}>
<FieldLabel htmlFor="pt-threshold">{f.thresholdPct}</FieldLabel>
<Input
id="pt-threshold"
inputMode="numeric"
value={v.thresholdPct}
onChange={(e) => set("thresholdPct", e.target.value)}
className="text-right tabular-nums"
aria-invalid={Boolean(errors.thresholdPct)}
/>
{errors.thresholdPct ? <FieldError>{errors.thresholdPct}</FieldError> : null}
</Field>
</div>
<FieldDescription className="-mt-2">
{f.targetHint} {f.thresholdHint}
</FieldDescription>
{!item ? (
<Field data-invalid={Boolean(errors.initialQuantity)}>
<FieldLabel htmlFor="pt-initial">
{f.initialQuantity}
{unitSuffix}
</FieldLabel>
<Input
id="pt-initial"
inputMode="decimal"
value={v.initialQuantity}
onChange={(e) => set("initialQuantity", e.target.value)}
className="text-right tabular-nums"
aria-invalid={Boolean(errors.initialQuantity)}
/>
<FieldDescription>{f.initialHint}</FieldDescription>
{errors.initialQuantity ? <FieldError>{errors.initialQuantity}</FieldError> : null}
</Field>
) : null}
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.step)}>
<FieldLabel htmlFor="pt-step">
{f.step}
{unitSuffix}
</FieldLabel>
<Input
id="pt-step"
inputMode="decimal"
value={v.step}
onChange={(e) => set("step", e.target.value)}
className="text-right tabular-nums"
aria-invalid={Boolean(errors.step)}
/>
{errors.step ? <FieldError>{errors.step}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.countEveryDays)}>
<FieldLabel htmlFor="pt-count">{f.countEveryDays}</FieldLabel>
<Input
id="pt-count"
inputMode="numeric"
value={v.countEveryDays}
onChange={(e) => set("countEveryDays", e.target.value)}
className="text-right tabular-nums"
aria-invalid={Boolean(errors.countEveryDays)}
/>
{errors.countEveryDays ? <FieldError>{errors.countEveryDays}</FieldError> : null}
</Field>
</div>
<FieldDescription className="-mt-2">
{f.stepHint} {f.countEveryDaysHint}
</FieldDescription>
<Field>
<FieldLabel htmlFor="pt-note">{f.note}</FieldLabel>
<Input id="pt-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
</Field>
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
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<MovementKindInput>(initialKind);
const [v, setV] = useState({
date: todayIstanbul(),
quantity:
initialKind === "purchase" && item.suggestedQuantity
? quantityInput(item.suggestedQuantity)
: "",
wasteReason: "" as WasteReason | "",
expiryDate: "",
note: "",
});
const [errors, setErrors] = useState<Errors>({});
const [busy, setBusy] = useState(false);
const set = <K extends keyof typeof v>(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 (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={`${t.movementTitles[kind]} · ${item.name}`}
description={`${t.detail.current}: ${formatStock(item.current, item.unit)}`}
footer={<Footer busy={busy} formId="stock-movement-form" onClose={onClose} />}
>
<form id="stock-movement-form" onSubmit={submit} noValidate>
<FieldGroup>
<Tabs
value={kind}
onValueChange={(x) => {
setKind(x as MovementKindInput);
setErrors({});
}}
>
<TabsList className="grid w-full grid-cols-3">
{(["purchase", "consume", "waste"] as const).map((k) => (
<TabsTrigger key={k} value={k}>
{t.kinds[k]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.quantity)}>
<FieldLabel htmlFor="sm-qty">
{f.quantity} ({item.unit})
</FieldLabel>
<Input
id="sm-qty"
inputMode="decimal"
value={v.quantity}
onChange={(e) => set("quantity", e.target.value)}
className="text-right tabular-nums"
aria-invalid={Boolean(errors.quantity)}
autoFocus
/>
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.date)}>
<FieldLabel htmlFor="sm-date">{f.date}</FieldLabel>
<Input
id="sm-date"
type="date"
value={v.date}
max={todayIstanbul()}
onChange={(e) => set("date", e.target.value)}
/>
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
</Field>
</div>
{kind === "purchase" ? (
<Field data-invalid={Boolean(errors.expiryDate)}>
<FieldLabel htmlFor="sm-expiry">{f.expiryDate}</FieldLabel>
<Input
id="sm-expiry"
type="date"
value={v.expiryDate}
min={v.date}
onChange={(e) => set("expiryDate", e.target.value)}
/>
<FieldDescription>{f.expiryHint}</FieldDescription>
{errors.expiryDate ? <FieldError>{errors.expiryDate}</FieldError> : null}
</Field>
) : null}
{kind === "waste" ? (
<Field data-invalid={Boolean(errors.wasteReason)}>
<FieldLabel htmlFor="sm-reason">{f.wasteReason}</FieldLabel>
<Select
value={v.wasteReason}
onValueChange={(x) => set("wasteReason", x as WasteReason)}
>
<SelectTrigger
id="sm-reason"
className="w-full"
aria-invalid={Boolean(errors.wasteReason)}
>
<SelectValue placeholder={t.validation.wasteReason}>
{v.wasteReason ? t.wasteReasons[v.wasteReason] : null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{WASTE_REASONS.map((r) => (
<SelectItem key={r} value={r}>
{t.wasteReasons[r]}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.wasteReason ? <FieldError>{errors.wasteReason}</FieldError> : null}
</Field>
) : null}
<Field>
<FieldLabel htmlFor="sm-note">{f.note}</FieldLabel>
<Input id="sm-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
</Field>
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
/** 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<Errors>({});
const [busy, setBusy] = useState(false);
const set = <K extends keyof typeof v>(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 (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={`${t.countTitle} · ${item.name}`}
description={t.countDescription}
footer={<Footer busy={busy} formId="stock-count-form" onClose={onClose} />}
>
<form id="stock-count-form" onSubmit={submit} noValidate>
<FieldGroup>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.actualQuantity)}>
<FieldLabel htmlFor="sc-qty">
{f.actualQuantity} ({item.unit})
</FieldLabel>
<Input
id="sc-qty"
inputMode="decimal"
value={v.actualQuantity}
onChange={(e) => set("actualQuantity", e.target.value)}
className="text-right tabular-nums"
aria-invalid={Boolean(errors.actualQuantity)}
autoFocus
/>
{errors.actualQuantity ? <FieldError>{errors.actualQuantity}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.date)}>
<FieldLabel htmlFor="sc-date">{f.date}</FieldLabel>
<Input
id="sc-date"
type="date"
value={v.date}
max={todayIstanbul()}
onChange={(e) => set("date", e.target.value)}
/>
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
</Field>
</div>
<FieldDescription className="-mt-2">
{f.expected(formatStock(Math.max(0, item.current), item.unit))}
</FieldDescription>
<Field>
<FieldLabel htmlFor="sc-note">{f.note}</FieldLabel>
<Input id="sc-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
</Field>
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
@@ -0,0 +1,397 @@
"use client";
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";
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 (
<div
className="relative h-1.5 w-full overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-label={t.list.of(
formatStock(Math.max(0, item.current), item.unit),
formatStock(item.targetQuantity, item.unit),
)}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
>
<div
className={cn("h-full rounded-full", item.level.low ? "bg-expense" : "bg-budget")}
style={{ width: `${pct}%` }}
/>
{item.thresholdPct > 0 && item.thresholdPct < 100 ? (
<span
aria-hidden
className="absolute top-0 h-full w-0.5 bg-foreground/50"
style={{ left: `${item.thresholdPct}%` }}
/>
) : null}
</div>
);
}
export function LevelBadge({ item }: { item: ProductTypeView }) {
if (item.current <= 0 && item.targetQuantity > 0)
return <Badge variant="destructive">{t.list.outBadge}</Badge>;
if (item.level.low)
return (
<Badge variant="outline" className="gap-1 border-expense/50 text-expense">
<AlertTriangle className="size-3" />
{t.list.lowBadge}
</Badge>
);
return null;
}
export function ExpiryBadge({ lot }: { lot: Pick<ExpiringLot, "expiryDate" | "status"> }) {
return (
<Badge
variant="outline"
className={cn(
"gap-1",
lot.status === "expired"
? "border-expense/50 text-expense"
: "border-amber-500/50 text-amber-700 dark:text-amber-400",
)}
>
<Clock className="size-3" />
{t.expiring.on(ymdLabel(lot.expiryDate))}
</Badge>
);
}
/** "1 azalt" düğmesi. */
export function QuickConsumeButton({ item }: { item: ProductTypeView }) {
const [pending, start] = useTransition();
return (
<Button
size="sm"
variant="outline"
className="shrink-0 tabular-nums"
disabled={pending || item.current <= 0}
aria-label={t.list.quickConsume(item.name)}
onClick={() =>
start(async () => {
const r = await quickConsumeAction(item.id);
if (r.ok)
toast.success(
t.list.quickDone(
item.name,
formatStock((r.data as { remaining: number }).remaining, item.unit),
),
);
else toast.error(r.error);
})
}
>
{stepLabel(item.step, item.unit)}
</Button>
);
}
/** Ö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">
<p className="text-xs text-muted-foreground">{label}</p>
<p className={cn("text-lg font-semibold tabular-nums", value > 0 && tone)}>{value}</p>
</div>
);
}
/** /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 (
<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 />
{t.categoriesLink}
</Link>
</Button>
{data.canWrite ? (
<Button size="sm" onClick={() => setCreating(true)}>
<Plus />
{t.newProduct}
</Button>
) : null}
</div>
<Card size="sm">
<CardContent className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label={t.summary.products} value={data.items.length} />
<Stat label={t.summary.low} value={data.suggestions.length} tone="text-expense" />
<Stat
label={t.summary.expiring}
value={data.expiring.length - expired}
tone="text-amber-700 dark:text-amber-400"
/>
<Stat label={t.summary.expired} value={expired} tone="text-expense" />
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<Card size="sm">
<CardHeader>
<CardTitle className="text-base">{t.suggestions.title}</CardTitle>
<CardDescription>{t.suggestions.description}</CardDescription>
</CardHeader>
<CardContent>
{data.suggestions.length === 0 ? (
<p className="text-sm text-muted-foreground">{t.suggestions.empty}</p>
) : (
<ul className="divide-y text-sm">
{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>
<CardTitle className="text-base">{t.expiring.title}</CardTitle>
<CardDescription>{t.expiring.description}</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y text-sm">
{data.expiring.map(({ item, lot }) => (
<li
key={`${item.id}-${lot.index}`}
className="flex items-center justify-between gap-3 py-2"
>
<Link href={`/stok/${item.id}`} className="min-w-0 hover:underline">
<span className="block truncate font-medium">{item.name}</span>
<span className="block text-xs text-muted-foreground tabular-nums">
{formatStock(lot.remaining, item.unit)}
</span>
</Link>
<ExpiryBadge lot={lot} />
</li>
))}
</ul>
</CardContent>
</Card>
) : null}
</div>
{data.items.length === 0 ? (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.list.empty}
</p>
) : (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-48 flex-1">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t.list.search}
aria-label={t.list.search}
className="pl-8"
/>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={onlyLow} onCheckedChange={(x) => setOnlyLow(x === true)} />
{t.list.onlyLow}
</label>
</div>
{groups.length === 0 ? (
<p className="text-sm text-muted-foreground">{t.list.noMatch}</p>
) : null}
{groups.map(({ category, items }) => (
<section key={category.id} className="flex flex-col gap-2">
<h2 className="text-sm font-medium text-muted-foreground">{category.path}</h2>
<ul className="flex flex-col gap-2">
{items.map((item) => (
<StockRow key={item.id} item={item} canWrite={data.canWrite} />
))}
</ul>
</section>
))}
</>
)}
{creating ? (
<ProductTypeFormDialog
item={null}
categories={options}
onClose={() => setCreating(false)}
onCreated={(id) => router.push(`/stok/${id}`)}
/>
) : null}
</div>
);
}
function StockRow({ item, canWrite }: { item: ProductTypeView; canWrite: boolean }) {
const lot = item.expiring[0];
return (
<li className="flex items-center gap-2 rounded-xl border bg-card p-3">
<Link href={`/stok/${item.id}`} className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<p className="flex min-w-0 flex-wrap items-center gap-1.5 font-medium">
<span className="truncate">{item.name}</span>
<LevelBadge item={item} />
{lot ? <ExpiryBadge lot={lot} /> : null}
</p>
<span className="shrink-0 text-sm tabular-nums">
<span className="font-semibold">
{formatStock(Math.max(0, item.current), item.unit)}
</span>
<span className="text-muted-foreground">
{" "}
/ {formatStock(item.targetQuantity, item.unit)}
</span>
</span>
</div>
<StockBar item={item} />
{item.location ? (
<p className="truncate text-xs text-muted-foreground">{item.location}</p>
) : null}
</div>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</Link>
{canWrite ? <QuickConsumeButton item={item} /> : null}
</li>
);
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Varsayılan stok kategori seti (Bölüm 6.10). `key` sabittir (tekrar yüklemede kopya olmaz).
* `expenseKey`: eşlenen gider kategorisinin varsayılan anahtarı (Faz 13'te alışveriş giderleri).
* Parantez içindeki örnekler (Kırmızı Mercimek, Toz Deterjan …) ürün tipidir; birim ve hedef
* haneye göre değiştiği için otomatik eklenmez (seed örnek verisinde vardır).
*/
export type DefaultStockCategory = {
key: string;
name: string;
expenseKey?: string;
children?: Omit<DefaultStockCategory, "children" | "expenseKey">[];
};
export const DEFAULT_STOCK_CATEGORIES: DefaultStockCategory[] = [
{
key: "mutfak",
name: "Mutfak",
expenseKey: "market.gida",
children: [
{ key: "mutfak.manav", name: "Manav" },
{ key: "mutfak.sarkuteri", name: "Şarküteri" },
{ key: "mutfak.bakliyat", name: "Bakliyat" },
],
},
{
key: "genel-temizlik",
name: "Genel Temizlik",
expenseKey: "market.temizlik",
children: [
{ key: "genel-temizlik.bulasik", name: "Bulaşık" },
{ key: "genel-temizlik.camasir", name: "Çamaşır" },
{ key: "genel-temizlik.genel", name: "Genel" },
],
},
{
key: "kisisel-temizlik",
name: "Kişisel Temizlik",
expenseKey: "market.kisisel-bakim",
children: [
{ key: "kisisel-temizlik.sampuan", name: "Şampuan" },
{ key: "kisisel-temizlik.tiras", name: "Tıraş" },
{ key: "kisisel-temizlik.kisisel-bakim", name: "Kişisel Bakım" },
],
},
];
+446
View File
@@ -0,0 +1,446 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { tr } from "@/i18n/tr";
import { AuditLogModel } from "@/models/audit-log";
import { StockCountModel } from "@/models/stock-count";
import { StockMovementModel } from "@/models/stock-movement";
import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
import {
productTypeFormSchema,
stockCategoryFormSchema,
stockCountFormSchema,
stockCountSheetSchema,
stockMovementFormSchema,
type ProductTypeFormInput,
} from "@/shared/schemas/stock";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
addStockMovement,
countStock,
createProductType,
createStockCategory,
deleteStockMovement,
getCountSheet,
getProductType,
getStock,
getStockAnalysis,
getStockOptions,
listStockCategories,
quickConsume,
setProductTypeArchived,
setStockCategoryArchived,
submitCount,
updateStockCategory,
} from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let zeynep = "";
let fatma = "";
const today = todayIstanbul();
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
ayse = ids.user("ayse");
zeynep = ids.user("zeynep");
fatma = ids.user("fatma");
});
afterAll(teardownDb);
async function categoryId(path: string) {
const o = (await getStockOptions(ayse, cakir))!;
return o.categories.find((c) => c.path === path)!.id;
}
function product(input: Partial<ProductTypeFormInput> & { categoryId: string }) {
return productTypeFormSchema.parse({
name: "Test Ürün",
unit: "kg",
targetQuantity: "4",
thresholdPct: "20",
step: "0,5",
...input,
});
}
describe("seed ve görünüm", () => {
it("varsayılan ağaç, gider eşlemesi devralınır; azalanlar ve SKT", async () => {
const s = (await getStock(ayse, cakir))!;
expect(s.items).toHaveLength(19);
const bakliyat = s.categories.find((c) => c.path === "Mutfak › Bakliyat")!;
expect(bakliyat.depth).toBe(1);
expect(bakliyat.expense).toEqual(
expect.objectContaining({ path: "Market › Gıda", inherited: true }),
);
expect(s.categories.find((c) => c.path === "Mutfak")!.expense?.inherited).toBe(false);
// Bir süredir alınmayanlar eşiğin altında.
const low = s.suggestions.map((i) => i.name);
expect(low).toEqual(expect.arrayContaining(["Kırmızı Mercimek", "Tablet Deterjan"]));
for (const i of s.suggestions) {
expect(i.level.low).toBe(true);
expect(i.suggestedQuantity).toBe(i.targetQuantity - Math.max(0, i.current));
}
// Hiçbir stok eksiye düşmez; süt partilerinin SKT'si yakın.
expect(s.items.every((i) => i.current >= 0)).toBe(true);
expect(s.expiring.some((e) => e.item.name === "Süt")).toBe(true);
// Kategori sırası: Mutfak ürünleri temizlikten önce.
expect(s.items.findIndex((i) => i.name === "Domates")).toBeLessThan(
s.items.findIndex((i) => i.name === "Toz Deterjan"),
);
const annemler = (await getStock(fatma, ids.household("Annemler")))!;
expect(annemler.items).toHaveLength(0);
expect(annemler.categories.length).toBe(12);
});
it("izolasyon: üye olmayan göremez, izleyici yazamaz", async () => {
expect(await getStock(fatma, cakir)).toBeNull();
const s = (await getStock(zeynep, cakir))!;
expect(s.canWrite).toBe(false);
const any = s.items[0]!;
expect((await quickConsume(zeynep, cakir, any.id)).ok).toBe(false);
expect(await getProductType(fatma, cakir, any.id)).toBeNull();
});
});
describe("ürün tipi ve hareketler", () => {
it("başlangıç miktarı, tüketim sınırı, 1 azalt, fire, SKT FIFO, sayım, silme", async () => {
const cat = await categoryId("Mutfak › Bakliyat");
const created = await createProductType(
ayse,
cakir,
product({ categoryId: cat, name: "Bulgur", initialQuantity: "1,5" }),
);
expect(created.ok).toBe(true);
const id = created.ok ? created.data.id : "";
let p = (await getProductType(ayse, cakir, id))!;
expect(p.current).toBe(1_500);
expect(p.movements[0]).toMatchObject({ kind: "count_adjust", initial: true, delta: 1_500 });
// 1,5 < %20 × 4 = 0,8? hayır → azalmadı.
expect(p.level.low).toBe(false);
const dup = await createProductType(ayse, cakir, product({ categoryId: cat, name: "bulgur" }));
expect(dup.ok).toBe(false);
const tooMuch = await addStockMovement(
ayse,
cakir,
id,
stockMovementFormSchema.parse({ kind: "consume", date: today, quantity: "2" }),
);
expect(tooMuch).toMatchObject({ ok: false, fieldErrors: { quantity: expect.any(String) } });
const q = await quickConsume(ayse, cakir, id);
expect(q).toEqual({ ok: true, data: { remaining: 1_000 } });
// Alım (SKT'li) sonra fire: fire en eski partiden (başlangıç) düşer.
await addStockMovement(
ayse,
cakir,
id,
stockMovementFormSchema.parse({
kind: "purchase",
date: today,
quantity: "1",
expiryDate: addDays(today, 3),
}),
);
expect(
stockMovementFormSchema.safeParse({ kind: "waste", date: today, quantity: "1" }).success,
).toBe(false);
await addStockMovement(
ayse,
cakir,
id,
stockMovementFormSchema.parse({
kind: "waste",
date: today,
quantity: "0,8",
wasteReason: "spoiled",
}),
);
p = (await getProductType(ayse, cakir, id))!;
expect(p.current).toBe(1_200);
expect(p.lots).toEqual([
expect.objectContaining({ remaining: 200, expiryDate: null }),
expect.objectContaining({ remaining: 1_000, expiryDate: addDays(today, 3), status: "soon" }),
]);
expect(p.expiring).toHaveLength(1);
// Sayım: 0,5 sayıldı → −0,7 düzeltme; aynı miktar tekrar → fark yok.
const c = await countStock(
ayse,
cakir,
id,
stockCountFormSchema.parse({ date: today, actualQuantity: "0,5" }),
);
expect(c).toEqual({ ok: true, data: { diff: -700 } });
const same = await countStock(
ayse,
cakir,
id,
stockCountFormSchema.parse({ date: today, actualQuantity: "0,5" }),
);
expect(same).toEqual({ ok: true, data: { diff: 0 } });
p = (await getProductType(ayse, cakir, id))!;
expect(p.current).toBe(500);
expect(p.level.low).toBe(true);
expect(p.suggestedQuantity).toBe(3_500);
// Hareket silme: yeniden hesap + denetim kaydı.
const adj = p.movements[0]!;
expect(adj.kind).toBe("count_adjust");
expect((await deleteStockMovement(ayse, cakir, adj.id)).ok).toBe(true);
expect((await getProductType(ayse, cakir, id))!.current).toBe(1_200);
expect(
await AuditLogModel.exists({ action: "stock.movement.delete", targetId: adj.id }),
).toBeTruthy();
expect(await StockMovementModel.exists({ _id: adj.id, deletedAt: null })).toBeNull();
// Arşivli üründe hareket yok; arşivden çıkınca devam.
expect((await setProductTypeArchived(ayse, cakir, id, true)).ok).toBe(true);
expect((await quickConsume(ayse, cakir, id)).ok).toBe(false);
expect((await getStock(ayse, cakir))!.items.some((i) => i.id === id)).toBe(false);
expect((await setProductTypeArchived(ayse, cakir, id, false)).ok).toBe(true);
});
it("boş stokta 1 azalt reddedilir; özel birim", async () => {
const cat = await categoryId("Genel Temizlik › Çamaşır");
const r = await createProductType(
ayse,
cakir,
product({ categoryId: cat, name: "Leke Çıkarıcı", unit: "yıkama", step: "1" }),
);
const id = r.ok ? r.data.id : "";
expect(await quickConsume(ayse, cakir, id)).toEqual({
ok: false,
error: tr.stock.validation.emptyStock,
});
expect((await getProductType(ayse, cakir, id))!.unit).toBe("yıkama");
});
});
describe("stok kategorileri", () => {
it("oluştur, gider eşle, taşıma kuralları, arşiv koşulu", async () => {
const opts = (await getStockOptions(ayse, cakir))!;
const market = opts.expenseCategories.find((c) => c.path === "Market")!;
const parent = await createStockCategory(
ayse,
cakir,
stockCategoryFormSchema.parse({
name: "Bebek",
parentId: "",
expenseCategoryId: market.id,
countEveryDays: "30",
}),
);
const parentId = parent.ok ? parent.data.id : "";
const child = await createStockCategory(
ayse,
cakir,
stockCategoryFormSchema.parse({
name: "Bez",
parentId,
expenseCategoryId: "",
countEveryDays: "",
}),
);
const childId = child.ok ? child.data.id : "";
let list = (await listStockCategories(ayse, cakir))!.categories;
const bez = list.find((c) => c.id === childId)!;
expect(bez).toMatchObject({
path: "Bebek › Bez",
effectiveCountDays: 30,
expense: expect.objectContaining({ path: "Market", inherited: true }),
});
// Kendi altına taşınamaz; aynı adlı kardeş olamaz.
const loop = await updateStockCategory(
ayse,
cakir,
parentId,
stockCategoryFormSchema.parse({
name: "Bebek",
parentId: childId,
expenseCategoryId: "",
countEveryDays: "",
}),
);
expect(loop.ok).toBe(false);
const dup = await createStockCategory(
ayse,
cakir,
stockCategoryFormSchema.parse({
name: "mutfak",
parentId: "",
expenseCategoryId: "",
countEveryDays: "",
}),
);
expect(dup.ok).toBe(false);
// Gelir kategorisi eşlenemez.
const income = await createStockCategory(ayse, cakir, {
name: "Yanlış",
parentId: null,
expenseCategoryId: "0".repeat(24),
countEveryDays: null,
});
expect(income.ok).toBe(false);
// Ürün varken arşivlenemez; ürün yokken alt ağaçla arşivlenir.
const r = await createProductType(
ayse,
cakir,
product({ categoryId: childId, name: "Islak Mendil" }),
);
expect((await setStockCategoryArchived(ayse, cakir, parentId, true)).ok).toBe(false);
await setProductTypeArchived(ayse, cakir, r.ok ? r.data.id : "", true);
expect((await setStockCategoryArchived(ayse, cakir, parentId, true)).ok).toBe(true);
list = (await listStockCategories(ayse, cakir))!.categories;
expect(list.find((c) => c.id === childId)!.archived).toBe(true);
expect((await getStockOptions(ayse, cakir))!.categories.some((c) => c.id === childId)).toBe(
false,
);
// Arşivli kategoriye ürün eklenemez.
expect(
(await createProductType(ayse, cakir, product({ categoryId: childId, name: "Pudra" }))).ok,
).toBe(false);
});
});
describe("periyodik sayım, öneri ve analiz (Faz 11b)", () => {
it("seed: Mutfak sayımı gecikti, Genel Temizlik değil; fire öneriyi düşürür", async () => {
const s = (await getStock(ayse, cakir))!;
const paths = s.countDue.map((g) => g.path);
expect(paths).toEqual(expect.arrayContaining(["Mutfak › Manav", "Mutfak › Bakliyat"]));
expect(paths.some((p) => p.startsWith("Genel Temizlik"))).toBe(false);
// Son sayım 21 gün önce, sıklık 15 → 6 gün gecikti.
expect(s.countDue.find((g) => g.path === "Mutfak › Manav")!.overdueDays).toBe(6);
const domates = s.items.find((i) => i.name === "Domates")!;
expect(domates.count).toMatchObject({ everyDays: 15, lastDate: addDays(today, -21) });
expect(domates.suggestion.waste?.pct).toBeGreaterThan(0);
const deterjan = s.items.find((i) => i.name === "Toz Deterjan")!;
expect(deterjan.count).toMatchObject({ everyDays: 30, due: false });
expect(s.items.find((i) => i.name === "Şampuan")!.count).toBeNull();
// Öneri hedefi aşmaz, tüketim hızı biliniyor.
for (const i of s.suggestions) {
expect(i.suggestedQuantity).toBeGreaterThan(0);
expect(i.suggestedQuantity).toBeLessThanOrEqual(
Math.ceil(i.suggestion.base / i.step) * i.step,
);
// Bugün eklenen test ürünlerinin geçmişi kısa: kullanım hızı yok.
if (i.movementCount > 5) expect(i.suggestion.usage).not.toBeNull();
}
});
it("sayım listesi kategori alt ağacıyla; toplu sayım fire + düzeltme, oturum kaydı", async () => {
const opts = (await getStockOptions(ayse, cakir))!;
const manav = opts.categories.find((c) => c.path === "Mutfak › Manav")!.id;
const mutfak = opts.categories.find((c) => c.path === "Mutfak")!.id;
const sheet = (await getCountSheet(ayse, cakir, mutfak))!;
expect(sheet.rows.every((r) => r.categoryPath.startsWith("Mutfak"))).toBe(true);
expect(sheet.categories.find((c) => c.id === manav)!.due).toBeGreaterThan(0);
const patates = sheet.rows.find((r) => r.name === "Patates")!;
const sogan = sheet.rows.find((r) => r.name === "Kuru Soğan")!;
const bad = await submitCount(
ayse,
cakir,
stockCountSheetSchema.parse({
date: today,
categoryId: mutfak,
rows: [
{
productTypeId: patates.id,
actualQuantity: String((patates.expected - 500) / 1000).replace(".", ","),
wasteQuantity: "0,6",
wasteReason: "spoiled",
},
],
}),
);
expect(bad).toMatchObject({
ok: false,
fieldErrors: { "rows.0.wasteQuantity": expect.any(String) },
});
const r = await submitCount(
ayse,
cakir,
stockCountSheetSchema.parse({
date: today,
categoryId: mutfak,
note: "Ay sonu",
rows: [
{
productTypeId: patates.id,
actualQuantity: String((patates.expected - 500) / 1000).replace(".", ","),
wasteQuantity: "0,3",
wasteReason: "spoiled",
},
{
productTypeId: sogan.id,
actualQuantity: String(sogan.expected / 1000).replace(".", ","),
},
{ productTypeId: manav, actualQuantity: "" },
].map((x) => ({ wasteQuantity: "", wasteReason: "", ...x })),
}),
);
expect(r).toMatchObject({ ok: true, data: { counted: 2, adjusted: 1, diffs: [-500, 0] } });
const countId = r.ok ? r.data.countId : "";
const session = await StockCountModel.findById(countId).lean();
expect(session!.items).toHaveLength(2);
expect(session!.items[0]).toMatchObject({ expected: patates.expected, wasted: 300 });
const moves = await StockMovementModel.find({ countId }).sort({ createdAt: 1 }).lean();
expect(moves.map((m) => [m.kind, m.quantity, m.wasteReason])).toEqual([
["waste", 300, "spoiled"],
["count_adjust", -200, null],
]);
// Fark sıfır olan soğan da sayıldı: takvim yenilendi.
const p = (await getProductType(ayse, cakir, sogan.id))!;
expect(p.count).toMatchObject({ lastDate: today, due: false, nextDate: addDays(today, 15) });
expect((await getProductType(ayse, cakir, patates.id))!.current).toBe(patates.expected - 500);
// Hiçbir satır dolu değilse şema reddeder; izleyici sayamaz.
expect(
stockCountSheetSchema.safeParse({
date: today,
categoryId: "",
rows: [{ productTypeId: sogan.id, actualQuantity: "", wasteQuantity: "", wasteReason: "" }],
}).success,
).toBe(false);
expect(
(
await submitCount(
zeynep,
cakir,
stockCountSheetSchema.parse({
date: today,
categoryId: "",
rows: [
{ productTypeId: sogan.id, actualQuantity: "1", wasteQuantity: "", wasteReason: "" },
],
}),
)
).ok,
).toBe(false);
expect(await getCountSheet(fatma, cakir, null)).toBeNull();
});
it("tüketim analizi: aylık ortalama ve fire oranı", async () => {
const a = (await getStockAnalysis(ayse, cakir))!;
expect(a.months).toHaveLength(6);
expect(a.months.at(-1)! < a.thisMonth).toBe(true);
const domates = a.rows.find((r) => r.name === "Domates")!;
expect(domates.avgMonthlyUsed).toBeGreaterThan(5_000);
expect(domates.wastePct).toBeGreaterThan(0);
const sampuan = a.rows.find((r) => r.name === "Şampuan")!;
expect(sampuan.avgMonthlyWasted).toBe(0);
expect(sampuan.wastePct).toBe(0);
expect(await getStockAnalysis(fatma, cakir)).toBeNull();
});
});
File diff suppressed because it is too large Load Diff
+267
View File
@@ -929,6 +929,273 @@ export const tr = {
category: "Kategori seçin", category: "Kategori seçin",
}, },
}, },
stock: {
title: "Ev stoğu",
description:
"Evdeki ürünlerin miktarı, kritik eşik ve son kullanma tarihleri. Stok ürün tipi düzeyinde tutulur.",
newProduct: "Yeni ürün tipi",
categoriesLink: "Stok kategorileri",
countLink: "Sayım",
analysisLink: "Tüketim analizi",
countNote: "Sayım",
countDue: {
title: "Sayımı gelenler",
description:
"Sayım sıklığı dolan kategoriler. Sıklık ürün tipinde veya stok kategorisinde ayarlanır.",
products: (n: number) => `${n} ürün`,
overdue: (days: number) => (days > 0 ? `${days} gün gecikti` : "Bugün"),
start: "Sayıma başla",
},
count: {
title: "Sayım",
description:
"Evdeki gerçek miktarları girin. Kayıtlarla arasındaki fark sayım düzeltmesi olur; eksiğin bir kısmını fire olarak işaretleyebilirsiniz. Boş bıraktığınız ürünler sayılmaz.",
back: "Ev stoğuna dön",
allCategories: "Tüm stok",
category: "Kategori",
dueCount: (n: number) => `${n} sayımı gelen`,
onlyDue: "Yalnızca sayımı gelenler",
expected: "Beklenen",
actual: "Sayılan",
diff: "Fark",
same: "Aynı",
markWaste: "Eksiğin bir kısmı fire",
wasteQuantity: "Fire miktarı",
wasteMax: (q: string) => `En çok ${q}`,
dueBadge: (days: number) => (days > 0 ? `Sayım ${days} gün gecikti` : "Sayım bugün"),
lastCount: (date: string) => `Son sayım ${date}`,
neverCounted: "Hiç sayılmadı",
fillExpected: "Beklenenle doldur",
fillExpectedHint: "Sayılmayan satırlara beklenen miktarı yazar; değişenleri düzeltin.",
clear: "Temizle",
note: "Not",
counted: (n: number) => `${n} ürün sayılacak`,
submit: "Sayımı kaydet",
empty: "Bu kategoride sayılacak ürün yok.",
done: (counted: number, adjusted: number) =>
adjusted
? `${counted} ürün sayıldı; ${adjusted} üründe fark düzeltildi.`
: `${counted} ürün sayıldı; kayıtlarla aynı.`,
},
analysis: {
title: "Tüketim analizi",
description:
"Ürün tipi başına aylık tüketim ve fire. Tüketim: girilen tüketimler ve sayımda çıkan eksikler. Maliyet, ürün fiyatları (Faz 12) gelince eklenecek.",
back: "Ev stoğuna dön",
product: "Ürün tipi",
avgUsed: "Aylık ort. tüketim",
avgWasted: "Aylık ort. fire",
wastePct: "Fire oranı",
thisMonth: "Bu ay",
trend: (n: number) => `Son ${n} ay`,
noData: "—",
empty: "Analiz için ürün tipi yok.",
chartLabel: (name: string) => `${name}: aylık tüketim`,
monthValue: (month: string, used: string, wasted: string) =>
`${month}: tüketim ${used}, fire ${wasted}`,
highWaste: "Fire oranı yüksek",
table: "Tablo",
},
readOnly: "İzleyici rolündesiniz; stoğu değiştiremezsiniz.",
summary: {
products: "Ürün tipi",
low: "Azalan",
expiring: "SKT yaklaşan",
expired: "SKT geçmiş",
},
suggestions: {
title: "Alınması önerilenler",
description: "Kritik eşiğin altına düşen ürünler ve hedefe tamamlamak için gereken miktar.",
empty: "Eşiğin altına düşen ürün yok.",
suggested: (q: string) => `Öneri: ${q}`,
wasteReduced: (purchases: number, pct: number) =>
`Son ${purchases} alımda %${pct} fire, miktar azaltıldı.`,
roundedByUsage: (week: string) => `Haftalık tüketime (${week}) göre yuvarlandı.`,
covers: (days: number) => `Mevcutla birlikte yaklaşık ${days} gün yeter.`,
},
expiring: {
title: "Son kullanma tarihi",
description: "Evde kalan miktarı olan ve SKT'si 7 gün içinde dolan veya geçmiş partiler.",
expired: "Geçti",
soon: "Yaklaşıyor",
on: (date: string) => `SKT ${date}`,
},
list: {
empty: "Henüz ürün tipi yok. “Yeni ürün tipi” ile evdeki ürünleri ekleyin.",
emptyCategory: "Bu kategoride ürün yok.",
search: "Ürün ara",
onlyLow: "Yalnızca azalanlar",
noMatch: "Aramaya uyan ürün yok.",
lowBadge: "Azaldı",
outBadge: "Bitti",
of: (current: string, target: string) => `${current} / ${target}`,
quickConsume: (name: string) => `${name}: 1 azalt`,
quickDone: (name: string, rest: string) => `${name} azaltıldı, kalan ${rest}.`,
},
fields: {
name: "Ad",
namePlaceholder: "ör. Toz Deterjan, Kırmızı Mercimek",
category: "Stok kategorisi",
unit: "Temel ölçü birimi",
customUnit: "Özel birim",
customUnitOption: "Özel birim…",
customUnitPlaceholder: "ör. yıkama, tablet",
target: "Hedef (tam) stok",
targetHint: "Evde bulunmasını istediğiniz tam miktar.",
thresholdPct: "Kritik eşik (%)",
thresholdHint: "Mevcut miktar hedefin bu yüzdesinin altına düşerse ürün önerilir.",
location: "Saklama yeri",
locationPlaceholder: "Kiler, banyo dolabı…",
step: "“1 azalt” miktarı",
stepHint: "Hızlı azaltma düğmesinin düşeceği miktar.",
countEveryDays: "Sayım sıklığı (gün)",
countEveryDaysHint: "Boş bırakılırsa kategoriden devralınır.",
note: "Not",
initialQuantity: "Evdeki mevcut miktar",
initialHint: "Tahmini veya ambalaj üzerinden. Boş bırakılırsa 0 kabul edilir.",
date: "Tarih",
quantity: "Miktar",
wasteReason: "Fire nedeni",
expiryDate: "Son kullanma tarihi",
expiryHint: "İsteğe bağlı. Yaklaşınca uyarı verilir.",
actualQuantity: "Sayılan (gerçek) miktar",
expected: (q: string) => `Kayıtlara göre beklenen: ${q}`,
},
createTitle: "Yeni ürün tipi",
editTitle: "Ürün tipini düzenle",
kinds: {
purchase: "Alım",
consume: "Tüketim",
waste: "Fire / zarar",
count_adjust: "Sayım düzeltmesi",
},
actions: {
purchase: "Alım ekle",
consume: "Tüketim gir",
waste: "Fire gir",
count: "Sayım yap",
},
movementTitles: {
purchase: "Alım ekle",
consume: "Tüketim gir",
waste: "Fire / zarar gir",
},
countTitle: "Sayım yap",
countDescription:
"Evdeki gerçek miktarı girin; kayıtlarla arasındaki fark sayım düzeltmesi olarak işlenir.",
wasteReasons: {
spoiled: "Bozuldu",
expired: "Son kullanma tarihi geçti",
damaged: "Hasar / döküldü",
other: "Diğer",
},
initial: "Başlangıç miktarı",
detail: {
back: "Ev stoğuna dön",
current: "Mevcut",
target: "Hedef",
threshold: "Eşik",
suggested: "Önerilen alım",
usage: "Tüketim hızı",
perWeek: (q: string) => `Haftada ~${q}`,
noUsage: "Henüz yeterli geçmiş yok",
countSchedule: "Periyodik sayım",
countEvery: (n: number) => `${n} günde bir`,
nextCount: (date: string) => `Sonraki sayım ${date}`,
noCountSchedule: "Sayım sıklığı ayarlanmadı",
location: "Saklama yeri",
lots: "Evdeki partiler",
lotsHint: "Çıkışlar en eski alımdan düşülür (FIFO).",
noLots: "Evde bu üründen yok.",
noExpiry: "SKT yok",
movements: "Hareketler",
noMovements: "Henüz hareket yok.",
after: (q: string) => `Sonra: ${q}`,
archive: "Arşivle",
unarchive: "Arşivden çıkar",
archived: "Arşivli",
archiveTitle: "Ürün tipi arşivlensin mi?",
archiveDescription: (name: string) =>
`“${name}” stok listesinde ve önerilerde görünmez. Hareketleri silinmez; istediğinizde arşivden çıkarabilirsiniz.`,
deleteMovement: "Hareketi sil",
deleteMovementTitle: "Hareket silinsin mi?",
deleteMovementDescription: "Mevcut miktar bu hareket olmadan yeniden hesaplanır.",
},
categories: {
title: "Stok kategorileri",
description:
"Stok kategorileri gider kategorilerinden ayrıdır. Her kategori bir gider kategorisine eşlenebilir; alışveriş tamamlanınca ürünler o gidere yazılır.",
back: "Ev stoğuna dön",
new: "Yeni kategori",
addChild: "Alt kategori ekle",
edit: "Düzenle",
archive: "Arşivle",
unarchive: "Arşivden çıkar",
showArchived: "Arşivi göster",
archived: "Arşivli",
defaultBadge: "Varsayılan",
empty: "Stok kategorisi yok.",
createTitle: "Yeni stok kategorisi",
editTitle: "Stok kategorisini düzenle",
name: "Ad",
parent: "Üst kategori",
noParent: "— Ana kategori —",
expenseCategory: "Gider kategorisi",
expenseInherit: "— Üstten devral —",
expenseHint: "Alışveriş tamamlanınca bu kategorideki ürünlerin yazılacağı gider kategorisi.",
inherited: (name: string) => `${name} (üstten)`,
noExpense: "Eşlenmedi",
countEveryDays: "Sayım sıklığı (gün)",
countEveryDaysHint: "Boş bırakılırsa üst kategoriden devralınır.",
everyDays: (n: number) => `${n} günde bir sayım`,
productCount: (n: number) => `${n} ürün`,
archiveTitle: "Stok kategorisi arşivlensin mi?",
archiveDescription: (name: string) =>
`“${name}” ve alt kategorileri yeni ürünlerde seçilemez.`,
hasProducts:
"Bu kategoride veya altında aktif ürün tipi var; önce onları başka kategoriye taşıyın veya arşivleyin.",
duplicate: "Bu düzeyde aynı adlı bir kategori zaten var.",
expenseInvalid: "Geçerli bir gider kategorisi seçin.",
},
toast: {
saved: "Kaydedildi.",
movementSaved: "Hareket kaydedildi.",
deleted: "Silindi.",
archived: "Arşivlendi.",
unarchived: "Arşivden çıkarıldı.",
counted: (diff: string) => `Sayım kaydedildi; fark ${diff}.`,
noChange: "Sayım kayıtlarla aynı; düzeltme gerekmedi.",
},
validation: {
selectCategory: "Bir stok kategorisi seçin",
quantity: "Geçerli bir miktar girin (ör. 1,5)",
countDays: "1 ile 365 arasında gün sayısı girin",
unit: "Birim 1–20 karakter olmalı",
thresholdPct: "0 ile 100 arasında tam sayı girin",
wasteReason: "Fire nedenini seçin",
expiryBeforeDate: "Son kullanma tarihi alım tarihinden önce olamaz",
notEnough: (q: string) => `Stokta yalnızca ${q} var.`,
nothingCounted: "En az bir ürünün sayılan miktarını girin.",
wasteOverShortage: (q: string) => `Fire, sayımda çıkan eksikten (${q}) fazla olamaz.`,
emptyStock: "Stok zaten boş.",
duplicate: "Bu adla bir ürün tipi zaten var.",
categoryArchived: "Seçilen stok kategorisi arşivli veya bulunamadı.",
archived: "Ürün tipi arşivli.",
},
dashboard: {
title: "Stok uyarıları",
description: (low: number, expiring: number, countDue = 0) =>
[
low ? `${low} ürün eşiğin altında` : null,
expiring ? `${expiring} partinin SKT'si yaklaşıyor` : null,
countDue ? `${countDue} kategoride sayım zamanı` : null,
]
.filter(Boolean)
.join(" · "),
more: (n: number) => `+${n} ürün daha`,
open: "Ev stoğuna git",
},
},
prices: { prices: {
title: "Fiyat tablosu", title: "Fiyat tablosu",
description: description:
+5
View File
@@ -35,6 +35,11 @@ export const AUDIT_ACTIONS = [
"inkind.delete", "inkind.delete",
"asset.delete", "asset.delete",
"asset.movement.delete", "asset.movement.delete",
"stock.category.archive",
"stock.category.unarchive",
"stock.product.archive",
"stock.product.unarchive",
"stock.movement.delete",
] as const; ] as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[number]; export type AuditAction = (typeof AUDIT_ACTIONS)[number];
+43
View File
@@ -0,0 +1,43 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
/**
* Ürün tipi (genel ürün, markadan bağımsız ihtiyaç: "Toz Deterjan"). Stok bu seviyede tutulur.
* Miktarlar binde bir (QUANTITY_SCALE). Birim hazır listeden ya da özel ("yıkama").
*/
const productTypeSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
categoryId: { type: Schema.Types.ObjectId, required: true },
name: { type: String, required: true, trim: true, maxlength: 80 },
nameKey: { type: String, required: true },
unit: { type: String, required: true, trim: true, maxlength: 20 },
/** Hedef (tam) stok miktarı. */
targetQuantity: { type: Number, required: true, min: 0 },
/** Kritik eşik, hedefin yüzdesi (varsayılan 20). */
thresholdPct: { type: Number, default: 20, min: 0, max: 100 },
/** Saklama yeri (kiler, banyo dolabı …). */
location: { type: String, default: "", maxlength: 80 },
/** "1 azalt" düğmesinin düşeceği miktar. */
step: { type: Number, default: 1000, min: 1 },
/** Sayım sıklığı (gün); null ise kategoriden devralınır (Faz 11b). */
countEveryDays: { type: Number, default: null, min: 1, max: 365 },
note: { type: String, default: "", maxlength: 300 },
archivedAt: { type: Date, default: null },
seedRef: { type: String, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
updatedBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: true, collection: "productTypes" },
);
productTypeSchema.index({ householdId: 1, archivedAt: 1, categoryId: 1 });
productTypeSchema.index(
{ householdId: 1, seedRef: 1 },
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
);
export type ProductType = InferSchemaType<typeof productTypeSchema>;
export const ProductTypeModel: Model<ProductType> =
(models.ProductType as Model<ProductType> | undefined) ??
model<ProductType>("ProductType", productTypeSchema);
+37
View File
@@ -0,0 +1,37 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
/**
* Stok kategori ağacı (Bölüm 6.10), gelir/gider kategorilerinden ayrı. Silme yok; arşivleme.
* `expenseCategoryId`: alışveriş tamamlanınca (Faz 13) kalemlerin yazılacağı gider kategorisi;
* null ise üst kategoriden devralınır. `countEveryDays`: periyodik sayım sıklığı (Faz 11b);
* null ise üstten devralınır.
*/
const stockCategorySchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
name: { type: String, required: true, trim: true, maxlength: 80 },
nameKey: { type: String, required: true },
parentId: { type: Schema.Types.ObjectId, default: null },
ancestors: { type: [Schema.Types.ObjectId], default: [] },
order: { type: Number, default: 0 },
expenseCategoryId: { type: Schema.Types.ObjectId, default: null },
countEveryDays: { type: Number, default: null, min: 1, max: 365 },
archivedAt: { type: Date, default: null },
defaultKey: { type: String, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
updatedBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: true, collection: "stockCategories" },
);
stockCategorySchema.index({ householdId: 1, parentId: 1, order: 1 });
stockCategorySchema.index(
{ householdId: 1, defaultKey: 1 },
{ unique: true, partialFilterExpression: { defaultKey: { $type: "string" } } },
);
export type StockCategory = InferSchemaType<typeof stockCategorySchema>;
export const StockCategoryModel: Model<StockCategory> =
(models.StockCategory as Model<StockCategory> | undefined) ??
model<StockCategory>("StockCategory", stockCategorySchema);
+45
View File
@@ -0,0 +1,45 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
/**
* Sayım oturumu (Bölüm 6.10, periyodik sayım). Her sayılan ürün için beklenen ve sayılan miktar,
* fireye ayrılan kısım. Fark sıfır olsa da kayıt tutulur: "son sayım" ve sayım takvimi buradan
* türetilir. Oluşan hareketler `countId` ile bağlanır.
*/
const stockCountItemSchema = new Schema(
{
productTypeId: { type: Schema.Types.ObjectId, required: true },
expected: { type: Number, required: true },
actual: { type: Number, required: true },
/** Eksiğin fire olarak işaretlenen kısmı (≥ 0). */
wasted: { type: Number, default: 0 },
},
{ _id: false },
);
const stockCountSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
/** "YYYY-MM-DD" (İstanbul). */
date: { type: String, required: true },
/** Sayılan stok kategorisi (alt ağacıyla); null: tüm stok veya tek ürün. */
categoryId: { type: Schema.Types.ObjectId, default: null },
items: { type: [stockCountItemSchema], default: [] },
note: { type: String, default: "", maxlength: 300 },
seedRef: { type: String, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: true, collection: "stockCounts" },
);
stockCountSchema.index({ householdId: 1, "items.productTypeId": 1, date: -1 });
stockCountSchema.index({ householdId: 1, date: -1 });
stockCountSchema.index(
{ householdId: 1, seedRef: 1 },
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
);
export type StockCount = InferSchemaType<typeof stockCountSchema>;
export const StockCountModel: Model<StockCount> =
(models.StockCount as Model<StockCount> | undefined) ??
model<StockCount>("StockCount", stockCountSchema);
+48
View File
@@ -0,0 +1,48 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
import { STOCK_MOVEMENT_KINDS, WASTE_REASONS } from "@/shared/stock";
/**
* Stok hareketi: alım, tüketim, fire (neden ile), sayım düzeltmesi. Mevcut miktar hareketlerden
* türetilir. Miktar binde bir; yalnızca sayım düzeltmesi işaretlidir. Sıra: tarih, oluşturulma.
*/
const stockMovementSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
productTypeId: { type: Schema.Types.ObjectId, required: true },
kind: { type: String, enum: STOCK_MOVEMENT_KINDS, required: true },
/** "YYYY-MM-DD" (İstanbul). */
date: { type: String, required: true },
quantity: { type: Number, required: true },
wasteReason: { type: String, enum: [...WASTE_REASONS, null], default: null },
/** Son kullanma tarihi (yalnızca alımda, isteğe bağlı). */
expiryDate: { type: String, default: null },
note: { type: String, default: "", maxlength: 300 },
/** İlk kurulumda girilen başlangıç miktarı (sayım düzeltmesi olarak). */
initial: { type: Boolean, default: false },
/** Sayım oturumu (Faz 11b). */
countId: { type: Schema.Types.ObjectId, default: null },
deletedAt: { type: Date, default: null },
seedRef: { type: String, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: true, collection: "stockMovements" },
);
stockMovementSchema.index({
householdId: 1,
productTypeId: 1,
deletedAt: 1,
date: 1,
createdAt: 1,
});
stockMovementSchema.index({ householdId: 1, kind: 1, date: -1 });
stockMovementSchema.index(
{ householdId: 1, seedRef: 1 },
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
);
export type StockMovement = InferSchemaType<typeof stockMovementSchema>;
export const StockMovementModel: Model<StockMovement> =
(models.StockMovement as Model<StockMovement> | undefined) ??
model<StockMovement>("StockMovement", stockMovementSchema);
+160
View File
@@ -0,0 +1,160 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import { parseQuantity } from "../quantity";
import { WASTE_REASONS } from "../stock";
const v = tr.validation;
const s = tr.stock.validation;
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date);
const optionalId = z
.string()
.regex(/^([a-f0-9]{24})?$/i)
.transform((x) => (x === "" ? null : x));
const requiredId = z.string().regex(/^[a-f0-9]{24}$/i, s.selectCategory);
/** Miktar metni → binde bir tamsayı. `allowZero` sayım ve başlangıç için. */
const quantity = (allowZero = false) =>
z
.string()
.trim()
.transform((x, ctx) => {
const q = parseQuantity(x);
if (q === null || q < 0 || (!allowZero && q === 0)) {
ctx.addIssue({ code: "custom", message: s.quantity });
return z.NEVER;
}
return q;
});
const optionalQuantity = z.preprocess(
(x) => (x === undefined || x === null || String(x).trim() === "" ? null : x),
quantity(true).nullable(),
);
/** Boş → null; dolu → 1..365 gün. */
const countEveryDays = z.preprocess(
(x) => (x === undefined || x === null || String(x).trim() === "" ? null : x),
z
.string()
.trim()
.transform((x, ctx) => {
const n = Number(x);
if (!/^\d+$/.test(x) || n < 1 || n > 365) {
ctx.addIssue({ code: "custom", message: s.countDays });
return z.NEVER;
}
return n;
})
.nullable(),
);
export const stockCategoryFormSchema = z.object({
name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax),
parentId: optionalId,
expenseCategoryId: optionalId,
countEveryDays,
});
export type StockCategoryFormInput = z.input<typeof stockCategoryFormSchema>;
export type StockCategoryForm = z.output<typeof stockCategoryFormSchema>;
export const productTypeFormSchema = z.object({
name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax),
categoryId: requiredId,
unit: z.string().trim().min(1, s.unit).max(20, s.unit),
targetQuantity: quantity(),
thresholdPct: z
.string()
.trim()
.transform((x, ctx) => {
const n = Number(x);
if (!/^\d+$/.test(x) || n > 100) {
ctx.addIssue({ code: "custom", message: s.thresholdPct });
return z.NEVER;
}
return n;
}),
location: z.string().trim().max(80).default(""),
step: quantity(),
countEveryDays,
note: z.string().trim().max(300).default(""),
/** Yalnızca yeni ürün tipinde: evdeki başlangıç miktarı. */
initialQuantity: optionalQuantity.default(null),
});
export type ProductTypeFormInput = z.input<typeof productTypeFormSchema>;
export type ProductTypeForm = z.output<typeof productTypeFormSchema>;
/** Alım, tüketim veya fire. Sayım düzeltmesi ayrı formdan (gerçek miktar). */
export const stockMovementFormSchema = z
.object({
kind: z.enum(["purchase", "consume", "waste"]),
date: ymd,
quantity: quantity(),
wasteReason: z.preprocess(
(x) => (x === "" || x === undefined ? null : x),
z.enum(WASTE_REASONS).nullable(),
),
expiryDate: z.preprocess((x) => (x === "" || x === undefined ? null : x), ymd.nullable()),
note: z.string().trim().max(300).default(""),
})
.superRefine((x, ctx) => {
if (x.kind === "waste" && !x.wasteReason)
ctx.addIssue({ code: "custom", path: ["wasteReason"], message: s.wasteReason });
if (x.expiryDate && x.expiryDate < x.date)
ctx.addIssue({ code: "custom", path: ["expiryDate"], message: s.expiryBeforeDate });
})
.transform((x) => ({
...x,
wasteReason: x.kind === "waste" ? x.wasteReason : null,
expiryDate: x.kind === "purchase" ? x.expiryDate : null,
}));
export type StockMovementFormInput = z.input<typeof stockMovementFormSchema>;
export type StockMovementForm = z.output<typeof stockMovementFormSchema>;
/** Tek ürün sayımı: gerçek miktar → fark sayım düzeltmesi olur. */
export const stockCountFormSchema = z.object({
date: ymd,
actualQuantity: quantity(true),
note: z.string().trim().max(300).default(""),
});
export type StockCountFormInput = z.input<typeof stockCountFormSchema>;
export type StockCountForm = z.output<typeof stockCountFormSchema>;
/**
* Toplu sayım satırı: sayılan miktar boşsa ürün sayılmamış sayılır (atlanır). Eksiğin bir kısmı
* fire olarak işaretlenebilir (neden zorunlu).
*/
const stockCountRowSchema = z
.object({
productTypeId: z.string().regex(/^[a-f0-9]{24}$/i),
actualQuantity: optionalQuantity,
wasteQuantity: optionalQuantity.default(null),
wasteReason: z.preprocess(
(x) => (x === "" || x === undefined ? null : x),
z.enum(WASTE_REASONS).nullable(),
),
})
.superRefine((x, ctx) => {
if (x.wasteQuantity && !x.wasteReason)
ctx.addIssue({ code: "custom", path: ["wasteReason"], message: s.wasteReason });
if (x.wasteQuantity && x.actualQuantity === null)
ctx.addIssue({ code: "custom", path: ["actualQuantity"], message: s.quantity });
})
.transform((x) => ({
...x,
wasteQuantity: x.wasteQuantity || null,
wasteReason: x.wasteQuantity ? x.wasteReason : null,
}));
export const stockCountSheetSchema = z
.object({
date: ymd,
categoryId: optionalId,
note: z.string().trim().max(300).default(""),
rows: z.array(stockCountRowSchema).max(500),
})
.superRefine((x, ctx) => {
if (!x.rows.some((r) => r.actualQuantity !== null))
ctx.addIssue({ code: "custom", path: ["rows"], message: s.nothingCounted });
});
export type StockCountSheetInput = z.input<typeof stockCountSheetSchema>;
export type StockCountSheet = z.output<typeof stockCountSheetSchema>;
+229
View File
@@ -0,0 +1,229 @@
import { describe, expect, it } from "vitest";
import {
countSchedule,
currentQuantity,
expiringLots,
expiryStatus,
fifoLots,
monthlyUsage,
quantityInput,
recentWaste,
roundForDisplay,
refillQuantity,
stepLabel,
stockLevel,
suggestPurchase,
usageRate,
type StockMove,
} from "./stock";
const m = (
kind: StockMove["kind"],
date: string,
quantity: number,
expiryDate: string | null = null,
): StockMove => ({ kind, date, quantity, expiryDate });
describe("mevcut miktar", () => {
it("alım − tüketim − fire ± sayım", () => {
expect(
currentQuantity([
m("purchase", "2026-10-01", 5_000),
m("consume", "2026-10-05", 1_500),
m("waste", "2026-10-06", 500),
m("count_adjust", "2026-10-10", -250),
m("count_adjust", "2026-10-11", 100),
]),
).toBe(2_850);
expect(currentQuantity([])).toBe(0);
});
});
describe("FIFO partileri ve SKT", () => {
it("çıkışlar en eski partiden düşer", () => {
const lots = fifoLots([
m("purchase", "2026-10-01", 2_000, "2026-11-10"),
m("purchase", "2026-10-20", 3_000, "2026-12-01"),
m("consume", "2026-10-25", 2_500),
m("count_adjust", "2026-10-26", 400),
]);
expect(lots).toEqual([
{ index: 1, date: "2026-10-20", expiryDate: "2026-12-01", quantity: 3_000, remaining: 2_500 },
{ index: 3, date: "2026-10-26", expiryDate: null, quantity: 400, remaining: 400 },
]);
});
it("geçici eksi stok sonraki girişten kapatılır", () => {
const lots = fifoLots([
m("consume", "2026-10-01", 1_000),
m("purchase", "2026-10-02", 3_000, "2026-10-30"),
]);
expect(lots).toEqual([expect.objectContaining({ quantity: 3_000, remaining: 2_000 })]);
expect(fifoLots([m("purchase", "2026-10-01", 1_000), m("waste", "2026-10-02", 1_000)])).toEqual(
[],
);
});
it("SKT durumu: geçmiş, 7 gün içinde, sorunsuz", () => {
expect(expiryStatus("2026-11-05", "2026-11-06")).toBe("expired");
expect(expiryStatus("2026-11-06", "2026-11-06")).toBe("soon");
expect(expiryStatus("2026-11-13", "2026-11-06")).toBe("soon");
expect(expiryStatus("2026-11-14", "2026-11-06")).toBe("ok");
const lots = fifoLots([
m("purchase", "2026-10-01", 1_000, "2026-11-20"),
m("purchase", "2026-10-02", 1_000, "2026-11-08"),
m("purchase", "2026-10-03", 1_000, "2026-11-01"),
m("purchase", "2026-10-04", 1_000),
]);
expect(expiringLots(lots, "2026-11-06").map((l) => [l.expiryDate, l.status])).toEqual([
["2026-11-01", "expired"],
["2026-11-08", "soon"],
]);
});
});
describe("eşik ve öneri", () => {
it("%20 eşiğinin altı azalan sayılır", () => {
expect(stockLevel(1_000, 5_000, 20)).toEqual({ thresholdQuantity: 1_000, pct: 20, low: false });
expect(stockLevel(999, 5_000, 20).low).toBe(true);
expect(stockLevel(-200, 5_000, 20)).toMatchObject({ pct: 0, low: true });
expect(stockLevel(3_000, 10_000, 35)).toMatchObject({ thresholdQuantity: 3_500, low: true });
expect(stockLevel(0, 0, 20)).toEqual({ thresholdQuantity: 0, pct: null, low: false });
expect(refillQuantity(800, 5_000)).toBe(4_200);
expect(refillQuantity(-300, 5_000)).toBe(5_000);
expect(refillQuantity(6_000, 5_000)).toBe(0);
});
it("form metni gruplamasız ve geri okunabilir", () => {
expect(quantityInput(1_000_000)).toBe("1000");
expect(quantityInput(1_500)).toBe("1,5");
expect(quantityInput(250)).toBe("0,25");
});
it("gösterim yuvarlaması", () => {
expect(roundForDisplay(18_368)).toBe(18_400);
expect(roundForDisplay(1_672)).toBe(1_670);
expect(roundForDisplay(441.4)).toBe(440);
});
it("azaltma adımı etiketi", () => {
expect(stepLabel(1_000, "adet")).toBe("−1 adet");
expect(stepLabel(500, "kg")).toBe("−0,5 kg");
});
});
describe("fire oranı ve önerilen alım", () => {
const wasteful = [
m("purchase", "2026-11-01", 3_000),
m("waste", "2026-11-02", 900),
m("purchase", "2026-11-03", 3_000),
m("waste", "2026-11-04", 900),
m("consume", "2026-11-05", 4_200),
];
it("fire FIFO ile alımlara yazılır; son alımların oranı", () => {
expect(recentWaste(wasteful)).toEqual({
purchases: 2,
purchased: 6_000,
wasted: 1_800,
pct: 30,
});
// Başlangıç partisinden düşen fire alım oranına girmez.
expect(
recentWaste([
{ ...m("count_adjust", "2026-10-01", 1_000), initial: true } as StockMove,
m("waste", "2026-10-02", 500),
m("purchase", "2026-10-03", 2_000),
]),
).toMatchObject({ purchases: 1, wasted: 0, pct: 0 });
expect(recentWaste([m("consume", "2026-10-01", 1)])).toBeNull();
});
it("%30 fire öneriyi düşürür, adıma yuvarlanır", () => {
const s = suggestPurchase({
current: 0,
target: 3_000,
step: 500,
moves: wasteful,
today: "2026-11-06",
});
// 3 kg × %70 = 2,1 kg → 0,5'in katı 2 kg. Geçmiş 14 günden kısa: kullanım yok.
expect(s).toMatchObject({ base: 3_000, quantity: 2_000, roundTo: 500, usage: null });
expect(s.waste?.pct).toBe(30);
});
it("tüketim hızına (haftalık kullanımın katına) yuvarlanır, hedefi aşmaz", () => {
const moves = [
{ ...m("count_adjust", "2026-10-10", 10_000), initial: true },
m("consume", "2026-11-01", 8_400),
];
expect(usageRate(moves, "2026-11-06")).toEqual({ perDay: 300, days: 28 });
const s = suggestPurchase({
current: 1_600,
target: 6_000,
step: 500,
moves,
today: "2026-11-06",
});
// Haftalık 2,1 → 2 kg; taban 4,4 → 2 hafta = 4 kg; (1,6 + 4) / 0,3 = 18 gün.
expect(s).toMatchObject({
base: 4_400,
quantity: 4_000,
roundTo: 2_000,
roundedByUsage: true,
waste: null,
coverDays: 18,
});
// Taban küçükse en az bir adım; hedef doluysa öneri yok.
expect(
suggestPurchase({ current: 950, target: 1_000, step: 100, moves: [], today: "2026-11-06" })
.quantity,
).toBe(100);
expect(
suggestPurchase({ current: 1_000, target: 1_000, step: 100, moves: [], today: "2026-11-06" })
.quantity,
).toBe(0);
});
});
describe("sayım takvimi ve aylık kullanım", () => {
it("son sayımdan n gün sonra sayım gelir", () => {
expect(countSchedule(15, "2026-10-20", "2026-01-01", "2026-11-06")).toEqual({
everyDays: 15,
lastDate: "2026-10-20",
nextDate: "2026-11-04",
due: true,
overdueDays: 2,
});
expect(countSchedule(15, null, "2026-10-25", "2026-11-06")).toMatchObject({
nextDate: "2026-11-09",
due: false,
overdueDays: 0,
});
expect(countSchedule(7, "2026-10-30", "2026-10-01", "2026-11-06")).toMatchObject({
due: true,
overdueDays: 0,
});
expect(countSchedule(null, null, "2026-10-01", "2026-11-06")).toBeNull();
});
it("kullanım = tüketim + sayım eksiği; fire ayrı", () => {
expect(
monthlyUsage(
[
{ ...m("count_adjust", "2026-10-01", 5_000), initial: true },
m("consume", "2026-10-05", 1_000),
m("count_adjust", "2026-10-10", -200),
m("waste", "2026-10-12", 300),
m("count_adjust", "2026-10-15", 100),
m("consume", "2026-11-02", 500),
],
["2026-09", "2026-10", "2026-11"],
),
).toEqual([
{ month: "2026-09", used: 0, wasted: 0 },
{ month: "2026-10", used: 1_200, wasted: 300 },
{ month: "2026-11", used: 500, wasted: 0 },
]);
});
});
+375
View File
@@ -0,0 +1,375 @@
import { daysBetween } from "./budget";
import { addDays } from "./recurrence";
import { formatQuantity, QUANTITY_SCALE } from "./quantity";
/**
* Ev stoğu motoru (Bölüm 6.10). Saf fonksiyonlar, framework'ten bağımsız.
*
* Miktarlar binde bir tamsayı (QUANTITY_SCALE). Mevcut miktar hareketlerden türetilir:
* alım (+) − tüketim − fire ± sayım düzeltmesi.
* SKT: alım hareketinde (isteğe bağlı). Çıkışlar (tüketim, fire, eksi sayım farkı) FIFO ile en
* eski girişten düşülür; evde kalan miktarın SKT'si uyarı için kullanılır.
*/
export const STOCK_MOVEMENT_KINDS = ["purchase", "consume", "waste", "count_adjust"] as const;
export type StockMovementKind = (typeof STOCK_MOVEMENT_KINDS)[number];
export const WASTE_REASONS = ["spoiled", "expired", "damaged", "other"] as const;
export type WasteReason = (typeof WASTE_REASONS)[number];
/** Hazır birimler; bunların dışındaki her değer özel birimdir ("yıkama", "tablet"). */
export const STOCK_UNITS = ["adet", "kg", "g", "lt", "ml", "paket"] as const;
export const DEFAULT_THRESHOLD_PCT = 20;
/** SKT'ye bu kadar gün (dahil) kala uyarı. */
export const EXPIRY_WARN_DAYS = 7;
export type StockMove = {
kind: StockMovementKind;
/** "YYYY-MM-DD". */
date: string;
/** Pozitif; yalnızca sayım düzeltmesi işaretli (+ fazla çıktı, − eksik çıktı). */
quantity: number;
expiryDate?: string | null;
};
/** Hareketin stoka etkisi (işaretli). */
export function signedDelta(m: Pick<StockMove, "kind" | "quantity">): number {
switch (m.kind) {
case "purchase":
return m.quantity;
case "consume":
case "waste":
return -m.quantity;
case "count_adjust":
return m.quantity;
}
}
export function currentQuantity(moves: readonly Pick<StockMove, "kind" | "quantity">[]): number {
return moves.reduce((sum, m) => sum + signedDelta(m), 0);
}
export type Lot = {
/** Girişin hareket listesindeki sırası. */
index: number;
date: string;
expiryDate: string | null;
quantity: number;
remaining: number;
};
type TrackedLot = Lot & { kind: StockMovementKind; wasted: number };
/** FIFO çalıştırır; her girişin kalanını ve fireyle düşülen kısmını izler. */
function runFifo(moves: readonly StockMove[]): TrackedLot[] {
const lots: TrackedLot[] = [];
let deficit = 0;
moves.forEach((m, index) => {
const delta = signedDelta(m);
if (delta > 0) {
const covered = Math.min(deficit, delta);
deficit -= covered;
lots.push({
index,
kind: m.kind,
date: m.date,
expiryDate: m.kind === "purchase" ? (m.expiryDate ?? null) : null,
quantity: delta,
remaining: delta - covered,
wasted: 0,
});
return;
}
let out = -delta;
for (const lot of lots) {
if (out === 0) break;
const take = Math.min(lot.remaining, out);
lot.remaining -= take;
if (m.kind === "waste") lot.wasted += take;
out -= take;
}
deficit += out;
});
return lots;
}
/**
* FIFO partileri. `moves` tarih (ve oluşturulma) sırasında olmalı. Girişler (alım, artı sayım
* farkı) parti açar; çıkışlar en eski partiden düşer. Stok geçici olarak eksiye düşerse (geriye
* tarihli tüketim) açık, sonraki girişten kapatılır. Yalnızca kalanı olan partiler döner.
*/
export function fifoLots(moves: readonly StockMove[]): Lot[] {
return runFifo(moves)
.filter((l) => l.remaining > 0)
.map(({ index, date, expiryDate, quantity, remaining }) => ({
index,
date,
expiryDate,
quantity,
remaining,
}));
}
export type ExpiryStatus = "expired" | "soon" | "ok";
export function expiryStatus(
expiryDate: string,
today: string,
warnDays = EXPIRY_WARN_DAYS,
): ExpiryStatus {
if (expiryDate < today) return "expired";
// daysBetween dahil sayar: bugün = 1.
return daysBetween(today, expiryDate) - 1 <= warnDays ? "soon" : "ok";
}
export type ExpiringLot = Lot & { expiryDate: string; status: Exclude<ExpiryStatus, "ok"> };
/** Evde kalan ve SKT'si geçmiş/yaklaşan partiler (en erken SKT önce). */
export function expiringLots(
lots: readonly Lot[],
today: string,
warnDays = EXPIRY_WARN_DAYS,
): ExpiringLot[] {
const out: ExpiringLot[] = [];
for (const lot of lots) {
if (!lot.expiryDate) continue;
const status = expiryStatus(lot.expiryDate, today, warnDays);
if (status !== "ok") out.push({ ...lot, expiryDate: lot.expiryDate, status });
}
return out.sort((a, b) =>
a.expiryDate < b.expiryDate ? -1 : a.expiryDate > b.expiryDate ? 1 : 0,
);
}
export type StockLevel = {
/** Eşik miktarı: hedef × eşik yüzdesi. */
thresholdQuantity: number;
/** Mevcut / hedef (yüzde, tamsayı; hedef yoksa null). */
pct: number | null;
/** Eşiğin altında mı ("altına düşerse": kesin küçük). */
low: boolean;
};
export function stockLevel(current: number, target: number, thresholdPct: number): StockLevel {
if (target <= 0) return { thresholdQuantity: 0, pct: null, low: false };
const thresholdQuantity = Math.round((target * thresholdPct) / 100);
return {
thresholdQuantity,
pct: Math.round((Math.max(0, current) * 100) / target),
low: current < thresholdQuantity,
};
}
/** Hedefe tamamlayan miktar (öneri motorunun tabanı). */
export function refillQuantity(current: number, target: number): number {
return Math.max(0, target - Math.max(0, current));
}
/** Miktar + birim: 1500, "kg" → "1,5 kg". */
export function formatStock(quantity: number, unit: string): string {
return formatQuantity(quantity, unit);
}
/** "1 azalt" düğmesi etiketi: adım 1 birim ise "−1 adet", değilse "−0,5 kg". */
export function stepLabel(step: number, unit: string): string {
return `−${formatQuantity(step || QUANTITY_SCALE, unit)}`;
}
/** Ortalama/hız gösterimi: 10 birimden büyükse bir, değilse iki ondalık (18,368 → 18,4). */
export function roundForDisplay(quantity: number): number {
const unit = Math.abs(quantity) >= 10 * QUANTITY_SCALE ? 100 : 10;
return Math.round(quantity / unit) * unit;
}
/** Form alanı için gruplamasız metin: 1500 → "1,5"; 1_000_000 → "1000" (parseQuantity ile uyumlu). */
export function quantityInput(quantity: number): string {
const int = Math.trunc(quantity / QUANTITY_SCALE);
const frac = (quantity % QUANTITY_SCALE).toString().padStart(3, "0").replace(/0+$/, "");
return frac ? `${int},${frac}` : String(int);
}
// --- Öneri, tüketim hızı, sayım ve analiz (Faz 11b) ---
/** Fire oranı son bu kadar alımdan hesaplanır. */
export const WASTE_LOOKBACK_PURCHASES = 3;
/** Bu oranın (%) altındaki fire öneriyi değiştirmez. */
export const WASTE_MIN_PCT = 5;
/** Tüketim hızı son bu kadar günden hesaplanır. */
export const USAGE_WINDOW_DAYS = 90;
/** Bundan kısa geçmişte tüketim hızı hesaplanmaz. */
export const USAGE_MIN_DAYS = 14;
export type WasteStat = {
/** Hesaba katılan alım sayısı. */
purchases: number;
purchased: number;
wasted: number;
/** Fire oranı (%, tamsayı). */
pct: number;
};
/**
* Son alımların fire oranı: fireler FIFO ile hangi alımdan düşüldüyse ona yazılır
* (ör. iki alımın 6 kg'ından 1,8 kg'ı bozuldu → %30). Alım yoksa null.
*/
export function recentWaste(
moves: readonly StockMove[],
lookback = WASTE_LOOKBACK_PURCHASES,
): WasteStat | null {
const purchases = runFifo(moves)
.filter((l) => l.kind === "purchase")
.slice(-lookback);
if (purchases.length === 0) return null;
const purchased = purchases.reduce((s, l) => s + l.quantity, 0);
const wasted = purchases.reduce((s, l) => s + l.wasted, 0);
return {
purchases: purchases.length,
purchased,
wasted,
pct: purchased > 0 ? Math.round((wasted * 100) / purchased) : 0,
};
}
/** Kullanım: tüketim + sayımda çıkan eksik (başlangıç hariç). Fire ayrı tutulur. */
function usageOf(m: StockMove & { initial?: boolean }): number {
if (m.kind === "consume") return m.quantity;
if (m.kind === "count_adjust" && !m.initial && m.quantity < 0) return -m.quantity;
return 0;
}
export type UsageRate = {
/** Günlük ortalama kullanım (binde bir, kesirli olabilir). */
perDay: number;
/** Hesaba katılan gün sayısı. */
days: number;
};
/**
* Son `window` günün günlük ortalama kullanımı. Pencere, ilk hareketten önce başlıyorsa ilk
* hareketten sayılır. Geçmiş `USAGE_MIN_DAYS`'ten kısaysa null.
*/
export function usageRate(
moves: readonly (StockMove & { initial?: boolean })[],
today: string,
window = USAGE_WINDOW_DAYS,
): UsageRate | null {
const first = moves[0]?.date;
if (!first) return null;
const windowStart = addDays(today, -(window - 1));
const start = first > windowStart ? first : windowStart;
if (start > today) return null;
const days = daysBetween(start, today);
if (days < USAGE_MIN_DAYS) return null;
const used = moves
.filter((m) => m.date >= start && m.date <= today)
.reduce((s, m) => s + usageOf(m), 0);
return { perDay: used / days, days };
}
export type PurchaseSuggestion = {
/** Önerilen alım (binde bir). */
quantity: number;
/** Hedefe tamamlayan ham miktar. */
base: number;
/** Öneriyi düşüren fire (oran WASTE_MIN_PCT'nin altındaysa null). */
waste: WasteStat | null;
/** Yuvarlama birimi: haftalık kullanım (adımın katı) veya adım. */
roundTo: number;
roundedByUsage: boolean;
usage: UsageRate | null;
/** Önerilen alım + mevcut kaç gün yeter (kullanım bilinmiyorsa null). */
coverDays: number | null;
};
const roundUp = (x: number, unit: number) => Math.ceil(x / unit) * unit;
/**
* Önerilen alım miktarı (Bölüm 6.10):
* 1. Taban = hedef − mevcut.
* 2. Son alımlarda fire varsa (≥ %5) taban fire oranı kadar düşürülür.
* 3. Tüketim hızına göre yuvarlanır: haftalık kullanım adımdan büyükse haftalık kullanımın
* (adıma yuvarlanmış) katına, değilse adıma en yakın kata. Sonuç tabanı (adıma yukarı
* yuvarlanmış) aşmaz; taban > 0 iken en az bir adımdır.
*/
export function suggestPurchase(input: {
current: number;
target: number;
step: number;
moves: readonly (StockMove & { initial?: boolean })[];
today: string;
}): PurchaseSuggestion {
const step = Math.max(1, input.step);
const base = refillQuantity(input.current, input.target);
const stat = recentWaste(input.moves);
const waste = stat && stat.pct >= WASTE_MIN_PCT && stat.wasted > 0 ? stat : null;
const usage = usageRate(input.moves, input.today);
const weekly = usage ? Math.round((usage.perDay * 7) / step) * step : 0;
const roundTo = weekly > step ? weekly : step;
let quantity = 0;
if (base > 0) {
const reduced = waste ? (base * (100 - waste.pct)) / 100 : base;
quantity = Math.round(reduced / roundTo) * roundTo;
if (quantity === 0) quantity = roundUp(reduced, step);
quantity = Math.max(step, Math.min(quantity, roundUp(base, step)));
}
const after = Math.max(0, input.current) + quantity;
return {
quantity,
base,
waste,
roundTo,
roundedByUsage: roundTo !== step,
usage,
coverDays: usage && usage.perDay > 0 ? Math.floor(after / usage.perDay) : null,
};
}
export type CountSchedule = {
everyDays: number;
/** Son sayım (yoksa null). */
lastDate: string | null;
nextDate: string;
due: boolean;
/** Sayım günü geçtiyse kaç gün geçti (bugün sayım günüyse 0). */
overdueDays: number;
};
/**
* Periyodik sayım takvimi: son sayımdan (hiç sayılmadıysa `since`'ten, ör. ilk hareket)
* `everyDays` gün sonra sayım gelir. Sıklık yoksa null.
*/
export function countSchedule(
everyDays: number | null,
lastDate: string | null,
since: string,
today: string,
): CountSchedule | null {
if (!everyDays) return null;
const nextDate = addDays(lastDate ?? since, everyDays);
const due = nextDate <= today;
return {
everyDays,
lastDate,
nextDate,
due,
overdueDays: due ? daysBetween(nextDate, today) - 1 : 0,
};
}
export type MonthUsage = { month: string; used: number; wasted: number };
/** Ay ay ("YYYY-MM") kullanım ve fire. */
export function monthlyUsage(
moves: readonly (StockMove & { initial?: boolean })[],
months: readonly string[],
): MonthUsage[] {
const by = new Map(months.map((m) => [m, { month: m, used: 0, wasted: 0 }]));
for (const mv of moves) {
const row = by.get(mv.date.slice(0, 7));
if (!row) continue;
row.used += usageOf(mv);
if (mv.kind === "waste") row.wasted += mv.quantity;
}
return months.map((m) => by.get(m)!);
}