Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47396cba52 | |||
| 6ec29aa030 | |||
| c8678da7b5 | |||
| bd5a20ce64 | |||
| 3dd9834a3d | |||
| 55f2269f82 | |||
| fa70168622 | |||
| 8ab2244755 | |||
| 0b7de17b5c | |||
| d46869c644 | |||
| 9e32f2988d | |||
| e67a1d5411 | |||
| 78503b1787 | |||
| d52f41ab0c | |||
| f0b570284e |
+58
-1
@@ -6,6 +6,62 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
|||||||
|
|
||||||
## [Yayınlanmamış]
|
## [Yayınlanmamış]
|
||||||
|
|
||||||
|
## [0.13.0] - 2026-11-06
|
||||||
|
|
||||||
|
Faz 13: alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu ve alışveriş sonrası giriş (13a ve 13b alt fazları).
|
||||||
|
|
||||||
|
### Eklendi — Faz 13b: alışveriş sonrası giriş, karşılaştırma ve "Alışverişi tamamla"
|
||||||
|
|
||||||
|
- Alışveriş sonrası giriş (`/alisveris/:id/tamamla`): liste kalemleri tek tek işaretlenir; her biri için alınan ürün (aynı ya da aynı ürün tipinden muadil; ürün tipi kaleminde marka belirtmeden de), mağaza, paket adedi ve ödenen tutar. Tutar, bilinen paket fiyatıyla ön doldurulur. Listede olmayan ekstra ürünler eklenebilir. Mağaza başına ödeme hesabı (yemek kartı geçen mağazada uygun yemek kartı önseçili) ve fiş görselleri.
|
||||||
|
- "Alışverişi tamamla" tek MongoDB transaction'ı: stok alım hareketleri, fiyat kayıtları (kaynak `receipt`; ürün × mağaza başına, paket fiyatı = tutar / paket), mağaza başına bölünmüş gider işlemi (kalemler stok kategorisinin eşlendiği gider kategorisine; eşlenmemiş ve serbest metin kalemleri seçilen yedek kategoriye), fiş görselleri işlemin eki, alınmayanların taşınması ve listenin kapanması. Herhangi bir adım başarısızsa hiçbir şey yazılmaz.
|
||||||
|
- Alınmayanlar: başka aktif listeye taşı, yeni liste aç ya da bırak.
|
||||||
|
- Karşılaştırma raporu (tamamlanan listede): planlanan (tamamlama anındaki akıllı öneri) vs. gerçekleşen tutar ve fark; farkın nedenleri (aynı üründe fiyat farkı, miktar farkı, muadil, alınmayanlar, plansız ekstralar, planda fiyatı olmayanlar; toplamları farka eşit); kalem bazında Aynı / Muadil / Alınmadı / Plansız ekstra; mağaza giderleri ve kullanılan hesaplar. Etiketler tamamlama anındaki hâliyle saklanır.
|
||||||
|
- Liste sayfasında ve alışveriş modunda "Alışverişi tamamla" düğmesi.
|
||||||
|
- Stok hareketleri ve işlemlerde `shoppingListId` (kaynağı izlemek için).
|
||||||
|
- Testler: eşleşme ve fark ayrıştırması (birim); tamamlama ekranı verisi, doğrulamalar (muadil tipi, gelecek tarih, izleyici), ek dosya hatasında geri alma, stok/fiyat/mağaza başına bölünmüş gider, markasız kalem, kalanların taşınması ve rapor (entegrasyon).
|
||||||
|
|
||||||
|
### Eklendi — Faz 13a: alışveriş listeleri, stok önerileri, akıllı mağaza önerisi, alışveriş modu
|
||||||
|
|
||||||
|
- Alışveriş listeleri (`shoppingLists`, `/alisveris`): birden çok aktif liste; kalem olarak ürün tipi (genel), belirli ürün (marka/paket) ya da serbest metin. Miktar ürün tipinde tipin biriminde, üründe paket adedi; boşsa en az bir paket. Aynı ürün listede iki kez olamaz; kalem düzenleme, çıkarma ve başka aktif listeye taşıma. Liste silme soft delete ve denetim kaydı. Aile üyeleri aynı listeyi kullanır: sayfa odaklanınca ve 30 saniyede bir yenilenir.
|
||||||
|
- Stok eşiği önerileri: eşiğin altındaki ürün tipleri hiçbir aktif listede yoksa liste sayfasında onayla/reddet önerisi olarak gelir (fire ayarlı önerilen miktar ve nedeni). Onaylanan önerilen miktarla eklenir ("Stok önerisi" rozeti), "Tümünü ekle" ile toplu. Reddedilen öneri, ürün için yeni alım girilene (stok yenilenene) kadar gösterilmez.
|
||||||
|
- Akıllı mağaza önerisi (saf fonksiyon, birim testli): son bilinen fiyatlarla her kalem için mağaza ve marka; ihtiyacı karşılayan paket sayısı × paket fiyatı. Fiziksel mağaza ziyareti ve hacimli kalemler için zahmet skoruna bağlı maliyet cezası, online teslimat ücreti ve ücretsiz teslimat alt limiti, yemek kartı önceliği (kartın geçtiği mağazada kartla ödenebilecek tutar avantajlı sayılır; bakiye mağazalar arasında bir kez harcanır). En az mağazalı eksiksiz plandan başlanır; ek mağaza ancak tasarruf hane ayarındaki eşiği aşarsa eklenir. Çıktı: "Tek mağaza en iyi seçenek: X" ya da "Önerilen dağılım: A (7 ürün), B (3 ürün): Y (Z tasarruf)"; önerilen ve tek mağaza dağılımları, fiyatı bilinmeyen kalemler.
|
||||||
|
- Alışveriş modu (`/alisveris/:id/mod`): plana göre mağazaya gruplanmış, büyük dokunma alanlı kontrol listesi; "sepete alındı" işareti anında görünür ve kaydedilir, ilerleme çubuğu; önerilen / tek mağaza planı arasında geçiş.
|
||||||
|
- Ürün tipine "Ağır / hacimli" işareti (akıllı öneride taşıma cezası).
|
||||||
|
- Dashboard'da "Alışveriş" kartı (aktif listeler ve bekleyen öneri sayısı); stok sayfasındaki "Alınması önerilenler" alışveriş listelerine bağlanır. Kenar menüde "Alışveriş".
|
||||||
|
- Migration `015-shopping-lists`. Seed: "Haftalık market" (9 kalem: tip, marka, serbest metin, fiyatı olmayan domates) ve "Ay sonu toptan" listeleri; deterjan, yumuşatıcı, çamaşır suyu ve tuvalet kağıdı hacimli.
|
||||||
|
- Testler: planlayıcı (paket yuvarlama, marka kısıtı, eşik, eksik kapsama, zahmet ve hacimli cezası, teslimat, yemek kartı), miktar ve öneri reddi kuralları (birim); liste/kalem işlemleri, yetki, öneri onay/red, plan çıktısı, silme ve denetim (entegrasyon).
|
||||||
|
|
||||||
|
### Değişti — Faz 13a
|
||||||
|
|
||||||
|
- Fiyat servisinde ürün tipi teklifleri ortak yükleyicide (`loadTypeOffers`); tüketim analizindeki son birim fiyat da bunu kullanır.
|
||||||
|
|
||||||
|
## [0.12.0] - 2026-11-06
|
||||||
|
|
||||||
|
Faz 12: mağazalar, ürünler, fiyatlar ve birim fiyat (12a ve 12b alt fazları).
|
||||||
|
|
||||||
|
### Eklendi — Faz 12b: fiyat kayıtları ve birim fiyat
|
||||||
|
|
||||||
|
- Fiyat kayıtları (`productPrices`): ürün × mağaza × tarih × paket fiyatı; kampanya/indirim işareti ve geçerlilik sonu; kaynak `manual` | `receipt` | `provider:<ad>` (şimdilik elle giriş). Gelecek tarihli fiyat ve arşivli ürün/mağazaya fiyat girilemez; kayıt silme denetim kaydına yazılır.
|
||||||
|
- Birim fiyat: her fiyat ürün tipinin birimine çevrilir (TL/kg, TL/lt, TL/adet, TL/yıkama; g ve ml gösterimde kg ve lt). Yuvarlama yalnızca gösterimde. Geçerli fiyat mağaza başına son kayıt; süresi biten kampanyada önceki normal fiyata dönülür ("Kampanya bitti, normal fiyat"), 90 günden eski fiyat işaretlenir.
|
||||||
|
- Ürün tipi detayında "Birim fiyat karşılaştırması": tüm marka/paket ve mağazalar birim fiyata göre sıralı, en uygun vurgulu, diğerleri "%X pahalı"; buradan fiyat girişi (birim fiyat anlık önizlemesiyle).
|
||||||
|
- Ürün sayfası (`/stok/urunler/:id`): görseller, mağazalardaki geçerli fiyatlar, mağaza başına fiyat geçmişi grafiği (basamaklı çizgi, bugüne uzanır) ve fiyat kayıtları listesi. Mağaza renkleri sabit kategorik paletten (mağazanın ekleniş sırası; açık/koyu tema için doğrulanmış `--series-1…8`).
|
||||||
|
- Tüketim analizinde aylık ortalama maliyet: aylık ortalama tüketim × ürün tipinin son bilinen birim fiyatı; fiyatı bilinen ürünlerin toplamı.
|
||||||
|
- Migration `014-product-prices`. Seed: 22 ürün için son 6 ayın 265 fiyat kaydı (mağaza farkları, aylık artış, süren ve biten kampanya, eski fiyat örneği).
|
||||||
|
- Testler: birim fiyat ve gösterim birimi, geçerli fiyat (biten kampanya, eski fiyat), sıralama ve eşitlik, renk yuvası (birim); karşılaştırma sırası, kampanya dönüşü, fiyat girişi kuralları, silme ve denetim, analiz maliyeti (entegrasyon).
|
||||||
|
|
||||||
|
### Değişti — Faz 12b
|
||||||
|
|
||||||
|
- Sayfa başlığı açıklaması bağlantı içerebilir; ek dosya seçicisine özel düğme metni ve ipucu.
|
||||||
|
|
||||||
|
### Eklendi — Faz 12a: mağazalar ve ürünler (marka/paket)
|
||||||
|
|
||||||
|
- Mağazalar (`stores`, `/magazalar`): ad, tür (fiziksel market / online platform / pazar), şube, eve uzaklık (km), taşıma güçlüğü (1–5), online ise teslimat ücreti ve ücretsiz teslimat alt limiti, yemek kartı geçerliliği ve geçerli sağlayıcılar, not. Zahmet skoru (0–100; uzaklık 20 km'ye kadar ve taşıma güçlüğü eşit ağırlıkla, online mağazada uzaklık sayılmaz) ve düzeyi; "ada göre / en yakın / en uzak / en zahmetli" sıralama. Aynı ad + şube tekil; arşivleme denetim kaydına yazılır. Kenar menüde "Mağazalar".
|
||||||
|
- Ürünler (`products`): ürün tipine bağlı marka, tanım, isteğe bağlı barkod (8–14 rakam, hanede tekil), paket miktarı ve birimi. Paket birimi ürün tipinin birimine kendiliğinden çevrilebiliyorsa (aynı birim, kg↔g, lt↔ml) dönüşüm otomatik; çevrilemiyorsa dönüşüm zorunlu ("5 kg = 33 yıkama", "1 paket = 50 tablet", "75 ml = 1 adet"). Ürün tipinin birimi sonradan değişirse "Dönüşüm gerekli" uyarısı.
|
||||||
|
- Ürün görselleri: birden çok görsel, mevcut ek dosya altyapısıyla (WebP'ye dönüştürme, küçük resim, yetkili okuma; hane üyeleri görür). Liste küçük resmi ve görüntüleyici.
|
||||||
|
- Ürün tipi detayında "Ürünler (marka / paket)" bölümü; `/stok/urunler` ürün kataloğu (ürün tipine göre gruplu, marka/tanım/barkodla arama, arşiv).
|
||||||
|
- Migration `013-stores-products` (index'ler). Seed: Çakır Ailesi için 8 mağaza ve 22 ürün.
|
||||||
|
- Testler: zahmet skoru, sıralama, uzaklık gösterimi, paket dönüşümü ve açıklaması (birim); mağaza şeması ve kuralları, ürün dönüşüm zorunluluğu, barkod tekilliği, görsel bağlama/sıralama/çıkarma ve okuma yetkisi, arşiv (entegrasyon).
|
||||||
|
|
||||||
## [0.11.0] - 2026-11-06
|
## [0.11.0] - 2026-11-06
|
||||||
|
|
||||||
Faz 11: ev stoğu (11a ve 11b alt fazları).
|
Faz 11: ev stoğu (11a ve 11b alt fazları).
|
||||||
@@ -350,7 +406,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.11.0...HEAD
|
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.12.0...HEAD
|
||||||
|
[0.12.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.11.0...v0.12.0
|
||||||
[0.11.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.10.0...v0.11.0
|
[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
|
||||||
|
|||||||
@@ -323,6 +323,8 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
|
|||||||
- **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.
|
- **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.
|
||||||
|
- **Faz 12 kararları (2026-11-06):** 12a mağazalar ve ürünler (marka/paket, görseller), 12b fiyat kayıtları, birim fiyat ve fiyat geçmişi; `v0.12.0` 12b sonunda. Mağazalar, ürünler ve fiyatlar hane bazlıdır (hane içinde ortak, başka hane görmez). Paket birimi ürün tipinin birimine kendiliğinden çevrilemiyorsa ürün kaydında dönüşüm zorunludur ("5 kg = 33 yıkama"); her fiyat ürün tipinin biriminde karşılaştırılır.
|
||||||
|
- **Faz 13 kararları (2026-11-06):** 13a alışveriş listeleri, stok eşiği önerileri (onayla/reddet), akıllı mağaza önerisi ve alışveriş modu; 13b alışveriş sonrası giriş, karşılaştırma raporu ve "Alışverişi tamamla"; `v0.13.0` 13b sonunda. Birden çok mağazadan alışverişte mağaza başına ayrı bölünmüş gider (her mağaza için ayrı hesap ve fiş). Tamamlanınca liste kapanır ve rapor sabitlenir; alınmayanlar başka aktif listeye taşınır, yeni listeye alınır ya da bırakılır. Reddedilen öneri yeni alım girilene kadar gösterilmez. Markası belirtilmeyen ürün tipi kalemi (ör. pazardan domates) tipin biriminde stoğa işlenir, fiyat kaydı oluşmaz.
|
||||||
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.11.0",
|
"version": "0.13.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.11.0",
|
"version": "0.13.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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.11.0",
|
"version": "0.13.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": {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { ProductModel } from "@/models/product";
|
||||||
|
import { StoreModel } from "@/models/store";
|
||||||
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: "013-stores-products",
|
||||||
|
description: "mağaza ve ürün (marka/paket) koleksiyonlarının index'leri",
|
||||||
|
async up() {
|
||||||
|
await StoreModel.createIndexes();
|
||||||
|
await ProductModel.createIndexes();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ProductPriceModel } from "@/models/product-price";
|
||||||
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: "014-product-prices",
|
||||||
|
description: "ürün fiyat kayıtları (productPrices) index'leri",
|
||||||
|
async up() {
|
||||||
|
await ProductPriceModel.createIndexes();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ShoppingListModel } from "@/models/shopping-list";
|
||||||
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: "015-shopping-lists",
|
||||||
|
description: "alışveriş listeleri (shoppingLists) index'leri",
|
||||||
|
async up() {
|
||||||
|
await ShoppingListModel.createIndexes();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -12,6 +12,9 @@ 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 m011 } from "./011-stock";
|
||||||
import { migration as m012 } from "./012-stock-counts";
|
import { migration as m012 } from "./012-stock-counts";
|
||||||
|
import { migration as m013 } from "./013-stores-products";
|
||||||
|
import { migration as m014 } from "./014-product-prices";
|
||||||
|
import { migration as m015 } from "./015-shopping-lists";
|
||||||
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. */
|
||||||
@@ -28,6 +31,9 @@ export const MIGRATIONS: Migration[] = [
|
|||||||
m010,
|
m010,
|
||||||
m011,
|
m011,
|
||||||
m012,
|
m012,
|
||||||
|
m013,
|
||||||
|
m014,
|
||||||
|
m015,
|
||||||
];
|
];
|
||||||
|
|
||||||
const COLLECTION = "migrations";
|
const COLLECTION = "migrations";
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ describe("seed", () => {
|
|||||||
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(summary.created.stock).toBe(19);
|
||||||
|
expect(summary.created.stores).toBe(8);
|
||||||
|
expect(summary.created.products).toBe(22);
|
||||||
|
expect(summary.created.prices).toBeGreaterThan(200);
|
||||||
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);
|
||||||
});
|
});
|
||||||
@@ -55,6 +58,10 @@ describe("seed", () => {
|
|||||||
assets: 0,
|
assets: 0,
|
||||||
budgets: 0,
|
budgets: 0,
|
||||||
stock: 0,
|
stock: 0,
|
||||||
|
stores: 0,
|
||||||
|
products: 0,
|
||||||
|
prices: 0,
|
||||||
|
shoppingLists: 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);
|
||||||
|
|||||||
@@ -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 { seedProductPrices, seedShopping, seedShoppingLists } from "./shopping";
|
||||||
import { seedStock } from "./stock";
|
import { seedStock } from "./stock";
|
||||||
import { seedInKind } from "./in-kind";
|
import { seedInKind } from "./in-kind";
|
||||||
import { seedInstallments } from "./installments";
|
import { seedInstallments } from "./installments";
|
||||||
@@ -57,6 +58,10 @@ export type SeedSummary = {
|
|||||||
assets: number;
|
assets: number;
|
||||||
budgets: number;
|
budgets: number;
|
||||||
stock: number;
|
stock: number;
|
||||||
|
stores: number;
|
||||||
|
products: number;
|
||||||
|
prices: number;
|
||||||
|
shoppingLists: number;
|
||||||
};
|
};
|
||||||
accounts: SeedAccountRow[];
|
accounts: SeedAccountRow[];
|
||||||
};
|
};
|
||||||
@@ -84,6 +89,10 @@ const DOMAIN_COLLECTIONS = [
|
|||||||
"productTypes",
|
"productTypes",
|
||||||
"stockMovements",
|
"stockMovements",
|
||||||
"stockCounts",
|
"stockCounts",
|
||||||
|
"stores",
|
||||||
|
"products",
|
||||||
|
"productPrices",
|
||||||
|
"shoppingLists",
|
||||||
];
|
];
|
||||||
|
|
||||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||||
@@ -140,6 +149,10 @@ export async function runSeed({
|
|||||||
assets: 0,
|
assets: 0,
|
||||||
budgets: 0,
|
budgets: 0,
|
||||||
stock: 0,
|
stock: 0,
|
||||||
|
stores: 0,
|
||||||
|
products: 0,
|
||||||
|
prices: 0,
|
||||||
|
shoppingLists: 0,
|
||||||
},
|
},
|
||||||
accounts: [],
|
accounts: [],
|
||||||
};
|
};
|
||||||
@@ -324,6 +337,16 @@ export async function runSeed({
|
|||||||
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);
|
summary.created.stock = await seedStock(householdIds, userIdMap);
|
||||||
if (summary.created.stock) log(`✓ Örnek stok ürün tipleri: ${summary.created.stock}`);
|
if (summary.created.stock) log(`✓ Örnek stok ürün tipleri: ${summary.created.stock}`);
|
||||||
|
const shopping = await seedShopping(householdIds, userIdMap);
|
||||||
|
summary.created.stores = shopping.stores;
|
||||||
|
summary.created.products = shopping.products;
|
||||||
|
if (shopping.stores || shopping.products)
|
||||||
|
log(`✓ Örnek mağazalar ve ürünler: ${shopping.stores} / ${shopping.products}`);
|
||||||
|
summary.created.prices = await seedProductPrices(householdIds, userIdMap);
|
||||||
|
if (summary.created.prices) log(`✓ Örnek ürün fiyatları: ${summary.created.prices}`);
|
||||||
|
summary.created.shoppingLists = await seedShoppingLists(householdIds, userIdMap);
|
||||||
|
if (summary.created.shoppingLists)
|
||||||
|
log(`✓ Örnek alışveriş listeleri: ${summary.created.shoppingLists}`);
|
||||||
|
|
||||||
// 7) Geliştirme hesapları tablosu
|
// 7) Geliştirme hesapları tablosu
|
||||||
const all = [
|
const all = [
|
||||||
|
|||||||
@@ -0,0 +1,580 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { ProductModel } from "@/models/product";
|
||||||
|
import { ProductPriceModel } from "@/models/product-price";
|
||||||
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { ShoppingListModel } from "@/models/shopping-list";
|
||||||
|
import { StoreModel } from "@/models/store";
|
||||||
|
import type { MealCardProvider } from "@/shared/accounts";
|
||||||
|
import { autoConvertible } from "@/shared/products";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import { foldTr } from "@/shared/search";
|
||||||
|
import type { StoreKind } from "@/shared/stores";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Örnek mağazalar ve ürünler (Faz 12). Çakır Ailesi (Kadıköy) için marketler, pazar ve online
|
||||||
|
* platform; stok ürün tiplerine bağlı marka/paketler. Elle dönüşüm gereken örnekler: deterjan
|
||||||
|
* (kg → yıkama), tablet paketi (paket → tablet), diş macunu/şampuan (ml → adet). Görsel yok.
|
||||||
|
* Idempotent (`seedRef`). Miktarlar binde bir, tutarlar kuruş.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type SeedStore = {
|
||||||
|
ref: string;
|
||||||
|
name: string;
|
||||||
|
kind: StoreKind;
|
||||||
|
branch?: string;
|
||||||
|
distanceM?: number;
|
||||||
|
carry: number;
|
||||||
|
deliveryFee?: number;
|
||||||
|
freeDeliveryMin?: number;
|
||||||
|
mealCards?: MealCardProvider[];
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STORES: SeedStore[] = [
|
||||||
|
{
|
||||||
|
ref: "migros",
|
||||||
|
name: "Migros",
|
||||||
|
kind: "market",
|
||||||
|
branch: "Bahariye",
|
||||||
|
distanceM: 1_200,
|
||||||
|
carry: 2,
|
||||||
|
mealCards: ["pluxee", "multinet", "edenred", "setcard"],
|
||||||
|
},
|
||||||
|
{ ref: "a101", name: "A101", kind: "market", branch: "Moda", distanceM: 400, carry: 1 },
|
||||||
|
{ ref: "bim", name: "BİM", kind: "market", branch: "Yeldeğirmeni", distanceM: 650, carry: 2 },
|
||||||
|
{
|
||||||
|
ref: "sok",
|
||||||
|
name: "Şok",
|
||||||
|
kind: "market",
|
||||||
|
branch: "Rasimpaşa",
|
||||||
|
distanceM: 300,
|
||||||
|
carry: 1,
|
||||||
|
mealCards: ["multinet", "pluxee"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "carrefoursa",
|
||||||
|
name: "CarrefourSA",
|
||||||
|
kind: "market",
|
||||||
|
branch: "Acıbadem",
|
||||||
|
distanceM: 3_800,
|
||||||
|
carry: 4,
|
||||||
|
mealCards: ["pluxee", "multinet", "edenred", "setcard", "metropol"],
|
||||||
|
note: "Hafta sonu arabayla; büyük alışveriş.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "sali-pazari",
|
||||||
|
name: "Salı Pazarı",
|
||||||
|
kind: "bazaar",
|
||||||
|
branch: "Kadıköy",
|
||||||
|
distanceM: 1_900,
|
||||||
|
carry: 4,
|
||||||
|
note: "Salı ve cuma; sebze-meyve.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "getir-buyuk",
|
||||||
|
name: "Getir Büyük",
|
||||||
|
kind: "online",
|
||||||
|
carry: 1,
|
||||||
|
deliveryFee: 39_90,
|
||||||
|
freeDeliveryMin: 750_00,
|
||||||
|
mealCards: ["multinet"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "metro",
|
||||||
|
name: "Metro Toptancı",
|
||||||
|
kind: "market",
|
||||||
|
branch: "Kozyatağı",
|
||||||
|
distanceM: 9_500,
|
||||||
|
carry: 5,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type SeedProduct = {
|
||||||
|
ref: string;
|
||||||
|
/** Stok seed'indeki ürün tipi `seedRef`'i. */
|
||||||
|
type: string;
|
||||||
|
brand: string;
|
||||||
|
name?: string;
|
||||||
|
barcode?: string;
|
||||||
|
qty: number;
|
||||||
|
unit: string;
|
||||||
|
/** Elle dönüşüm (bir paket = bu kadar ürün tipi birimi). */
|
||||||
|
typeQty?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const K = 1000;
|
||||||
|
|
||||||
|
export const PRODUCTS: SeedProduct[] = [
|
||||||
|
{
|
||||||
|
ref: "ariel-5kg",
|
||||||
|
type: "toz-deterjan",
|
||||||
|
brand: "Ariel",
|
||||||
|
name: "Matik Dağ Esintisi",
|
||||||
|
qty: 5 * K,
|
||||||
|
unit: "kg",
|
||||||
|
typeQty: 33 * K,
|
||||||
|
barcode: "8690000100015",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "omo-6kg",
|
||||||
|
type: "toz-deterjan",
|
||||||
|
brand: "Omo",
|
||||||
|
name: "Matik Active Fresh",
|
||||||
|
qty: 6 * K,
|
||||||
|
unit: "kg",
|
||||||
|
typeQty: 40 * K,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "bingo-4kg",
|
||||||
|
type: "toz-deterjan",
|
||||||
|
brand: "Bingo",
|
||||||
|
name: "Matik",
|
||||||
|
qty: 4 * K,
|
||||||
|
unit: "kg",
|
||||||
|
typeQty: 26 * K,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "fairy-50",
|
||||||
|
type: "tablet-deterjan",
|
||||||
|
brand: "Fairy",
|
||||||
|
name: "Platinum Plus",
|
||||||
|
qty: 1 * K,
|
||||||
|
unit: "paket",
|
||||||
|
typeQty: 50 * K,
|
||||||
|
barcode: "8690000100022",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "finish-40",
|
||||||
|
type: "tablet-deterjan",
|
||||||
|
brand: "Finish",
|
||||||
|
name: "Quantum",
|
||||||
|
qty: 40 * K,
|
||||||
|
unit: "tablet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "yayla-mercimek",
|
||||||
|
type: "kirmizi-mercimek",
|
||||||
|
brand: "Yayla",
|
||||||
|
name: "Kırmızı Mercimek",
|
||||||
|
qty: 1 * K,
|
||||||
|
unit: "kg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "reis-mercimek",
|
||||||
|
type: "kirmizi-mercimek",
|
||||||
|
brand: "Reis",
|
||||||
|
name: "Kırmızı Mercimek",
|
||||||
|
qty: 1 * K,
|
||||||
|
unit: "kg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "duru-mercimek",
|
||||||
|
type: "kirmizi-mercimek",
|
||||||
|
brand: "Duru",
|
||||||
|
name: "Kırmızı Mercimek",
|
||||||
|
qty: 2_500,
|
||||||
|
unit: "kg",
|
||||||
|
},
|
||||||
|
{ ref: "yayla-baldo", type: "pirinc", brand: "Yayla", name: "Baldo", qty: 2_500, unit: "kg" },
|
||||||
|
{ ref: "reis-baldo", type: "pirinc", brand: "Reis", name: "Baldo", qty: 1 * K, unit: "kg" },
|
||||||
|
{
|
||||||
|
ref: "pinar-sut",
|
||||||
|
type: "sut",
|
||||||
|
brand: "Pınar",
|
||||||
|
name: "Tam Yağlı Süt",
|
||||||
|
qty: 1 * K,
|
||||||
|
unit: "lt",
|
||||||
|
barcode: "8690000100039",
|
||||||
|
},
|
||||||
|
{ ref: "sutas-sut", type: "sut", brand: "Sütaş", name: "Tam Yağlı Süt", qty: 1 * K, unit: "lt" },
|
||||||
|
{
|
||||||
|
ref: "icim-sut-500",
|
||||||
|
type: "sut",
|
||||||
|
brand: "İçim",
|
||||||
|
name: "Yarım Yağlı Süt",
|
||||||
|
qty: 500 * K,
|
||||||
|
unit: "ml",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "keskinoglu-30",
|
||||||
|
type: "yumurta",
|
||||||
|
brand: "Keskinoğlu",
|
||||||
|
name: "L Boy 30'lu",
|
||||||
|
qty: 30 * K,
|
||||||
|
unit: "adet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "koy-yumurta-15",
|
||||||
|
type: "yumurta",
|
||||||
|
brand: "Köy Yumurtası",
|
||||||
|
name: "15'li",
|
||||||
|
qty: 15 * K,
|
||||||
|
unit: "adet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "pinar-peynir",
|
||||||
|
type: "beyaz-peynir",
|
||||||
|
brand: "Pınar",
|
||||||
|
name: "Beyaz Peynir",
|
||||||
|
qty: 500 * K,
|
||||||
|
unit: "g",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "yumos-1440",
|
||||||
|
type: "yumusatici",
|
||||||
|
brand: "Yumoş",
|
||||||
|
name: "Extra Lilyum",
|
||||||
|
qty: 1_440 * K,
|
||||||
|
unit: "ml",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "selpak-32",
|
||||||
|
type: "tuvalet-kagidi",
|
||||||
|
brand: "Selpak",
|
||||||
|
name: "32'li",
|
||||||
|
qty: 32 * K,
|
||||||
|
unit: "rulo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "solo-24",
|
||||||
|
type: "tuvalet-kagidi",
|
||||||
|
brand: "Solo",
|
||||||
|
name: "24'lü",
|
||||||
|
qty: 24 * K,
|
||||||
|
unit: "rulo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "colgate-75",
|
||||||
|
type: "dis-macunu",
|
||||||
|
brand: "Colgate",
|
||||||
|
name: "Total 75 ml",
|
||||||
|
qty: 75 * K,
|
||||||
|
unit: "ml",
|
||||||
|
typeQty: 1 * K,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "sensodyne-75",
|
||||||
|
type: "dis-macunu",
|
||||||
|
brand: "Sensodyne",
|
||||||
|
name: "Repair 75 ml",
|
||||||
|
qty: 75 * K,
|
||||||
|
unit: "ml",
|
||||||
|
typeQty: 1 * K,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "elidor-500",
|
||||||
|
type: "sampuan",
|
||||||
|
brand: "Elidor",
|
||||||
|
name: "500 ml",
|
||||||
|
qty: 500 * K,
|
||||||
|
unit: "ml",
|
||||||
|
typeQty: 1 * K,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function seedShopping(
|
||||||
|
householdIds: Map<string, Types.ObjectId>,
|
||||||
|
users: Map<string, string>,
|
||||||
|
): Promise<{ stores: number; products: number }> {
|
||||||
|
const actor = new Types.ObjectId(users.get("ayse")!);
|
||||||
|
const householdId = householdIds.get("Çakır Ailesi")!;
|
||||||
|
let stores = 0;
|
||||||
|
for (const s of STORES) {
|
||||||
|
if (await StoreModel.exists({ householdId, seedRef: s.ref })) continue;
|
||||||
|
await StoreModel.create({
|
||||||
|
householdId,
|
||||||
|
name: s.name,
|
||||||
|
nameKey: foldTr(s.name),
|
||||||
|
kind: s.kind,
|
||||||
|
branch: s.branch ?? "",
|
||||||
|
distanceM: s.kind === "online" ? null : (s.distanceM ?? null),
|
||||||
|
carryDifficulty: s.carry,
|
||||||
|
deliveryFeeMinor: s.kind === "online" ? (s.deliveryFee ?? null) : null,
|
||||||
|
freeDeliveryMinMinor: s.kind === "online" ? (s.freeDeliveryMin ?? null) : null,
|
||||||
|
mealCardAccepted: Boolean(s.mealCards?.length),
|
||||||
|
mealCardProviders: s.mealCards ?? [],
|
||||||
|
note: s.note ?? "",
|
||||||
|
seedRef: s.ref,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
stores++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = await ProductTypeModel.find({ householdId, seedRef: { $type: "string" } })
|
||||||
|
.select({ seedRef: 1, unit: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; seedRef: string; unit: string }[]>();
|
||||||
|
const typeByRef = new Map(types.map((x) => [x.seedRef, x]));
|
||||||
|
let products = 0;
|
||||||
|
for (const p of PRODUCTS) {
|
||||||
|
const type = typeByRef.get(p.type);
|
||||||
|
if (!type || (await ProductModel.exists({ householdId, seedRef: p.ref }))) continue;
|
||||||
|
if (!autoConvertible(p.unit, type.unit) && !p.typeQty)
|
||||||
|
throw new Error(`Seed ürünü ${p.ref}: ${p.unit} → ${type.unit} dönüşümü eksik`);
|
||||||
|
await ProductModel.create({
|
||||||
|
householdId,
|
||||||
|
productTypeId: type._id,
|
||||||
|
brand: p.brand,
|
||||||
|
name: p.name ?? "",
|
||||||
|
searchKey: foldTr(`${p.brand} ${p.name ?? ""}`),
|
||||||
|
barcode: p.barcode ?? null,
|
||||||
|
packageQuantity: p.qty,
|
||||||
|
packageUnit: p.unit,
|
||||||
|
typeQuantity: autoConvertible(p.unit, type.unit) ? null : p.typeQty,
|
||||||
|
seedRef: p.ref,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
products++;
|
||||||
|
}
|
||||||
|
return { stores, products };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fiyatlar (Faz 12b) ---
|
||||||
|
|
||||||
|
/** Bugünkü paket fiyatı (kuruş, Kasım 2026 tahmini). */
|
||||||
|
const CURRENT_PRICE: Record<string, number> = {
|
||||||
|
"ariel-5kg": 519_90,
|
||||||
|
"omo-6kg": 559_90,
|
||||||
|
"bingo-4kg": 329_90,
|
||||||
|
"fairy-50": 479_90,
|
||||||
|
"finish-40": 419_90,
|
||||||
|
"yayla-mercimek": 84_90,
|
||||||
|
"reis-mercimek": 79_90,
|
||||||
|
"duru-mercimek": 189_90,
|
||||||
|
"yayla-baldo": 259_90,
|
||||||
|
"reis-baldo": 109_90,
|
||||||
|
"pinar-sut": 41_90,
|
||||||
|
"sutas-sut": 39_90,
|
||||||
|
"icim-sut-500": 23_90,
|
||||||
|
"keskinoglu-30": 174_90,
|
||||||
|
"koy-yumurta-15": 119_90,
|
||||||
|
"pinar-peynir": 209_90,
|
||||||
|
"yumos-1440": 139_90,
|
||||||
|
"selpak-32": 329_90,
|
||||||
|
"solo-24": 229_90,
|
||||||
|
"colgate-75": 84_90,
|
||||||
|
"sensodyne-75": 159_90,
|
||||||
|
"elidor-500": 119_90,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mağaza fiyat çarpanı (indirim marketi ucuz, online pahalı). */
|
||||||
|
const STORE_FACTOR: Record<string, number> = {
|
||||||
|
migros: 1.06,
|
||||||
|
a101: 0.94,
|
||||||
|
bim: 0.91,
|
||||||
|
sok: 0.96,
|
||||||
|
carrefoursa: 1.0,
|
||||||
|
"getir-buyuk": 1.12,
|
||||||
|
metro: 0.88,
|
||||||
|
};
|
||||||
|
const PRICE_STORES = Object.keys(STORE_FACTOR);
|
||||||
|
/** Aylık fiyat artışı. */
|
||||||
|
const MONTHLY_INFLATION = 0.025;
|
||||||
|
|
||||||
|
/** x,90 biçimine yuvarla (ör. 87,34 → 86,90 / 87,90). */
|
||||||
|
const ninety = (minor: number) => Math.max(90, Math.round((minor - 90) / 100) * 100 + 90);
|
||||||
|
|
||||||
|
type SeedPrice = {
|
||||||
|
ref: string;
|
||||||
|
product: string;
|
||||||
|
store: string;
|
||||||
|
daysAgo: number;
|
||||||
|
priceMinor: number;
|
||||||
|
isPromo?: boolean;
|
||||||
|
promoUntilDaysAgo?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Her ürün 3 mağazada (ürün sırasına göre dönüşümlü), son 6 ayda ~45 günde bir fiyat.
|
||||||
|
* Kampanyalar: Ariel @ Migros hâlâ süren (%20), Pınar süt @ A101 süresi bitmiş (önceki normal
|
||||||
|
* fiyata dönülür). Reis mercimek @ Metro yalnızca eski kayıtlarla ("eski fiyat").
|
||||||
|
*/
|
||||||
|
export function seedPrices(): SeedPrice[] {
|
||||||
|
const out: SeedPrice[] = [];
|
||||||
|
PRODUCTS.forEach((p, i) => {
|
||||||
|
const current = CURRENT_PRICE[p.ref]!;
|
||||||
|
const stores = [0, 2, 4].map((k) => PRICE_STORES[(i + k) % PRICE_STORES.length]!);
|
||||||
|
stores.forEach((store, si) => {
|
||||||
|
for (let step = 0; step < 4; step++) {
|
||||||
|
const daysAgo = 170 - step * 45 - si * 7 - (i % 5);
|
||||||
|
if (daysAgo < 0) continue;
|
||||||
|
if (p.ref === "reis-mercimek" && store === "metro" && daysAgo < 100) continue;
|
||||||
|
const months = daysAgo / 30;
|
||||||
|
const price = (current * STORE_FACTOR[store]!) / (1 + MONTHLY_INFLATION) ** months;
|
||||||
|
out.push({
|
||||||
|
ref: `${p.ref}:${store}:${step}`,
|
||||||
|
product: p.ref,
|
||||||
|
store,
|
||||||
|
daysAgo,
|
||||||
|
priceMinor: ninety(price),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
out.push(
|
||||||
|
{
|
||||||
|
ref: "ariel-5kg:migros:promo",
|
||||||
|
product: "ariel-5kg",
|
||||||
|
store: "migros",
|
||||||
|
daysAgo: 4,
|
||||||
|
priceMinor: ninety(CURRENT_PRICE["ariel-5kg"]! * 1.06 * 0.8),
|
||||||
|
isPromo: true,
|
||||||
|
promoUntilDaysAgo: -6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "pinar-sut:a101:normal",
|
||||||
|
product: "pinar-sut",
|
||||||
|
store: "a101",
|
||||||
|
daysAgo: 30,
|
||||||
|
priceMinor: ninety(CURRENT_PRICE["pinar-sut"]! * 0.94),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "pinar-sut:a101:promo",
|
||||||
|
product: "pinar-sut",
|
||||||
|
store: "a101",
|
||||||
|
daysAgo: 12,
|
||||||
|
priceMinor: ninety(CURRENT_PRICE["pinar-sut"]! * 0.75),
|
||||||
|
isPromo: true,
|
||||||
|
promoUntilDaysAgo: 3,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedProductPrices(
|
||||||
|
householdIds: Map<string, Types.ObjectId>,
|
||||||
|
users: Map<string, string>,
|
||||||
|
): Promise<number> {
|
||||||
|
const actor = new Types.ObjectId(users.get("ayse")!);
|
||||||
|
const householdId = householdIds.get("Çakır Ailesi")!;
|
||||||
|
const [products, stores] = await Promise.all([
|
||||||
|
ProductModel.find({ householdId, seedRef: { $type: "string" } })
|
||||||
|
.select({ seedRef: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
|
||||||
|
StoreModel.find({ householdId, seedRef: { $type: "string" } })
|
||||||
|
.select({ seedRef: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
|
||||||
|
]);
|
||||||
|
const productByRef = new Map(products.map((p) => [p.seedRef, p._id]));
|
||||||
|
const storeByRef = new Map(stores.map((x) => [x.seedRef, x._id]));
|
||||||
|
const existing = new Set(
|
||||||
|
(
|
||||||
|
await ProductPriceModel.find({ householdId, seedRef: { $type: "string" } })
|
||||||
|
.select({ seedRef: 1 })
|
||||||
|
.lean<{ seedRef: string }[]>()
|
||||||
|
).map((x) => x.seedRef),
|
||||||
|
);
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const base = Date.now() - 200 * 86_400_000;
|
||||||
|
const docs = seedPrices()
|
||||||
|
.filter((x) => !existing.has(x.ref) && productByRef.has(x.product) && storeByRef.has(x.store))
|
||||||
|
.sort((a, b) => b.daysAgo - a.daysAgo)
|
||||||
|
.map((x, i) => {
|
||||||
|
const at = new Date(base + i * 1000);
|
||||||
|
return {
|
||||||
|
householdId,
|
||||||
|
productId: productByRef.get(x.product)!,
|
||||||
|
storeId: storeByRef.get(x.store)!,
|
||||||
|
date: addDays(today, -x.daysAgo),
|
||||||
|
priceMinor: x.priceMinor,
|
||||||
|
isPromo: x.isPromo ?? false,
|
||||||
|
promoUntil: x.promoUntilDaysAgo === undefined ? null : addDays(today, -x.promoUntilDaysAgo),
|
||||||
|
source: "manual",
|
||||||
|
note: "",
|
||||||
|
deletedAt: null,
|
||||||
|
seedRef: x.ref,
|
||||||
|
createdBy: actor,
|
||||||
|
createdAt: at,
|
||||||
|
updatedAt: at,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (docs.length) await ProductPriceModel.collection.insertMany(docs);
|
||||||
|
return docs.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SeedListItem =
|
||||||
|
| { type: string; qty?: number }
|
||||||
|
| { product: string; qty?: number }
|
||||||
|
| { text: string; qty?: number; unit?: string; note?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Örnek alışveriş listeleri (Faz 13). Eşiğin altındaki ürünler (mercimek, tablet deterjan, diş
|
||||||
|
* macunu) listelere eklenmez; liste sayfasında öneri olarak görünür. Domatesin fiyatı yok:
|
||||||
|
* akıllı öneride "fiyatı bilinmeyen" örneği.
|
||||||
|
*/
|
||||||
|
const SHOPPING_LISTS: { ref: string; name: string; note?: string; items: SeedListItem[] }[] = [
|
||||||
|
{
|
||||||
|
ref: "haftalik",
|
||||||
|
name: "Haftalık market",
|
||||||
|
items: [
|
||||||
|
{ type: "sut", qty: 4 * K },
|
||||||
|
{ type: "yumurta", qty: 30 * K },
|
||||||
|
{ type: "pirinc", qty: 2_500 },
|
||||||
|
{ type: "beyaz-peynir", qty: 500 },
|
||||||
|
{ type: "domates", qty: 2 * K },
|
||||||
|
{ type: "toz-deterjan", qty: 40 * K },
|
||||||
|
{ type: "tuvalet-kagidi", qty: 32 * K },
|
||||||
|
{ product: "yumos-1440", qty: 1 * K },
|
||||||
|
{ text: "Doğum günü mumu", qty: 1 * K, unit: "paket", note: "Rakam mum: 7" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "ay-sonu",
|
||||||
|
name: "Ay sonu toptan",
|
||||||
|
note: "Metro'ya arabayla.",
|
||||||
|
items: [{ type: "camasir-suyu", qty: 4 * K }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function seedShoppingLists(
|
||||||
|
householdIds: Map<string, Types.ObjectId>,
|
||||||
|
users: Map<string, string>,
|
||||||
|
): Promise<number> {
|
||||||
|
const actor = new Types.ObjectId(users.get("ayse")!);
|
||||||
|
const householdId = householdIds.get("Çakır Ailesi")!;
|
||||||
|
const [types, products] = await Promise.all([
|
||||||
|
ProductTypeModel.find({ householdId, seedRef: { $type: "string" } })
|
||||||
|
.select({ seedRef: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
|
||||||
|
ProductModel.find({ householdId, seedRef: { $type: "string" } })
|
||||||
|
.select({ seedRef: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
|
||||||
|
]);
|
||||||
|
const typeByRef = new Map(types.map((x) => [x.seedRef, x._id]));
|
||||||
|
const productByRef = new Map(products.map((x) => [x.seedRef, x._id]));
|
||||||
|
let created = 0;
|
||||||
|
for (const l of SHOPPING_LISTS) {
|
||||||
|
if (await ShoppingListModel.exists({ householdId, seedRef: l.ref })) continue;
|
||||||
|
const items = l.items.flatMap((i): Record<string, unknown>[] => {
|
||||||
|
const common = {
|
||||||
|
_id: new Types.ObjectId(),
|
||||||
|
quantity: i.qty ?? null,
|
||||||
|
source: "manual",
|
||||||
|
addedBy: actor,
|
||||||
|
addedAt: new Date(),
|
||||||
|
};
|
||||||
|
if ("type" in i) {
|
||||||
|
const id = typeByRef.get(i.type);
|
||||||
|
return id ? [{ ...common, kind: "type", productTypeId: id }] : [];
|
||||||
|
}
|
||||||
|
if ("product" in i) {
|
||||||
|
const id = productByRef.get(i.product);
|
||||||
|
return id ? [{ ...common, kind: "product", productId: id }] : [];
|
||||||
|
}
|
||||||
|
return [{ ...common, kind: "text", text: i.text, unit: i.unit ?? "", note: i.note ?? "" }];
|
||||||
|
});
|
||||||
|
await ShoppingListModel.create({
|
||||||
|
householdId,
|
||||||
|
name: l.name,
|
||||||
|
note: l.note ?? "",
|
||||||
|
items,
|
||||||
|
seedRef: l.ref,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
+16
-1
@@ -33,6 +33,8 @@ type SeedProduct = {
|
|||||||
waste?: { every: number; pct: number; reason: "spoiled" | "expired" | "damaged" };
|
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. */
|
/** Bu günden (gün önce) sonra yeniden alınmaz: bugün azalmış görünür. */
|
||||||
stopRefillDaysAgo?: number;
|
stopRefillDaysAgo?: number;
|
||||||
|
/** Ağır/hacimli (akıllı öneride taşıma cezası). */
|
||||||
|
bulky?: boolean;
|
||||||
/** Her n. sayımda küçük eksik (−%, hedefin yüzdesi). */
|
/** Her n. sayımda küçük eksik (−%, hedefin yüzdesi). */
|
||||||
countDrift?: { everyCounts: number; pct: number };
|
countDrift?: { everyCounts: number; pct: number };
|
||||||
};
|
};
|
||||||
@@ -184,6 +186,7 @@ export const PRODUCTS: SeedProduct[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ref: "toz-deterjan",
|
ref: "toz-deterjan",
|
||||||
|
bulky: true,
|
||||||
category: "genel-temizlik.camasir",
|
category: "genel-temizlik.camasir",
|
||||||
name: "Toz Deterjan",
|
name: "Toz Deterjan",
|
||||||
unit: "yıkama",
|
unit: "yıkama",
|
||||||
@@ -195,6 +198,7 @@ export const PRODUCTS: SeedProduct[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ref: "yumusatici",
|
ref: "yumusatici",
|
||||||
|
bulky: true,
|
||||||
category: "genel-temizlik.camasir",
|
category: "genel-temizlik.camasir",
|
||||||
name: "Yumuşatıcı",
|
name: "Yumuşatıcı",
|
||||||
unit: "lt",
|
unit: "lt",
|
||||||
@@ -206,6 +210,7 @@ export const PRODUCTS: SeedProduct[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ref: "camasir-suyu",
|
ref: "camasir-suyu",
|
||||||
|
bulky: true,
|
||||||
category: "genel-temizlik.genel",
|
category: "genel-temizlik.genel",
|
||||||
name: "Çamaşır Suyu",
|
name: "Çamaşır Suyu",
|
||||||
unit: "lt",
|
unit: "lt",
|
||||||
@@ -217,6 +222,7 @@ export const PRODUCTS: SeedProduct[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ref: "tuvalet-kagidi",
|
ref: "tuvalet-kagidi",
|
||||||
|
bulky: true,
|
||||||
category: "genel-temizlik.genel",
|
category: "genel-temizlik.genel",
|
||||||
name: "Tuvalet Kağıdı",
|
name: "Tuvalet Kağıdı",
|
||||||
unit: "rulo",
|
unit: "rulo",
|
||||||
@@ -359,7 +365,15 @@ export async function seedStock(
|
|||||||
const today = todayIstanbul();
|
const today = todayIstanbul();
|
||||||
let created = 0;
|
let created = 0;
|
||||||
for (const p of PRODUCTS) {
|
for (const p of PRODUCTS) {
|
||||||
if (await ProductTypeModel.exists({ householdId, seedRef: p.ref })) continue;
|
if (await ProductTypeModel.exists({ householdId, seedRef: p.ref })) {
|
||||||
|
// Faz 13 öncesi seed'lenmiş veritabanında hacimli işaretini tamamla.
|
||||||
|
if (p.bulky)
|
||||||
|
await ProductTypeModel.updateOne(
|
||||||
|
{ householdId, seedRef: p.ref, bulky: { $exists: false } },
|
||||||
|
{ $set: { bulky: true } },
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const doc = await ProductTypeModel.create({
|
const doc = await ProductTypeModel.create({
|
||||||
householdId,
|
householdId,
|
||||||
categoryId: catByKey.get(p.category)!,
|
categoryId: catByKey.get(p.category)!,
|
||||||
@@ -370,6 +384,7 @@ export async function seedStock(
|
|||||||
thresholdPct: p.threshold ?? 20,
|
thresholdPct: p.threshold ?? 20,
|
||||||
location: p.location,
|
location: p.location,
|
||||||
step: p.step ?? K,
|
step: p.step ?? K,
|
||||||
|
bulky: p.bulky ?? false,
|
||||||
seedRef: p.ref,
|
seedRef: p.ref,
|
||||||
createdBy: actor,
|
createdBy: actor,
|
||||||
updatedBy: actor,
|
updatedBy: actor,
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ShoppingModeView } from "@/features/shopping/components/shopping-mode";
|
||||||
|
import { getShoppingList } from "@/features/shopping/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tr.shopping.mode.title };
|
||||||
|
|
||||||
|
/** /alisveris/:id/mod — alışverişe çıkış: mağazaya göre gruplu kontrol listesi. */
|
||||||
|
export default async function ShoppingModePage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: PageProps<"/alisveris/[id]/mod">) {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const [{ id }, query] = await Promise.all([params, searchParams]);
|
||||||
|
const data = await getShoppingList(user.id, household.id, id);
|
||||||
|
if (!data) notFound();
|
||||||
|
return (
|
||||||
|
<ShoppingModeView data={data} choice={query.plan === "single" ? "single" : "recommended"} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ShoppingListDetailView } from "@/features/shopping/components/list-detail";
|
||||||
|
import { getShoppingList } from "@/features/shopping/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tr.shopping.title };
|
||||||
|
|
||||||
|
/** /alisveris/:id — liste kalemleri, stok önerileri ve akıllı mağaza önerisi. */
|
||||||
|
export default async function ShoppingListPage({ params }: PageProps<"/alisveris/[id]">) {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const { id } = await params;
|
||||||
|
const data = await getShoppingList(user.id, household.id, id);
|
||||||
|
if (!data) notFound();
|
||||||
|
return <ShoppingListDetailView data={data} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { CompletionForm } from "@/features/shopping/components/completion-form";
|
||||||
|
import { getCompletionSheet } from "@/features/shopping/completion";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tr.shopping.complete.title };
|
||||||
|
|
||||||
|
/** /alisveris/:id/tamamla — alışveriş sonrası giriş: alınanlar, ödeme, fiş, alınmayanlar. */
|
||||||
|
export default async function CompleteShoppingPage({
|
||||||
|
params,
|
||||||
|
}: PageProps<"/alisveris/[id]/tamamla">) {
|
||||||
|
const { user, household } = await requireActiveHousehold("member");
|
||||||
|
const { id } = await params;
|
||||||
|
const sheet = await getCompletionSheet(user.id, household.id, id);
|
||||||
|
if (!sheet) notFound();
|
||||||
|
return <CompletionForm sheet={sheet} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { ShoppingListsView } from "@/features/shopping/components/lists-view";
|
||||||
|
import { listShoppingLists } from "@/features/shopping/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tr.shopping.title };
|
||||||
|
|
||||||
|
/** /alisveris — aktif ve tamamlanan alışveriş listeleri. */
|
||||||
|
export default async function ShoppingPage() {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const data = await listShoppingLists(user.id, household.id);
|
||||||
|
if (!data) notFound();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title={tr.shopping.title} description={tr.shopping.description} />
|
||||||
|
{!data.canWrite ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{tr.shopping.readOnly}</p>
|
||||||
|
) : null}
|
||||||
|
<ShoppingListsView
|
||||||
|
active={data.active}
|
||||||
|
completed={data.completed}
|
||||||
|
suggestions={data.suggestions}
|
||||||
|
canWrite={data.canWrite}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { ShoppingCard } from "@/features/shopping/components/shopping-card";
|
||||||
import { LowStockCard } from "@/features/stock/components/low-stock-card";
|
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";
|
||||||
@@ -106,9 +107,14 @@ 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}>
|
<div className="grid gap-4 lg:grid-cols-2 lg:items-start">
|
||||||
<LowStockCard userId={user.id} householdId={household.id} />
|
<Suspense fallback={null}>
|
||||||
</Suspense>
|
<LowStockCard userId={user.id} householdId={household.id} />
|
||||||
|
</Suspense>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ShoppingCard userId={user.id} householdId={household.id} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { StoresView } from "@/features/stores/components/stores-view";
|
||||||
|
import { listStores } from "@/features/stores/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tr.stores.title };
|
||||||
|
|
||||||
|
/** /magazalar — marketler, online platformlar ve pazarlar; uzaklık, taşıma ve zahmet skoru. */
|
||||||
|
export default async function StoresPage() {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const data = await listStores(user.id, household.id);
|
||||||
|
if (!data) notFound();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title={tr.stores.title} description={tr.stores.description} />
|
||||||
|
{!data.canWrite ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{tr.stores.readOnly}</p>
|
||||||
|
) : null}
|
||||||
|
<StoresView stores={data.stores} canWrite={data.canWrite} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { TypePriceComparison } from "@/features/prices/components/type-price-comparison";
|
||||||
|
import { getTypePriceComparison } from "@/features/prices/service";
|
||||||
|
import { ProductsSection } from "@/features/products/components/products-section";
|
||||||
|
import { listProducts, listProductTypeOptions } from "@/features/products/service";
|
||||||
import { ProductDetailView } from "@/features/stock/components/product-detail-view";
|
import { ProductDetailView } from "@/features/stock/components/product-detail-view";
|
||||||
import { getProductType, getStockOptions } from "@/features/stock/service";
|
import { getProductType, getStockOptions } from "@/features/stock/service";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
@@ -11,10 +15,29 @@ export const metadata: Metadata = { title: tr.stock.detail.movements };
|
|||||||
export default async function ProductTypePage({ params }: PageProps<"/stok/[id]">) {
|
export default async function ProductTypePage({ params }: PageProps<"/stok/[id]">) {
|
||||||
const { user, household } = await requireActiveHousehold("viewer");
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const [data, options] = await Promise.all([
|
const [data, options, products, types, prices] = await Promise.all([
|
||||||
getProductType(user.id, household.id, id),
|
getProductType(user.id, household.id, id),
|
||||||
getStockOptions(user.id, household.id),
|
getStockOptions(user.id, household.id),
|
||||||
|
listProducts(user.id, household.id, { productTypeId: id, includeArchived: true }),
|
||||||
|
listProductTypeOptions(user.id, household.id),
|
||||||
|
getTypePriceComparison(user.id, household.id, id),
|
||||||
]);
|
]);
|
||||||
if (!data || !options) notFound();
|
if (!data || !options || !products || !types || !prices) notFound();
|
||||||
return <ProductDetailView data={data} categories={options.categories} />;
|
return (
|
||||||
|
<ProductDetailView
|
||||||
|
data={data}
|
||||||
|
categories={options.categories}
|
||||||
|
products={
|
||||||
|
<>
|
||||||
|
<TypePriceComparison data={prices} />
|
||||||
|
<ProductsSection
|
||||||
|
productTypeId={data.id}
|
||||||
|
products={products.products}
|
||||||
|
types={types}
|
||||||
|
canWrite={products.canWrite}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ProductPriceView } from "@/features/prices/components/product-price-view";
|
||||||
|
import { getProductPrices } from "@/features/prices/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tr.products.title };
|
||||||
|
|
||||||
|
/** /stok/urunler/:id — ürün (marka/paket): mağaza fiyatları, birim fiyat, fiyat geçmişi. */
|
||||||
|
export default async function ProductPage({ params }: PageProps<"/stok/urunler/[id]">) {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const { id } = await params;
|
||||||
|
const data = await getProductPrices(user.id, household.id, id);
|
||||||
|
if (!data) notFound();
|
||||||
|
return <ProductPriceView data={data} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ProductsCatalog } from "@/features/products/components/products-catalog";
|
||||||
|
import { listProducts, listProductTypeOptions } from "@/features/products/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tr.products.title };
|
||||||
|
|
||||||
|
/** /stok/urunler — ürün kataloğu (marka/paket), ürün tipine göre gruplu. */
|
||||||
|
export default async function ProductsPage() {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const [data, types] = await Promise.all([
|
||||||
|
listProducts(user.id, household.id, { includeArchived: true }),
|
||||||
|
listProductTypeOptions(user.id, household.id),
|
||||||
|
]);
|
||||||
|
if (!data || !types) notFound();
|
||||||
|
return <ProductsCatalog products={data.products} types={types} canWrite={data.canWrite} />;
|
||||||
|
}
|
||||||
@@ -31,6 +31,14 @@
|
|||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-series-1: var(--series-1);
|
||||||
|
--color-series-2: var(--series-2);
|
||||||
|
--color-series-3: var(--series-3);
|
||||||
|
--color-series-4: var(--series-4);
|
||||||
|
--color-series-5: var(--series-5);
|
||||||
|
--color-series-6: var(--series-6);
|
||||||
|
--color-series-7: var(--series-7);
|
||||||
|
--color-series-8: var(--series-8);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
@@ -95,6 +103,17 @@
|
|||||||
--chart-3: var(--expense);
|
--chart-3: var(--expense);
|
||||||
--chart-4: var(--warning);
|
--chart-4: var(--warning);
|
||||||
--chart-5: oklch(0.65 0.08 230);
|
--chart-5: oklch(0.65 0.08 230);
|
||||||
|
|
||||||
|
/* Kategorik seriler (mağaza vb. kimlik; sabit sıra, döngü yok). dataviz doğrulayıcısından
|
||||||
|
geçti (açık: kart #fff; 3 yuva 3:1 altında → lejant + tablo zorunlu). */
|
||||||
|
--series-1: #2a78d6;
|
||||||
|
--series-2: #eb6834;
|
||||||
|
--series-3: #1baf7a;
|
||||||
|
--series-4: #eda100;
|
||||||
|
--series-5: #e87ba4;
|
||||||
|
--series-6: #008300;
|
||||||
|
--series-7: #6250d6;
|
||||||
|
--series-8: #e34948;
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
|
|
||||||
/* Kullanıcı verisi renkleri (kategori/hesap): anahtar → değişken (shared/appearance.ts) */
|
/* Kullanıcı verisi renkleri (kategori/hesap): anahtar → değişken (shared/appearance.ts) */
|
||||||
@@ -156,6 +175,16 @@
|
|||||||
--chart-4: var(--warning);
|
--chart-4: var(--warning);
|
||||||
--chart-5: oklch(0.7 0.07 230);
|
--chart-5: oklch(0.7 0.07 230);
|
||||||
|
|
||||||
|
/* Koyu yüzey (#101a2f) için aynı tonların koyu adımları; hepsi ≥ 3:1. */
|
||||||
|
--series-1: #3987e5;
|
||||||
|
--series-2: #d95926;
|
||||||
|
--series-3: #199e70;
|
||||||
|
--series-4: #c98500;
|
||||||
|
--series-5: #d55181;
|
||||||
|
--series-6: #008300;
|
||||||
|
--series-7: #9085e9;
|
||||||
|
--series-8: #e66767;
|
||||||
|
|
||||||
--swatch-blue: oklch(0.74 0.13 258);
|
--swatch-blue: oklch(0.74 0.13 258);
|
||||||
--swatch-sky: oklch(0.74 0.13 230);
|
--swatch-sky: oklch(0.74 0.13 230);
|
||||||
--swatch-teal: oklch(0.74 0.13 185);
|
--swatch-teal: oklch(0.74 0.13 185);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
Settings,
|
Settings,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
|
Store,
|
||||||
Tags,
|
Tags,
|
||||||
Target,
|
Target,
|
||||||
UserCog,
|
UserCog,
|
||||||
@@ -27,7 +28,7 @@ export type NavItem = {
|
|||||||
|
|
||||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||||
export const CURRENT_PHASE = 11.2;
|
export const CURRENT_PHASE = 13.2;
|
||||||
|
|
||||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||||
|
|
||||||
@@ -41,7 +42,8 @@ export const MAIN_NAV: NavItem[] = [
|
|||||||
{ 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.1 },
|
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 },
|
||||||
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
|
{ href: "/magazalar", label: tr.nav.stores, icon: Store, phase: 12.1 },
|
||||||
|
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13.1 },
|
||||||
{ 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 },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export function PageHeader({
|
|||||||
actions,
|
actions,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: React.ReactNode;
|
||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -38,11 +38,17 @@ export function AttachmentPicker({
|
|||||||
onChange,
|
onChange,
|
||||||
allowedKinds,
|
allowedKinds,
|
||||||
onBusyChange,
|
onBusyChange,
|
||||||
|
hint,
|
||||||
|
addLabel,
|
||||||
}: {
|
}: {
|
||||||
value: AttachmentInfo[];
|
value: AttachmentInfo[];
|
||||||
onChange: (items: AttachmentInfo[]) => void;
|
onChange: (items: AttachmentInfo[]) => void;
|
||||||
allowedKinds: readonly AttachmentKind[];
|
allowedKinds: readonly AttachmentKind[];
|
||||||
onBusyChange?: (busy: boolean) => void;
|
onBusyChange?: (busy: boolean) => void;
|
||||||
|
/** Boşken gösterilen ipucu (varsayılan: fiş/fatura). */
|
||||||
|
hint?: string;
|
||||||
|
/** Düğme metni (varsayılan: "Ek ekle"). */
|
||||||
|
addLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploading, setUploading] = useState(0);
|
const [uploading, setUploading] = useState(0);
|
||||||
@@ -91,14 +97,16 @@ export function AttachmentPicker({
|
|||||||
disabled={room <= 0 || uploading > 0}
|
disabled={room <= 0 || uploading > 0}
|
||||||
>
|
>
|
||||||
{uploading > 0 ? <Loader2 className="animate-spin" /> : <Paperclip />}
|
{uploading > 0 ? <Loader2 className="animate-spin" /> : <Paperclip />}
|
||||||
{uploading > 0 ? t.uploading : t.add}
|
{uploading > 0 ? t.uploading : (addLabel ?? t.add)}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{value.length
|
{value.length
|
||||||
? t.count(value.length)
|
? t.count(value.length)
|
||||||
: allowedKinds.includes("pdf")
|
: hint
|
||||||
? t.addHint.imagePdf
|
? hint
|
||||||
: t.addHint.image}
|
: allowedKinds.includes("pdf")
|
||||||
|
? t.addHint.imagePdf
|
||||||
|
: t.addHint.image}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getStorage } from "@/lib/storage";
|
|||||||
import { isPlausiblePdf, processImage, UnreadableImageError } from "@/lib/uploads/process";
|
import { isPlausiblePdf, processImage, UnreadableImageError } from "@/lib/uploads/process";
|
||||||
import { AccountModel } from "@/models/account";
|
import { AccountModel } from "@/models/account";
|
||||||
import { AttachmentModel, type AttachmentOwnerType } from "@/models/attachment";
|
import { AttachmentModel, type AttachmentOwnerType } from "@/models/attachment";
|
||||||
|
import { ProductModel } from "@/models/product";
|
||||||
import { TransactionModel } from "@/models/transaction";
|
import { TransactionModel } from "@/models/transaction";
|
||||||
import {
|
import {
|
||||||
detectFileType,
|
detectFileType,
|
||||||
@@ -267,6 +268,9 @@ export async function readAttachmentForUser(
|
|||||||
ownerUserId: { $ne: new Types.ObjectId(userId) },
|
ownerUserId: { $ne: new Types.ObjectId(userId) },
|
||||||
});
|
});
|
||||||
if (hidden) return null;
|
if (hidden) return null;
|
||||||
|
} else if (a.ownerType === "product") {
|
||||||
|
// Ürün görselleri hane ortaktır (arşivli ürün dahil).
|
||||||
|
if (!(await ProductModel.exists({ _id: a.ownerId, householdId: a.householdId }))) return null;
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { getHouseholdActionContext } from "@/lib/dal";
|
||||||
|
import { productPriceFormSchema } from "@/shared/schemas/product-price";
|
||||||
|
import { addProductPrice, deleteProductPrice } 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addProductPriceAction(input: unknown) {
|
||||||
|
return run(productPriceFormSchema, input, (u, h, d) => addProductPrice(u, h, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProductPriceAction(priceId: unknown) {
|
||||||
|
const id = idSchema.safeParse(priceId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => deleteProductPrice(u, h, id.data));
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Clock, Tag, Trophy } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import { formatUnitPrice, type RankedOffer } from "@/shared/prices";
|
||||||
|
import type { StoreOffer } from "../service";
|
||||||
|
|
||||||
|
const t = tr.productPrices;
|
||||||
|
export const ymdLabel = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
|
||||||
|
/** Mağaza renk işareti (kimlik; metin her zaman yanında). */
|
||||||
|
export function StoreDot({ slot, className }: { slot: number; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn("mr-1.5 inline-block size-2.5 shrink-0 rounded-full", className)}
|
||||||
|
style={{ backgroundColor: `var(--series-${slot})` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Teklif rozetleri: en uygun, kampanya, kampanya bitti, eski fiyat. */
|
||||||
|
function OfferBadges({ offer }: { offer: RankedOffer<StoreOffer> }) {
|
||||||
|
const p = offer.price;
|
||||||
|
return (
|
||||||
|
<span className="flex flex-wrap items-center gap-1">
|
||||||
|
{offer.cheapest ? (
|
||||||
|
<Badge className="gap-1 bg-income text-income-foreground">
|
||||||
|
<Trophy className="size-3" aria-hidden />
|
||||||
|
{t.cheapest}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{p.isPromo && !offer.promoEnded ? (
|
||||||
|
<Badge variant="outline" className="gap-1 border-budget/50 text-budget">
|
||||||
|
<Tag className="size-3" aria-hidden />
|
||||||
|
{t.promo}
|
||||||
|
{p.promoUntil ? ` · ${t.promoUntil(ymdLabel(p.promoUntil))}` : ""}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{offer.fellBack ? <Badge variant="secondary">{t.fellBack}</Badge> : null}
|
||||||
|
{offer.promoEnded ? <Badge variant="secondary">{t.promoEnded}</Badge> : null}
|
||||||
|
{offer.stale ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1 border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||||||
|
title={t.staleHint}
|
||||||
|
>
|
||||||
|
<Clock className="size-3" aria-hidden />
|
||||||
|
{t.stale(offer.ageDays)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Birim fiyata göre sıralı teklifler. `showProduct`: ürün tipi karşılaştırmasında marka/paket
|
||||||
|
* ve ürün sayfası bağlantısı.
|
||||||
|
*/
|
||||||
|
export function OffersList({
|
||||||
|
offers,
|
||||||
|
typeUnit,
|
||||||
|
showProduct = false,
|
||||||
|
}: {
|
||||||
|
offers: RankedOffer<StoreOffer>[];
|
||||||
|
typeUnit: string;
|
||||||
|
showProduct?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ol className="divide-y rounded-xl border bg-card">
|
||||||
|
{offers.map((o) => (
|
||||||
|
<li
|
||||||
|
key={o.key}
|
||||||
|
className={cn(
|
||||||
|
"grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1 p-3",
|
||||||
|
o.cheapest && "bg-income/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="pt-0.5 text-sm text-muted-foreground tabular-nums">{o.rank ?? "–"}</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
{showProduct ? (
|
||||||
|
<p className="truncate font-medium">
|
||||||
|
<Link href={`/stok/urunler/${o.productId}`} className="hover:underline">
|
||||||
|
{o.productLabel}
|
||||||
|
</Link>{" "}
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">{o.packageLabel}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center",
|
||||||
|
showProduct ? "text-sm text-muted-foreground" : "font-medium",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<StoreDot slot={o.store.slot} />
|
||||||
|
{o.store.label}
|
||||||
|
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
|
||||||
|
· {ymdLabel(o.price.date)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div className="mt-1">
|
||||||
|
<OfferBadges offer={o} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right tabular-nums">
|
||||||
|
<p className="font-semibold">
|
||||||
|
{o.unitPriceMinor === null ? (
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">{t.noConversion}</span>
|
||||||
|
) : (
|
||||||
|
formatUnitPrice(o.unitPriceMinor, typeUnit)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatMoney(o.price.priceMinor)}
|
||||||
|
{o.diffPct ? ` · ${t.diff(o.diffPct)}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
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 { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { parseMoney } from "@/shared/money";
|
||||||
|
import { formatUnitPrice, unitPrice } from "@/shared/prices";
|
||||||
|
import { addProductPriceAction } from "../actions";
|
||||||
|
import type { PriceProductOption } from "../options";
|
||||||
|
import type { StoreRef } from "../service";
|
||||||
|
import { StoreDot } from "./offers-list";
|
||||||
|
|
||||||
|
const t = tr.productPrices;
|
||||||
|
type Errors = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
/** Elle fiyat girişi: ürün (sabit olabilir), mağaza, tarih, paket fiyatı, kampanya. */
|
||||||
|
export function PriceFormDialog({
|
||||||
|
products,
|
||||||
|
stores,
|
||||||
|
productId,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
products: PriceProductOption[];
|
||||||
|
stores: StoreRef[];
|
||||||
|
/** Ürün sayfasından açılınca sabit. */
|
||||||
|
productId?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [v, setV] = useState({
|
||||||
|
productId: productId ?? (products.length === 1 ? products[0]!.id : ""),
|
||||||
|
storeId: "",
|
||||||
|
date: todayIstanbul(),
|
||||||
|
price: "",
|
||||||
|
isPromo: false,
|
||||||
|
promoUntil: "",
|
||||||
|
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 }));
|
||||||
|
const product = products.find((p) => p.id === v.productId);
|
||||||
|
const minor = parseMoney(v.price);
|
||||||
|
const preview =
|
||||||
|
product?.inTypeUnit && minor && minor > 0
|
||||||
|
? formatUnitPrice(unitPrice(minor, product.inTypeUnit), product.typeUnit)
|
||||||
|
: null;
|
||||||
|
const f = t.fields;
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const r = await addProductPriceAction(v);
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.saved);
|
||||||
|
onClose();
|
||||||
|
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={t.addTitle}
|
||||||
|
description={product ? `${product.label} · ${product.packageLabel}` : undefined}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="price-form" disabled={busy}>
|
||||||
|
{tr.common.save}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="price-form" onSubmit={submit} noValidate>
|
||||||
|
<FieldGroup>
|
||||||
|
{productId ? null : (
|
||||||
|
<Field data-invalid={Boolean(errors.productId)}>
|
||||||
|
<FieldLabel htmlFor="pp-product">{f.product}</FieldLabel>
|
||||||
|
<Select value={v.productId} onValueChange={(x) => set("productId", x)}>
|
||||||
|
<SelectTrigger
|
||||||
|
id="pp-product"
|
||||||
|
className="w-full"
|
||||||
|
aria-invalid={Boolean(errors.productId)}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={t.validation.product} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{products.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.label}{" "}
|
||||||
|
<span className="text-xs text-muted-foreground">({p.packageLabel})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.productId ? <FieldError>{errors.productId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field data-invalid={Boolean(errors.storeId)}>
|
||||||
|
<FieldLabel htmlFor="pp-store">{f.store}</FieldLabel>
|
||||||
|
<Select value={v.storeId} onValueChange={(x) => set("storeId", x)}>
|
||||||
|
<SelectTrigger
|
||||||
|
id="pp-store"
|
||||||
|
className="w-full"
|
||||||
|
aria-invalid={Boolean(errors.storeId)}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={t.validation.store} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{stores.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>
|
||||||
|
<StoreDot slot={s.slot} />
|
||||||
|
{s.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.storeId ? <FieldError>{errors.storeId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field data-invalid={Boolean(errors.date)}>
|
||||||
|
<FieldLabel htmlFor="pp-date">{f.date}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="pp-date"
|
||||||
|
type="date"
|
||||||
|
value={v.date}
|
||||||
|
max={todayIstanbul()}
|
||||||
|
onChange={(e) => set("date", e.target.value)}
|
||||||
|
aria-invalid={Boolean(errors.date)}
|
||||||
|
/>
|
||||||
|
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={Boolean(errors.price)}>
|
||||||
|
<FieldLabel htmlFor="pp-price">{f.price}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="pp-price"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={v.price}
|
||||||
|
onChange={(e) => set("price", e.target.value)}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.price)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{errors.price ? <FieldError>{errors.price}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{preview ? (
|
||||||
|
<FieldDescription className="-mt-2 text-right tabular-nums">
|
||||||
|
{f.unitPreview(preview)}
|
||||||
|
</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox
|
||||||
|
id="pp-promo"
|
||||||
|
checked={v.isPromo}
|
||||||
|
onCheckedChange={(x) => set("isPromo", x === true)}
|
||||||
|
/>
|
||||||
|
<FieldLabel htmlFor="pp-promo">{f.isPromo}</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
{v.isPromo ? (
|
||||||
|
<Field data-invalid={Boolean(errors.promoUntil)}>
|
||||||
|
<FieldLabel htmlFor="pp-until">{f.promoUntil}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="pp-until"
|
||||||
|
type="date"
|
||||||
|
value={v.promoUntil}
|
||||||
|
min={v.date}
|
||||||
|
onChange={(e) => set("promoUntil", e.target.value)}
|
||||||
|
aria-invalid={Boolean(errors.promoUntil)}
|
||||||
|
/>
|
||||||
|
<FieldDescription>{f.promoUntilHint}</FieldDescription>
|
||||||
|
{errors.promoUntil ? <FieldError>{errors.promoUntil}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="pp-note">{f.note}</FieldLabel>
|
||||||
|
<Input id="pp-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,271 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||||
|
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, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from "@/components/ui/chart";
|
||||||
|
import { AttachmentStrip } from "@/features/attachments/components/attachment-strip";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import { formatUnitPrice } from "@/shared/prices";
|
||||||
|
import { deleteProductPriceAction } from "../actions";
|
||||||
|
import { priceProductOption } from "../options";
|
||||||
|
import type { PriceRow, ProductPriceDetail } from "../service";
|
||||||
|
import { OffersList, StoreDot, ymdLabel } from "./offers-list";
|
||||||
|
import { PriceFormDialog } from "./price-dialog";
|
||||||
|
|
||||||
|
const t = tr.productPrices;
|
||||||
|
|
||||||
|
const shortDate = (ymd: string) =>
|
||||||
|
new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short" }).format(
|
||||||
|
new Date(`${ymd}T12:00:00+03:00`),
|
||||||
|
);
|
||||||
|
const compactTl = (minor: number) =>
|
||||||
|
new Intl.NumberFormat("tr-TR", { maximumFractionDigits: 0 }).format(minor / 100) + " ₺";
|
||||||
|
|
||||||
|
/** Mağaza başına paket fiyatı; fiyat yeni kayda kadar geçerli (basamaklı çizgi). */
|
||||||
|
function PriceChart({ data }: { data: ProductPriceDetail }) {
|
||||||
|
const config = Object.fromEntries(
|
||||||
|
data.series.map((s) => [`s${s.id}`, { label: s.label, color: `var(--series-${s.slot})` }]),
|
||||||
|
) satisfies ChartConfig;
|
||||||
|
return (
|
||||||
|
<ChartContainer
|
||||||
|
config={config}
|
||||||
|
className="aspect-auto h-64 w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label={t.detail.chartLabel(data.product.label)}
|
||||||
|
>
|
||||||
|
<LineChart data={data.points} margin={{ left: 4, right: 12, top: 8 }}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={24}
|
||||||
|
tickFormatter={(v: string) => shortDate(v)}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={64}
|
||||||
|
domain={["auto", "auto"]}
|
||||||
|
tickFormatter={(v: number) => compactTl(v)}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, payload) =>
|
||||||
|
payload?.[0] ? ymdLabel(String(payload[0].payload.date)) : ""
|
||||||
|
}
|
||||||
|
formatter={(value, name) => (
|
||||||
|
<div className="flex w-full items-center justify-between gap-3">
|
||||||
|
<span className="flex items-center">
|
||||||
|
<StoreDot slot={data.series.find((s) => `s${s.id}` === name)?.slot ?? 1} />
|
||||||
|
{config[String(name)]?.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium tabular-nums">{formatMoney(Number(value))}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
{data.series.map((s) => (
|
||||||
|
<Line
|
||||||
|
key={s.id}
|
||||||
|
dataKey={`s${s.id}`}
|
||||||
|
type="stepAfter"
|
||||||
|
stroke={`var(--color-s${s.id})`}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
connectNulls
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /stok/urunler/:id — ürün bilgisi, mağazalardaki fiyatlar, fiyat geçmişi ve kayıtlar. */
|
||||||
|
export function ProductPriceView({ data }: { data: ProductPriceDetail }) {
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<PriceRow | null>(null);
|
||||||
|
const p = data.product;
|
||||||
|
const canAdd = data.canWrite && !p.archived && data.stores.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||||
|
<Link href="/stok/urunler">
|
||||||
|
<ArrowLeft />
|
||||||
|
{t.detail.back}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<PageHeader
|
||||||
|
title={p.label}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<Link href={`/stok/${p.productTypeId}`} className="hover:underline">
|
||||||
|
{t.detail.typeLink(p.productTypeName)}
|
||||||
|
</Link>
|
||||||
|
{" · "}
|
||||||
|
{p.packageLabel}
|
||||||
|
{p.barcode ? ` · ${tr.products.barcode(p.barcode)}` : ""}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
canAdd ? (
|
||||||
|
<Button size="sm" onClick={() => setAdding(true)}>
|
||||||
|
<Plus />
|
||||||
|
{t.add}
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="-mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
{p.archived ? <Badge variant="secondary">{tr.products.archived}</Badge> : null}
|
||||||
|
{p.conversionMissing ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
{tr.products.conversionMissing}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
<AttachmentStrip items={p.images} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t.detail.currentTitle}</CardTitle>
|
||||||
|
<CardDescription>{t.detail.currentDescription}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{data.offers.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.noPrices}</p>
|
||||||
|
) : (
|
||||||
|
<OffersList offers={data.offers} typeUnit={p.typeUnit} />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{data.history.length > 1 ? (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t.detail.chartTitle}</CardTitle>
|
||||||
|
<CardDescription>{t.detail.chartDescription}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PriceChart data={data} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data.history.length ? (
|
||||||
|
<section className="flex flex-col gap-2" aria-labelledby="price-history-title">
|
||||||
|
<h2 id="price-history-title" className="text-base font-medium">
|
||||||
|
{t.detail.historyTitle}
|
||||||
|
</h2>
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{data.history.map((r) => (
|
||||||
|
<li key={r.id} className="flex items-start gap-3 p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="flex items-center text-sm font-medium">
|
||||||
|
<StoreDot slot={r.store.slot} />
|
||||||
|
{r.store.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{ymdLabel(r.date)}
|
||||||
|
{r.isPromo
|
||||||
|
? ` · ${r.promoEnded ? t.promoEnded : t.promo}${
|
||||||
|
r.promoUntil ? ` (${t.promoUntil(ymdLabel(r.promoUntil))})` : ""
|
||||||
|
}`
|
||||||
|
: ""}
|
||||||
|
{` · ${t.sources[r.source.split(":")[0]!] ?? r.source}`}
|
||||||
|
{r.note ? ` · ${r.note}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-sm tabular-nums">
|
||||||
|
<p className="font-medium">{formatMoney(r.priceMinor)}</p>
|
||||||
|
{r.unitPriceMinor !== null ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatUnitPrice(r.unitPriceMinor, p.typeUnit)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{data.canWrite ? (
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={t.detail.deletePrice}
|
||||||
|
onClick={() => setDeleting(r)}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{adding ? (
|
||||||
|
<PriceFormDialog
|
||||||
|
products={[priceProductOption(p)]}
|
||||||
|
productId={p.id}
|
||||||
|
stores={data.stores}
|
||||||
|
onClose={() => setAdding(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AlertDialog open={deleting !== null} onOpenChange={(o) => !o && setDeleting(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t.detail.deleteTitle}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t.detail.deleteDescription}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
const r = deleting;
|
||||||
|
setDeleting(null);
|
||||||
|
if (!r) return;
|
||||||
|
const res = await deleteProductPriceAction(r.id);
|
||||||
|
if (res.ok) toast.success(t.toast.deleted);
|
||||||
|
else toast.error(res.error);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.detail.deletePrice}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { unitPriceBase } from "@/shared/prices";
|
||||||
|
import type { TypePriceComparison as Data } from "../service";
|
||||||
|
import { OffersList } from "./offers-list";
|
||||||
|
import { PriceFormDialog } from "./price-dialog";
|
||||||
|
|
||||||
|
const t = tr.productPrices;
|
||||||
|
|
||||||
|
/** /stok/:id: markalar ve mağazalar birim fiyata göre; en uygun vurgulu. */
|
||||||
|
export function TypePriceComparison({ data }: { data: Data }) {
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{t.comparisonTitle}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t.comparisonDescription(unitPriceBase(data.typeUnit).unit)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{data.canWrite && data.products.length && data.stores.length ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
|
||||||
|
<Plus />
|
||||||
|
{t.add}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{data.products.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.noProducts}</p>
|
||||||
|
) : data.offers.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.noPrices}</p>
|
||||||
|
) : (
|
||||||
|
<OffersList offers={data.offers} typeUnit={data.typeUnit} showProduct />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
{adding ? (
|
||||||
|
<PriceFormDialog
|
||||||
|
products={data.products}
|
||||||
|
stores={data.stores}
|
||||||
|
onClose={() => setAdding(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ProductView } from "@/features/products/service";
|
||||||
|
|
||||||
|
/** Fiyat formu ürün seçeneği (birim fiyat önizlemesi için dönüşümle). İstemcide de kullanılır. */
|
||||||
|
export type PriceProductOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
packageLabel: string;
|
||||||
|
inTypeUnit: number | null;
|
||||||
|
typeUnit: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const priceProductOption = (p: ProductView): PriceProductOption => ({
|
||||||
|
id: p.id,
|
||||||
|
label: p.label,
|
||||||
|
packageLabel: p.packageLabel,
|
||||||
|
inTypeUnit: p.inTypeUnit,
|
||||||
|
typeUnit: p.typeUnit,
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { getStockAnalysis } from "@/features/stock/service";
|
||||||
|
import { AuditLogModel } from "@/models/audit-log";
|
||||||
|
import { ProductModel } from "@/models/product";
|
||||||
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { StoreModel } from "@/models/store";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import { productPriceFormSchema } from "@/shared/schemas/product-price";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import {
|
||||||
|
addProductPrice,
|
||||||
|
deleteProductPrice,
|
||||||
|
getProductPrices,
|
||||||
|
getTypePriceComparison,
|
||||||
|
} 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);
|
||||||
|
|
||||||
|
const ref = (
|
||||||
|
model: typeof ProductModel | typeof StoreModel | typeof ProductTypeModel,
|
||||||
|
seedRef: string,
|
||||||
|
) =>
|
||||||
|
(model as typeof ProductModel)
|
||||||
|
.findOne({ seedRef })
|
||||||
|
.lean()
|
||||||
|
.then((x) => String(x!._id));
|
||||||
|
|
||||||
|
describe("birim fiyat karşılaştırması", () => {
|
||||||
|
it("toz deterjan: birim fiyata göre sıralı, en uygun işaretli, süren kampanya", async () => {
|
||||||
|
const c = (await getTypePriceComparison(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await ref(ProductTypeModel, "toz-deterjan"),
|
||||||
|
))!;
|
||||||
|
expect(c.typeUnit).toBe("yıkama");
|
||||||
|
const units = c.offers.map((o) => o.unitPriceMinor!);
|
||||||
|
expect(units).toEqual([...units].sort((a, b) => a - b));
|
||||||
|
expect(c.offers[0]!.cheapest).toBe(true);
|
||||||
|
expect(c.offers.filter((o) => o.cheapest)).toHaveLength(1);
|
||||||
|
const ariel = c.offers.find(
|
||||||
|
(o) => o.productLabel.startsWith("Ariel") && o.store.label.startsWith("Migros"),
|
||||||
|
)!;
|
||||||
|
expect(ariel.price).toMatchObject({ isPromo: true, promoUntil: addDays(today, 6) });
|
||||||
|
expect(ariel.promoEnded).toBe(false);
|
||||||
|
// Birim fiyat = paket / 33 yıkama.
|
||||||
|
expect(ariel.unitPriceMinor!).toBeCloseTo((ariel.price.priceMinor * 1000) / 33_000, 6);
|
||||||
|
expect(c.products).toHaveLength(3);
|
||||||
|
expect(
|
||||||
|
await getTypePriceComparison(fatma, cakir, await ref(ProductTypeModel, "toz-deterjan")),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("süresi biten kampanyada normal fiyata dönülür; eski fiyat işaretlenir", async () => {
|
||||||
|
const sut = (await getProductPrices(ayse, cakir, await ref(ProductModel, "pinar-sut")))!;
|
||||||
|
const a101 = sut.offers.find((o) => o.store.label.startsWith("A101"))!;
|
||||||
|
expect(a101).toMatchObject({ fellBack: true, promoEnded: false });
|
||||||
|
expect(a101.price.isPromo).toBe(false);
|
||||||
|
expect(a101.price.date).toBe(addDays(today, -30));
|
||||||
|
expect(sut.history.some((h) => h.isPromo && h.promoEnded)).toBe(true);
|
||||||
|
expect(sut.series.length).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(sut.points.length).toBeGreaterThan(5);
|
||||||
|
|
||||||
|
const reis = (await getProductPrices(ayse, cakir, await ref(ProductModel, "reis-mercimek")))!;
|
||||||
|
expect(reis.offers.find((o) => o.store.label.startsWith("Metro"))!.stale).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fiyat girişi", () => {
|
||||||
|
it("ekle, gelecek tarih ve arşivli mağaza reddi, izleyici yazamaz, sil + denetim", async () => {
|
||||||
|
const productId = await ref(ProductModel, "omo-6kg");
|
||||||
|
const storeId = await ref(StoreModel, "sok");
|
||||||
|
const form = (over: Record<string, unknown> = {}) =>
|
||||||
|
productPriceFormSchema.parse({
|
||||||
|
productId,
|
||||||
|
storeId,
|
||||||
|
date: today,
|
||||||
|
price: "449,90",
|
||||||
|
isPromo: false,
|
||||||
|
promoUntil: "",
|
||||||
|
note: "",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
productPriceFormSchema.safeParse({ ...form(), price: "0", isPromo: false }).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
productPriceFormSchema.safeParse({
|
||||||
|
productId,
|
||||||
|
storeId,
|
||||||
|
date: today,
|
||||||
|
price: "10",
|
||||||
|
isPromo: true,
|
||||||
|
promoUntil: addDays(today, -1),
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect((await addProductPrice(ayse, cakir, form({ date: addDays(today, 1) }))).ok).toBe(false);
|
||||||
|
expect((await addProductPrice(zeynep, cakir, form())).ok).toBe(false);
|
||||||
|
|
||||||
|
const r = await addProductPrice(ayse, cakir, form());
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
let d = (await getProductPrices(ayse, cakir, productId))!;
|
||||||
|
const sok = d.offers.find((o) => o.store.id === storeId)!;
|
||||||
|
expect(sok.price).toMatchObject({ priceMinor: 449_90, date: today, source: "manual" });
|
||||||
|
// 6 kg = 40 yıkama → 11,2475 TL/yıkama.
|
||||||
|
expect(sok.unitPriceMinor).toBeCloseTo(1124.75, 6);
|
||||||
|
|
||||||
|
const priceId = r.ok ? r.data.id : "";
|
||||||
|
expect((await deleteProductPrice(fatma, cakir, priceId)).ok).toBe(false);
|
||||||
|
expect((await deleteProductPrice(ayse, cakir, priceId)).ok).toBe(true);
|
||||||
|
expect(await AuditLogModel.exists({ action: "price.delete", targetId: priceId })).toBeTruthy();
|
||||||
|
d = (await getProductPrices(ayse, cakir, productId))!;
|
||||||
|
expect(d.history.some((h) => h.id === priceId)).toBe(false);
|
||||||
|
|
||||||
|
await StoreModel.updateOne({ _id: storeId }, { $set: { archivedAt: new Date() } });
|
||||||
|
expect(await addProductPrice(ayse, cakir, form())).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
fieldErrors: { storeId: expect.any(String) },
|
||||||
|
});
|
||||||
|
await StoreModel.updateOne({ _id: storeId }, { $set: { archivedAt: null } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tüketim analizinde maliyet", () => {
|
||||||
|
it("aylık ortalama tüketim × son birim fiyat", async () => {
|
||||||
|
const a = (await getStockAnalysis(ayse, cakir))!;
|
||||||
|
const deterjan = a.rows.find((r) => r.name === "Toz Deterjan")!;
|
||||||
|
expect(deterjan.unitPrice).not.toBeNull();
|
||||||
|
// Maliyet yuvarlanmamış ortalamadan; gösterilen ortalama yuvarlıdır (≤ %1 fark).
|
||||||
|
const expected = (deterjan.avgMonthlyUsed! * deterjan.unitPrice!.minor) / 1000;
|
||||||
|
expect(Math.abs(deterjan.avgMonthlyCostMinor! - expected) / expected).toBeLessThan(0.01);
|
||||||
|
// Ürünü (marka/paket) olmayan tipte maliyet yok.
|
||||||
|
expect(a.rows.find((r) => r.name === "Domates")!.avgMonthlyCostMinor).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { resolveMembership, type Membership } from "@/features/households/service";
|
||||||
|
import { listProducts, type ProductView } from "@/features/products/service";
|
||||||
|
import { storeLabel } from "@/features/stores/service";
|
||||||
|
import { priceProductOption, type PriceProductOption } from "./options";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { writeAudit } from "@/lib/audit";
|
||||||
|
import { toObjectId } from "@/lib/object-id";
|
||||||
|
import { ProductModel } from "@/models/product";
|
||||||
|
import { ProductPriceModel } from "@/models/product-price";
|
||||||
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { StoreModel } from "@/models/store";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import {
|
||||||
|
effectivePrice,
|
||||||
|
promoEnded,
|
||||||
|
rankOffers,
|
||||||
|
seriesSlot,
|
||||||
|
unitPrice,
|
||||||
|
type PriceSource,
|
||||||
|
type RankedOffer,
|
||||||
|
} from "@/shared/prices";
|
||||||
|
import { packageInTypeUnit } from "@/shared/products";
|
||||||
|
import type { ProductPriceForm } from "@/shared/schemas/product-price";
|
||||||
|
import { compareTr } from "@/shared/search";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ürün fiyatları (Bölüm 6.11). Hane bazlı; okuma viewer, yazma member. Birim fiyat ürünün
|
||||||
|
* dönüşümüyle okuma sırasında hesaplanır (saf motor: shared/prices).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.productPrices;
|
||||||
|
|
||||||
|
type PriceLean = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
productId: Types.ObjectId;
|
||||||
|
storeId: Types.ObjectId;
|
||||||
|
date: string;
|
||||||
|
priceMinor: number;
|
||||||
|
isPromo: boolean;
|
||||||
|
promoUntil: string | null;
|
||||||
|
source: PriceSource;
|
||||||
|
note: string;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StoreLean = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
branch: string;
|
||||||
|
archivedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreRef = { id: string; label: string; slot: number; archived: boolean };
|
||||||
|
|
||||||
|
export type PriceRow = {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
store: StoreRef;
|
||||||
|
date: string;
|
||||||
|
priceMinor: number;
|
||||||
|
isPromo: boolean;
|
||||||
|
promoUntil: string | null;
|
||||||
|
promoEnded: boolean;
|
||||||
|
source: PriceSource;
|
||||||
|
note: string;
|
||||||
|
/** Ürün tipi biriminde 1 birimin fiyatı (kesirli kuruş; dönüşüm yoksa null). */
|
||||||
|
unitPriceMinor: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreOffer = {
|
||||||
|
/** Teklif anahtarı: ürün + mağaza. */
|
||||||
|
key: string;
|
||||||
|
productId: string;
|
||||||
|
productLabel: string;
|
||||||
|
packageLabel: string;
|
||||||
|
store: StoreRef;
|
||||||
|
price: PriceRow;
|
||||||
|
/** Son kayıt süresi biten kampanyaydı; önceki normal fiyat gösteriliyor. */
|
||||||
|
fellBack: boolean;
|
||||||
|
promoEnded: boolean;
|
||||||
|
ageDays: number;
|
||||||
|
stale: boolean;
|
||||||
|
unitPriceMinor: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductPriceDetail = {
|
||||||
|
product: ProductView;
|
||||||
|
offers: RankedOffer<StoreOffer>[];
|
||||||
|
/** Tüm kayıtlar, yeniden eskiye. */
|
||||||
|
history: PriceRow[];
|
||||||
|
/** Grafik: mağaza serileri ve tarih noktaları (paket fiyatı, kuruş). */
|
||||||
|
series: StoreRef[];
|
||||||
|
points: Record<string, number | string>[];
|
||||||
|
stores: StoreRef[];
|
||||||
|
canWrite: boolean;
|
||||||
|
today: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TypePriceComparison = {
|
||||||
|
typeUnit: string;
|
||||||
|
offers: RankedOffer<StoreOffer>[];
|
||||||
|
/** Fiyat girişi seçenekleri. */
|
||||||
|
products: PriceProductOption[];
|
||||||
|
stores: StoreRef[];
|
||||||
|
canWrite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hanenin tüm mağazaları; renk yuvası ekleniş sırasına göre sabit. */
|
||||||
|
async function loadStores(householdId: Types.ObjectId) {
|
||||||
|
const rows = await StoreModel.find({ householdId })
|
||||||
|
.select({ name: 1, branch: 1, archivedAt: 1, createdAt: 1 })
|
||||||
|
.sort({ createdAt: 1, _id: 1 })
|
||||||
|
.lean<StoreLean[]>();
|
||||||
|
return new Map(
|
||||||
|
rows.map((s, i) => [
|
||||||
|
String(s._id),
|
||||||
|
{
|
||||||
|
id: String(s._id),
|
||||||
|
label: storeLabel(s),
|
||||||
|
slot: seriesSlot(i),
|
||||||
|
archived: Boolean(s.archivedAt),
|
||||||
|
} satisfies StoreRef,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortRecords = (a: PriceLean, b: PriceLean) =>
|
||||||
|
a.date === b.date ? a.createdAt.getTime() - b.createdAt.getTime() : a.date < b.date ? -1 : 1;
|
||||||
|
|
||||||
|
async function loadPrices(householdId: Types.ObjectId, productIds: Types.ObjectId[]) {
|
||||||
|
const rows = await ProductPriceModel.find({
|
||||||
|
householdId,
|
||||||
|
productId: { $in: productIds },
|
||||||
|
deletedAt: null,
|
||||||
|
}).lean<PriceLean[]>();
|
||||||
|
return rows.sort(sortRecords);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRow(r: PriceLean, store: StoreRef, inTypeUnit: number | null, today: string): PriceRow {
|
||||||
|
return {
|
||||||
|
id: String(r._id),
|
||||||
|
productId: String(r.productId),
|
||||||
|
store,
|
||||||
|
date: r.date,
|
||||||
|
priceMinor: r.priceMinor,
|
||||||
|
isPromo: r.isPromo,
|
||||||
|
promoUntil: r.promoUntil,
|
||||||
|
promoEnded: promoEnded(r, today),
|
||||||
|
source: r.source,
|
||||||
|
note: r.note,
|
||||||
|
unitPriceMinor: inTypeUnit ? unitPrice(r.priceMinor, inTypeUnit) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ürün × mağaza başına geçerli fiyat teklifleri (sıralanmamış). */
|
||||||
|
function offersFor(
|
||||||
|
products: ProductView[],
|
||||||
|
prices: PriceLean[],
|
||||||
|
stores: Map<string, StoreRef>,
|
||||||
|
today: string,
|
||||||
|
): StoreOffer[] {
|
||||||
|
const groups = new Map<string, PriceLean[]>();
|
||||||
|
for (const r of prices) {
|
||||||
|
const key = `${r.productId}:${r.storeId}`;
|
||||||
|
groups.set(key, [...(groups.get(key) ?? []), r]);
|
||||||
|
}
|
||||||
|
const byId = new Map(products.map((p) => [p.id, p]));
|
||||||
|
const out: StoreOffer[] = [];
|
||||||
|
for (const [key, records] of groups) {
|
||||||
|
const p = byId.get(String(records[0]!.productId));
|
||||||
|
const store = stores.get(String(records[0]!.storeId));
|
||||||
|
if (!p || !store) continue;
|
||||||
|
const eff = effectivePrice(records, today)!;
|
||||||
|
const row = toRow(eff.record, store, p.inTypeUnit, today);
|
||||||
|
out.push({
|
||||||
|
key,
|
||||||
|
productId: p.id,
|
||||||
|
productLabel: p.label,
|
||||||
|
packageLabel: p.packageLabel,
|
||||||
|
store,
|
||||||
|
price: row,
|
||||||
|
fellBack: eff.fellBack,
|
||||||
|
promoEnded: eff.promoEnded,
|
||||||
|
ageDays: eff.ageDays,
|
||||||
|
stale: eff.stale,
|
||||||
|
unitPriceMinor: row.unitPriceMinor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeStores = (stores: Map<string, StoreRef>) =>
|
||||||
|
[...stores.values()].filter((s) => !s.archived).sort((a, b) => compareTr(a.label, b.label));
|
||||||
|
|
||||||
|
/** /stok/urunler/:id — ürünün mağaza bazında fiyatları, geçmişi ve grafiği. */
|
||||||
|
export async function getProductPrices(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
productId: string,
|
||||||
|
): Promise<ProductPriceDetail | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const id = toObjectId(productId);
|
||||||
|
if (!id) return null;
|
||||||
|
const doc = await ProductModel.findOne({ _id: id, householdId: m.householdId })
|
||||||
|
.select({ productTypeId: 1 })
|
||||||
|
.lean<{ productTypeId: Types.ObjectId }>();
|
||||||
|
if (!doc) return null;
|
||||||
|
const list = await listProducts(userId, householdId, {
|
||||||
|
productTypeId: String(doc.productTypeId),
|
||||||
|
includeArchived: true,
|
||||||
|
});
|
||||||
|
const product = list?.products.find((p) => p.id === productId);
|
||||||
|
if (!product) return null;
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const [stores, prices] = await Promise.all([
|
||||||
|
loadStores(m.householdId),
|
||||||
|
loadPrices(m.householdId, [id]),
|
||||||
|
]);
|
||||||
|
const history = prices
|
||||||
|
.map((r) => toRow(r, stores.get(String(r.storeId))!, product.inTypeUnit, today))
|
||||||
|
.filter((r) => r.store)
|
||||||
|
.reverse();
|
||||||
|
const seriesIds = [...new Set(prices.map((r) => String(r.storeId)))];
|
||||||
|
const series = seriesIds
|
||||||
|
.map((sid) => stores.get(sid)!)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.slot - b.slot);
|
||||||
|
// Aynı gün aynı mağazada birden çok kayıt: son giriş geçerli.
|
||||||
|
const byDate = new Map<string, Record<string, number | string>>();
|
||||||
|
for (const r of prices) {
|
||||||
|
const point = byDate.get(r.date) ?? { date: r.date };
|
||||||
|
point[`s${String(r.storeId)}`] = r.priceMinor;
|
||||||
|
byDate.set(r.date, point);
|
||||||
|
}
|
||||||
|
// Son nokta bugün: her mağazanın geçerli fiyatı (çizgi bugüne uzar; biten kampanyada normal fiyat).
|
||||||
|
const offers = offersFor([product], prices, stores, today);
|
||||||
|
if (offers.length) {
|
||||||
|
const point = byDate.get(today) ?? { date: today };
|
||||||
|
for (const o of offers) point[`s${o.store.id}`] ??= o.price.priceMinor;
|
||||||
|
byDate.set(today, point);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
product,
|
||||||
|
offers: rankOffers(offers),
|
||||||
|
history,
|
||||||
|
series,
|
||||||
|
points: [...byDate.values()].sort((a, b) => (String(a.date) < String(b.date) ? -1 : 1)),
|
||||||
|
stores: activeStores(stores),
|
||||||
|
canWrite: hasHouseholdRole(m.role, "member"),
|
||||||
|
today,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ürün tipi detayı: tüm marka/paket ve mağazalar birim fiyata göre. */
|
||||||
|
export async function getTypePriceComparison(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
productTypeId: string,
|
||||||
|
): Promise<TypePriceComparison | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const list = await listProducts(userId, householdId, { productTypeId });
|
||||||
|
if (!list) return null;
|
||||||
|
const type = await ProductTypeModel.findOne({
|
||||||
|
_id: new Types.ObjectId(productTypeId),
|
||||||
|
householdId: m.householdId,
|
||||||
|
})
|
||||||
|
.select({ unit: 1 })
|
||||||
|
.lean<{ unit: string }>();
|
||||||
|
if (!type) return null;
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const [stores, prices] = await Promise.all([
|
||||||
|
loadStores(m.householdId),
|
||||||
|
loadPrices(
|
||||||
|
m.householdId,
|
||||||
|
list.products.map((p) => new Types.ObjectId(p.id)),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
typeUnit: type.unit,
|
||||||
|
offers: rankOffers(
|
||||||
|
offersFor(list.products, prices, stores, today).filter((o) => !o.store.archived),
|
||||||
|
),
|
||||||
|
products: list.products.map(priceProductOption),
|
||||||
|
stores: activeStores(stores),
|
||||||
|
canWrite: list.canWrite,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ürün tipi birimine çevrilebilen ürünlerin mağaza başına geçerli fiyatı (planlayıcı girdisi). */
|
||||||
|
export type TypeOffer = {
|
||||||
|
productId: string;
|
||||||
|
productTypeId: string;
|
||||||
|
storeId: string;
|
||||||
|
priceMinor: number;
|
||||||
|
inTypeUnit: number;
|
||||||
|
unitPriceMinor: number;
|
||||||
|
date: string;
|
||||||
|
stale: boolean;
|
||||||
|
isPromo: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ürün tiplerinin tüm (arşivsiz) ürünlerinin mağaza başına geçerli fiyatları. Dönüşümü eksik
|
||||||
|
* ürünler atlanır. `activeOnly`: arşivli ürün ve mağazaları dışarıda bırak.
|
||||||
|
*/
|
||||||
|
export async function loadTypeOffers(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
productTypeIds: Types.ObjectId[],
|
||||||
|
{ today = todayIstanbul(), activeOnly = false } = {},
|
||||||
|
): Promise<TypeOffer[]> {
|
||||||
|
if (!productTypeIds.length) return [];
|
||||||
|
const [products, types, archivedStores] = await Promise.all([
|
||||||
|
ProductModel.find({
|
||||||
|
householdId,
|
||||||
|
productTypeId: { $in: productTypeIds },
|
||||||
|
...(activeOnly ? { archivedAt: null } : {}),
|
||||||
|
})
|
||||||
|
.select({ productTypeId: 1, packageQuantity: 1, packageUnit: 1, typeQuantity: 1 })
|
||||||
|
.lean<
|
||||||
|
{
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
productTypeId: Types.ObjectId;
|
||||||
|
packageQuantity: number;
|
||||||
|
packageUnit: string;
|
||||||
|
typeQuantity: number | null;
|
||||||
|
}[]
|
||||||
|
>(),
|
||||||
|
ProductTypeModel.find({ householdId, _id: { $in: productTypeIds } })
|
||||||
|
.select({ unit: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; unit: string }[]>(),
|
||||||
|
activeOnly
|
||||||
|
? StoreModel.find({ householdId, archivedAt: { $ne: null } })
|
||||||
|
.select({ _id: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId }[]>()
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
const skipStore = new Set(archivedStores.map((x) => String(x._id)));
|
||||||
|
const unitOf = new Map(types.map((x) => [String(x._id), x.unit]));
|
||||||
|
const prices = await loadPrices(
|
||||||
|
householdId,
|
||||||
|
products.map((p) => p._id),
|
||||||
|
);
|
||||||
|
const groups = new Map<string, PriceLean[]>();
|
||||||
|
for (const r of prices) {
|
||||||
|
if (skipStore.has(String(r.storeId))) continue;
|
||||||
|
const key = `${r.productId}:${r.storeId}`;
|
||||||
|
groups.set(key, [...(groups.get(key) ?? []), r]);
|
||||||
|
}
|
||||||
|
const productById = new Map(products.map((p) => [String(p._id), p]));
|
||||||
|
const out: TypeOffer[] = [];
|
||||||
|
for (const records of groups.values()) {
|
||||||
|
const p = productById.get(String(records[0]!.productId))!;
|
||||||
|
const typeId = String(p.productTypeId);
|
||||||
|
const inType = packageInTypeUnit({
|
||||||
|
packageQuantity: p.packageQuantity,
|
||||||
|
packageUnit: p.packageUnit,
|
||||||
|
typeUnit: unitOf.get(typeId) ?? "",
|
||||||
|
typeQuantity: p.typeQuantity,
|
||||||
|
});
|
||||||
|
if (!inType) continue;
|
||||||
|
const eff = effectivePrice(records, today)!;
|
||||||
|
out.push({
|
||||||
|
productId: String(p._id),
|
||||||
|
productTypeId: typeId,
|
||||||
|
storeId: String(records[0]!.storeId),
|
||||||
|
priceMinor: eff.record.priceMinor,
|
||||||
|
inTypeUnit: inType,
|
||||||
|
unitPriceMinor: unitPrice(eff.record.priceMinor, inType),
|
||||||
|
date: eff.record.date,
|
||||||
|
stale: eff.stale,
|
||||||
|
isPromo: eff.record.isPromo && !eff.promoEnded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ürün tipi başına son bilinen birim fiyat (en yeni geçerli fiyat kaydı; tüm marka/mağazalar).
|
||||||
|
* Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır.
|
||||||
|
*/
|
||||||
|
export async function latestTypeUnitPrices(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
productTypeIds: Types.ObjectId[],
|
||||||
|
today = todayIstanbul(),
|
||||||
|
): Promise<Map<string, { unitPriceMinor: number; date: string }>> {
|
||||||
|
const out = new Map<string, { unitPriceMinor: number; date: string }>();
|
||||||
|
for (const o of await loadTypeOffers(householdId, productTypeIds, { today })) {
|
||||||
|
const prev = out.get(o.productTypeId);
|
||||||
|
if (!prev || o.date > prev.date)
|
||||||
|
out.set(o.productTypeId, { unitPriceMinor: o.unitPriceMinor, date: o.date });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Yazma ---
|
||||||
|
|
||||||
|
async function activeRefs(m: Membership, input: Pick<ProductPriceForm, "productId" | "storeId">) {
|
||||||
|
const [product, store] = await Promise.all([
|
||||||
|
ProductModel.exists({
|
||||||
|
_id: new Types.ObjectId(input.productId),
|
||||||
|
householdId: m.householdId,
|
||||||
|
archivedAt: null,
|
||||||
|
}),
|
||||||
|
StoreModel.exists({
|
||||||
|
_id: new Types.ObjectId(input.storeId),
|
||||||
|
householdId: m.householdId,
|
||||||
|
archivedAt: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (!product) return fail(t.validation.product, { productId: t.validation.product });
|
||||||
|
if (!store) return fail(t.validation.store, { storeId: t.validation.store });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fiyat kaydı ekler. Gelecek tarihli fiyat girilemez. */
|
||||||
|
export async function addProductPrice(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: ProductPriceForm,
|
||||||
|
source: PriceSource = "manual",
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
if (input.date > todayIstanbul())
|
||||||
|
return fail(t.validation.futureDate, { date: t.validation.futureDate });
|
||||||
|
const bad = await activeRefs(m, input);
|
||||||
|
if (bad) return bad;
|
||||||
|
const doc = await ProductPriceModel.create({
|
||||||
|
householdId: m.householdId,
|
||||||
|
productId: new Types.ObjectId(input.productId),
|
||||||
|
storeId: new Types.ObjectId(input.storeId),
|
||||||
|
date: input.date,
|
||||||
|
priceMinor: input.priceMinor,
|
||||||
|
isPromo: input.isPromo,
|
||||||
|
promoUntil: input.promoUntil,
|
||||||
|
source,
|
||||||
|
note: input.note,
|
||||||
|
createdBy: new Types.ObjectId(actorId),
|
||||||
|
});
|
||||||
|
return ok({ id: String(doc._id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProductPrice(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
priceId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const id = toObjectId(priceId);
|
||||||
|
const row = id
|
||||||
|
? await ProductPriceModel.findOne({
|
||||||
|
_id: id,
|
||||||
|
householdId: m.householdId,
|
||||||
|
deletedAt: null,
|
||||||
|
}).lean<PriceLean>()
|
||||||
|
: null;
|
||||||
|
if (!row) return fail(tr.common.notFound);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await ProductPriceModel.updateOne({ _id: row._id }, { $set: { deletedAt: new Date() } });
|
||||||
|
await writeAudit({
|
||||||
|
action: "price.delete",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: m.householdId,
|
||||||
|
targetType: "productPrice",
|
||||||
|
targetId: priceId,
|
||||||
|
diff: {
|
||||||
|
productId: String(row.productId),
|
||||||
|
storeId: String(row.storeId),
|
||||||
|
date: row.date,
|
||||||
|
priceMinor: row.priceMinor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { PriceProductOption };
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { getHouseholdActionContext } from "@/lib/dal";
|
||||||
|
import { productFormSchema } from "@/shared/schemas/product";
|
||||||
|
import { createProduct, setProductArchived, updateProduct } 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yeni (id boş) veya mevcut ürün. */
|
||||||
|
export async function saveProductAction(productId: unknown, input: unknown) {
|
||||||
|
if (productId) {
|
||||||
|
const id = idSchema.safeParse(productId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(productFormSchema, input, (u, h, d) => updateProduct(u, h, id.data, d));
|
||||||
|
}
|
||||||
|
return run(productFormSchema, input, createProduct);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setProductArchivedAction(productId: unknown, archived: unknown) {
|
||||||
|
const id = idSchema.safeParse(productId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.boolean(), archived, (u, h, a) => setProductArchived(u, h, id.data, a));
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"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 { AttachmentPicker } from "@/features/attachments/components/attachment-picker";
|
||||||
|
import type { AttachmentInfo } from "@/features/attachments/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { autoConvertible, packageInTypeUnit } from "@/shared/products";
|
||||||
|
import { formatQuantity, parseQuantity } from "@/shared/quantity";
|
||||||
|
import { quantityInput, STOCK_UNITS } from "@/shared/stock";
|
||||||
|
import { saveProductAction } from "../actions";
|
||||||
|
import type { ProductTypeOption, ProductView } from "../service";
|
||||||
|
|
||||||
|
const t = tr.products;
|
||||||
|
const CUSTOM = "__custom__";
|
||||||
|
type Errors = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
/** Yeni ürün / düzenle: marka, tanım, barkod, paket, gerekirse dönüşüm, görseller. */
|
||||||
|
export function ProductFormDialog({
|
||||||
|
product,
|
||||||
|
types,
|
||||||
|
defaultTypeId,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
product: ProductView | null;
|
||||||
|
types: ProductTypeOption[];
|
||||||
|
defaultTypeId?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const initialType = product?.productTypeId ?? defaultTypeId ?? "";
|
||||||
|
const typeUnitOf = (id: string) => types.find((x) => x.id === id)?.unit ?? "";
|
||||||
|
const unitChoices = (typeUnit: string) => [
|
||||||
|
...new Set([typeUnit, ...STOCK_UNITS].filter(Boolean)),
|
||||||
|
];
|
||||||
|
const initialUnit = product?.packageUnit ?? typeUnitOf(initialType) ?? "adet";
|
||||||
|
const known = unitChoices(typeUnitOf(initialType)).includes(initialUnit);
|
||||||
|
|
||||||
|
const [v, setV] = useState({
|
||||||
|
productTypeId: initialType,
|
||||||
|
brand: product?.brand ?? "",
|
||||||
|
name: product?.name ?? "",
|
||||||
|
barcode: product?.barcode ?? "",
|
||||||
|
packageQuantity: product ? quantityInput(product.packageQuantity) : "1",
|
||||||
|
unitChoice: known ? initialUnit || "adet" : CUSTOM,
|
||||||
|
customUnit: known ? "" : initialUnit,
|
||||||
|
typeQuantity: product?.typeQuantity ? quantityInput(product.typeQuantity) : "",
|
||||||
|
note: product?.note ?? "",
|
||||||
|
});
|
||||||
|
const [images, setImages] = useState<AttachmentInfo[]>(product?.images ?? []);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
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 typeUnit = typeUnitOf(v.productTypeId);
|
||||||
|
const packageUnit = v.unitChoice === CUSTOM ? v.customUnit.trim() : v.unitChoice;
|
||||||
|
const needsConversion =
|
||||||
|
Boolean(typeUnit && packageUnit) && !autoConvertible(packageUnit, typeUnit);
|
||||||
|
const pkgQty = parseQuantity(v.packageQuantity);
|
||||||
|
const pkgText = pkgQty ? formatQuantity(pkgQty, packageUnit) : `1 ${packageUnit}`;
|
||||||
|
const autoValue =
|
||||||
|
pkgQty && typeUnit && !needsConversion
|
||||||
|
? packageInTypeUnit({ packageQuantity: pkgQty, packageUnit, typeUnit, typeQuantity: null })
|
||||||
|
: null;
|
||||||
|
const f = t.fields;
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const r = await saveProductAction(product?.id ?? null, {
|
||||||
|
productTypeId: v.productTypeId,
|
||||||
|
brand: v.brand,
|
||||||
|
name: v.name,
|
||||||
|
barcode: v.barcode,
|
||||||
|
packageQuantity: v.packageQuantity,
|
||||||
|
packageUnit,
|
||||||
|
typeQuantity: needsConversion ? v.typeQuantity : "",
|
||||||
|
note: v.note,
|
||||||
|
imageIds: images.map((i) => i.id),
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.saved);
|
||||||
|
onClose();
|
||||||
|
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={product ? t.editTitle : t.createTitle}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="product-variant-form" disabled={busy || uploading}>
|
||||||
|
{tr.common.save}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="product-variant-form" onSubmit={submit} noValidate>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={Boolean(errors.productTypeId)}>
|
||||||
|
<FieldLabel htmlFor="pr-type">{f.productType}</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={v.productTypeId}
|
||||||
|
onValueChange={(x) => {
|
||||||
|
set("productTypeId", x);
|
||||||
|
if (!product && v.unitChoice !== CUSTOM) set("unitChoice", typeUnitOf(x) || "adet");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="pr-type"
|
||||||
|
className="w-full"
|
||||||
|
aria-invalid={Boolean(errors.productTypeId)}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={t.validation.productType} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{types.map((x) => (
|
||||||
|
<SelectItem key={x.id} value={x.id}>
|
||||||
|
{x.name} <span className="text-xs text-muted-foreground">({x.unit})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.productTypeId ? <FieldError>{errors.productTypeId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field data-invalid={Boolean(errors.brand)}>
|
||||||
|
<FieldLabel htmlFor="pr-brand">{f.brand}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="pr-brand"
|
||||||
|
value={v.brand}
|
||||||
|
placeholder={f.brandPlaceholder}
|
||||||
|
onChange={(e) => set("brand", e.target.value)}
|
||||||
|
aria-invalid={Boolean(errors.brand)}
|
||||||
|
autoFocus={!product}
|
||||||
|
/>
|
||||||
|
{errors.brand ? <FieldError>{errors.brand}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="pr-name">{f.name}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="pr-name"
|
||||||
|
value={v.name}
|
||||||
|
placeholder={f.namePlaceholder}
|
||||||
|
onChange={(e) => set("name", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field data-invalid={Boolean(errors.packageQuantity)}>
|
||||||
|
<FieldLabel htmlFor="pr-qty">{f.packageQuantity}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="pr-qty"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={v.packageQuantity}
|
||||||
|
onChange={(e) => set("packageQuantity", e.target.value)}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.packageQuantity)}
|
||||||
|
/>
|
||||||
|
{errors.packageQuantity ? <FieldError>{errors.packageQuantity}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={Boolean(errors.packageUnit)}>
|
||||||
|
<FieldLabel htmlFor="pr-unit">{f.packageUnit}</FieldLabel>
|
||||||
|
<Select value={v.unitChoice} onValueChange={(x) => set("unitChoice", x)}>
|
||||||
|
<SelectTrigger id="pr-unit" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{v.unitChoice === CUSTOM ? tr.stock.fields.customUnit : v.unitChoice}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{unitChoices(typeUnit).map((u) => (
|
||||||
|
<SelectItem key={u} value={u}>
|
||||||
|
{u}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value={CUSTOM}>{tr.stock.fields.customUnitOption}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{v.unitChoice === CUSTOM ? (
|
||||||
|
<Input
|
||||||
|
value={v.customUnit}
|
||||||
|
placeholder={tr.stock.fields.customUnitPlaceholder}
|
||||||
|
aria-label={tr.stock.fields.customUnit}
|
||||||
|
onChange={(e) => set("customUnit", e.target.value)}
|
||||||
|
aria-invalid={Boolean(errors.packageUnit)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{errors.packageUnit ? <FieldError>{errors.packageUnit}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{needsConversion ? (
|
||||||
|
<Field data-invalid={Boolean(errors.typeQuantity)}>
|
||||||
|
<FieldLabel htmlFor="pr-conv">{f.typeQuantity(typeUnit)}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="pr-conv"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={v.typeQuantity}
|
||||||
|
onChange={(e) => set("typeQuantity", e.target.value)}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.typeQuantity)}
|
||||||
|
/>
|
||||||
|
<FieldDescription>{f.typeQuantityHint(pkgText, typeUnit)}</FieldDescription>
|
||||||
|
{errors.typeQuantity ? <FieldError>{errors.typeQuantity}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
) : autoValue !== null && packageUnit !== typeUnit ? (
|
||||||
|
<FieldDescription className="-mt-2">
|
||||||
|
{f.autoConversion(formatQuantity(autoValue, typeUnit))}
|
||||||
|
</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
<Field data-invalid={Boolean(errors.barcode)}>
|
||||||
|
<FieldLabel htmlFor="pr-barcode">{f.barcode}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="pr-barcode"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={v.barcode}
|
||||||
|
onChange={(e) => set("barcode", e.target.value)}
|
||||||
|
className="tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.barcode)}
|
||||||
|
/>
|
||||||
|
<FieldDescription>{f.barcodeHint}</FieldDescription>
|
||||||
|
{errors.barcode ? <FieldError>{errors.barcode}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={Boolean(errors.imageIds)}>
|
||||||
|
<FieldLabel>{f.images}</FieldLabel>
|
||||||
|
<AttachmentPicker
|
||||||
|
value={images}
|
||||||
|
onChange={setImages}
|
||||||
|
allowedKinds={["image"]}
|
||||||
|
onBusyChange={setUploading}
|
||||||
|
addLabel={f.addImage}
|
||||||
|
hint={f.imagesHint}
|
||||||
|
/>
|
||||||
|
{errors.imageIds ? <FieldError>{errors.imageIds}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="pr-note">{f.note}</FieldLabel>
|
||||||
|
<Input id="pr-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,188 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
ArchiveRestore,
|
||||||
|
AlertTriangle,
|
||||||
|
EllipsisVertical,
|
||||||
|
ImageIcon,
|
||||||
|
Pencil,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
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 {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { AttachmentThumb } from "@/features/attachments/components/attachment-strip";
|
||||||
|
import { AttachmentViewer } from "@/features/attachments/components/attachment-viewer";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { setProductArchivedAction } from "../actions";
|
||||||
|
import type { ProductTypeOption, ProductView } from "../service";
|
||||||
|
import { ProductFormDialog } from "./product-dialog";
|
||||||
|
|
||||||
|
const t = tr.products;
|
||||||
|
|
||||||
|
/** Ürün satırları: görsel, marka/tanım, paket, barkod; düzenle/arşivle. */
|
||||||
|
export function ProductList({
|
||||||
|
products,
|
||||||
|
types,
|
||||||
|
canWrite,
|
||||||
|
showType = false,
|
||||||
|
}: {
|
||||||
|
products: ProductView[];
|
||||||
|
types: ProductTypeOption[];
|
||||||
|
canWrite: boolean;
|
||||||
|
/** Katalogda ürün tipi bağlantısı. */
|
||||||
|
showType?: boolean;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState<ProductView | null>(null);
|
||||||
|
const [archiving, setArchiving] = useState<ProductView | null>(null);
|
||||||
|
const [viewer, setViewer] = useState<ProductView | null>(null);
|
||||||
|
|
||||||
|
async function setArchived(p: ProductView, archived: boolean) {
|
||||||
|
const r = await setProductArchivedAction(p.id, archived);
|
||||||
|
if (r.ok) toast.success(archived ? t.toast.archived : t.toast.unarchived);
|
||||||
|
else toast.error(r.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{products.map((p) => {
|
||||||
|
const cover = p.images[0];
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={p.id}
|
||||||
|
className={cn("flex items-center gap-3 p-3", p.archived && "opacity-60")}
|
||||||
|
>
|
||||||
|
{cover ? (
|
||||||
|
<AttachmentThumb item={cover} onClick={() => setViewer(p)} className="size-14" />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="flex size-14 shrink-0 items-center justify-center rounded-md border border-dashed bg-muted/40"
|
||||||
|
>
|
||||||
|
<ImageIcon className="size-5 text-muted-foreground/60" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">
|
||||||
|
<Link href={`/stok/urunler/${p.id}`} className="hover:underline">
|
||||||
|
{p.label}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-sm text-muted-foreground tabular-nums">
|
||||||
|
{p.packageLabel}
|
||||||
|
{showType ? (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
<Link href={`/stok/${p.productTypeId}`} className="hover:underline">
|
||||||
|
{p.productTypeName}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
{p.barcode ? <span className="tabular-nums">{t.barcode(p.barcode)}</span> : null}
|
||||||
|
{p.images.length > 1 ? <span>{t.images(p.images.length)}</span> : null}
|
||||||
|
{p.archived ? <Badge variant="secondary">{t.archived}</Badge> : null}
|
||||||
|
{p.conversionMissing ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1 border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||||||
|
title={t.conversionMissingHint}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="size-3" aria-hidden />
|
||||||
|
{t.conversionMissing}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canWrite ? (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`${p.label}: ${t.actions.more}`}
|
||||||
|
>
|
||||||
|
<EllipsisVertical />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem disabled={p.archived} onSelect={() => setEditing(p)}>
|
||||||
|
<Pencil />
|
||||||
|
{t.actions.edit}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{p.archived ? (
|
||||||
|
<DropdownMenuItem onSelect={() => setArchived(p, false)}>
|
||||||
|
<ArchiveRestore />
|
||||||
|
{t.actions.unarchive}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem variant="destructive" onSelect={() => setArchiving(p)}>
|
||||||
|
<Archive />
|
||||||
|
{t.actions.archive}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<ProductFormDialog product={editing} types={types} onClose={() => setEditing(null)} />
|
||||||
|
) : null}
|
||||||
|
<AttachmentViewer
|
||||||
|
items={viewer?.images ?? []}
|
||||||
|
startIndex={0}
|
||||||
|
open={viewer !== null}
|
||||||
|
onOpenChange={(o) => !o && setViewer(null)}
|
||||||
|
/>
|
||||||
|
<AlertDialog open={archiving !== null} onOpenChange={(o) => !o && setArchiving(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t.archiveTitle}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{archiving ? t.archiveDescription(archiving.label) : null}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
const p = archiving;
|
||||||
|
setArchiving(null);
|
||||||
|
if (p) await setArchived(p, true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.actions.archive}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft, Plus, Search } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { foldTr } from "@/shared/search";
|
||||||
|
import type { ProductTypeOption, ProductView } from "../service";
|
||||||
|
import { ProductFormDialog } from "./product-dialog";
|
||||||
|
import { ProductList } from "./product-list";
|
||||||
|
|
||||||
|
const t = tr.products;
|
||||||
|
|
||||||
|
/** /stok/urunler: tüm ürünler ürün tipine göre gruplu; marka, tanım veya barkodla arama. */
|
||||||
|
export function ProductsCatalog({
|
||||||
|
products,
|
||||||
|
types,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
products: ProductView[];
|
||||||
|
types: ProductTypeOption[];
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const q = foldTr(query.trim());
|
||||||
|
const digits = query.replace(/\s/g, "");
|
||||||
|
const match = (p: ProductView) =>
|
||||||
|
(showArchived || !p.archived) &&
|
||||||
|
(!q ||
|
||||||
|
foldTr(`${p.label} ${p.productTypeName}`).includes(q) ||
|
||||||
|
(/^\d+$/.test(digits) && (p.barcode ?? "").includes(digits)));
|
||||||
|
const by = new Map<string, { name: string; items: ProductView[] }>();
|
||||||
|
for (const p of products.filter(match)) {
|
||||||
|
const g = by.get(p.productTypeId) ?? { name: p.productTypeName, items: [] };
|
||||||
|
g.items.push(p);
|
||||||
|
by.set(p.productTypeId, g);
|
||||||
|
}
|
||||||
|
return [...by.entries()];
|
||||||
|
}, [products, query, showArchived]);
|
||||||
|
|
||||||
|
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}
|
||||||
|
actions={
|
||||||
|
canWrite && types.length ? (
|
||||||
|
<Button size="sm" onClick={() => setCreating(true)}>
|
||||||
|
<Plus />
|
||||||
|
{t.new}
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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.search}
|
||||||
|
aria-label={t.search}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{products.some((p) => p.archived) ? (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox checked={showArchived} onCheckedChange={(x) => setShowArchived(x === true)} />
|
||||||
|
{t.showArchived}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t.empty}
|
||||||
|
</p>
|
||||||
|
) : groups.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.noMatch}</p>
|
||||||
|
) : (
|
||||||
|
groups.map(([typeId, g]) => (
|
||||||
|
<section key={typeId} className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground">
|
||||||
|
<Link href={`/stok/${typeId}`} className="hover:underline">
|
||||||
|
{g.name}
|
||||||
|
</Link>
|
||||||
|
</h2>
|
||||||
|
<ProductList products={g.items} types={types} canWrite={canWrite} />
|
||||||
|
</section>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{creating ? (
|
||||||
|
<ProductFormDialog product={null} types={types} onClose={() => setCreating(false)} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import type { ProductTypeOption, ProductView } from "../service";
|
||||||
|
import { ProductFormDialog } from "./product-dialog";
|
||||||
|
import { ProductList } from "./product-list";
|
||||||
|
|
||||||
|
const t = tr.products;
|
||||||
|
|
||||||
|
/** /stok/:id içinde: bu ürün tipinin markaları ve paketleri. */
|
||||||
|
export function ProductsSection({
|
||||||
|
productTypeId,
|
||||||
|
products,
|
||||||
|
types,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
productTypeId: string;
|
||||||
|
products: ProductView[];
|
||||||
|
types: ProductTypeOption[];
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-2" aria-labelledby="products-title">
|
||||||
|
<div className="flex items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 id="products-title" className="text-base font-medium">
|
||||||
|
{t.sectionTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">{t.sectionDescription}</p>
|
||||||
|
</div>
|
||||||
|
{canWrite && types.some((x) => x.id === productTypeId) ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setCreating(true)}>
|
||||||
|
<Plus />
|
||||||
|
{t.new}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t.emptyType}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ProductList products={products} types={types} canWrite={canWrite} />
|
||||||
|
)}
|
||||||
|
{creating ? (
|
||||||
|
<ProductFormDialog
|
||||||
|
product={null}
|
||||||
|
types={types}
|
||||||
|
defaultTypeId={productTypeId}
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { readAttachmentForUser, uploadAttachment } from "@/features/attachments/service";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { AttachmentModel } from "@/models/attachment";
|
||||||
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { productFormSchema, type ProductFormInput } from "@/shared/schemas/product";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import {
|
||||||
|
createProduct,
|
||||||
|
listProducts,
|
||||||
|
listProductTypeOptions,
|
||||||
|
setProductArchived,
|
||||||
|
updateProduct,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let emre = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await rm(env().UPLOAD_DIR, { recursive: true, force: true });
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
emre = ids.user("emre");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await teardownDb();
|
||||||
|
await rm(env().UPLOAD_DIR, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeId = (ref: string) =>
|
||||||
|
ProductTypeModel.findOne({ seedRef: ref })
|
||||||
|
.lean()
|
||||||
|
.then((x) => String(x!._id));
|
||||||
|
|
||||||
|
const png = () =>
|
||||||
|
sharp({ create: { width: 40, height: 30, channels: 3, background: "#cc6633" } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
async function form(over: Partial<ProductFormInput> = {}) {
|
||||||
|
return productFormSchema.parse({
|
||||||
|
productTypeId: await typeId("toz-deterjan"),
|
||||||
|
brand: "Persil",
|
||||||
|
name: "Matik",
|
||||||
|
barcode: "",
|
||||||
|
packageQuantity: "4,5",
|
||||||
|
packageUnit: "kg",
|
||||||
|
typeQuantity: "30",
|
||||||
|
note: "",
|
||||||
|
imageIds: [],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ürünler (marka/paket)", () => {
|
||||||
|
it("seed: dönüşümler ve paket açıklaması; izolasyon", async () => {
|
||||||
|
const all = (await listProducts(ayse, cakir))!;
|
||||||
|
expect(all.products).toHaveLength(22);
|
||||||
|
const ariel = all.products.find((p) => p.brand === "Ariel")!;
|
||||||
|
expect(ariel).toMatchObject({
|
||||||
|
productTypeName: "Toz Deterjan",
|
||||||
|
typeUnit: "yıkama",
|
||||||
|
inTypeUnit: 33_000,
|
||||||
|
packageLabel: "5 kg · 33 yıkama",
|
||||||
|
conversionMissing: false,
|
||||||
|
});
|
||||||
|
const icim = all.products.find((p) => p.brand === "İçim")!;
|
||||||
|
expect(icim).toMatchObject({ typeQuantity: null, inTypeUnit: 500 });
|
||||||
|
const peynir = (await listProducts(ayse, cakir, {
|
||||||
|
productTypeId: await typeId("beyaz-peynir"),
|
||||||
|
}))!;
|
||||||
|
expect(peynir.products.map((p) => p.packageLabel)).toEqual(["500 g · 0,5 kg"]);
|
||||||
|
expect(await listProducts(fatma, cakir)).toBeNull();
|
||||||
|
expect((await listProductTypeOptions(ayse, cakir))!.length).toBe(19);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dönüşüm zorunlu; kendiliğinden çevrilebilende yok sayılır; barkod tek", async () => {
|
||||||
|
const missing = await createProduct(ayse, cakir, await form({ typeQuantity: "" }));
|
||||||
|
expect(missing).toMatchObject({ ok: false, fieldErrors: { typeQuantity: expect.any(String) } });
|
||||||
|
|
||||||
|
const ok = await createProduct(ayse, cakir, await form({ barcode: "8690 0000 0099 9" }));
|
||||||
|
expect(ok.ok).toBe(true);
|
||||||
|
const sut = await createProduct(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({
|
||||||
|
productTypeId: await typeId("sut"),
|
||||||
|
brand: "Torku",
|
||||||
|
packageQuantity: "1000",
|
||||||
|
packageUnit: "ml",
|
||||||
|
typeQuantity: "7",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(sut.ok).toBe(true);
|
||||||
|
const list = (await listProducts(ayse, cakir))!.products;
|
||||||
|
expect(list.find((p) => p.brand === "Persil")).toMatchObject({
|
||||||
|
barcode: "8690000000999",
|
||||||
|
inTypeUnit: 30_000,
|
||||||
|
});
|
||||||
|
expect(list.find((p) => p.brand === "Torku")).toMatchObject({
|
||||||
|
typeQuantity: null,
|
||||||
|
inTypeUnit: 1_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dup = await createProduct(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({ brand: "Başka", barcode: "8690000000999" }),
|
||||||
|
);
|
||||||
|
expect(dup).toMatchObject({ ok: false, fieldErrors: { barcode: expect.any(String) } });
|
||||||
|
expect(productFormSchema.safeParse({ ...(await form()), barcode: "12a" }).success).toBe(false);
|
||||||
|
expect((await createProduct(zeynep, cakir, await form({ brand: "İzleyici" }))).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("görseller: bağlanır, sıralanır, çıkarılır; okuma hane üyeleriyle sınırlı", async () => {
|
||||||
|
const a1 = await uploadAttachment(ayse, cakir, { name: "on.png", bytes: await png() });
|
||||||
|
const a2 = await uploadAttachment(ayse, cakir, { name: "arka.png", bytes: await png() });
|
||||||
|
const id1 = a1.ok ? a1.data.id : "";
|
||||||
|
const id2 = a2.ok ? a2.data.id : "";
|
||||||
|
const r = await createProduct(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({ brand: "Peros", imageIds: [id2, id1] }),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const pid = r.ok ? r.data.id : "";
|
||||||
|
let p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!;
|
||||||
|
expect(p.images.map((i) => i.id)).toEqual([id2, id1]);
|
||||||
|
|
||||||
|
// Aynı hanenin başka üyesi görür; başka hane görmez.
|
||||||
|
expect(await readAttachmentForUser(emre, id1, "thumb")).not.toBeNull();
|
||||||
|
expect(await readAttachmentForUser(fatma, id1, "full")).toBeNull();
|
||||||
|
|
||||||
|
// Başkasının yüklediği bekleyen görsel bağlanamaz.
|
||||||
|
const foreign = await uploadAttachment(emre, cakir, { name: "x.png", bytes: await png() });
|
||||||
|
const bad = await updateProduct(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
pid,
|
||||||
|
await form({ brand: "Peros", imageIds: [id1, foreign.ok ? foreign.data.id : ""] }),
|
||||||
|
);
|
||||||
|
expect(bad.ok).toBe(false);
|
||||||
|
p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!;
|
||||||
|
expect(p.images).toHaveLength(2);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await updateProduct(ayse, cakir, pid, await form({ brand: "Peros", imageIds: [id1] }))).ok,
|
||||||
|
).toBe(true);
|
||||||
|
expect(await AttachmentModel.exists({ _id: id2, deletedAt: { $ne: null } })).toBeTruthy();
|
||||||
|
p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!;
|
||||||
|
expect(p.images.map((i) => i.id)).toEqual([id1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("arşiv: listeden çıkar, düzenlenemez, barkod çakışırsa geri alınamaz", async () => {
|
||||||
|
const r = await createProduct(ayse, cakir, await form({ brand: "Arşiv", barcode: "12345678" }));
|
||||||
|
const id = r.ok ? r.data.id : "";
|
||||||
|
expect((await setProductArchived(ayse, cakir, id, true)).ok).toBe(true);
|
||||||
|
expect((await listProducts(ayse, cakir))!.products.some((p) => p.id === id)).toBe(false);
|
||||||
|
expect(
|
||||||
|
(await listProducts(ayse, cakir, { includeArchived: true }))!.products.find(
|
||||||
|
(p) => p.id === id,
|
||||||
|
)!.archived,
|
||||||
|
).toBe(true);
|
||||||
|
expect((await updateProduct(ayse, cakir, id, await form({ brand: "Arşiv" }))).ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
(await createProduct(ayse, cakir, await form({ brand: "Yeni", barcode: "12345678" }))).ok,
|
||||||
|
).toBe(true);
|
||||||
|
expect((await setProductArchived(ayse, cakir, id, false)).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import {
|
||||||
|
syncOwnerAttachments,
|
||||||
|
toAttachmentInfo,
|
||||||
|
type AttachmentInfo,
|
||||||
|
} from "@/features/attachments/service";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { resolveMembership, type Membership } from "@/features/households/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { writeAudit } from "@/lib/audit";
|
||||||
|
import { connectMongoose } from "@/lib/db/mongoose";
|
||||||
|
import { toObjectId } from "@/lib/object-id";
|
||||||
|
import { AttachmentModel } from "@/models/attachment";
|
||||||
|
import { ProductModel } from "@/models/product";
|
||||||
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { autoConvertible, describePackage, packageInTypeUnit } from "@/shared/products";
|
||||||
|
import type { ProductForm } from "@/shared/schemas/product";
|
||||||
|
import { compareTr, foldTr } from "@/shared/search";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ürünler (marka/varyant, Bölüm 6.11). Hane bazlı, hane içinde ortak. Okuma: viewer; yazma:
|
||||||
|
* member. Paket birimi ürün tipinin birimine kendiliğinden çevrilemiyorsa dönüşüm zorunludur.
|
||||||
|
* Görseller ek dosya altyapısıyla (ownerType "product", yalnızca görsel).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.products;
|
||||||
|
|
||||||
|
type ProductLean = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
productTypeId: Types.ObjectId;
|
||||||
|
brand: string;
|
||||||
|
name: string;
|
||||||
|
barcode: string | null;
|
||||||
|
packageQuantity: number;
|
||||||
|
packageUnit: string;
|
||||||
|
typeQuantity: number | null;
|
||||||
|
note: string;
|
||||||
|
imageCount: number;
|
||||||
|
archivedAt: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TypeLean = { _id: Types.ObjectId; name: string; unit: string; archivedAt: Date | null };
|
||||||
|
|
||||||
|
export type ProductView = {
|
||||||
|
id: string;
|
||||||
|
productTypeId: string;
|
||||||
|
productTypeName: string;
|
||||||
|
typeUnit: string;
|
||||||
|
brand: string;
|
||||||
|
name: string;
|
||||||
|
/** "Ariel Matik Dağ Esintisi". */
|
||||||
|
label: string;
|
||||||
|
barcode: string | null;
|
||||||
|
packageQuantity: number;
|
||||||
|
packageUnit: string;
|
||||||
|
/** Elle girilen dönüşüm (kendiliğinden çevrilebiliyorsa null). */
|
||||||
|
typeQuantity: number | null;
|
||||||
|
/** Bir paketin ürün tipi birimindeki karşılığı (çevrilemiyorsa null). */
|
||||||
|
inTypeUnit: number | null;
|
||||||
|
/** Ürün tipinin birimi sonradan değişti ve dönüşüm yok: fiyat karşılaştırmasına katılamaz. */
|
||||||
|
conversionMissing: boolean;
|
||||||
|
/** "5 kg · 50 yıkama". */
|
||||||
|
packageLabel: string;
|
||||||
|
note: string;
|
||||||
|
archived: boolean;
|
||||||
|
images: AttachmentInfo[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductTypeOption = { id: string; name: string; unit: string };
|
||||||
|
|
||||||
|
export const productLabel = (p: { brand: string; name: string }) =>
|
||||||
|
p.name ? `${p.brand} ${p.name}` : p.brand;
|
||||||
|
|
||||||
|
function toView(p: ProductLean, type: TypeLean | undefined, images: AttachmentInfo[]): ProductView {
|
||||||
|
const typeUnit = type?.unit ?? "";
|
||||||
|
const conv = {
|
||||||
|
packageQuantity: p.packageQuantity,
|
||||||
|
packageUnit: p.packageUnit,
|
||||||
|
typeUnit,
|
||||||
|
typeQuantity: p.typeQuantity,
|
||||||
|
};
|
||||||
|
const inTypeUnit = packageInTypeUnit(conv);
|
||||||
|
return {
|
||||||
|
id: String(p._id),
|
||||||
|
productTypeId: String(p.productTypeId),
|
||||||
|
productTypeName: type?.name ?? "",
|
||||||
|
typeUnit,
|
||||||
|
brand: p.brand,
|
||||||
|
name: p.name,
|
||||||
|
label: productLabel(p),
|
||||||
|
barcode: p.barcode,
|
||||||
|
packageQuantity: p.packageQuantity,
|
||||||
|
packageUnit: p.packageUnit,
|
||||||
|
typeQuantity: p.typeQuantity,
|
||||||
|
inTypeUnit,
|
||||||
|
conversionMissing: inTypeUnit === null,
|
||||||
|
packageLabel: describePackage(conv),
|
||||||
|
note: p.note,
|
||||||
|
archived: Boolean(p.archivedAt),
|
||||||
|
images,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadImages(householdId: Types.ObjectId, productIds: Types.ObjectId[]) {
|
||||||
|
const rows = await AttachmentModel.find({
|
||||||
|
householdId,
|
||||||
|
ownerType: "product",
|
||||||
|
ownerId: { $in: productIds },
|
||||||
|
deletedAt: null,
|
||||||
|
})
|
||||||
|
.sort({ order: 1, createdAt: 1 })
|
||||||
|
.lean();
|
||||||
|
const by = new Map<string, AttachmentInfo[]>();
|
||||||
|
for (const a of rows) {
|
||||||
|
const key = String(a.ownerId);
|
||||||
|
by.set(key, [
|
||||||
|
...(by.get(key) ?? []),
|
||||||
|
toAttachmentInfo(a as Parameters<typeof toAttachmentInfo>[0]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return by;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ürün listesi: ürün tipine göre (verilirse) veya tüm katalog. Sıra: ürün tipi adı, marka,
|
||||||
|
* tanım. Arşivliler `includeArchived` ile.
|
||||||
|
*/
|
||||||
|
export async function listProducts(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
opts: { productTypeId?: string; includeArchived?: boolean } = {},
|
||||||
|
): Promise<{ products: ProductView[]; canWrite: boolean } | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const typeId = opts.productTypeId ? toObjectId(opts.productTypeId) : null;
|
||||||
|
if (opts.productTypeId && !typeId) return null;
|
||||||
|
const rows = await ProductModel.find({
|
||||||
|
householdId: m.householdId,
|
||||||
|
...(typeId ? { productTypeId: typeId } : {}),
|
||||||
|
...(opts.includeArchived ? {} : { archivedAt: null }),
|
||||||
|
}).lean<ProductLean[]>();
|
||||||
|
const types = await ProductTypeModel.find({
|
||||||
|
householdId: m.householdId,
|
||||||
|
_id: {
|
||||||
|
$in: [...new Set(rows.map((r) => String(r.productTypeId)))].map(
|
||||||
|
(id) => new Types.ObjectId(id),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.select({ name: 1, unit: 1, archivedAt: 1 })
|
||||||
|
.lean<TypeLean[]>();
|
||||||
|
const typeById = new Map(types.map((x) => [String(x._id), x]));
|
||||||
|
const images = await loadImages(
|
||||||
|
m.householdId,
|
||||||
|
rows.filter((r) => r.imageCount > 0).map((r) => r._id),
|
||||||
|
);
|
||||||
|
const products = rows
|
||||||
|
.map((r) => toView(r, typeById.get(String(r.productTypeId)), images.get(String(r._id)) ?? []))
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
compareTr(a.productTypeName, b.productTypeName) ||
|
||||||
|
compareTr(a.brand, b.brand) ||
|
||||||
|
compareTr(a.name, b.name),
|
||||||
|
);
|
||||||
|
return { products, canWrite: hasHouseholdRole(m.role, "member") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ürün formu için aktif ürün tipleri (ada göre). */
|
||||||
|
export async function listProductTypeOptions(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<ProductTypeOption[] | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const rows = await ProductTypeModel.find({ householdId: m.householdId, archivedAt: null })
|
||||||
|
.select({ name: 1, unit: 1 })
|
||||||
|
.lean<TypeLean[]>();
|
||||||
|
return rows
|
||||||
|
.map((r) => ({ id: String(r._id), name: r.name, unit: r.unit }))
|
||||||
|
.sort((a, b) => compareTr(a.name, b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ürün tipi, dönüşüm ve barkod denetimi; geçerliyse kaydedilecek dönüşümü döner. */
|
||||||
|
async function checkInput(
|
||||||
|
m: Membership,
|
||||||
|
input: ProductForm,
|
||||||
|
exceptId?: Types.ObjectId,
|
||||||
|
): Promise<{ error: ActionResult<never> } | { error: null; typeQuantity: number | null }> {
|
||||||
|
const type = await ProductTypeModel.findOne({
|
||||||
|
_id: new Types.ObjectId(input.productTypeId),
|
||||||
|
householdId: m.householdId,
|
||||||
|
}).lean<TypeLean>();
|
||||||
|
if (!type || type.archivedAt)
|
||||||
|
return { error: fail(t.validation.productType, { productTypeId: t.validation.productType }) };
|
||||||
|
const auto = autoConvertible(input.packageUnit, type.unit);
|
||||||
|
if (!auto && !input.typeQuantity) {
|
||||||
|
const msg = t.validation.conversionRequired(input.packageUnit, type.unit);
|
||||||
|
return { error: fail(msg, { typeQuantity: msg }) };
|
||||||
|
}
|
||||||
|
if (input.barcode) {
|
||||||
|
const dup = await ProductModel.exists({
|
||||||
|
householdId: m.householdId,
|
||||||
|
barcode: input.barcode,
|
||||||
|
archivedAt: null,
|
||||||
|
...(exceptId ? { _id: { $ne: exceptId } } : {}),
|
||||||
|
});
|
||||||
|
if (dup)
|
||||||
|
return { error: fail(t.validation.barcodeTaken, { barcode: t.validation.barcodeTaken }) };
|
||||||
|
}
|
||||||
|
return { error: null, typeQuantity: auto ? null : input.typeQuantity };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ek senkronizasyonu başarısızsa transaction'ı geri almak için fırlatılır. */
|
||||||
|
class ImageError extends Error {
|
||||||
|
constructor(readonly result: ActionResult<never>) {
|
||||||
|
super(result.ok ? "" : result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(
|
||||||
|
m: Membership,
|
||||||
|
actorId: string,
|
||||||
|
input: ProductForm,
|
||||||
|
typeQuantity: number | null,
|
||||||
|
existing: Types.ObjectId | null,
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const fields = {
|
||||||
|
productTypeId: new Types.ObjectId(input.productTypeId),
|
||||||
|
brand: input.brand,
|
||||||
|
name: input.name,
|
||||||
|
searchKey: foldTr(`${input.brand} ${input.name}`),
|
||||||
|
barcode: input.barcode,
|
||||||
|
packageQuantity: input.packageQuantity,
|
||||||
|
packageUnit: input.packageUnit,
|
||||||
|
typeQuantity,
|
||||||
|
note: input.note,
|
||||||
|
updatedBy: actor,
|
||||||
|
};
|
||||||
|
const mongoose = await connectMongoose();
|
||||||
|
let id = existing;
|
||||||
|
try {
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
if (existing) {
|
||||||
|
await ProductModel.updateOne(
|
||||||
|
{ _id: existing, householdId: m.householdId },
|
||||||
|
{ $set: fields },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const [doc] = await ProductModel.create(
|
||||||
|
[{ householdId: m.householdId, ...fields, createdBy: actor }],
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
id = doc!._id;
|
||||||
|
}
|
||||||
|
const synced = await syncOwnerAttachments({
|
||||||
|
householdId: m.householdId,
|
||||||
|
actorId,
|
||||||
|
ownerType: "product",
|
||||||
|
ownerId: id!,
|
||||||
|
ids: input.imageIds,
|
||||||
|
allowedKinds: ["image"],
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
if (!synced.ok) throw new ImageError(synced);
|
||||||
|
await ProductModel.updateOne(
|
||||||
|
{ _id: id!, householdId: m.householdId },
|
||||||
|
{ $set: { imageCount: synced.data } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ImageError) return error.result;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return ok({ id: String(id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProduct(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: ProductForm,
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const c = await checkInput(m, input);
|
||||||
|
if (c.error) return c.error;
|
||||||
|
return save(m, actorId, input, c.typeQuantity, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProduct(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
productId: string,
|
||||||
|
input: ProductForm,
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const id = toObjectId(productId);
|
||||||
|
const current = id
|
||||||
|
? await ProductModel.findOne({ _id: id, householdId: m.householdId }).lean<ProductLean>()
|
||||||
|
: null;
|
||||||
|
if (!current) return fail(tr.common.notFound);
|
||||||
|
if (current.archivedAt) return fail(t.validation.archived);
|
||||||
|
const c = await checkInput(m, input, current._id);
|
||||||
|
if (c.error) return c.error;
|
||||||
|
return save(m, actorId, input, c.typeQuantity, current._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setProductArchived(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
productId: string,
|
||||||
|
archived: boolean,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const id = toObjectId(productId);
|
||||||
|
const current = id
|
||||||
|
? await ProductModel.findOne({ _id: id, householdId: m.householdId }).lean<ProductLean>()
|
||||||
|
: null;
|
||||||
|
if (!current) return fail(tr.common.notFound);
|
||||||
|
if (!archived) {
|
||||||
|
const type = await ProductTypeModel.exists({
|
||||||
|
_id: current.productTypeId,
|
||||||
|
householdId: m.householdId,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
if (!type) return fail(t.validation.productType);
|
||||||
|
if (
|
||||||
|
current.barcode &&
|
||||||
|
(await ProductModel.exists({
|
||||||
|
householdId: m.householdId,
|
||||||
|
barcode: current.barcode,
|
||||||
|
archivedAt: null,
|
||||||
|
_id: { $ne: current._id },
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return fail(t.validation.barcodeTaken);
|
||||||
|
}
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await ProductModel.updateOne(
|
||||||
|
{ _id: current._id, householdId: m.householdId },
|
||||||
|
{ $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } },
|
||||||
|
);
|
||||||
|
await writeAudit({
|
||||||
|
action: archived ? "product.archive" : "product.unarchive",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: m.householdId,
|
||||||
|
targetType: "product",
|
||||||
|
targetId: productId,
|
||||||
|
diff: { label: productLabel(current) },
|
||||||
|
});
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { getHouseholdActionContext } from "@/lib/dal";
|
||||||
|
import {
|
||||||
|
shoppingCompletionSchema,
|
||||||
|
shoppingItemFormSchema,
|
||||||
|
shoppingItemUpdateSchema,
|
||||||
|
shoppingListFormSchema,
|
||||||
|
} from "@/shared/schemas/shopping";
|
||||||
|
import { completeShopping } from "./completion";
|
||||||
|
import {
|
||||||
|
addShoppingItem,
|
||||||
|
approveSuggestions,
|
||||||
|
createShoppingList,
|
||||||
|
deleteShoppingList,
|
||||||
|
dismissSuggestion,
|
||||||
|
moveShoppingItem,
|
||||||
|
removeShoppingItem,
|
||||||
|
setShoppingItemChecked,
|
||||||
|
updateShoppingItem,
|
||||||
|
updateShoppingList,
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = (...keys: string[]) => z.object(Object.fromEntries(keys.map((k) => [k, idSchema])));
|
||||||
|
|
||||||
|
export async function saveShoppingListAction(listId: string | null, input: unknown) {
|
||||||
|
if (listId === null)
|
||||||
|
return run(shoppingListFormSchema, input, (u, h, d) => createShoppingList(u, h, d));
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(shoppingListFormSchema, input, (u, h, d) => updateShoppingList(u, h, id.data, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteShoppingListAction(listId: unknown) {
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => deleteShoppingList(u, h, id.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addShoppingItemAction(listId: unknown, input: unknown) {
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(shoppingItemFormSchema, input, (u, h, d) => addShoppingItem(u, h, id.data, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateShoppingItemAction(listId: unknown, itemId: unknown, input: unknown) {
|
||||||
|
const ref = ids("listId", "itemId").safeParse({ listId, itemId });
|
||||||
|
if (!ref.success) return fail(tr.common.notFound);
|
||||||
|
return run(shoppingItemUpdateSchema, input, (u, h, d) =>
|
||||||
|
updateShoppingItem(u, h, ref.data.listId!, ref.data.itemId!, d),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeShoppingItemAction(listId: unknown, itemId: unknown) {
|
||||||
|
const ref = ids("listId", "itemId").safeParse({ listId, itemId });
|
||||||
|
if (!ref.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) =>
|
||||||
|
removeShoppingItem(u, h, ref.data.listId!, ref.data.itemId!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setShoppingItemCheckedAction(
|
||||||
|
listId: unknown,
|
||||||
|
itemId: unknown,
|
||||||
|
checked: unknown,
|
||||||
|
) {
|
||||||
|
const ref = ids("listId", "itemId").safeParse({ listId, itemId });
|
||||||
|
if (!ref.success || typeof checked !== "boolean") return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) =>
|
||||||
|
setShoppingItemChecked(u, h, ref.data.listId!, ref.data.itemId!, checked),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveShoppingItemAction(listId: unknown, itemId: unknown, target: unknown) {
|
||||||
|
const ref = ids("listId", "itemId", "target").safeParse({ listId, itemId, target });
|
||||||
|
if (!ref.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) =>
|
||||||
|
moveShoppingItem(u, h, ref.data.listId!, ref.data.itemId!, ref.data.target!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveSuggestionsAction(listId: unknown, productTypeIds: unknown) {
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
const types = z.array(idSchema).min(1).max(200).safeParse(productTypeIds);
|
||||||
|
if (!id.success || !types.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => approveSuggestions(u, h, id.data, types.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dismissSuggestionAction(productTypeId: unknown) {
|
||||||
|
const id = idSchema.safeParse(productTypeId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => dismissSuggestion(u, h, id.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeShoppingAction(listId: unknown, input: unknown) {
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(shoppingCompletionSchema, input, (u, h, d) => completeShopping(u, h, id.data, d));
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { ProductModel } from "@/models/product";
|
||||||
|
import { ProductPriceModel } from "@/models/product-price";
|
||||||
|
import { ShoppingListModel } from "@/models/shopping-list";
|
||||||
|
import { StockMovementModel } from "@/models/stock-movement";
|
||||||
|
import { StoreModel } from "@/models/store";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { formatMoneyInput } from "@/shared/money";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import { shoppingCompletionSchema } from "@/shared/schemas/shopping";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import { completeShopping, getCompletionSheet } from "./completion";
|
||||||
|
import { getShoppingList } from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let weekly = "";
|
||||||
|
let monthEnd = "";
|
||||||
|
const today = todayIstanbul();
|
||||||
|
|
||||||
|
const bySeed = (model: typeof ProductModel | typeof StoreModel, seedRef: string) =>
|
||||||
|
(model as typeof ProductModel)
|
||||||
|
.findOne({ seedRef })
|
||||||
|
.lean()
|
||||||
|
.then((x) => String(x!._id));
|
||||||
|
const accountId = (name: string) =>
|
||||||
|
AccountModel.findOne({ name })
|
||||||
|
.lean()
|
||||||
|
.then((x) => String(x!._id));
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
weekly = String((await ShoppingListModel.findOne({ seedRef: "haftalik" }).lean())!._id);
|
||||||
|
monthEnd = String((await ShoppingListModel.findOne({ seedRef: "ay-sonu" }).lean())!._id);
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
async function scenario() {
|
||||||
|
const sheet = (await getCompletionSheet(ayse, cakir, weekly))!;
|
||||||
|
const item = (label: string) => sheet.items.find((i) => i.label === label)!;
|
||||||
|
const milk = item("Süt");
|
||||||
|
const eggs = item("Yumurta");
|
||||||
|
const detergent = item("Toz Deterjan");
|
||||||
|
const candle = item("Doğum günü mumu");
|
||||||
|
const tomato = item("Domates");
|
||||||
|
const sok = await bySeed(StoreModel, "sok");
|
||||||
|
const migros = await bySeed(StoreModel, "migros");
|
||||||
|
const a101 = await bySeed(StoreModel, "a101");
|
||||||
|
const milkPlan = sheet.planned[milk.id]!;
|
||||||
|
const detPlan = sheet.planned[detergent.id]!;
|
||||||
|
const market = (await CategoryModel.findOne({
|
||||||
|
defaultKey: "market",
|
||||||
|
householdId: cakir,
|
||||||
|
}).lean())!;
|
||||||
|
const accounts = {
|
||||||
|
bank: await accountId("Ziraat Maaş"),
|
||||||
|
meal: await accountId("Ayşe — Pluxee"),
|
||||||
|
card: await accountId("Garanti Bonus"),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
sheet,
|
||||||
|
milk,
|
||||||
|
eggs,
|
||||||
|
detergent,
|
||||||
|
candle,
|
||||||
|
tomato,
|
||||||
|
stores: { sok, migros, a101 },
|
||||||
|
form: (over: Record<string, unknown> = {}) =>
|
||||||
|
shoppingCompletionSchema.parse({
|
||||||
|
date: today,
|
||||||
|
lines: [
|
||||||
|
// Planlanan ürün, planlanan mağaza; paket fiyatı plandan 1 TL fazla.
|
||||||
|
{
|
||||||
|
itemId: milk.id,
|
||||||
|
productId: milkPlan.productId,
|
||||||
|
storeId: milkPlan.storeId,
|
||||||
|
packages: String(milkPlan.packages),
|
||||||
|
amount: formatMoneyInput((milkPlan.priceMinor + 100) * milkPlan.packages),
|
||||||
|
},
|
||||||
|
// Muadil: 15'li köy yumurtası, A101.
|
||||||
|
{
|
||||||
|
itemId: eggs.id,
|
||||||
|
productId: sheet.products.find((p) => p.label.includes("Köy"))!.id,
|
||||||
|
storeId: a101,
|
||||||
|
packages: "2",
|
||||||
|
amount: "189,80",
|
||||||
|
},
|
||||||
|
// Planlanan ürün başka mağazada (Migros, yemek kartı).
|
||||||
|
{
|
||||||
|
itemId: detergent.id,
|
||||||
|
productId: detPlan.productId,
|
||||||
|
storeId: migros,
|
||||||
|
packages: "1",
|
||||||
|
amount: "549,00",
|
||||||
|
},
|
||||||
|
// Markasız ürün tipi (pazardan domates): 2,5 kg, fiyat kaydı yok.
|
||||||
|
{ itemId: tomato.id, productId: "", storeId: sok, packages: "2,5", amount: "60" },
|
||||||
|
// Serbest metin kalemi: yedek gider kategorisi.
|
||||||
|
{ itemId: candle.id, productId: "", storeId: sok, packages: "1", amount: "25" },
|
||||||
|
// Plansız ekstra.
|
||||||
|
{
|
||||||
|
itemId: "",
|
||||||
|
productId: sheet.products.find((p) => p.label.startsWith("Fairy"))!.id,
|
||||||
|
storeId: sok,
|
||||||
|
packages: "1",
|
||||||
|
amount: "84,50",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stores: [
|
||||||
|
{ storeId: sok, accountId: accounts.bank, attachmentIds: [] },
|
||||||
|
{ storeId: migros, accountId: accounts.meal, attachmentIds: [] },
|
||||||
|
{ storeId: a101, accountId: accounts.card, attachmentIds: [] },
|
||||||
|
],
|
||||||
|
fallbackCategoryId: String(market._id),
|
||||||
|
leftover: { action: "move", targetListId: monthEnd, newListName: "" },
|
||||||
|
...over,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tamamlama ekranı", () => {
|
||||||
|
it("plan, fiyatlar, hesaplar ve eşleme bilgisi; izleyici açamaz", async () => {
|
||||||
|
const s = await scenario();
|
||||||
|
expect(s.sheet.items).toHaveLength(9);
|
||||||
|
expect(Object.keys(s.sheet.planned).length).toBe(7);
|
||||||
|
expect(Object.keys(s.sheet.prices).length).toBeGreaterThan(20);
|
||||||
|
expect(s.sheet.accounts.map((a) => a.name)).toContain("Ayşe — Pluxee");
|
||||||
|
expect(s.sheet.defaultFallbackCategoryId).not.toBeNull();
|
||||||
|
expect(s.sheet.otherLists.map((l) => l.name)).toEqual(["Ay sonu toptan"]);
|
||||||
|
expect(await getCompletionSheet(zeynep, cakir, weekly)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("alışverişi tamamla", () => {
|
||||||
|
it("doğrulama: muadil aynı tipten, gelecek tarih, izleyici, şema kuralları", async () => {
|
||||||
|
const s = await scenario();
|
||||||
|
const wrongType = s.form();
|
||||||
|
wrongType.lines[0]!.productId = await bySeed(ProductModel, "ariel-5kg");
|
||||||
|
expect((await completeShopping(ayse, cakir, weekly, wrongType)).ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
(await completeShopping(ayse, cakir, weekly, s.form({ date: addDays(today, 1) }))).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect((await completeShopping(zeynep, cakir, weekly, s.form())).ok).toBe(false);
|
||||||
|
// Mağazası için ödeme hesabı girilmemiş satır.
|
||||||
|
expect(
|
||||||
|
shoppingCompletionSchema.safeParse({
|
||||||
|
date: today,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
itemId: "",
|
||||||
|
productId: s.milk.productTypeId,
|
||||||
|
storeId: s.stores.sok,
|
||||||
|
packages: "1",
|
||||||
|
amount: "1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stores: [],
|
||||||
|
leftover: { action: "drop" },
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ek dosya hatasında hiçbir şey yazılmaz (transaction geri alınır)", async () => {
|
||||||
|
const s = await scenario();
|
||||||
|
const form = s.form();
|
||||||
|
form.stores[0]!.attachmentIds = [String(new Types.ObjectId())];
|
||||||
|
expect((await completeShopping(ayse, cakir, weekly, form)).ok).toBe(false);
|
||||||
|
expect(await TransactionModel.countDocuments({ shoppingListId: { $ne: null } })).toBe(0);
|
||||||
|
expect(await StockMovementModel.countDocuments({ shoppingListId: { $ne: null } })).toBe(0);
|
||||||
|
expect(await ProductPriceModel.countDocuments({ source: "receipt" })).toBe(0);
|
||||||
|
expect((await ShoppingListModel.findById(weekly).lean())!.status).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stok, fiyat, mağaza başına bölünmüş gider, kalanların taşınması ve rapor", async () => {
|
||||||
|
const s = await scenario();
|
||||||
|
const monthEndBefore = (await ShoppingListModel.findById(monthEnd).lean())!.items.length;
|
||||||
|
|
||||||
|
const r = await completeShopping(ayse, cakir, weekly, s.form());
|
||||||
|
expect(r).toMatchObject({ ok: true, data: { transactions: 3 } });
|
||||||
|
|
||||||
|
// Mağaza başına gider; Şok: gıda + temizlik + yedek (mum) bölünmüş.
|
||||||
|
const txs = await TransactionModel.find({ shoppingListId: new Types.ObjectId(weekly) }).lean();
|
||||||
|
expect(txs).toHaveLength(3);
|
||||||
|
const sokTx = txs.find((t) => t.payeeName.startsWith("Şok"))!;
|
||||||
|
expect(sokTx.splits).toHaveLength(3);
|
||||||
|
expect(sokTx.splits.reduce((a, x) => a + x.amountMinor, 0)).toBe(sokTx.amountMinor);
|
||||||
|
const migrosTx = txs.find((t) => t.payeeName.startsWith("Migros"))!;
|
||||||
|
expect(migrosTx).toMatchObject({ amountMinor: 549_00, splits: [] });
|
||||||
|
expect(String(migrosTx.accountId)).toBe(await accountId("Ayşe — Pluxee"));
|
||||||
|
|
||||||
|
// Stok alımları (serbest metin hariç 5 kalem) ve fiş kaynaklı fiyatlar (markasız domates hariç 4).
|
||||||
|
expect(
|
||||||
|
await StockMovementModel.countDocuments({
|
||||||
|
shoppingListId: new Types.ObjectId(weekly),
|
||||||
|
kind: "purchase",
|
||||||
|
}),
|
||||||
|
).toBe(5);
|
||||||
|
expect(await ProductPriceModel.countDocuments({ source: "receipt" })).toBe(4);
|
||||||
|
const eggPrice = await ProductPriceModel.findOne({ source: "receipt", priceMinor: 94_90 });
|
||||||
|
expect(eggPrice).not.toBeNull();
|
||||||
|
|
||||||
|
// Liste kapandı; alınmayan 5 kalem "Ay sonu toptan"a taşındı.
|
||||||
|
const after = (await getShoppingList(ayse, cakir, weekly))!;
|
||||||
|
expect(after.list.status).toBe("completed");
|
||||||
|
expect(after.canWrite).toBe(false);
|
||||||
|
expect(after.report!.completion.movedTo?.name).toBe("Ay sonu toptan");
|
||||||
|
expect((await ShoppingListModel.findById(monthEnd).lean())!.items.length).toBe(
|
||||||
|
monthEndBefore + 4,
|
||||||
|
);
|
||||||
|
// Süt alımı: paket × 1 lt.
|
||||||
|
const milkMove = await StockMovementModel.findOne({
|
||||||
|
shoppingListId: new Types.ObjectId(weekly),
|
||||||
|
productTypeId: new Types.ObjectId(s.milk.productTypeId!),
|
||||||
|
}).lean();
|
||||||
|
expect(milkMove!.quantity).toBe(s.sheet.planned[s.milk.id]!.packages * 1000);
|
||||||
|
// Markasız domates: tipin biriminde 2,5 kg.
|
||||||
|
const tomatoMove = await StockMovementModel.findOne({
|
||||||
|
shoppingListId: new Types.ObjectId(weekly),
|
||||||
|
productTypeId: new Types.ObjectId(s.tomato.productTypeId!),
|
||||||
|
}).lean();
|
||||||
|
expect(tomatoMove!.quantity).toBe(2500);
|
||||||
|
|
||||||
|
// Rapor: aynı/muadil/alınmadı/ekstra; nedenlerin toplamı farka eşit.
|
||||||
|
const { summary, completion } = after.report!;
|
||||||
|
expect(summary.counts).toEqual({ same: 4, substitute: 1, skipped: 4, extra: 1 });
|
||||||
|
const sum = Object.values(summary.reasons).reduce((a, b) => a + b, 0);
|
||||||
|
expect(sum).toBe(summary.diffMinor);
|
||||||
|
// Süt: paket başına +1 TL; deterjan: aynı ürün başka mağazada (Migros) farklı fiyat.
|
||||||
|
expect(summary.reasons.price).toBe(
|
||||||
|
100 * s.sheet.planned[s.milk.id]!.packages +
|
||||||
|
(549_00 - s.sheet.planned[s.detergent.id]!.priceMinor),
|
||||||
|
);
|
||||||
|
expect(summary.reasons.extra).toBe(84_50);
|
||||||
|
expect(summary.reasons.unplanned).toBe(25_00 + 60_00);
|
||||||
|
expect(summary.reasons.skipped).toBeLessThan(0);
|
||||||
|
expect(completion.stores).toHaveLength(3);
|
||||||
|
|
||||||
|
// Tamamlanan liste değiştirilemez, ikinci kez tamamlanamaz.
|
||||||
|
expect((await completeShopping(ayse, cakir, weekly, s.form())).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import { syncOwnerAttachments } from "@/features/attachments/service";
|
||||||
|
import type { CategoryOption } from "@/features/categories/tree";
|
||||||
|
import { buildTree, toOptions } from "@/features/categories/tree";
|
||||||
|
import { loadTypeOffers } from "@/features/prices/service";
|
||||||
|
import { listProducts, type ProductView } from "@/features/products/service";
|
||||||
|
import { getStock } from "@/features/stock/service";
|
||||||
|
import { buildDoc, loadContext } from "@/features/transactions/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { connectMongoose } from "@/lib/db/mongoose";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { ProductPriceModel } from "@/models/product-price";
|
||||||
|
import { ShoppingListModel } from "@/models/shopping-list";
|
||||||
|
import { StoreModel } from "@/models/store";
|
||||||
|
import { StockMovementModel } from "@/models/stock-movement";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { allowedAttachmentKinds } from "@/shared/attachments";
|
||||||
|
import type { MealCardProvider } from "@/shared/accounts";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { formatQuantity, QUANTITY_SCALE } from "@/shared/quantity";
|
||||||
|
import { formatStock } from "@/shared/stock";
|
||||||
|
import type { ShoppingCompletionForm } from "@/shared/schemas/shopping";
|
||||||
|
import type { CompletionRow, CompletionStore, ShoppingCompletion } from "@/shared/shopping";
|
||||||
|
import { matchKind, type PlannedPurchase } from "@/shared/shopping-compare";
|
||||||
|
import { MAX_SPLITS } from "@/shared/transactions";
|
||||||
|
import type { TransactionForm } from "@/shared/schemas/transaction";
|
||||||
|
import {
|
||||||
|
getShoppingList,
|
||||||
|
writableList,
|
||||||
|
type ItemLean,
|
||||||
|
type ShoppingItemView,
|
||||||
|
type ShoppingListSummary,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Alışverişi tamamla" (Bölüm 6.12). Tek MongoDB transaction içinde: stok alım hareketleri, fiyat
|
||||||
|
* kayıtları (kaynak: fiş), mağaza başına bölünmüş gider (stok → gider kategorisi eşlemesi), fiş
|
||||||
|
* görselleri ve alınmayan kalemlerin taşınması; liste kapanır ve karşılaştırma görüntüsü saklanır.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.shopping;
|
||||||
|
const v = t.validation;
|
||||||
|
|
||||||
|
export type CompletionSheet = {
|
||||||
|
list: ShoppingListSummary;
|
||||||
|
items: ShoppingItemView[];
|
||||||
|
/** Kalem başına akıllı önerinin planı (önerilen dağılım). */
|
||||||
|
planned: Record<
|
||||||
|
string,
|
||||||
|
{ productId: string; storeId: string; packages: number; priceMinor: number }
|
||||||
|
>;
|
||||||
|
products: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
packageLabel: string;
|
||||||
|
productTypeId: string;
|
||||||
|
typeName: string;
|
||||||
|
}[];
|
||||||
|
stores: { id: string; label: string; slot: number; mealCard: boolean; providers: string[] }[];
|
||||||
|
/** Geçerli paket fiyatları: `${productId}:${storeId}` → kuruş (tutar ön doldurması için). */
|
||||||
|
prices: Record<string, number>;
|
||||||
|
accounts: { id: string; name: string; type: string; mealCardProvider: MealCardProvider | null }[];
|
||||||
|
expenseCategories: CategoryOption[];
|
||||||
|
/** Gider kategorisine eşlenmemiş ürün tipleri (yedek kategori gerekir). */
|
||||||
|
unmappedTypeIds: string[];
|
||||||
|
defaultFallbackCategoryId: string | null;
|
||||||
|
otherLists: { id: string; name: string }[];
|
||||||
|
today: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function expenseCategoryOptions(householdId: Types.ObjectId) {
|
||||||
|
const rows = await CategoryModel.find({ householdId, kind: "expense" }).lean<
|
||||||
|
{
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
parentId: Types.ObjectId | null;
|
||||||
|
order: number;
|
||||||
|
archivedAt: Date | null;
|
||||||
|
defaultKey: string | null;
|
||||||
|
}[]
|
||||||
|
>();
|
||||||
|
const options = toOptions(
|
||||||
|
buildTree(
|
||||||
|
rows.map((c) => ({
|
||||||
|
id: String(c._id),
|
||||||
|
kind: "expense" as const,
|
||||||
|
name: c.name,
|
||||||
|
parentId: c.parentId ? String(c.parentId) : null,
|
||||||
|
order: c.order,
|
||||||
|
archived: Boolean(c.archivedAt),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const market = rows.find((c) => c.defaultKey === "market" && !c.archivedAt);
|
||||||
|
return { options, marketId: market ? String(market._id) : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ürün tipi → etkin gider kategorisi (stok kategorisinden, üstten devralınarak). */
|
||||||
|
async function typeExpenseMap(userId: string, householdId: string) {
|
||||||
|
const stock = await getStock(userId, householdId);
|
||||||
|
if (!stock) return null;
|
||||||
|
const catExpense = new Map(stock.categories.map((c) => [c.id, c.expense?.id ?? null]));
|
||||||
|
return new Map(stock.items.map((i) => [i.id, catExpense.get(i.categoryId) ?? null]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /alisveris/:id/tamamla — alınanları girme ekranının verisi. */
|
||||||
|
export async function getCompletionSheet(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
): Promise<CompletionSheet | null> {
|
||||||
|
const detail = await getShoppingList(userId, householdId, listId);
|
||||||
|
if (!detail || !detail.canWrite) return null;
|
||||||
|
const ctx = await loadContext(userId, householdId, "member");
|
||||||
|
if (!ctx) return null;
|
||||||
|
const [productList, expense, categories, accounts] = await Promise.all([
|
||||||
|
listProducts(userId, householdId),
|
||||||
|
typeExpenseMap(userId, householdId),
|
||||||
|
expenseCategoryOptions(ctx.m.householdId),
|
||||||
|
AccountModel.find({ householdId: ctx.m.householdId, archivedAt: null })
|
||||||
|
.select({ name: 1, type: 1, order: 1, isPrivate: 1, ownerUserId: 1, mealCardProvider: 1 })
|
||||||
|
.sort({ order: 1 })
|
||||||
|
.lean<
|
||||||
|
{
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
isPrivate: boolean;
|
||||||
|
ownerUserId: Types.ObjectId | null;
|
||||||
|
mealCardProvider: MealCardProvider | null;
|
||||||
|
}[]
|
||||||
|
>(),
|
||||||
|
]);
|
||||||
|
if (!productList || !expense) return null;
|
||||||
|
const products = productList.products.filter((p) => p.inTypeUnit !== null);
|
||||||
|
const offers = await loadTypeOffers(
|
||||||
|
ctx.m.householdId,
|
||||||
|
[...new Set(products.map((p) => p.productTypeId))].map((x) => new Types.ObjectId(x)),
|
||||||
|
{ activeOnly: true },
|
||||||
|
);
|
||||||
|
const planned: CompletionSheet["planned"] = {};
|
||||||
|
for (const g of detail.plan.recommended?.stores ?? [])
|
||||||
|
for (const l of g.lines)
|
||||||
|
planned[l.itemId] = {
|
||||||
|
productId: l.productId,
|
||||||
|
storeId: l.storeId,
|
||||||
|
packages: l.packages,
|
||||||
|
priceMinor: l.priceMinor,
|
||||||
|
};
|
||||||
|
const { stores: storeRefs } = detail.plan;
|
||||||
|
const storeRows = await StoreModel.find({ householdId: ctx.m.householdId, archivedAt: null })
|
||||||
|
.select({ mealCardProviders: 1, mealCardAccepted: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; mealCardAccepted: boolean; mealCardProviders: string[] }[]>();
|
||||||
|
return {
|
||||||
|
list: detail.list,
|
||||||
|
items: detail.items,
|
||||||
|
planned,
|
||||||
|
products: products.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
label: p.label,
|
||||||
|
packageLabel: p.packageLabel,
|
||||||
|
productTypeId: p.productTypeId,
|
||||||
|
typeName: p.productTypeName,
|
||||||
|
})),
|
||||||
|
stores: storeRows
|
||||||
|
.map((s) => {
|
||||||
|
const ref = storeRefs[String(s._id)]!;
|
||||||
|
return {
|
||||||
|
id: ref.id,
|
||||||
|
label: ref.label,
|
||||||
|
slot: ref.slot,
|
||||||
|
mealCard: s.mealCardAccepted,
|
||||||
|
providers: s.mealCardAccepted ? s.mealCardProviders : [],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label, "tr")),
|
||||||
|
prices: Object.fromEntries(offers.map((o) => [`${o.productId}:${o.storeId}`, o.priceMinor])),
|
||||||
|
accounts: accounts
|
||||||
|
.filter((a) => !a.isPrivate || String(a.ownerUserId) === userId)
|
||||||
|
.map((a) => ({
|
||||||
|
id: String(a._id),
|
||||||
|
name: a.name,
|
||||||
|
type: a.type,
|
||||||
|
mealCardProvider: a.mealCardProvider ?? null,
|
||||||
|
})),
|
||||||
|
expenseCategories: categories.options,
|
||||||
|
unmappedTypeIds: [...expense.entries()].filter(([, e]) => !e).map(([id]) => id),
|
||||||
|
defaultFallbackCategoryId: categories.marketId,
|
||||||
|
otherLists: detail.otherLists,
|
||||||
|
today: todayIstanbul(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transaction içindeki doğrulama hatası: geri alıp kullanıcıya döndürmek için. */
|
||||||
|
class CompletionError extends Error {
|
||||||
|
constructor(readonly result: ActionResult<never>) {
|
||||||
|
super(result.ok ? "" : result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolvedLine = {
|
||||||
|
item: ItemLean | null;
|
||||||
|
product: ProductView | null;
|
||||||
|
storeId: string;
|
||||||
|
/** Üründe paket adedi; markasız ürün tipi kaleminde tipin birimindeki miktar (binde bir). */
|
||||||
|
packages: number;
|
||||||
|
amountMinor: number;
|
||||||
|
categoryId: string;
|
||||||
|
/** Stoğa işlenecek ürün tipi ve miktar (serbest metinde null). */
|
||||||
|
stock: { productTypeId: string; quantity: number } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function completeShopping(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
input: ShoppingCompletionForm,
|
||||||
|
): Promise<ActionResult<{ transactions: number }>> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const { m, list, actor } = r;
|
||||||
|
if (input.date > todayIstanbul()) return fail(v.futureDate, { date: v.futureDate });
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
|
||||||
|
const [productList, expense, stockData, activeStores] = await Promise.all([
|
||||||
|
listProducts(actorId, householdId, { includeArchived: true }),
|
||||||
|
typeExpenseMap(actorId, householdId),
|
||||||
|
getShoppingList(actorId, householdId, listId),
|
||||||
|
StoreModel.find({ householdId: m.householdId, archivedAt: null })
|
||||||
|
.select({ _id: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId }[]>(),
|
||||||
|
]);
|
||||||
|
const activeStoreIds = new Set(activeStores.map((x) => String(x._id)));
|
||||||
|
if (!productList || !expense || !stockData) return fail(tr.common.notFound);
|
||||||
|
const products = new Map(productList.products.map((p) => [p.id, p]));
|
||||||
|
const views = new Map(stockData.items.map((i) => [i.id, i]));
|
||||||
|
const storeRefs = stockData.plan.stores;
|
||||||
|
const itemById = new Map(list.items.map((i) => [String(i._id), i]));
|
||||||
|
|
||||||
|
// Yedek gider kategorisi (eşlenmemiş ve serbest metin kalemleri için).
|
||||||
|
const fallback = input.fallbackCategoryId;
|
||||||
|
const fallbackOk =
|
||||||
|
fallback &&
|
||||||
|
ctx.categoryMap.get(fallback)?.kind === "expense" &&
|
||||||
|
!ctx.categoryMap.get(fallback)?.archived;
|
||||||
|
|
||||||
|
// --- Satırları doğrula ---
|
||||||
|
const lines: ResolvedLine[] = [];
|
||||||
|
for (const [i, l] of input.lines.entries()) {
|
||||||
|
const at = (field: string) => `lines.${i}.${field}`;
|
||||||
|
const item = l.itemId ? (itemById.get(l.itemId) ?? null) : null;
|
||||||
|
if (l.itemId && !item) return fail(v.itemGone);
|
||||||
|
if (!activeStoreIds.has(l.storeId))
|
||||||
|
return fail(tr.productPrices.validation.store, {
|
||||||
|
[at("storeId")]: tr.productPrices.validation.store,
|
||||||
|
});
|
||||||
|
|
||||||
|
let product: ProductView | null = null;
|
||||||
|
let typeId: string | null = null;
|
||||||
|
if (item?.kind === "text") {
|
||||||
|
if (l.productId) return fail(v.textNoProduct, { [at("productId")]: v.textNoProduct });
|
||||||
|
} else if (item?.kind === "type" && !l.productId) {
|
||||||
|
// Markasız (ör. pazardan domates): miktar tipin biriminde, fiyat kaydı yok.
|
||||||
|
typeId = String(item.productTypeId);
|
||||||
|
} else {
|
||||||
|
product = l.productId ? (products.get(l.productId) ?? null) : null;
|
||||||
|
if (!product || product.archived) return fail(v.product, { [at("productId")]: v.product });
|
||||||
|
if (product.inTypeUnit === null)
|
||||||
|
return fail(v.conversion(product.label), {
|
||||||
|
[at("productId")]: v.conversion(product.label),
|
||||||
|
});
|
||||||
|
const expectedType =
|
||||||
|
item?.kind === "type"
|
||||||
|
? String(item.productTypeId)
|
||||||
|
: item?.kind === "product"
|
||||||
|
? (products.get(String(item.productId))?.productTypeId ?? null)
|
||||||
|
: null;
|
||||||
|
if (expectedType && product.productTypeId !== expectedType)
|
||||||
|
return fail(v.substituteType, { [at("productId")]: v.substituteType });
|
||||||
|
typeId = product.productTypeId;
|
||||||
|
}
|
||||||
|
const mapped = typeId ? (expense.get(typeId) ?? null) : null;
|
||||||
|
const categoryId = mapped ?? (fallbackOk ? fallback : null);
|
||||||
|
if (!categoryId) return fail(v.fallbackCategory, { fallbackCategoryId: v.fallbackCategory });
|
||||||
|
lines.push({
|
||||||
|
item,
|
||||||
|
product,
|
||||||
|
storeId: l.storeId,
|
||||||
|
packages: l.packages,
|
||||||
|
amountMinor: l.amount,
|
||||||
|
categoryId,
|
||||||
|
stock: typeId
|
||||||
|
? {
|
||||||
|
productTypeId: typeId,
|
||||||
|
quantity: product
|
||||||
|
? Math.max(1, Math.round((l.packages * product.inTypeUnit!) / QUANTITY_SCALE))
|
||||||
|
: l.packages,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mağaza başına gider işlemleri (doğrulama buildDoc ile) ---
|
||||||
|
const accountOf = new Map(input.stores.map((s) => [s.storeId, s]));
|
||||||
|
const byStore = new Map<string, ResolvedLine[]>();
|
||||||
|
for (const l of lines) byStore.set(l.storeId, [...(byStore.get(l.storeId) ?? []), l]);
|
||||||
|
const txDocs: { storeId: string; doc: Record<string, unknown>; attachmentIds: string[] }[] = [];
|
||||||
|
for (const [storeId, group] of byStore) {
|
||||||
|
const entry = accountOf.get(storeId)!;
|
||||||
|
const amount = group.reduce((a, l) => a + l.amountMinor, 0);
|
||||||
|
const perCategory = new Map<string, { amount: number; count: number }>();
|
||||||
|
for (const l of group) {
|
||||||
|
const c = perCategory.get(l.categoryId) ?? { amount: 0, count: 0 };
|
||||||
|
c.amount += l.amountMinor;
|
||||||
|
c.count++;
|
||||||
|
perCategory.set(l.categoryId, c);
|
||||||
|
}
|
||||||
|
if (perCategory.size > MAX_SPLITS) return fail(v.tooManySplits);
|
||||||
|
const split = perCategory.size >= 2;
|
||||||
|
const form: TransactionForm = {
|
||||||
|
type: "expense",
|
||||||
|
amount,
|
||||||
|
date: input.date,
|
||||||
|
accountId: entry.accountId,
|
||||||
|
toAccountId: null,
|
||||||
|
categoryId: split ? null : [...perCategory.keys()][0]!,
|
||||||
|
splits: split
|
||||||
|
? [...perCategory.entries()].map(([categoryId, c]) => ({
|
||||||
|
categoryId,
|
||||||
|
amount: c.amount,
|
||||||
|
note: t.complete.splitNote(c.count),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
payee: storeRefs[storeId]!.label,
|
||||||
|
description: t.complete.txDescription(list.name),
|
||||||
|
tags: [],
|
||||||
|
installments: 1,
|
||||||
|
attachmentIds: entry.attachmentIds,
|
||||||
|
};
|
||||||
|
const built = buildDoc(ctx, form);
|
||||||
|
if (!built.ok) {
|
||||||
|
const msg = built.error;
|
||||||
|
return fail(msg, { [`stores.${input.stores.indexOf(entry)}.accountId`]: msg });
|
||||||
|
}
|
||||||
|
txDocs.push({ storeId, doc: built.data, attachmentIds: entry.attachmentIds });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Alınmayanlar ---
|
||||||
|
const boughtIds = new Set(lines.flatMap((l) => (l.item ? [String(l.item._id)] : [])));
|
||||||
|
const leftover = list.items.filter((i) => !boughtIds.has(String(i._id)));
|
||||||
|
let target: { _id: Types.ObjectId; name: string; items: ItemLean[] } | null = null;
|
||||||
|
if (leftover.length && input.leftover.action === "move") {
|
||||||
|
const tl = await writableList(actorId, householdId, input.leftover.targetListId!);
|
||||||
|
if (tl.error || tl.list._id.equals(list._id))
|
||||||
|
return fail(v.targetList, { "leftover.targetListId": v.targetList });
|
||||||
|
target = tl.list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Plan ve karşılaştırma görüntüsü ---
|
||||||
|
// Kullanıcının gördüğü akıllı öneri (önerilen dağılım).
|
||||||
|
const plan = stockData.plan;
|
||||||
|
const planned = new Map<string, PlannedPurchase & { storeId: string }>();
|
||||||
|
for (const g of plan.recommended?.stores ?? [])
|
||||||
|
for (const l of g.lines)
|
||||||
|
planned.set(l.itemId, {
|
||||||
|
productId: l.productId,
|
||||||
|
storeId: l.storeId,
|
||||||
|
packages: l.packages * QUANTITY_SCALE,
|
||||||
|
priceMinor: l.priceMinor,
|
||||||
|
costMinor: l.costMinor,
|
||||||
|
});
|
||||||
|
const label = (id: string) => products.get(id)?.label ?? "—";
|
||||||
|
const pkgLabel = (id: string) => {
|
||||||
|
const p = products.get(id);
|
||||||
|
return p ? `${p.label} · ${p.packageLabel}` : "—";
|
||||||
|
};
|
||||||
|
const planOf = (itemId: string): CompletionRow["plan"] => {
|
||||||
|
const p = planned.get(itemId);
|
||||||
|
return p
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
productLabel: pkgLabel(p.productId),
|
||||||
|
storeLabel: storeRefs[p.storeId]?.label ?? "—",
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
const rows: CompletionRow[] = [];
|
||||||
|
const lineByItem = new Map(lines.flatMap((l) => (l.item ? [[String(l.item._id), l]] : [])));
|
||||||
|
for (const item of list.items) {
|
||||||
|
const id = String(item._id);
|
||||||
|
const l = lineByItem.get(id) ?? null;
|
||||||
|
const p = planned.get(id) ?? null;
|
||||||
|
rows.push({
|
||||||
|
itemId: id,
|
||||||
|
match: matchKind(
|
||||||
|
{ kind: item.kind, productId: item.productId ? String(item.productId) : null },
|
||||||
|
p,
|
||||||
|
l
|
||||||
|
? { productId: l.product?.id ?? null, packages: l.packages, amountMinor: l.amountMinor }
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
label: views.get(id)?.label ?? item.text,
|
||||||
|
productId: l?.product?.id ?? null,
|
||||||
|
productLabel: l?.product ? pkgLabel(l.product.id) : "",
|
||||||
|
storeId: l?.storeId ?? null,
|
||||||
|
storeLabel: l ? (storeRefs[l.storeId]?.label ?? "—") : "",
|
||||||
|
packages: l?.packages ?? 0,
|
||||||
|
quantityLabel: !l
|
||||||
|
? ""
|
||||||
|
: l.product
|
||||||
|
? `${formatQuantity(l.packages)} ${t.packageUnit}`
|
||||||
|
: l.stock
|
||||||
|
? formatStock(l.packages, views.get(id)?.unit ?? "")
|
||||||
|
: "",
|
||||||
|
amountMinor: l?.amountMinor ?? null,
|
||||||
|
plan: planOf(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const l of lines.filter((x) => !x.item))
|
||||||
|
rows.push({
|
||||||
|
itemId: null,
|
||||||
|
match: "extra",
|
||||||
|
label: label(l.product!.id),
|
||||||
|
productId: l.product!.id,
|
||||||
|
productLabel: pkgLabel(l.product!.id),
|
||||||
|
storeId: l.storeId,
|
||||||
|
storeLabel: storeRefs[l.storeId]?.label ?? "—",
|
||||||
|
packages: l.packages,
|
||||||
|
quantityLabel: `${formatQuantity(l.packages)} ${t.packageUnit}`,
|
||||||
|
amountMinor: l.amountMinor,
|
||||||
|
plan: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Yaz (tek transaction) ---
|
||||||
|
const mongoose = await connectMongoose();
|
||||||
|
const accountNames = new Map([...ctx.accountMap.values()].map((a) => [String(a.id), a.name]));
|
||||||
|
try {
|
||||||
|
const stores: CompletionStore[] = [];
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
stores.length = 0;
|
||||||
|
for (const { storeId, doc, attachmentIds } of txDocs) {
|
||||||
|
const [tx] = await TransactionModel.create(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...doc,
|
||||||
|
householdId: m.householdId,
|
||||||
|
status: "posted",
|
||||||
|
shoppingListId: list._id,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
const synced = await syncOwnerAttachments({
|
||||||
|
householdId: m.householdId,
|
||||||
|
actorId,
|
||||||
|
ownerType: "transaction",
|
||||||
|
ownerId: tx!._id,
|
||||||
|
ids: attachmentIds,
|
||||||
|
allowedKinds: allowedAttachmentKinds("expense"),
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
if (!synced.ok) throw new CompletionError(synced);
|
||||||
|
await TransactionModel.updateOne(
|
||||||
|
{ _id: tx!._id },
|
||||||
|
{ $set: { attachmentCount: synced.data } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
const accountId = String(doc.accountId);
|
||||||
|
stores.push({
|
||||||
|
storeId,
|
||||||
|
label: storeRefs[storeId]!.label,
|
||||||
|
accountId,
|
||||||
|
accountName: accountNames.get(accountId) ?? "—",
|
||||||
|
transactionId: String(tx!._id),
|
||||||
|
amountMinor: doc.amountMinor as number,
|
||||||
|
attachmentCount: synced.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stockLines = lines.filter((l) => l.stock);
|
||||||
|
if (stockLines.length)
|
||||||
|
await StockMovementModel.insertMany(
|
||||||
|
stockLines.map((l) => ({
|
||||||
|
householdId: m.householdId,
|
||||||
|
productTypeId: new Types.ObjectId(l.stock!.productTypeId),
|
||||||
|
kind: "purchase",
|
||||||
|
date: input.date,
|
||||||
|
quantity: l.stock!.quantity,
|
||||||
|
note: t.complete.txDescription(list.name),
|
||||||
|
shoppingListId: list._id,
|
||||||
|
createdBy: actor,
|
||||||
|
})),
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
const productLines = lines.filter((l) => l.product);
|
||||||
|
if (productLines.length) {
|
||||||
|
// Fiyat kaydı: ürün × mağaza başına tek kayıt; paket fiyatı = tutar / paket adedi.
|
||||||
|
const prices = new Map<string, ResolvedLine[]>();
|
||||||
|
for (const l of productLines) {
|
||||||
|
const key = `${l.product!.id}:${l.storeId}`;
|
||||||
|
prices.set(key, [...(prices.get(key) ?? []), l]);
|
||||||
|
}
|
||||||
|
await ProductPriceModel.insertMany(
|
||||||
|
[...prices.values()].map((group) => {
|
||||||
|
const packages = group.reduce((a, l) => a + l.packages, 0);
|
||||||
|
const amount = group.reduce((a, l) => a + l.amountMinor, 0);
|
||||||
|
return {
|
||||||
|
householdId: m.householdId,
|
||||||
|
productId: new Types.ObjectId(group[0]!.product!.id),
|
||||||
|
storeId: new Types.ObjectId(group[0]!.storeId),
|
||||||
|
date: input.date,
|
||||||
|
priceMinor: Math.max(1, Math.round((amount * QUANTITY_SCALE) / packages)),
|
||||||
|
isPromo: false,
|
||||||
|
promoUntil: null,
|
||||||
|
source: "receipt",
|
||||||
|
note: "",
|
||||||
|
createdBy: actor,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let movedTo: ShoppingCompletion["movedTo"] = null;
|
||||||
|
const carry = leftover.map((i) => ({
|
||||||
|
...i,
|
||||||
|
checked: false,
|
||||||
|
checkedBy: null,
|
||||||
|
checkedAt: null,
|
||||||
|
}));
|
||||||
|
if (leftover.length && input.leftover.action === "move" && target) {
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: target._id },
|
||||||
|
{ $push: { items: { $each: carry } }, $set: { updatedBy: actor } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
movedTo = { listId: String(target._id), name: target.name };
|
||||||
|
} else if (leftover.length && input.leftover.action === "new") {
|
||||||
|
const [created] = await ShoppingListModel.create(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
householdId: m.householdId,
|
||||||
|
name: input.leftover.newListName,
|
||||||
|
items: carry,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
movedTo = { listId: String(created!._id), name: input.leftover.newListName };
|
||||||
|
}
|
||||||
|
|
||||||
|
const completion: ShoppingCompletion = {
|
||||||
|
date: input.date,
|
||||||
|
completedBy: actorId,
|
||||||
|
rows,
|
||||||
|
stores,
|
||||||
|
movedTo,
|
||||||
|
};
|
||||||
|
const res = await ShoppingListModel.updateOne(
|
||||||
|
{ _id: list._id, status: "active", deletedAt: null },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
status: "completed",
|
||||||
|
completedAt: new Date(),
|
||||||
|
completion,
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
if (!res.modifiedCount) throw new CompletionError(fail(v.notActive));
|
||||||
|
});
|
||||||
|
return ok({ transactions: txDocs.length });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CompletionError) return error.result;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,660 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { AttachmentPicker } from "@/features/attachments/components/attachment-picker";
|
||||||
|
import type { AttachmentInfo } from "@/features/attachments/service";
|
||||||
|
import { StoreDot } from "@/features/prices/components/offers-list";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import { formatMoneyInput, parseMoney } from "@/shared/money";
|
||||||
|
import { parseQuantity } from "@/shared/quantity";
|
||||||
|
import type { LeftoverAction } from "@/shared/shopping";
|
||||||
|
import { quantityInput } from "@/shared/stock";
|
||||||
|
import { completeShoppingAction } from "../actions";
|
||||||
|
import type { CompletionSheet } from "../completion";
|
||||||
|
import type { ShoppingItemView } from "../service";
|
||||||
|
|
||||||
|
const t = tr.shopping.complete;
|
||||||
|
/** Ürün tipi kaleminde marka belirtmeden alım (Radix Select boş değer kabul etmez). */
|
||||||
|
const GENERIC = "__generic__";
|
||||||
|
|
||||||
|
type Line = {
|
||||||
|
productId: string;
|
||||||
|
storeId: string;
|
||||||
|
packages: string;
|
||||||
|
amount: string;
|
||||||
|
/** Kullanıcı tutarı elle değiştirdi: fiyattan yeniden hesaplanmaz. */
|
||||||
|
touched: boolean;
|
||||||
|
};
|
||||||
|
type ItemLine = Line & { bought: boolean };
|
||||||
|
type Extra = Line & { key: number };
|
||||||
|
|
||||||
|
/** Paket fiyatı biliniyorsa tutar = fiyat × paket. */
|
||||||
|
function priceAmount(sheet: CompletionSheet, l: Pick<Line, "productId" | "storeId" | "packages">) {
|
||||||
|
const price = sheet.prices[`${l.productId}:${l.storeId}`];
|
||||||
|
const q = parseQuantity(l.packages);
|
||||||
|
return price && q ? formatMoneyInput(Math.round((price * q) / 1000)) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function withPrice(sheet: CompletionSheet, l: Line, patch: Partial<Line>): Line {
|
||||||
|
const next = { ...l, ...patch };
|
||||||
|
if ("amount" in patch) return { ...next, touched: true };
|
||||||
|
if (next.touched) return next;
|
||||||
|
return { ...next, amount: priceAmount(sheet, next) || next.amount };
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialLine(sheet: CompletionSheet, item: ShoppingItemView): Line {
|
||||||
|
const plan = sheet.planned[item.id];
|
||||||
|
const typeOf = (id: string | null) => sheet.products.find((p) => p.id === id)?.productTypeId;
|
||||||
|
const typeId = item.kind === "product" ? typeOf(item.productId) : item.productTypeId;
|
||||||
|
const productId =
|
||||||
|
item.kind === "text"
|
||||||
|
? ""
|
||||||
|
: (plan?.productId ??
|
||||||
|
item.productId ??
|
||||||
|
sheet.products.find((p) => p.productTypeId === typeId)?.id ??
|
||||||
|
GENERIC);
|
||||||
|
const packages = plan
|
||||||
|
? String(plan.packages)
|
||||||
|
: item.quantity && (item.kind === "product" || productId === GENERIC)
|
||||||
|
? quantityInput(item.quantity)
|
||||||
|
: "1";
|
||||||
|
const base = {
|
||||||
|
productId,
|
||||||
|
storeId: plan?.storeId ?? defaultStore(sheet),
|
||||||
|
packages,
|
||||||
|
amount: "",
|
||||||
|
touched: false,
|
||||||
|
};
|
||||||
|
return { ...base, amount: priceAmount(sheet, base) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Planda en çok kalemin gittiği mağaza; plan yoksa ilk mağaza. */
|
||||||
|
function defaultStore(sheet: CompletionSheet) {
|
||||||
|
const count = new Map<string, number>();
|
||||||
|
for (const p of Object.values(sheet.planned))
|
||||||
|
count.set(p.storeId, (count.get(p.storeId) ?? 0) + 1);
|
||||||
|
return [...count.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? sheet.stores[0]?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mağazaya uygun varsayılan hesap: geçerli yemek kartı, yoksa banka/nakit, yoksa ilk hesap. */
|
||||||
|
function defaultAccount(sheet: CompletionSheet, storeId: string) {
|
||||||
|
const store = sheet.stores.find((s) => s.id === storeId);
|
||||||
|
const meal = sheet.accounts.find(
|
||||||
|
(a) =>
|
||||||
|
a.type === "meal_card" && a.mealCardProvider && store?.providers.includes(a.mealCardProvider),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
meal?.id ??
|
||||||
|
sheet.accounts.find((a) => a.type === "bank" || a.type === "cash")?.id ??
|
||||||
|
sheet.accounts[0]?.id ??
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LineFields({
|
||||||
|
sheet,
|
||||||
|
line,
|
||||||
|
products,
|
||||||
|
text,
|
||||||
|
onChange,
|
||||||
|
errors,
|
||||||
|
idPrefix,
|
||||||
|
genericUnit,
|
||||||
|
}: {
|
||||||
|
sheet: CompletionSheet;
|
||||||
|
line: Line;
|
||||||
|
products: CompletionSheet["products"];
|
||||||
|
text: boolean;
|
||||||
|
onChange: (patch: Partial<Line>) => void;
|
||||||
|
errors: Record<string, string | undefined>;
|
||||||
|
idPrefix: string;
|
||||||
|
/** Ürün tipi kalemi: markasız seçeneği ve tipin birimi. */
|
||||||
|
genericUnit?: string;
|
||||||
|
}) {
|
||||||
|
const generic = line.productId === GENERIC;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)_5rem_7rem]">
|
||||||
|
{text ? (
|
||||||
|
<p className="col-span-2 self-center text-xs text-muted-foreground sm:col-span-1">
|
||||||
|
{tr.shopping.complete.textItem}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Field data-invalid={Boolean(errors.productId)} className="col-span-2 sm:col-span-1">
|
||||||
|
<FieldLabel htmlFor={`${idPrefix}-p`} className="text-xs">
|
||||||
|
{t.product}
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={line.productId} onValueChange={(x) => onChange({ productId: x })}>
|
||||||
|
<SelectTrigger id={`${idPrefix}-p`} className="w-full">
|
||||||
|
<SelectValue placeholder={tr.shopping.validation.product} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{genericUnit ? (
|
||||||
|
<SelectItem value={GENERIC}>{t.generic(genericUnit)}</SelectItem>
|
||||||
|
) : null}
|
||||||
|
{products.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.label}{" "}
|
||||||
|
<span className="text-xs text-muted-foreground">({p.packageLabel})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.productId ? <FieldError>{errors.productId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field data-invalid={Boolean(errors.storeId)} className="col-span-2 sm:col-span-1">
|
||||||
|
<FieldLabel htmlFor={`${idPrefix}-s`} className="text-xs">
|
||||||
|
{t.store}
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={line.storeId} onValueChange={(x) => onChange({ storeId: x })}>
|
||||||
|
<SelectTrigger id={`${idPrefix}-s`} className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{sheet.stores.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>
|
||||||
|
<StoreDot slot={s.slot} />
|
||||||
|
{s.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
{text ? (
|
||||||
|
<span className="hidden sm:block" />
|
||||||
|
) : (
|
||||||
|
<Field data-invalid={Boolean(errors.packages)}>
|
||||||
|
<FieldLabel htmlFor={`${idPrefix}-q`} className="text-xs">
|
||||||
|
{generic && genericUnit ? `${t.quantity} (${genericUnit})` : t.packages}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`${idPrefix}-q`}
|
||||||
|
inputMode="decimal"
|
||||||
|
value={line.packages}
|
||||||
|
onChange={(e) => onChange({ packages: e.target.value })}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.packages)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field
|
||||||
|
data-invalid={Boolean(errors.amount)}
|
||||||
|
className={text ? "col-span-2 sm:col-span-1" : ""}
|
||||||
|
>
|
||||||
|
<FieldLabel htmlFor={`${idPrefix}-a`} className="text-xs">
|
||||||
|
{t.amount}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`${idPrefix}-a`}
|
||||||
|
inputMode="decimal"
|
||||||
|
value={line.amount}
|
||||||
|
onChange={(e) => onChange({ amount: e.target.value })}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.amount)}
|
||||||
|
/>
|
||||||
|
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /alisveris/:id/tamamla — alınanları gir, mağaza başına hesap ve fiş, alınmayanlar. */
|
||||||
|
export function CompletionForm({ sheet }: { sheet: CompletionSheet }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const anyChecked = sheet.items.some((i) => i.checked);
|
||||||
|
const [date, setDate] = useState(sheet.today);
|
||||||
|
const [rows, setRows] = useState<Record<string, ItemLine>>(() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
sheet.items.map((i) => [
|
||||||
|
i.id,
|
||||||
|
{ ...initialLine(sheet, i), bought: anyChecked ? i.checked : !i.archived },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const [extras, setExtras] = useState<Extra[]>([]);
|
||||||
|
const [accounts, setAccounts] = useState<Record<string, string>>({});
|
||||||
|
const [receipts, setReceipts] = useState<Record<string, AttachmentInfo[]>>({});
|
||||||
|
const [uploading, setUploading] = useState(0);
|
||||||
|
const [fallback, setFallback] = useState(sheet.defaultFallbackCategoryId ?? "");
|
||||||
|
const [leftover, setLeftover] = useState<{
|
||||||
|
action: LeftoverAction;
|
||||||
|
target: string;
|
||||||
|
name: string;
|
||||||
|
}>({
|
||||||
|
action: sheet.otherLists.length ? "move" : "new",
|
||||||
|
target: sheet.otherLists[0]?.id ?? "",
|
||||||
|
name: t.newListDefault(sheet.list.name),
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const typeProducts = (item: ShoppingItemView) => {
|
||||||
|
const typeId =
|
||||||
|
item.kind === "product"
|
||||||
|
? sheet.products.find((p) => p.id === item.productId)?.productTypeId
|
||||||
|
: item.productTypeId;
|
||||||
|
return sheet.products.filter((p) => p.productTypeId === typeId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bought = sheet.items.filter((i) => rows[i.id]!.bought);
|
||||||
|
const unbought = sheet.items.filter((i) => !rows[i.id]!.bought);
|
||||||
|
const allLines: (Line & { itemId: string; text: boolean })[] = [
|
||||||
|
...bought.map((i) => ({ ...rows[i.id]!, itemId: i.id, text: i.kind === "text" })),
|
||||||
|
...extras.map((e) => ({ ...e, itemId: "", text: false })),
|
||||||
|
];
|
||||||
|
const storeIds = [...new Set(allLines.map((l) => l.storeId).filter(Boolean))];
|
||||||
|
const unmapped = new Set(sheet.unmappedTypeIds);
|
||||||
|
const needsFallback = allLines.some(
|
||||||
|
(l) =>
|
||||||
|
l.text || unmapped.has(sheet.products.find((p) => p.id === l.productId)?.productTypeId ?? ""),
|
||||||
|
);
|
||||||
|
const total = allLines.reduce((a, l) => a + (parseMoney(l.amount) ?? 0), 0);
|
||||||
|
const plannedTotal = useMemo(
|
||||||
|
() => Object.values(sheet.planned).reduce((a, p) => a + p.priceMinor * p.packages, 0),
|
||||||
|
[sheet.planned],
|
||||||
|
);
|
||||||
|
|
||||||
|
function setRow(id: string, patch: Partial<ItemLine>) {
|
||||||
|
setRows((s) => {
|
||||||
|
const cur = s[id]!;
|
||||||
|
const { bought: b, ...rest } = patch;
|
||||||
|
const next = Object.keys(rest).length ? { ...cur, ...withPrice(sheet, cur, rest) } : cur;
|
||||||
|
return { ...s, [id]: { ...next, bought: b ?? cur.bought } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setBusy(true);
|
||||||
|
const lineKeys = allLines.map((l, i) =>
|
||||||
|
l.itemId ? `item:${l.itemId}` : `extra:${extras[i - bought.length]?.key}`,
|
||||||
|
);
|
||||||
|
const r = await completeShoppingAction(sheet.list.id, {
|
||||||
|
date,
|
||||||
|
lines: allLines.map((l) => ({
|
||||||
|
itemId: l.itemId,
|
||||||
|
productId: l.text || l.productId === GENERIC ? "" : l.productId,
|
||||||
|
storeId: l.storeId,
|
||||||
|
packages: l.text ? "1" : l.packages,
|
||||||
|
amount: l.amount,
|
||||||
|
})),
|
||||||
|
stores: storeIds.map((id) => ({
|
||||||
|
storeId: id,
|
||||||
|
accountId: accounts[id] ?? defaultAccount(sheet, id),
|
||||||
|
attachmentIds: (receipts[id] ?? []).map((a) => a.id),
|
||||||
|
})),
|
||||||
|
fallbackCategoryId: needsFallback ? fallback : "",
|
||||||
|
leftover: unbought.length
|
||||||
|
? { action: leftover.action, targetListId: leftover.target, newListName: leftover.name }
|
||||||
|
: { action: "drop" },
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.saved);
|
||||||
|
router.push(`/alisveris/${sheet.list.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Satır hatalarını satır anahtarına çevir.
|
||||||
|
const mapped: Record<string, string | undefined> = {};
|
||||||
|
for (const [k, msg] of Object.entries(r.fieldErrors ?? {})) {
|
||||||
|
const m = /^lines\.(\d+)\.(\w+)$/.exec(k);
|
||||||
|
if (m) mapped[`${lineKeys[Number(m[1])]}.${m[2]}`] = msg;
|
||||||
|
else {
|
||||||
|
const s = /^stores\.(\d+)\.(\w+)$/.exec(k);
|
||||||
|
mapped[s ? `store:${storeIds[Number(s[1])]}` : k] = msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setErrors({ ...mapped, root: r.error });
|
||||||
|
toast.error(r.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errs = (key: string) => ({
|
||||||
|
productId: errors[`${key}.productId`],
|
||||||
|
storeId: errors[`${key}.storeId`],
|
||||||
|
packages: errors[`${key}.packages`],
|
||||||
|
amount: errors[`${key}.amount`],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||||
|
<Link href={`/alisveris/${sheet.list.id}`}>
|
||||||
|
<ArrowLeft />
|
||||||
|
{tr.shopping.actions.backToList}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
{t.title}: {sheet.list.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t.description}</p>
|
||||||
|
</div>
|
||||||
|
{sheet.stores.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm">{t.noStores}</p>
|
||||||
|
) : null}
|
||||||
|
<Field className="max-w-48" data-invalid={Boolean(errors.date)}>
|
||||||
|
<FieldLabel htmlFor="sc-date">{t.date}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="sc-date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
max={sheet.today}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<section aria-labelledby="sc-items" className="flex flex-col gap-2">
|
||||||
|
<h2 id="sc-items" className="font-medium">
|
||||||
|
{t.itemsTitle}
|
||||||
|
</h2>
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{sheet.items.map((item) => {
|
||||||
|
const row = rows[item.id]!;
|
||||||
|
const plan = sheet.planned[item.id];
|
||||||
|
const planProduct = plan ? sheet.products.find((p) => p.id === plan.productId) : null;
|
||||||
|
const planStore = plan ? sheet.stores.find((s) => s.id === plan.storeId) : null;
|
||||||
|
const substitute =
|
||||||
|
row.bought &&
|
||||||
|
item.kind !== "text" &&
|
||||||
|
(item.kind === "product"
|
||||||
|
? row.productId !== item.productId
|
||||||
|
: plan && row.productId !== plan.productId);
|
||||||
|
const key = `item:${item.id}`;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className={cn("flex flex-col gap-3 p-3", !row.bought && "bg-muted/30")}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id={`b-${item.id}`}
|
||||||
|
checked={row.bought}
|
||||||
|
onCheckedChange={(x) => setRow(item.id, { bought: x === true })}
|
||||||
|
className="mt-0.5 size-5"
|
||||||
|
/>
|
||||||
|
<label htmlFor={`b-${item.id}`} className="min-w-0 flex-1 cursor-pointer">
|
||||||
|
<span
|
||||||
|
className={cn("block font-medium", !row.bought && "text-muted-foreground")}
|
||||||
|
>
|
||||||
|
{item.label}{" "}
|
||||||
|
{item.quantityLabel ? (
|
||||||
|
<span className="text-sm font-normal text-muted-foreground tabular-nums">
|
||||||
|
· {item.quantityLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{plan && planProduct && planStore
|
||||||
|
? t.planned(
|
||||||
|
`${planProduct.label} · ${planStore.label} · ${formatMoney(plan.priceMinor * plan.packages)}`,
|
||||||
|
)
|
||||||
|
: item.kind === "text"
|
||||||
|
? null
|
||||||
|
: t.noPlan}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{substitute ? <Badge variant="outline">{t.substitute}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
{row.bought ? (
|
||||||
|
<LineFields
|
||||||
|
sheet={sheet}
|
||||||
|
line={row}
|
||||||
|
products={typeProducts(item)}
|
||||||
|
text={item.kind === "text"}
|
||||||
|
genericUnit={item.kind === "type" ? item.unit : undefined}
|
||||||
|
onChange={(patch) => setRow(item.id, patch)}
|
||||||
|
errors={errs(key)}
|
||||||
|
idPrefix={`l-${item.id}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="sc-extras" className="flex flex-col gap-2">
|
||||||
|
<h2 id="sc-extras" className="font-medium">
|
||||||
|
{t.extrasTitle}
|
||||||
|
</h2>
|
||||||
|
{extras.length ? (
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{extras.map((e) => (
|
||||||
|
<li key={e.key} className="flex items-start gap-2 p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<LineFields
|
||||||
|
sheet={sheet}
|
||||||
|
line={e}
|
||||||
|
products={sheet.products}
|
||||||
|
text={false}
|
||||||
|
onChange={(patch) =>
|
||||||
|
setExtras((s) =>
|
||||||
|
s.map((x) =>
|
||||||
|
x.key === e.key ? { ...x, ...withPrice(sheet, x, patch) } : x,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
errors={errs(`extra:${e.key}`)}
|
||||||
|
idPrefix={`e-${e.key}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="mt-6"
|
||||||
|
aria-label={t.removeExtra}
|
||||||
|
onClick={() => setExtras((s) => s.filter((x) => x.key !== e.key))}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-fit"
|
||||||
|
onClick={() =>
|
||||||
|
setExtras((s) => [
|
||||||
|
...s,
|
||||||
|
{
|
||||||
|
key: Date.now(),
|
||||||
|
productId: "",
|
||||||
|
storeId: storeIds[0] ?? defaultStore(sheet),
|
||||||
|
packages: "1",
|
||||||
|
amount: "",
|
||||||
|
touched: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
{t.addExtra}
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{storeIds.length ? (
|
||||||
|
<section aria-labelledby="sc-stores" className="flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 id="sc-stores" className="font-medium">
|
||||||
|
{t.storesTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t.storesDescription}</p>
|
||||||
|
</div>
|
||||||
|
<ul className="grid gap-3 md:grid-cols-2">
|
||||||
|
{storeIds.map((id) => {
|
||||||
|
const store = sheet.stores.find((s) => s.id === id);
|
||||||
|
const lines = allLines.filter((l) => l.storeId === id);
|
||||||
|
const sum = lines.reduce((a, l) => a + (parseMoney(l.amount) ?? 0), 0);
|
||||||
|
return (
|
||||||
|
<li key={id} className="flex flex-col gap-3 rounded-xl border bg-card p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex min-w-0 items-center font-medium">
|
||||||
|
<StoreDot slot={store?.slot ?? 1} />
|
||||||
|
<span className="truncate">{store?.label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-sm tabular-nums">
|
||||||
|
{t.subtotal(lines.length)} · {formatMoney(sum)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Field data-invalid={Boolean(errors[`store:${id}`])}>
|
||||||
|
<FieldLabel htmlFor={`acc-${id}`}>{t.account}</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={accounts[id] ?? defaultAccount(sheet, id)}
|
||||||
|
onValueChange={(x) => setAccounts((s) => ({ ...s, [id]: x }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={`acc-${id}`} className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sheet.accounts.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors[`store:${id}`] ? (
|
||||||
|
<FieldError>{errors[`store:${id}`]}</FieldError>
|
||||||
|
) : null}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{t.receipts}</FieldLabel>
|
||||||
|
<AttachmentPicker
|
||||||
|
value={receipts[id] ?? []}
|
||||||
|
onChange={(items) => setReceipts((s) => ({ ...s, [id]: items }))}
|
||||||
|
allowedKinds={["image"]}
|
||||||
|
onBusyChange={(b) => setUploading((n) => n + (b ? 1 : -1))}
|
||||||
|
addLabel={t.addReceipt}
|
||||||
|
hint={t.receiptHint}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{needsFallback ? (
|
||||||
|
<Field data-invalid={Boolean(errors.fallbackCategoryId)} className="max-w-md">
|
||||||
|
<FieldLabel htmlFor="sc-fallback">{t.fallbackCategory}</FieldLabel>
|
||||||
|
<Select value={fallback} onValueChange={setFallback}>
|
||||||
|
<SelectTrigger id="sc-fallback" className="w-full">
|
||||||
|
<SelectValue placeholder={t.fallbackCategory} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{sheet.expenseCategories.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.path}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldDescription>{t.fallbackHint}</FieldDescription>
|
||||||
|
{errors.fallbackCategoryId ? <FieldError>{errors.fallbackCategoryId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{unbought.length ? (
|
||||||
|
<section aria-labelledby="sc-left" className="flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 id="sc-left" className="font-medium">
|
||||||
|
{t.leftoverTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t.leftoverDescription(unbought.length)} {unbought.map((i) => i.label).join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div role="radiogroup" aria-labelledby="sc-left" className="flex flex-col gap-2">
|
||||||
|
{(["move", "new", "drop"] as const)
|
||||||
|
.filter((a) => a !== "move" || sheet.otherLists.length)
|
||||||
|
.map((a) => (
|
||||||
|
<label key={a} className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="leftover"
|
||||||
|
value={a}
|
||||||
|
checked={leftover.action === a}
|
||||||
|
onChange={() => setLeftover((s) => ({ ...s, action: a }))}
|
||||||
|
className="size-4 accent-primary"
|
||||||
|
/>
|
||||||
|
{t.leftoverActions[a]}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{leftover.action === "move" ? (
|
||||||
|
<Select
|
||||||
|
value={leftover.target}
|
||||||
|
onValueChange={(x) => setLeftover((s) => ({ ...s, target: x }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full max-w-md" aria-label={tr.shopping.moveTarget}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sheet.otherLists.map((l) => (
|
||||||
|
<SelectItem key={l.id} value={l.id}>
|
||||||
|
{l.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : leftover.action === "new" ? (
|
||||||
|
<Input
|
||||||
|
aria-label={t.newListName}
|
||||||
|
value={leftover.name}
|
||||||
|
onChange={(e) => setLeftover((s) => ({ ...s, name: e.target.value }))}
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{errors["leftover.targetListId"] || errors["leftover.newListName"] ? (
|
||||||
|
<FieldError>
|
||||||
|
{errors["leftover.targetListId"] ?? errors["leftover.newListName"]}
|
||||||
|
</FieldError>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="sticky bottom-[calc(4rem+env(safe-area-inset-bottom))] z-10 -mx-4 border-t bg-background/95 backdrop-blur md:bottom-0 md:mx-0 md:rounded-xl md:border">
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<div className="min-w-0 flex-1 text-sm">
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{t.total}: {formatMoney(total)}
|
||||||
|
</span>
|
||||||
|
{plannedTotal ? (
|
||||||
|
<span className="text-muted-foreground tabular-nums">
|
||||||
|
{" · "}
|
||||||
|
{t.plannedTotal}: {formatMoney(plannedTotal)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{errors.root ? (
|
||||||
|
<span className="block text-xs text-destructive">{errors.root}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button onClick={submit} disabled={busy || uploading > 0 || allLines.length === 0}>
|
||||||
|
{t.submit}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"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,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { SHOPPING_ITEM_KINDS, type ShoppingItemKind } from "@/shared/shopping";
|
||||||
|
import { quantityInput } from "@/shared/stock";
|
||||||
|
import { addShoppingItemAction, updateShoppingItemAction } from "../actions";
|
||||||
|
import type { ItemOptions, ShoppingItemView } from "../service";
|
||||||
|
|
||||||
|
const t = tr.shopping;
|
||||||
|
const f = t.fields;
|
||||||
|
type Errors = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
/** Kalem ekle (ürün tipi / belirli marka / serbest metin) ya da düzenle (miktar, not). */
|
||||||
|
export function ShoppingItemDialog({
|
||||||
|
listId,
|
||||||
|
item,
|
||||||
|
options,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
listId: string;
|
||||||
|
item: ShoppingItemView | null;
|
||||||
|
options: ItemOptions;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [v, setV] = useState({
|
||||||
|
kind: (item?.kind ?? "type") as ShoppingItemKind,
|
||||||
|
productTypeId: item?.productTypeId ?? "",
|
||||||
|
productId: item?.productId ?? "",
|
||||||
|
text: item?.text ?? "",
|
||||||
|
quantity: item?.quantity != null ? quantityInput(item.quantity) : "",
|
||||||
|
unit: item?.kind === "text" ? item.unit : "",
|
||||||
|
note: item?.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 }));
|
||||||
|
|
||||||
|
const typeUnit = options.types.find((x) => x.id === v.productTypeId)?.unit ?? "";
|
||||||
|
const unitSuffix =
|
||||||
|
v.kind === "type" ? (item?.unit ?? typeUnit) : v.kind === "product" ? t.packageUnit : "";
|
||||||
|
const productGroups = new Map<string, ItemOptions["products"]>();
|
||||||
|
for (const p of options.products)
|
||||||
|
productGroups.set(p.typeName, [...(productGroups.get(p.typeName) ?? []), p]);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const r = item
|
||||||
|
? await updateShoppingItemAction(listId, item.id, {
|
||||||
|
quantity: v.quantity,
|
||||||
|
text: v.text,
|
||||||
|
unit: v.unit,
|
||||||
|
note: v.note,
|
||||||
|
})
|
||||||
|
: await addShoppingItemAction(listId, v);
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(item ? t.toast.itemSaved : t.toast.itemAdded);
|
||||||
|
onClose();
|
||||||
|
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={item ? t.editItemTitle : t.addItemTitle}
|
||||||
|
description={item && item.kind !== "text" ? item.label : undefined}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="shopping-item-form" disabled={busy}>
|
||||||
|
{tr.common.save}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="shopping-item-form" onSubmit={submit} noValidate>
|
||||||
|
<FieldGroup>
|
||||||
|
{item ? null : (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{f.kind}</FieldLabel>
|
||||||
|
<Tabs value={v.kind} onValueChange={(x) => set("kind", x as ShoppingItemKind)}>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
{SHOPPING_ITEM_KINDS.map((k) => (
|
||||||
|
<TabsTrigger key={k} value={k}>
|
||||||
|
{t.kinds[k]}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<FieldDescription>{t.kindHints[v.kind]}</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{!item && v.kind === "type" ? (
|
||||||
|
<Field data-invalid={Boolean(errors.productTypeId)}>
|
||||||
|
<FieldLabel htmlFor="si-type">{f.productType}</FieldLabel>
|
||||||
|
<Select value={v.productTypeId} onValueChange={(x) => set("productTypeId", x)}>
|
||||||
|
<SelectTrigger
|
||||||
|
id="si-type"
|
||||||
|
className="w-full"
|
||||||
|
aria-invalid={Boolean(errors.productTypeId)}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={t.validation.productType} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{options.types.map((x) => (
|
||||||
|
<SelectItem key={x.id} value={x.id}>
|
||||||
|
{x.name} <span className="text-xs text-muted-foreground">({x.unit})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.productTypeId ? <FieldError>{errors.productTypeId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
{!item && v.kind === "product" ? (
|
||||||
|
<Field data-invalid={Boolean(errors.productId)}>
|
||||||
|
<FieldLabel htmlFor="si-product">{f.product}</FieldLabel>
|
||||||
|
<Select value={v.productId} onValueChange={(x) => set("productId", x)}>
|
||||||
|
<SelectTrigger
|
||||||
|
id="si-product"
|
||||||
|
className="w-full"
|
||||||
|
aria-invalid={Boolean(errors.productId)}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={t.validation.product} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{[...productGroups.entries()].map(([group, rows]) => (
|
||||||
|
<SelectGroup key={group}>
|
||||||
|
<SelectLabel>{group}</SelectLabel>
|
||||||
|
{rows.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.label}{" "}
|
||||||
|
<span className="text-xs text-muted-foreground">({p.packageLabel})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.productId ? <FieldError>{errors.productId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
{v.kind === "text" ? (
|
||||||
|
<Field data-invalid={Boolean(errors.text)}>
|
||||||
|
<FieldLabel htmlFor="si-text">{f.text}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="si-text"
|
||||||
|
value={v.text}
|
||||||
|
placeholder={f.textPlaceholder}
|
||||||
|
onChange={(e) => set("text", e.target.value)}
|
||||||
|
aria-invalid={Boolean(errors.text)}
|
||||||
|
autoFocus={!item}
|
||||||
|
/>
|
||||||
|
{errors.text ? <FieldError>{errors.text}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field data-invalid={Boolean(errors.quantity)}>
|
||||||
|
<FieldLabel htmlFor="si-qty">
|
||||||
|
{v.kind === "product" ? f.packages : f.quantity}
|
||||||
|
{unitSuffix ? ` (${unitSuffix})` : ""}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="si-qty"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={v.quantity}
|
||||||
|
onChange={(e) => set("quantity", e.target.value)}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.quantity)}
|
||||||
|
/>
|
||||||
|
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
{v.kind === "text" ? (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="si-unit">{f.unit}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="si-unit"
|
||||||
|
value={v.unit}
|
||||||
|
placeholder={f.unitPlaceholder}
|
||||||
|
onChange={(e) => set("unit", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{v.kind !== "text" ? (
|
||||||
|
<FieldDescription className="-mt-2">{f.quantityHint}</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="si-note">{f.itemNote}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="si-note"
|
||||||
|
value={v.note}
|
||||||
|
placeholder={f.itemNotePlaceholder}
|
||||||
|
onChange={(e) => set("note", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRightLeft,
|
||||||
|
Check,
|
||||||
|
ClipboardCheck,
|
||||||
|
EllipsisVertical,
|
||||||
|
Package,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
ShoppingCart,
|
||||||
|
Sparkles,
|
||||||
|
Tag,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Field, FieldLabel } from "@/components/ui/field";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { SHOPPING_REFRESH_MS } from "@/shared/shopping";
|
||||||
|
import { formatStock } from "@/shared/stock";
|
||||||
|
import {
|
||||||
|
approveSuggestionsAction,
|
||||||
|
deleteShoppingListAction,
|
||||||
|
dismissSuggestionAction,
|
||||||
|
moveShoppingItemAction,
|
||||||
|
removeShoppingItemAction,
|
||||||
|
} from "../actions";
|
||||||
|
import type { ShoppingItemView, ShoppingListDetail, SuggestionView } from "../service";
|
||||||
|
import { ShoppingItemDialog } from "./item-dialog";
|
||||||
|
import { ShoppingListDialog } from "./list-dialog";
|
||||||
|
import { PlanPanel } from "./plan-panel";
|
||||||
|
import { ShoppingReport } from "./report-view";
|
||||||
|
|
||||||
|
const t = tr.shopping;
|
||||||
|
|
||||||
|
function SuggestionsPanel({
|
||||||
|
listId,
|
||||||
|
suggestions,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
listId: string;
|
||||||
|
suggestions: SuggestionView[];
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const s = t.suggestions;
|
||||||
|
|
||||||
|
async function approve(ids: string[]) {
|
||||||
|
setBusy(ids.length > 1 ? "all" : ids[0]!);
|
||||||
|
const r = await approveSuggestionsAction(listId, ids);
|
||||||
|
setBusy(null);
|
||||||
|
if (r.ok) toast.success(t.toast.suggestionsAdded(r.data.added));
|
||||||
|
else toast.error(r.error);
|
||||||
|
}
|
||||||
|
async function dismiss(id: string) {
|
||||||
|
setBusy(id);
|
||||||
|
const r = await dismissSuggestionAction(id);
|
||||||
|
setBusy(null);
|
||||||
|
if (r.ok) toast.success(t.toast.dismissed);
|
||||||
|
else toast.error(r.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby="sugg-h"
|
||||||
|
className="flex flex-col gap-3 rounded-xl border border-amber-500/40 bg-amber-500/5 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 id="sugg-h" className="flex items-center gap-1.5 font-medium">
|
||||||
|
<Sparkles className="size-4 text-amber-600" aria-hidden />
|
||||||
|
{s.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{s.description}</p>
|
||||||
|
</div>
|
||||||
|
{canWrite && suggestions.length > 1 ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() => approve(suggestions.map((x) => x.productTypeId))}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
{s.addAll}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y rounded-lg border bg-card">
|
||||||
|
{suggestions.map((x) => {
|
||||||
|
const reasons = [
|
||||||
|
x.suggestion.waste
|
||||||
|
? tr.stock.suggestions.wasteReduced(
|
||||||
|
x.suggestion.waste.purchases,
|
||||||
|
x.suggestion.waste.pct,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return (
|
||||||
|
<li key={x.productTypeId} className="flex flex-wrap items-center gap-x-3 gap-y-2 p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">
|
||||||
|
<Link href={`/stok/${x.productTypeId}`} className="hover:underline">
|
||||||
|
{x.name}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{s.line(
|
||||||
|
formatStock(Math.max(0, x.current), x.unit),
|
||||||
|
formatStock(x.target, x.unit),
|
||||||
|
x.pct,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm tabular-nums">
|
||||||
|
{s.suggested(formatStock(x.suggestedQuantity, x.unit))}
|
||||||
|
{reasons.length ? (
|
||||||
|
<span className="text-xs text-muted-foreground"> · {reasons.join(" · ")}</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canWrite ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy !== null}
|
||||||
|
title={s.dismissHint}
|
||||||
|
onClick={() => dismiss(x.productTypeId)}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
{s.dismiss}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() => approve([x.productTypeId])}
|
||||||
|
>
|
||||||
|
<Check />
|
||||||
|
{s.add}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_ICON = { type: Package, product: Tag, text: null } as const;
|
||||||
|
|
||||||
|
function ItemRow({
|
||||||
|
item,
|
||||||
|
canWrite,
|
||||||
|
onEdit,
|
||||||
|
onMove,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
item: ShoppingItemView;
|
||||||
|
canWrite: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
onMove: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const Icon = KIND_ICON[item.kind];
|
||||||
|
return (
|
||||||
|
<li className={cn("flex items-center gap-3 p-3", item.checked && "bg-muted/30")}>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"
|
||||||
|
>
|
||||||
|
{item.checked ? (
|
||||||
|
<Check className="size-4 text-income" />
|
||||||
|
) : Icon ? (
|
||||||
|
<Icon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs">Aa</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"truncate font-medium",
|
||||||
|
item.checked && "text-muted-foreground line-through",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.kind === "type" && item.productTypeId ? (
|
||||||
|
<Link href={`/stok/${item.productTypeId}`} className="hover:underline">
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
) : item.kind === "product" && item.productId ? (
|
||||||
|
<Link href={`/stok/urunler/${item.productId}`} className="hover:underline">
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
item.label
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{item.detail || item.stock ? (
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{item.stock ? t.stockLine(item.stock.current, item.stock.target) : item.detail}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||||
|
{item.checked ? <Badge variant="secondary">{tr.shopping.mode.inCart}</Badge> : null}
|
||||||
|
{item.source === "suggestion" ? (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<Sparkles className="size-3" aria-hidden />
|
||||||
|
{t.fromSuggestion}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{item.archived ? <Badge variant="secondary">{t.archivedRef}</Badge> : null}
|
||||||
|
{item.note ? <span className="text-xs text-muted-foreground">{item.note}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-sm tabular-nums">{item.quantityLabel}</span>
|
||||||
|
{canWrite ? (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="icon-sm" variant="ghost" aria-label={`${item.label}: ${t.actions.more}`}>
|
||||||
|
<EllipsisVertical />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onSelect={onEdit}>
|
||||||
|
<Pencil />
|
||||||
|
{t.actions.edit}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={onMove}>
|
||||||
|
<ArrowRightLeft />
|
||||||
|
{t.actions.moveTo}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive" onSelect={onRemove}>
|
||||||
|
<Trash2 />
|
||||||
|
{t.actions.remove}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoveDialog({
|
||||||
|
listId,
|
||||||
|
item,
|
||||||
|
lists,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
listId: string;
|
||||||
|
item: ShoppingItemView;
|
||||||
|
lists: { id: string; name: string }[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [target, setTarget] = useState(lists[0]?.id ?? "");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
async function submit() {
|
||||||
|
setBusy(true);
|
||||||
|
const r = await moveShoppingItemAction(listId, item.id, target);
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.moved);
|
||||||
|
onClose();
|
||||||
|
} else toast.error(r.error);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={t.moveTitle}
|
||||||
|
description={t.moveDescription(item.label)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={busy || !target}>
|
||||||
|
{t.actions.moveTo}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{lists.length ? (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="sm-target">{t.moveTarget}</FieldLabel>
|
||||||
|
<Select value={target} onValueChange={setTarget}>
|
||||||
|
<SelectTrigger id="sm-target" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{lists.map((l) => (
|
||||||
|
<SelectItem key={l.id} value={l.id}>
|
||||||
|
{l.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.noOtherLists}</p>
|
||||||
|
)}
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /alisveris/:id — kalemler, stok önerileri (onayla/reddet), akıllı mağaza önerisi. */
|
||||||
|
export function ShoppingListDetailView({ data }: { data: ShoppingListDetail }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { list, items, canWrite } = data;
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<ShoppingItemView | null>(null);
|
||||||
|
const [moving, setMoving] = useState<ShoppingItemView | null>(null);
|
||||||
|
const [editingList, setEditingList] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
useAutoRefresh(SHOPPING_REFRESH_MS);
|
||||||
|
|
||||||
|
async function remove(item: ShoppingItemView) {
|
||||||
|
const r = await removeShoppingItemAction(list.id, item.id);
|
||||||
|
if (r.ok) toast.success(t.toast.itemRemoved);
|
||||||
|
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="/alisveris">
|
||||||
|
<ArrowLeft />
|
||||||
|
{t.actions.back}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<PageHeader
|
||||||
|
title={list.name}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{t.itemCount(list.itemCount)}
|
||||||
|
{list.checkedCount ? ` · ${t.checkedCount(list.checkedCount, list.itemCount)}` : ""}
|
||||||
|
{list.note ? ` · ${list.note}` : ""}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{canWrite ? (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="icon-sm" variant="ghost" aria-label={t.actions.more}>
|
||||||
|
<EllipsisVertical />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onSelect={() => setEditingList(true)}>
|
||||||
|
<Pencil />
|
||||||
|
{t.actions.editList}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive" onSelect={() => setDeleting(true)}>
|
||||||
|
<Trash2 />
|
||||||
|
{t.actions.deleteList}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{canWrite ? (
|
||||||
|
<Button size="sm" onClick={() => setAdding(true)}>
|
||||||
|
<Plus />
|
||||||
|
{t.actions.addItem}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{items.length && list.status === "active" ? (
|
||||||
|
<Button size="sm" variant="outline" asChild>
|
||||||
|
<Link href={`/alisveris/${list.id}/mod`}>
|
||||||
|
<ShoppingCart />
|
||||||
|
{t.actions.goShopping}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{items.length && canWrite ? (
|
||||||
|
<Button size="sm" variant="outline" asChild>
|
||||||
|
<Link href={`/alisveris/${list.id}/tamamla`}>
|
||||||
|
<ClipboardCheck />
|
||||||
|
{tr.shopping.complete.action}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.report ? (
|
||||||
|
<ShoppingReport completion={data.report.completion} summary={data.report.summary} />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,26rem)] xl:items-start">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{data.suggestions.length ? (
|
||||||
|
<SuggestionsPanel
|
||||||
|
listId={list.id}
|
||||||
|
suggestions={data.suggestions}
|
||||||
|
canWrite={canWrite}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{items.length ? (
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{items.map((i) => (
|
||||||
|
<ItemRow
|
||||||
|
key={i.id}
|
||||||
|
item={i}
|
||||||
|
canWrite={canWrite}
|
||||||
|
onEdit={() => setEditing(i)}
|
||||||
|
onMove={() => setMoving(i)}
|
||||||
|
onRemove={() => remove(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t.emptyList}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<PlanPanel plan={data.plan} items={items} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{adding || editing ? (
|
||||||
|
<ShoppingItemDialog
|
||||||
|
listId={list.id}
|
||||||
|
item={editing}
|
||||||
|
options={data.options}
|
||||||
|
onClose={() => {
|
||||||
|
setAdding(false);
|
||||||
|
setEditing(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{moving ? (
|
||||||
|
<MoveDialog
|
||||||
|
listId={list.id}
|
||||||
|
item={moving}
|
||||||
|
lists={data.otherLists}
|
||||||
|
onClose={() => setMoving(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{editingList ? (
|
||||||
|
<ShoppingListDialog list={list} onClose={() => setEditingList(false)} />
|
||||||
|
) : null}
|
||||||
|
<AlertDialog open={deleting} onOpenChange={setDeleting}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t.deleteTitle}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t.deleteDescription(list.name)}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
setDeleting(false);
|
||||||
|
const r = await deleteShoppingListAction(list.id);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.deleted);
|
||||||
|
router.push("/alisveris");
|
||||||
|
} else toast.error(r.error);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.actions.deleteList}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { saveShoppingListAction } from "../actions";
|
||||||
|
|
||||||
|
const t = tr.shopping;
|
||||||
|
|
||||||
|
/** Yeni liste / listeyi düzenle. Yeni liste açılınca detayına gider. */
|
||||||
|
export function ShoppingListDialog({
|
||||||
|
list,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
list: { id: string; name: string; note: string } | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [v, setV] = useState({ name: list?.name ?? "", note: list?.note ?? "" });
|
||||||
|
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const r = await saveShoppingListAction(list?.id ?? null, v);
|
||||||
|
setBusy(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(t.toast.saved);
|
||||||
|
onClose();
|
||||||
|
if (!list && r.data && typeof r.data === "object" && "id" in r.data)
|
||||||
|
router.push(`/alisveris/${r.data.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={list ? t.editTitle : t.createTitle}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="shopping-list-form" disabled={busy}>
|
||||||
|
{tr.common.save}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="shopping-list-form" onSubmit={submit} noValidate>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={Boolean(errors.name)}>
|
||||||
|
<FieldLabel htmlFor="sl-name">{t.fields.name}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="sl-name"
|
||||||
|
value={v.name}
|
||||||
|
placeholder={t.fields.namePlaceholder}
|
||||||
|
onChange={(e) => setV((s) => ({ ...s, name: e.target.value }))}
|
||||||
|
aria-invalid={Boolean(errors.name)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="sl-note">{t.fields.note}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="sl-note"
|
||||||
|
value={v.note}
|
||||||
|
onChange={(e) => setV((s) => ({ ...s, note: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CheckCircle2, ListChecks, Plus, Sparkles } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { formatDate } from "@/shared/format";
|
||||||
|
import { SHOPPING_REFRESH_MS } from "@/shared/shopping";
|
||||||
|
import type { ShoppingListSummary } from "../service";
|
||||||
|
import { ShoppingListDialog } from "./list-dialog";
|
||||||
|
|
||||||
|
const t = tr.shopping;
|
||||||
|
|
||||||
|
function ListCard({ list }: { list: ShoppingListSummary }) {
|
||||||
|
const done = list.status === "completed";
|
||||||
|
const Icon = done ? CheckCircle2 : ListChecks;
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/alisveris/${list.id}`}
|
||||||
|
className="flex h-full flex-col gap-2 rounded-xl border bg-card p-4 transition-colors hover:bg-accent/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||||
|
<Icon className="size-4.5 text-muted-foreground" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">{list.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{t.itemCount(list.itemCount)}
|
||||||
|
{list.checkedCount && !done
|
||||||
|
? ` · ${t.checkedCount(list.checkedCount, list.itemCount)}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{list.preview.length ? (
|
||||||
|
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{list.preview.join(", ")}
|
||||||
|
{list.itemCount > list.preview.length ? "…" : ""}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.emptyList}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-auto text-xs text-muted-foreground">
|
||||||
|
{done && list.completedAt
|
||||||
|
? t.completedAt(formatDate(list.completedAt))
|
||||||
|
: t.updated(formatDate(list.updatedAt))}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /alisveris: aktif ve tamamlanan listeler, yeni liste, bekleyen stok önerileri. */
|
||||||
|
export function ShoppingListsView({
|
||||||
|
active,
|
||||||
|
completed,
|
||||||
|
suggestions,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
active: ShoppingListSummary[];
|
||||||
|
completed: ShoppingListSummary[];
|
||||||
|
suggestions: number;
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
useAutoRefresh(SHOPPING_REFRESH_MS);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{suggestions > 0 ? (
|
||||||
|
<p className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/5 p-3 text-sm">
|
||||||
|
<Sparkles className="mt-0.5 size-4 shrink-0 text-amber-600" aria-hidden />
|
||||||
|
{t.suggestionsBanner(suggestions)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground">{t.active}</h2>
|
||||||
|
{canWrite ? (
|
||||||
|
<Button size="sm" className="ml-auto" onClick={() => setCreating(true)}>
|
||||||
|
<Plus />
|
||||||
|
{t.newList}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{active.length ? (
|
||||||
|
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{active.map((l) => (
|
||||||
|
<ListCard key={l.id} list={l} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t.emptyActive}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
{completed.length ? (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground">{t.completed}</h2>
|
||||||
|
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{completed.map((l) => (
|
||||||
|
<ListCard key={l.id} list={l} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{creating ? <ShoppingListDialog list={null} onClose={() => setCreating(false)} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Globe, Info, Utensils } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { StoreDot } from "@/features/prices/components/offers-list";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import { formatUnitPrice } from "@/shared/prices";
|
||||||
|
import { formatQuantity } from "@/shared/quantity";
|
||||||
|
import type { PlanOption } from "@/shared/shopping-plan";
|
||||||
|
import type { ShoppingItemView, ShoppingPlanView } from "../service";
|
||||||
|
|
||||||
|
const t = tr.shopping.plan;
|
||||||
|
|
||||||
|
export type PlanChoice = "recommended" | "single";
|
||||||
|
|
||||||
|
const sameStores = (a: PlanOption | null, b: PlanOption | null) =>
|
||||||
|
(a?.stores.map((s) => s.storeId).join() ?? "") === (b?.stores.map((s) => s.storeId).join() ?? "");
|
||||||
|
|
||||||
|
/** "Tek mağaza en iyi seçenek: X" ya da "Önerilen dağılım: A (7 ürün), B (3 ürün): Y (Z tasarruf)". */
|
||||||
|
export function planHeadline(plan: ShoppingPlanView): string | null {
|
||||||
|
const rec = plan.recommended;
|
||||||
|
if (!rec) return null;
|
||||||
|
const label = (id: string) => plan.stores[id]?.label ?? "—";
|
||||||
|
if (rec.stores.length === 1 && rec.missing.length === 0)
|
||||||
|
return t.singleBest(label(rec.stores[0]!.storeId), formatMoney(rec.totalMinor));
|
||||||
|
const parts = rec.stores.map((g) => t.part(label(g.storeId), g.lines.length)).join(", ");
|
||||||
|
const saving =
|
||||||
|
plan.savingMinor && plan.savingMinor > 0 ? ` (${t.saving(formatMoney(plan.savingMinor))})` : "";
|
||||||
|
return `${t.split(parts, formatMoney(rec.totalMinor))}${saving}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanGroups({
|
||||||
|
option,
|
||||||
|
plan,
|
||||||
|
itemLabel,
|
||||||
|
}: {
|
||||||
|
option: PlanOption;
|
||||||
|
plan: ShoppingPlanView;
|
||||||
|
itemLabel: Map<string, string>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{option.stores.map((g) => {
|
||||||
|
const store = plan.stores[g.storeId];
|
||||||
|
return (
|
||||||
|
<section key={g.storeId} className="overflow-hidden rounded-lg border">
|
||||||
|
<header className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b bg-muted/40 px-3 py-2">
|
||||||
|
<h3 className="flex min-w-0 items-center text-sm font-medium">
|
||||||
|
<StoreDot slot={store?.slot ?? 1} />
|
||||||
|
<span className="truncate">{store?.label ?? "—"}</span>
|
||||||
|
{store?.kind === "online" ? (
|
||||||
|
<Globe className="ml-1.5 size-3.5 text-muted-foreground" aria-hidden />
|
||||||
|
) : null}
|
||||||
|
</h3>
|
||||||
|
{g.mealCardMinor > 0 ? (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<Utensils className="size-3" aria-hidden />
|
||||||
|
{t.mealCard(formatMoney(g.mealCardMinor))}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
<span className="ml-auto text-sm font-medium tabular-nums">
|
||||||
|
{formatMoney(g.subtotalMinor + g.deliveryFeeMinor)}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<ul className="divide-y">
|
||||||
|
{g.lines.map((l) => {
|
||||||
|
const p = plan.products[l.productId];
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={l.itemId}
|
||||||
|
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate">{itemLabel.get(l.itemId) ?? "—"}</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{p
|
||||||
|
? p.label === itemLabel.get(l.itemId)
|
||||||
|
? p.packageLabel
|
||||||
|
: `${p.label} · ${p.packageLabel}`
|
||||||
|
: "—"}
|
||||||
|
{p ? ` · ${formatUnitPrice(l.unitPriceMinor, p.typeUnit)}` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-right tabular-nums">
|
||||||
|
<span className="block">{formatMoney(l.costMinor)}</span>
|
||||||
|
{l.packages > 1 ? (
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{t.line(formatQuantity(l.packages * 1000), formatMoney(l.priceMinor))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{store?.kind === "online" ? (
|
||||||
|
<li className="flex justify-between px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{g.deliveryFeeMinor
|
||||||
|
? t.delivery(formatMoney(g.deliveryFeeMinor))
|
||||||
|
: t.freeDelivery}
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{g.deliveryFeeMinor ? formatMoney(g.deliveryFeeMinor) : ""}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{option.missing.length ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t.missing}: {option.missing.map((id) => itemLabel.get(id) ?? "—").join(", ")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="flex justify-between border-t pt-2 text-sm font-medium">
|
||||||
|
<span>{t.total}</span>
|
||||||
|
<span className="tabular-nums">{formatMoney(option.totalMinor)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste detayında akıllı mağaza önerisi: özet cümle, önerilen / tek mağaza dağılımı. */
|
||||||
|
export function PlanPanel({ plan, items }: { plan: ShoppingPlanView; items: ShoppingItemView[] }) {
|
||||||
|
const [choice, setChoice] = useState<PlanChoice>("recommended");
|
||||||
|
const itemLabel = new Map(items.map((i) => [i.id, i.label]));
|
||||||
|
const headline = planHeadline(plan);
|
||||||
|
const differs = plan.single && !sameStores(plan.single, plan.recommended);
|
||||||
|
const option = choice === "single" && differs ? plan.single! : plan.recommended;
|
||||||
|
const hasText = items.some((i) => i.kind === "text");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-3 rounded-xl border bg-card p-4" aria-labelledby="plan-h">
|
||||||
|
<div>
|
||||||
|
<h2 id="plan-h" className="font-medium">
|
||||||
|
{t.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t.description}</p>
|
||||||
|
</div>
|
||||||
|
{!option ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.empty}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{headline ? <p className="text-sm font-medium">{headline}</p> : null}
|
||||||
|
{plan.single && plan.single.missing.length && differs ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t.singleMissing(plan.single.missing.length)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{differs ? (
|
||||||
|
<Tabs value={choice} onValueChange={(x) => setChoice(x as PlanChoice)}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="recommended">{t.recommended}</TabsTrigger>
|
||||||
|
<TabsTrigger value="single">{t.single}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
) : null}
|
||||||
|
<PlanGroups option={option} plan={plan} itemLabel={itemLabel} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{plan.unpriced.length ? (
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="font-medium">{t.unpriced}:</span>{" "}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{plan.unpriced.map((id) => itemLabel.get(id) ?? "—").join(", ")}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{t.unpricedHint}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<ul className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
|
<li className="flex gap-1.5">
|
||||||
|
<Info className="mt-0.5 size-3 shrink-0" aria-hidden />
|
||||||
|
{t.threshold(formatMoney(plan.thresholdMinor))}
|
||||||
|
</li>
|
||||||
|
{plan.mealCardBalanceMinor > 0 ? (
|
||||||
|
<li className="flex gap-1.5">
|
||||||
|
<Utensils className="mt-0.5 size-3 shrink-0" aria-hidden />
|
||||||
|
{t.mealCardBalance(formatMoney(plan.mealCardBalanceMinor))}
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
{hasText ? (
|
||||||
|
<li className="flex gap-1.5">
|
||||||
|
<Info className="mt-0.5 size-3 shrink-0" aria-hidden />
|
||||||
|
{t.textItems}
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { ArrowRight, Receipt } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import { formatQuantity } from "@/shared/quantity";
|
||||||
|
import type { CompletionRow, ShoppingCompletion } from "@/shared/shopping";
|
||||||
|
import type { ComparisonReasons, ComparisonSummary, MatchKind } from "@/shared/shopping-compare";
|
||||||
|
|
||||||
|
const t = tr.shopping.report;
|
||||||
|
|
||||||
|
const MATCH_STYLE: Record<MatchKind, string> = {
|
||||||
|
same: "border-income/40 text-income",
|
||||||
|
substitute: "border-amber-500/50 text-amber-700 dark:text-amber-400",
|
||||||
|
skipped: "text-muted-foreground",
|
||||||
|
extra: "border-budget/40 text-budget",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** İşaretli tutar: "+₺12,00" / "−₺5,00". Renk tek başına anlam taşımaz. */
|
||||||
|
function Signed({ value, className }: { value: number; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"tabular-nums",
|
||||||
|
value > 0 ? "text-expense" : value < 0 ? "text-income" : "text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value > 0 ? "+" : value < 0 ? "−" : ""}
|
||||||
|
{formatMoney(Math.abs(value))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Cell({ label, sub, amount }: { label: string; sub: string; amount: number | null }) {
|
||||||
|
return (
|
||||||
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate">{label || "—"}</span>
|
||||||
|
{sub ? <span className="truncate text-xs text-muted-foreground">{sub}</span> : null}
|
||||||
|
{amount !== null ? <span className="text-xs tabular-nums">{formatMoney(amount)}</span> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkgs = (packages: number) => `${formatQuantity(packages)} ${tr.shopping.packageUnit}`;
|
||||||
|
|
||||||
|
function Row({ row }: { row: CompletionRow }) {
|
||||||
|
return (
|
||||||
|
<li className="grid grid-cols-2 gap-x-3 gap-y-2 p-3 text-sm md:grid-cols-[minmax(0,1.2fr)_7rem_minmax(0,1.5fr)_minmax(0,1.5fr)] md:items-start">
|
||||||
|
<span className="font-medium">{row.label}</span>
|
||||||
|
<span className="justify-self-end md:justify-self-start">
|
||||||
|
<Badge variant="outline" className={MATCH_STYLE[row.match]}>
|
||||||
|
{t.matches[row.match]}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
<span className="flex gap-1.5 md:block">
|
||||||
|
<span className="text-xs text-muted-foreground md:hidden">{t.plan}:</span>
|
||||||
|
{row.plan ? (
|
||||||
|
<Cell
|
||||||
|
label={row.plan.productLabel}
|
||||||
|
sub={`${row.plan.storeLabel} · ${pkgs(row.plan.packages)}`}
|
||||||
|
amount={row.plan.costMinor}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">{t.noPlanCell}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="flex gap-1.5 md:block">
|
||||||
|
<span className="text-xs text-muted-foreground md:hidden">{t.bought}:</span>
|
||||||
|
{row.amountMinor !== null ? (
|
||||||
|
<Cell
|
||||||
|
label={row.productLabel || row.label}
|
||||||
|
sub={[row.storeLabel, row.quantityLabel ?? (row.productId ? pkgs(row.packages) : "")]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
amount={row.amountMinor}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">{t.noPlanCell}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tamamlanan liste: planlanan vs. gerçek, farkın nedenleri, kalem bazında eşleşme, giderler. */
|
||||||
|
export function ShoppingReport({
|
||||||
|
completion,
|
||||||
|
summary,
|
||||||
|
}: {
|
||||||
|
completion: ShoppingCompletion;
|
||||||
|
summary: ComparisonSummary;
|
||||||
|
}) {
|
||||||
|
const reasons = (Object.entries(summary.reasons) as [keyof ComparisonReasons, number][]).filter(
|
||||||
|
([, v]) => v !== 0,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="report-h" className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 id="report-h" className="font-medium">
|
||||||
|
{t.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t.description(formatDate(`${completion.date}T12:00:00+03:00`))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<dl className="grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: t.planned, node: formatMoney(summary.plannedMinor) },
|
||||||
|
{ label: t.actual, node: formatMoney(summary.actualMinor) },
|
||||||
|
{ label: t.diff, node: <Signed value={summary.diffMinor} /> },
|
||||||
|
].map((x) => (
|
||||||
|
<div key={x.label} className="rounded-xl border bg-card p-3">
|
||||||
|
<dt className="text-xs text-muted-foreground">{x.label}</dt>
|
||||||
|
<dd className="text-lg font-semibold tabular-nums">{x.node}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
{reasons.length ? (
|
||||||
|
<div className="rounded-xl border bg-card p-3">
|
||||||
|
<h3 className="mb-2 text-sm font-medium">{t.reasonsTitle}</h3>
|
||||||
|
<ul className="flex flex-col gap-1 text-sm">
|
||||||
|
{reasons.map(([k, v]) => (
|
||||||
|
<li key={k} className="flex justify-between gap-3">
|
||||||
|
<span>{t.reasons[k]}</span>
|
||||||
|
<Signed value={v} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-xl border bg-card">
|
||||||
|
<div className="hidden grid-cols-[minmax(0,1.2fr)_7rem_minmax(0,1.5fr)_minmax(0,1.5fr)] gap-3 border-b px-3 py-2 text-xs text-muted-foreground md:grid">
|
||||||
|
<span>{t.item}</span>
|
||||||
|
<span />
|
||||||
|
<span>{t.plan}</span>
|
||||||
|
<span>{t.bought}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y">
|
||||||
|
{completion.rows.map((r, i) => (
|
||||||
|
<Row key={r.itemId ?? `extra-${i}`} row={r} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-medium">{t.expenses}</h3>
|
||||||
|
<ul className="grid gap-2 md:grid-cols-2">
|
||||||
|
{completion.stores.map((s) => (
|
||||||
|
<li
|
||||||
|
key={s.transactionId}
|
||||||
|
className="flex items-center gap-3 rounded-lg border bg-card p-3 text-sm"
|
||||||
|
>
|
||||||
|
<Receipt className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate font-medium">{s.label}</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{t.expenseLine(s.accountName, s.attachmentCount)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href={`/islemler?hesap=${s.accountId}`}
|
||||||
|
className="shrink-0 font-medium tabular-nums hover:underline"
|
||||||
|
>
|
||||||
|
{formatMoney(s.amountMinor)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{completion.movedTo ? (
|
||||||
|
<Link
|
||||||
|
href={`/alisveris/${completion.movedTo.listId}`}
|
||||||
|
className="flex w-fit items-center gap-1 text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t.movedTo(completion.movedTo.name)}
|
||||||
|
<ArrowRight className="size-3.5" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { ShoppingCart, Sparkles } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { getActiveListsSummary } from "../service";
|
||||||
|
|
||||||
|
const t = tr.shopping;
|
||||||
|
const LIMIT = 3;
|
||||||
|
|
||||||
|
/** Dashboard: aktif alışveriş listeleri ve bekleyen stok önerileri. Liste ve öneri yoksa gizli. */
|
||||||
|
export async function ShoppingCard({
|
||||||
|
userId,
|
||||||
|
householdId,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
householdId: string;
|
||||||
|
}) {
|
||||||
|
const data = await getActiveListsSummary(userId, householdId);
|
||||||
|
if (!data || (!data.lists.length && !data.suggestions)) return null;
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ShoppingCart className="size-4 text-muted-foreground" aria-hidden />
|
||||||
|
{t.dashboard.title}
|
||||||
|
</CardTitle>
|
||||||
|
{data.suggestions ? (
|
||||||
|
<CardDescription className="flex items-center gap-1.5">
|
||||||
|
<Sparkles className="size-3.5 text-amber-600" aria-hidden />
|
||||||
|
{t.dashboard.suggestions(data.suggestions)}
|
||||||
|
</CardDescription>
|
||||||
|
) : null}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
{data.lists.length ? (
|
||||||
|
<ul className="divide-y text-sm">
|
||||||
|
{data.lists.slice(0, LIMIT).map((l) => (
|
||||||
|
<li key={l.id} className="flex items-center justify-between gap-3 py-1.5">
|
||||||
|
<Link href={`/alisveris/${l.id}`} className="min-w-0 truncate hover:underline">
|
||||||
|
{l.name}
|
||||||
|
</Link>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||||
|
{l.checkedCount
|
||||||
|
? t.checkedCount(l.checkedCount, l.itemCount)
|
||||||
|
: t.itemCount(l.itemCount)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.dashboard.empty}</p>
|
||||||
|
)}
|
||||||
|
<Link href="/alisveris" className="text-sm text-primary hover:underline">
|
||||||
|
{t.dashboard.open}
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft, Check, ClipboardCheck, Globe } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { startTransition, useOptimistic } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { StoreDot } from "@/features/prices/components/offers-list";
|
||||||
|
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import { formatQuantity } from "@/shared/quantity";
|
||||||
|
import { SHOPPING_REFRESH_MS } from "@/shared/shopping";
|
||||||
|
import type { PlanLine } from "@/shared/shopping-plan";
|
||||||
|
import { setShoppingItemCheckedAction } from "../actions";
|
||||||
|
import type { ShoppingItemView, ShoppingListDetail } from "../service";
|
||||||
|
import { planHeadline, type PlanChoice } from "./plan-panel";
|
||||||
|
|
||||||
|
const t = tr.shopping;
|
||||||
|
const m = t.mode;
|
||||||
|
|
||||||
|
type Group = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
slot: number | null;
|
||||||
|
online: boolean;
|
||||||
|
hint?: string;
|
||||||
|
rows: { item: ShoppingItemView; line: PlanLine | null }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
item,
|
||||||
|
line,
|
||||||
|
product,
|
||||||
|
canWrite,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
item: ShoppingItemView;
|
||||||
|
line: PlanLine | null;
|
||||||
|
product: { label: string; packageLabel: string } | undefined;
|
||||||
|
canWrite: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const sub = [
|
||||||
|
line
|
||||||
|
? line.packages > 1
|
||||||
|
? t.plan.line(formatQuantity(line.packages * 1000), formatMoney(line.priceMinor))
|
||||||
|
: formatMoney(line.priceMinor)
|
||||||
|
: null,
|
||||||
|
line && product && item.kind === "type" ? `${product.label} · ${product.packageLabel}` : null,
|
||||||
|
item.note || null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={item.checked}
|
||||||
|
aria-label={m.check(item.label)}
|
||||||
|
disabled={!canWrite}
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-16 w-full items-center gap-4 px-4 py-3 text-left transition-colors hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:outline-none disabled:cursor-default",
|
||||||
|
item.checked && "bg-muted/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"flex size-7 shrink-0 items-center justify-center rounded-md border-2",
|
||||||
|
item.checked ? "border-income bg-income text-white" : "border-muted-foreground/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.checked ? <Check className="size-4.5" strokeWidth={3} /> : null}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"block text-base font-medium",
|
||||||
|
item.checked && "text-muted-foreground line-through",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
{sub.length ? (
|
||||||
|
<span className="line-clamp-2 text-sm text-muted-foreground">{sub.join(" · ")}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-base tabular-nums">{item.quantityLabel}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /alisveris/:id/mod — mağazaya göre gruplanmış, büyük dokunma alanlı kontrol listesi. */
|
||||||
|
export function ShoppingModeView({
|
||||||
|
data,
|
||||||
|
choice,
|
||||||
|
}: {
|
||||||
|
data: ShoppingListDetail;
|
||||||
|
choice: PlanChoice;
|
||||||
|
}) {
|
||||||
|
useAutoRefresh(SHOPPING_REFRESH_MS);
|
||||||
|
const { list, plan, canWrite } = data;
|
||||||
|
const [items, setOptimistic] = useOptimistic(
|
||||||
|
data.items,
|
||||||
|
(state, change: { id: string; checked: boolean }) =>
|
||||||
|
state.map((i) => (i.id === change.id ? { ...i, checked: change.checked } : i)),
|
||||||
|
);
|
||||||
|
const option = (choice === "single" ? plan.single : null) ?? plan.recommended;
|
||||||
|
const byId = new Map(items.map((i) => [i.id, i]));
|
||||||
|
|
||||||
|
const groups: Group[] = [];
|
||||||
|
const placed = new Set<string>();
|
||||||
|
for (const g of option?.stores ?? []) {
|
||||||
|
const store = plan.stores[g.storeId];
|
||||||
|
const rows = g.lines.flatMap((line) => {
|
||||||
|
const item = byId.get(line.itemId);
|
||||||
|
if (!item) return [];
|
||||||
|
placed.add(item.id);
|
||||||
|
return [{ item, line }];
|
||||||
|
});
|
||||||
|
groups.push({
|
||||||
|
key: g.storeId,
|
||||||
|
title: store?.label ?? "—",
|
||||||
|
slot: store?.slot ?? null,
|
||||||
|
online: store?.kind === "online",
|
||||||
|
rows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const rest = items.filter((i) => !placed.has(i.id));
|
||||||
|
if (rest.length)
|
||||||
|
groups.push({
|
||||||
|
key: "other",
|
||||||
|
title: m.other,
|
||||||
|
slot: null,
|
||||||
|
online: false,
|
||||||
|
hint: m.otherHint,
|
||||||
|
rows: rest.map((item) => ({ item, line: null })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const done = items.filter((i) => i.checked).length;
|
||||||
|
const pct = items.length ? Math.round((done / items.length) * 100) : 0;
|
||||||
|
|
||||||
|
function toggle(item: ShoppingItemView) {
|
||||||
|
const checked = !item.checked;
|
||||||
|
startTransition(async () => {
|
||||||
|
setOptimistic({ id: item.id, checked });
|
||||||
|
const r = await setShoppingItemCheckedAction(list.id, item.id, checked);
|
||||||
|
if (!r.ok) toast.error(r.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const headline = planHeadline(plan);
|
||||||
|
const differs =
|
||||||
|
plan.single &&
|
||||||
|
plan.single.stores.map((s) => s.storeId).join() !==
|
||||||
|
plan.recommended?.stores.map((s) => s.storeId).join();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||||
|
<Link href={`/alisveris/${list.id}`}>
|
||||||
|
<ArrowLeft />
|
||||||
|
{t.actions.backToList}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
{m.title}: {list.name}
|
||||||
|
</h1>
|
||||||
|
{headline ? <p className="text-sm text-muted-foreground">{headline}</p> : null}
|
||||||
|
{differs ? (
|
||||||
|
<nav aria-label={m.planChoice} className="flex gap-2">
|
||||||
|
{(["recommended", "single"] as const).map((c) => (
|
||||||
|
<Button
|
||||||
|
key={c}
|
||||||
|
size="sm"
|
||||||
|
variant={c === choice ? "secondary" : "ghost"}
|
||||||
|
asChild
|
||||||
|
aria-current={c === choice ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<Link href={c === "single" ? "?plan=single" : "?"} replace scroll={false}>
|
||||||
|
{c === "single" ? t.plan.single : t.plan.recommended}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sticky top-14 z-10 -mx-4 bg-background/95 px-4 py-2 backdrop-blur">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium tabular-nums">{m.progress(done, items.length)}</span>
|
||||||
|
{done === items.length && items.length ? (
|
||||||
|
<span className="text-income">{m.allDone}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mt-1.5 h-2 overflow-hidden rounded-full bg-muted"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={items.length}
|
||||||
|
aria-valuenow={done}
|
||||||
|
aria-label={m.progress(done, items.length)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-income transition-all"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.map((g) => {
|
||||||
|
const groupDone = g.rows.filter((r) => r.item.checked).length;
|
||||||
|
return (
|
||||||
|
<section key={g.key} className="overflow-hidden rounded-xl border bg-card">
|
||||||
|
<header className="flex items-center gap-2 border-b bg-muted/40 px-4 py-2.5">
|
||||||
|
<h2 className="flex min-w-0 items-center font-medium">
|
||||||
|
{g.slot ? <StoreDot slot={g.slot} /> : null}
|
||||||
|
<span className="truncate">{g.title}</span>
|
||||||
|
{g.online ? (
|
||||||
|
<Globe className="ml-1.5 size-3.5 text-muted-foreground" aria-hidden />
|
||||||
|
) : null}
|
||||||
|
</h2>
|
||||||
|
<span className="ml-auto text-sm text-muted-foreground tabular-nums">
|
||||||
|
{groupDone}/{g.rows.length}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
{g.hint ? <p className="px-4 pt-2 text-xs text-muted-foreground">{g.hint}</p> : null}
|
||||||
|
<ul className="divide-y">
|
||||||
|
{g.rows.map(({ item, line }) => (
|
||||||
|
<Row
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
line={line}
|
||||||
|
product={line ? plan.products[line.productId] : undefined}
|
||||||
|
canWrite={canWrite}
|
||||||
|
onToggle={() => toggle(item)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{canWrite && done > 0 ? (
|
||||||
|
<Button asChild size="lg" className="w-full">
|
||||||
|
<Link href={`/alisveris/${list.id}/tamamla`}>
|
||||||
|
<ClipboardCheck />
|
||||||
|
{t.complete.action}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { AuditLogModel } from "@/models/audit-log";
|
||||||
|
import { ProductModel } from "@/models/product";
|
||||||
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { ShoppingListModel } from "@/models/shopping-list";
|
||||||
|
import { shoppingItemFormSchema, shoppingItemUpdateSchema } from "@/shared/schemas/shopping";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import {
|
||||||
|
addShoppingItem,
|
||||||
|
approveSuggestions,
|
||||||
|
createShoppingList,
|
||||||
|
deleteShoppingList,
|
||||||
|
dismissSuggestion,
|
||||||
|
getShoppingList,
|
||||||
|
listShoppingLists,
|
||||||
|
moveShoppingItem,
|
||||||
|
removeShoppingItem,
|
||||||
|
setShoppingItemChecked,
|
||||||
|
updateShoppingItem,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
let weekly = "";
|
||||||
|
let monthEnd = "";
|
||||||
|
|
||||||
|
const typeId = (seedRef: string) =>
|
||||||
|
ProductTypeModel.findOne({ seedRef })
|
||||||
|
.lean()
|
||||||
|
.then((x) => String(x!._id));
|
||||||
|
const productId = (seedRef: string) =>
|
||||||
|
ProductModel.findOne({ seedRef })
|
||||||
|
.lean()
|
||||||
|
.then((x) => String(x!._id));
|
||||||
|
const item = (input: Record<string, unknown>) => shoppingItemFormSchema.parse(input);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
weekly = String((await ShoppingListModel.findOne({ seedRef: "haftalik" }).lean())!._id);
|
||||||
|
monthEnd = String((await ShoppingListModel.findOne({ seedRef: "ay-sonu" }).lean())!._id);
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
describe("liste okuma ve akıllı öneri", () => {
|
||||||
|
it("aktif listeler, bekleyen öneri sayısı; başka hane göremez", async () => {
|
||||||
|
const data = (await listShoppingLists(ayse, cakir))!;
|
||||||
|
expect(data.active.map((l) => l.name).sort()).toEqual(["Ay sonu toptan", "Haftalık market"]);
|
||||||
|
expect(data.suggestions).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(data.canWrite).toBe(true);
|
||||||
|
expect(await listShoppingLists(fatma, cakir)).toBeNull();
|
||||||
|
expect(await getShoppingList(fatma, cakir, weekly)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kalem görünümleri, eşik önerileri ve plan", async () => {
|
||||||
|
const d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||||
|
expect(d.items).toHaveLength(9);
|
||||||
|
const milk = d.items.find((i) => i.label === "Süt")!;
|
||||||
|
expect(milk).toMatchObject({ kind: "type", unit: "lt", quantityLabel: "4 lt" });
|
||||||
|
expect(milk.stock).not.toBeNull();
|
||||||
|
const yumos = d.items.find((i) => i.kind === "product")!;
|
||||||
|
expect(yumos.quantityLabel).toBe("1 paket");
|
||||||
|
const candle = d.items.find((i) => i.kind === "text")!;
|
||||||
|
expect(candle).toMatchObject({ label: "Doğum günü mumu", note: "Rakam mum: 7" });
|
||||||
|
|
||||||
|
// Eşiğin altındaki mercimek listede yok: öneri olarak gelir.
|
||||||
|
expect(d.suggestions.map((s) => s.name)).toContain("Kırmızı Mercimek");
|
||||||
|
expect(d.suggestions.every((s) => s.suggestedQuantity >= 0)).toBe(true);
|
||||||
|
|
||||||
|
// Plan: domatesin fiyatı yok; serbest metin plana katılmaz; diğerleri karşılanır.
|
||||||
|
const tomato = d.items.find((i) => i.label === "Domates")!;
|
||||||
|
expect(d.plan.unpriced).toEqual([tomato.id]);
|
||||||
|
const rec = d.plan.recommended!;
|
||||||
|
expect(rec.missing).toEqual([]);
|
||||||
|
const planned = rec.stores.flatMap((g) => g.lines.map((l) => l.itemId));
|
||||||
|
expect(planned).toHaveLength(7);
|
||||||
|
expect(planned).not.toContain(candle.id);
|
||||||
|
for (const g of rec.stores) {
|
||||||
|
expect(d.plan.stores[g.storeId]).toBeDefined();
|
||||||
|
for (const l of g.lines) expect(d.plan.products[l.productId]).toBeDefined();
|
||||||
|
}
|
||||||
|
// Belirli marka kalemi yalnızca o ürünle planlanır.
|
||||||
|
const yumosLine = rec.stores.flatMap((g) => g.lines).find((l) => l.itemId === yumos.id)!;
|
||||||
|
expect(yumosLine.productId).toBe(yumos.productId);
|
||||||
|
expect(d.plan.single).not.toBeNull();
|
||||||
|
expect(d.plan.thresholdMinor).toBe(10_000);
|
||||||
|
// Ayşe'nin yemek kartı bakiyesi plana girer (seed: Multinet).
|
||||||
|
expect(d.plan.mealCardBalanceMinor).toBeGreaterThan(0);
|
||||||
|
expect(d.options.types.length).toBeGreaterThan(10);
|
||||||
|
expect(d.otherLists.map((o) => o.name)).toEqual(["Ay sonu toptan"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("kalem işlemleri", () => {
|
||||||
|
it("ekle (çift kayıt reddi, izleyici yazamaz), düzenle, işaretle, taşı, çıkar", async () => {
|
||||||
|
const milk = await typeId("sut");
|
||||||
|
expect(shoppingItemFormSchema.safeParse({ kind: "type", productTypeId: "" }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(shoppingItemFormSchema.safeParse({ kind: "text", text: "" }).success).toBe(false);
|
||||||
|
expect(
|
||||||
|
(await addShoppingItem(ayse, cakir, weekly, item({ kind: "type", productTypeId: milk }))).ok,
|
||||||
|
).toBe(false);
|
||||||
|
const text = item({ kind: "text", text: "Pil", quantity: "4", unit: "adet" });
|
||||||
|
expect((await addShoppingItem(zeynep, cakir, weekly, text)).ok).toBe(false);
|
||||||
|
|
||||||
|
const added = await addShoppingItem(ayse, cakir, weekly, text);
|
||||||
|
expect(added.ok).toBe(true);
|
||||||
|
const id = added.ok ? added.data.id : "";
|
||||||
|
const brand = await addShoppingItem(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
weekly,
|
||||||
|
item({ kind: "product", productId: await productId("ariel-5kg"), quantity: "2" }),
|
||||||
|
);
|
||||||
|
expect(brand.ok).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await updateShoppingItem(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
weekly,
|
||||||
|
id,
|
||||||
|
shoppingItemUpdateSchema.parse({ quantity: "6", text: "AA pil", unit: "adet" }),
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(true);
|
||||||
|
let d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||||
|
expect(d.items.find((i) => i.id === id)).toMatchObject({
|
||||||
|
label: "AA pil",
|
||||||
|
quantityLabel: "6 adet",
|
||||||
|
});
|
||||||
|
const ariel = d.items.find((i) => i.label.startsWith("Ariel"))!;
|
||||||
|
expect(ariel.quantityLabel).toBe("2 paket");
|
||||||
|
|
||||||
|
expect((await setShoppingItemChecked(ayse, cakir, weekly, id, true)).ok).toBe(true);
|
||||||
|
d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||||
|
// Sepete alınanlar sona.
|
||||||
|
expect(d.items.at(-1)).toMatchObject({ id, checked: true });
|
||||||
|
expect(d.list.checkedCount).toBe(1);
|
||||||
|
|
||||||
|
expect((await moveShoppingItem(ayse, cakir, weekly, id, weekly)).ok).toBe(false);
|
||||||
|
expect((await moveShoppingItem(ayse, cakir, weekly, id, monthEnd)).ok).toBe(true);
|
||||||
|
const target = (await getShoppingList(ayse, cakir, monthEnd))!;
|
||||||
|
expect(target.items.find((i) => i.id === id)).toMatchObject({ checked: false });
|
||||||
|
|
||||||
|
expect((await removeShoppingItem(ayse, cakir, monthEnd, id)).ok).toBe(true);
|
||||||
|
expect((await removeShoppingItem(ayse, cakir, monthEnd, id)).ok).toBe(false);
|
||||||
|
expect((await removeShoppingItem(ayse, cakir, weekly, ariel.id)).ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("eşik önerileri", () => {
|
||||||
|
it("onayla: önerilen miktarla eklenir, öneriden düşer; tekrar onay reddedilir", async () => {
|
||||||
|
const lentil = await typeId("kirmizi-mercimek");
|
||||||
|
const r = await approveSuggestions(ayse, cakir, weekly, [lentil]);
|
||||||
|
expect(r).toMatchObject({ ok: true, data: { added: 1 } });
|
||||||
|
const d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||||
|
const row = d.items.find((i) => i.productTypeId === lentil)!;
|
||||||
|
expect(row.source).toBe("suggestion");
|
||||||
|
expect(row.quantity).toBeGreaterThan(0);
|
||||||
|
expect(d.suggestions.some((s) => s.productTypeId === lentil)).toBe(false);
|
||||||
|
expect((await approveSuggestions(ayse, cakir, weekly, [lentil])).ok).toBe(false);
|
||||||
|
// Başka aktif listede de öneri olarak görünmez.
|
||||||
|
const other = (await getShoppingList(ayse, cakir, monthEnd))!;
|
||||||
|
expect(other.suggestions.some((s) => s.productTypeId === lentil)).toBe(false);
|
||||||
|
await removeShoppingItem(ayse, cakir, weekly, row.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reddet: stok yenilenene kadar gizli; izleyici reddedemez", async () => {
|
||||||
|
const tablet = await typeId("tablet-deterjan");
|
||||||
|
expect((await dismissSuggestion(zeynep, cakir, tablet)).ok).toBe(false);
|
||||||
|
expect((await dismissSuggestion(ayse, cakir, tablet)).ok).toBe(true);
|
||||||
|
const d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||||
|
expect(d.suggestions.some((s) => s.productTypeId === tablet)).toBe(false);
|
||||||
|
await ProductTypeModel.updateOne(
|
||||||
|
{ seedRef: "tablet-deterjan" },
|
||||||
|
{ $set: { suggestionDismissedAt: null } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("liste yönetimi", () => {
|
||||||
|
it("oluştur ve sil (soft delete + denetim)", async () => {
|
||||||
|
const r = await createShoppingList(ayse, cakir, { name: "Deneme", note: "" });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const id = r.ok ? r.data.id : "";
|
||||||
|
expect((await deleteShoppingList(zeynep, cakir, id)).ok).toBe(false);
|
||||||
|
expect((await deleteShoppingList(ayse, cakir, id)).ok).toBe(true);
|
||||||
|
expect(await getShoppingList(ayse, cakir, id)).toBeNull();
|
||||||
|
expect(
|
||||||
|
await AuditLogModel.exists({ action: "shoppingList.delete", targetId: id }),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,898 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import { listAccounts } from "@/features/accounts/service";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { resolveMembership, type Membership } from "@/features/households/service";
|
||||||
|
import { loadTypeOffers } from "@/features/prices/service";
|
||||||
|
import { listProducts, type ProductView } from "@/features/products/service";
|
||||||
|
import { getStock, type ProductTypeView } from "@/features/stock/service";
|
||||||
|
import { storeLabel } from "@/features/stores/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { writeAudit } from "@/lib/audit";
|
||||||
|
import { toObjectId } from "@/lib/object-id";
|
||||||
|
import { HouseholdModel } from "@/models/household";
|
||||||
|
import { ProductModel } from "@/models/product";
|
||||||
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { ShoppingListModel } from "@/models/shopping-list";
|
||||||
|
import { StockMovementModel } from "@/models/stock-movement";
|
||||||
|
import { StoreModel } from "@/models/store";
|
||||||
|
import { seriesSlot } from "@/shared/prices";
|
||||||
|
import { formatQuantity, QUANTITY_SCALE } from "@/shared/quantity";
|
||||||
|
import type {
|
||||||
|
ShoppingItemForm,
|
||||||
|
ShoppingItemUpdate,
|
||||||
|
ShoppingListForm,
|
||||||
|
} from "@/shared/schemas/shopping";
|
||||||
|
import {
|
||||||
|
requiredQuantity,
|
||||||
|
suggestionHidden,
|
||||||
|
type ShoppingItemKind,
|
||||||
|
type ShoppingItemSource,
|
||||||
|
type ShoppingCompletion,
|
||||||
|
type ShoppingListStatus,
|
||||||
|
} from "@/shared/shopping";
|
||||||
|
import { compareShopping, type ComparisonSummary } from "@/shared/shopping-compare";
|
||||||
|
import { planShopping, type PlanResult } from "@/shared/shopping-plan";
|
||||||
|
import { compareTr } from "@/shared/search";
|
||||||
|
import { formatStock, type PurchaseSuggestion } from "@/shared/stock";
|
||||||
|
import type { StoreKind } from "@/shared/stores";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alışveriş listeleri (Bölüm 6.12). Hane içinde ortak; okuma viewer, yazma member.
|
||||||
|
* Stok eşiğinin altındaki ürün tipleri aktif listelere onayla/reddet önerisi olarak gelir.
|
||||||
|
* Akıllı mağaza önerisi saf motorla (shared/shopping-plan) okuma sırasında hesaplanır.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.shopping;
|
||||||
|
|
||||||
|
export type ItemLean = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
kind: ShoppingItemKind;
|
||||||
|
productTypeId: Types.ObjectId | null;
|
||||||
|
productId: Types.ObjectId | null;
|
||||||
|
text: string;
|
||||||
|
quantity: number | null;
|
||||||
|
unit: string;
|
||||||
|
note: string;
|
||||||
|
source: ShoppingItemSource;
|
||||||
|
checked: boolean;
|
||||||
|
checkedAt: Date | null;
|
||||||
|
addedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListLean = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
status: ShoppingListStatus;
|
||||||
|
note: string;
|
||||||
|
items: ItemLean[];
|
||||||
|
completedAt: Date | null;
|
||||||
|
completion: ShoppingCompletion | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShoppingListSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: ShoppingListStatus;
|
||||||
|
note: string;
|
||||||
|
itemCount: number;
|
||||||
|
checkedCount: number;
|
||||||
|
/** İlk birkaç kalemin adı (kart önizlemesi). */
|
||||||
|
preview: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShoppingItemView = {
|
||||||
|
id: string;
|
||||||
|
kind: ShoppingItemKind;
|
||||||
|
productTypeId: string | null;
|
||||||
|
productId: string | null;
|
||||||
|
text: string;
|
||||||
|
label: string;
|
||||||
|
/** Ürün: paket ve ürün tipi; ürün tipi: kategori yolu. */
|
||||||
|
detail: string;
|
||||||
|
quantity: number | null;
|
||||||
|
/** Ürün tipinde tipin birimi, üründe "paket", serbest metinde girilen birim. */
|
||||||
|
unit: string;
|
||||||
|
/** "2 kg", "3 paket"; boş miktarda ürün tipinde "" (en az 1 paket). */
|
||||||
|
quantityLabel: string;
|
||||||
|
note: string;
|
||||||
|
source: ShoppingItemSource;
|
||||||
|
checked: boolean;
|
||||||
|
/** Ürün tipi/ürün arşivlendi. */
|
||||||
|
archived: boolean;
|
||||||
|
/** Ürün tipi kaleminde evdeki stok. */
|
||||||
|
stock: { current: string; target: string; low: boolean } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SuggestionView = {
|
||||||
|
productTypeId: string;
|
||||||
|
name: string;
|
||||||
|
categoryPath: string;
|
||||||
|
unit: string;
|
||||||
|
current: number;
|
||||||
|
target: number;
|
||||||
|
pct: number | null;
|
||||||
|
suggestedQuantity: number;
|
||||||
|
suggestion: PurchaseSuggestion;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanStoreRef = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: StoreKind;
|
||||||
|
slot: number;
|
||||||
|
mealCard: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShoppingPlanView = PlanResult & {
|
||||||
|
stores: Record<string, PlanStoreRef>;
|
||||||
|
products: Record<string, { label: string; packageLabel: string; typeUnit: string }>;
|
||||||
|
thresholdMinor: number;
|
||||||
|
/** Bu kullanıcının görebildiği yemek kartı bakiyelerinin toplamı. */
|
||||||
|
mealCardBalanceMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ItemOptions = {
|
||||||
|
types: { id: string; name: string; unit: string; path: string }[];
|
||||||
|
products: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
packageLabel: string;
|
||||||
|
productTypeId: string;
|
||||||
|
typeName: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShoppingListDetail = {
|
||||||
|
list: ShoppingListSummary;
|
||||||
|
items: ShoppingItemView[];
|
||||||
|
suggestions: SuggestionView[];
|
||||||
|
plan: ShoppingPlanView;
|
||||||
|
options: ItemOptions;
|
||||||
|
/** Kalem taşımak için diğer aktif listeler. */
|
||||||
|
otherLists: { id: string; name: string }[];
|
||||||
|
canWrite: boolean;
|
||||||
|
/** Tamamlanmış listede karşılaştırma raporu. */
|
||||||
|
report: { completion: ShoppingCompletion; summary: ComparisonSummary } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Yardımcılar ---
|
||||||
|
|
||||||
|
const iso = (d: Date | null) => (d ? d.toISOString() : null);
|
||||||
|
|
||||||
|
function summary(l: ListLean, labels: Map<string, string>): ShoppingListSummary {
|
||||||
|
return {
|
||||||
|
id: String(l._id),
|
||||||
|
name: l.name,
|
||||||
|
status: l.status,
|
||||||
|
note: l.note,
|
||||||
|
itemCount: l.items.length,
|
||||||
|
checkedCount: l.items.filter((i) => i.checked).length,
|
||||||
|
preview: l.items.slice(0, 4).map((i) => itemName(i, labels)),
|
||||||
|
updatedAt: l.updatedAt.toISOString(),
|
||||||
|
completedAt: iso(l.completedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const refKey = (i: Pick<ItemLean, "kind" | "productTypeId" | "productId">) =>
|
||||||
|
i.kind === "type" ? `t:${i.productTypeId}` : i.kind === "product" ? `p:${i.productId}` : null;
|
||||||
|
|
||||||
|
function itemName(i: ItemLean, labels: Map<string, string>) {
|
||||||
|
const key = refKey(i);
|
||||||
|
return key ? (labels.get(key) ?? "—") : i.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kalemlerin ürün tipi ve ürün adları (arşivliler dahil). */
|
||||||
|
async function loadLabels(householdId: Types.ObjectId, lists: ListLean[]) {
|
||||||
|
const typeIds = new Set<string>();
|
||||||
|
const productIds = new Set<string>();
|
||||||
|
for (const l of lists)
|
||||||
|
for (const i of l.items) {
|
||||||
|
if (i.productTypeId) typeIds.add(String(i.productTypeId));
|
||||||
|
if (i.productId) productIds.add(String(i.productId));
|
||||||
|
}
|
||||||
|
const [types, products] = await Promise.all([
|
||||||
|
ProductTypeModel.find({
|
||||||
|
householdId,
|
||||||
|
_id: { $in: [...typeIds].map((x) => new Types.ObjectId(x)) },
|
||||||
|
})
|
||||||
|
.select({ name: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; name: string }[]>(),
|
||||||
|
ProductModel.find({
|
||||||
|
householdId,
|
||||||
|
_id: { $in: [...productIds].map((x) => new Types.ObjectId(x)) },
|
||||||
|
})
|
||||||
|
.select({ brand: 1, name: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; brand: string; name: string }[]>(),
|
||||||
|
]);
|
||||||
|
const labels = new Map<string, string>();
|
||||||
|
for (const x of types) labels.set(`t:${x._id}`, x.name);
|
||||||
|
for (const x of products) labels.set(`p:${x._id}`, x.name ? `${x.brand} ${x.name}` : x.brand);
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadList(m: Membership, listId: string) {
|
||||||
|
const id = toObjectId(listId);
|
||||||
|
if (!id) return null;
|
||||||
|
return ShoppingListModel.findOne({
|
||||||
|
_id: id,
|
||||||
|
householdId: m.householdId,
|
||||||
|
deletedAt: null,
|
||||||
|
}).lean<ListLean>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ürün tipi başına son alım hareketinin giriş zamanı (öneri reddi için). */
|
||||||
|
async function lastPurchaseAt(householdId: Types.ObjectId, typeIds: Types.ObjectId[]) {
|
||||||
|
if (!typeIds.length) return new Map<string, Date>();
|
||||||
|
const rows = await StockMovementModel.aggregate<{ _id: Types.ObjectId; at: Date }>([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
householdId,
|
||||||
|
productTypeId: { $in: typeIds },
|
||||||
|
kind: "purchase",
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $group: { _id: "$productTypeId", at: { $max: "$createdAt" } } },
|
||||||
|
]);
|
||||||
|
return new Map(rows.map((r) => [String(r._id), r.at]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bekleyen eşik önerileri: eşiğin altındaki ürün tipleri; hiçbir aktif listede (alınmamış kalem
|
||||||
|
* olarak, tip ya da o tipin ürünü) yoksa ve reddedilmemişse.
|
||||||
|
*/
|
||||||
|
async function pendingSuggestions(
|
||||||
|
m: Membership,
|
||||||
|
low: ProductTypeView[],
|
||||||
|
): Promise<SuggestionView[]> {
|
||||||
|
if (!low.length) return [];
|
||||||
|
const lowIds = low.map((x) => new Types.ObjectId(x.id));
|
||||||
|
const [active, dismissed, purchases] = await Promise.all([
|
||||||
|
ShoppingListModel.find({ householdId: m.householdId, deletedAt: null, status: "active" })
|
||||||
|
.select({ items: 1 })
|
||||||
|
.lean<{ items: ItemLean[] }[]>(),
|
||||||
|
ProductTypeModel.find({ householdId: m.householdId, _id: { $in: lowIds } })
|
||||||
|
.select({ suggestionDismissedAt: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; suggestionDismissedAt: Date | null }[]>(),
|
||||||
|
lastPurchaseAt(m.householdId, lowIds),
|
||||||
|
]);
|
||||||
|
const listed = new Set<string>();
|
||||||
|
const productIds: Types.ObjectId[] = [];
|
||||||
|
for (const l of active)
|
||||||
|
for (const i of l.items) {
|
||||||
|
if (i.checked) continue;
|
||||||
|
if (i.productTypeId) listed.add(String(i.productTypeId));
|
||||||
|
if (i.productId) productIds.push(i.productId);
|
||||||
|
}
|
||||||
|
if (productIds.length) {
|
||||||
|
const rows = await ProductModel.find({ householdId: m.householdId, _id: { $in: productIds } })
|
||||||
|
.select({ productTypeId: 1 })
|
||||||
|
.lean<{ productTypeId: Types.ObjectId }[]>();
|
||||||
|
for (const r of rows) listed.add(String(r.productTypeId));
|
||||||
|
}
|
||||||
|
const dismissedAt = new Map(dismissed.map((d) => [String(d._id), d.suggestionDismissedAt]));
|
||||||
|
return low
|
||||||
|
.filter(
|
||||||
|
(x) =>
|
||||||
|
!listed.has(x.id) &&
|
||||||
|
!suggestionHidden(dismissedAt.get(x.id) ?? null, purchases.get(x.id) ?? null),
|
||||||
|
)
|
||||||
|
.map((x) => ({
|
||||||
|
productTypeId: x.id,
|
||||||
|
name: x.name,
|
||||||
|
categoryPath: x.categoryPath,
|
||||||
|
unit: x.unit,
|
||||||
|
current: x.current,
|
||||||
|
target: x.targetQuantity,
|
||||||
|
pct: x.level.pct,
|
||||||
|
suggestedQuantity: x.suggestedQuantity,
|
||||||
|
suggestion: x.suggestion,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eşik önerileri (dashboard ve stok sayfası özetleri için). */
|
||||||
|
export async function getPendingSuggestions(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<SuggestionView[] | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const stock = await getStock(userId, householdId);
|
||||||
|
return stock ? pendingSuggestions(m, stock.suggestions) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Okuma ---
|
||||||
|
|
||||||
|
export async function listShoppingLists(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<{
|
||||||
|
active: ShoppingListSummary[];
|
||||||
|
completed: ShoppingListSummary[];
|
||||||
|
suggestions: number;
|
||||||
|
canWrite: boolean;
|
||||||
|
} | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const [lists, suggestions] = await Promise.all([
|
||||||
|
ShoppingListModel.find({ householdId: m.householdId, deletedAt: null })
|
||||||
|
.sort({ updatedAt: -1 })
|
||||||
|
.lean<ListLean[]>(),
|
||||||
|
getPendingSuggestions(userId, householdId),
|
||||||
|
]);
|
||||||
|
const labels = await loadLabels(m.householdId, lists);
|
||||||
|
return {
|
||||||
|
active: lists.filter((l) => l.status === "active").map((l) => summary(l, labels)),
|
||||||
|
completed: lists
|
||||||
|
.filter((l) => l.status === "completed")
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((l) => summary(l, labels)),
|
||||||
|
suggestions: suggestions?.length ?? 0,
|
||||||
|
canWrite: hasHouseholdRole(m.role, "member"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashboard: aktif listelerin özeti. */
|
||||||
|
export async function getActiveListsSummary(userId: string, householdId: string) {
|
||||||
|
const data = await listShoppingLists(userId, householdId);
|
||||||
|
if (!data) return null;
|
||||||
|
return { lists: data.active, suggestions: data.suggestions };
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemView(
|
||||||
|
i: ItemLean,
|
||||||
|
types: Map<string, ProductTypeView>,
|
||||||
|
products: Map<string, ProductView>,
|
||||||
|
archivedTypes: Map<string, { name: string; unit: string }>,
|
||||||
|
): ShoppingItemView {
|
||||||
|
const base = {
|
||||||
|
id: String(i._id),
|
||||||
|
kind: i.kind,
|
||||||
|
productTypeId: i.productTypeId ? String(i.productTypeId) : null,
|
||||||
|
productId: i.productId ? String(i.productId) : null,
|
||||||
|
text: i.text,
|
||||||
|
quantity: i.quantity,
|
||||||
|
note: i.note,
|
||||||
|
source: i.source,
|
||||||
|
checked: i.checked,
|
||||||
|
};
|
||||||
|
if (i.kind === "type") {
|
||||||
|
const ty = types.get(String(i.productTypeId));
|
||||||
|
const fallback = archivedTypes.get(String(i.productTypeId));
|
||||||
|
const unit = ty?.unit ?? fallback?.unit ?? "";
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
label: ty?.name ?? fallback?.name ?? "—",
|
||||||
|
detail: ty?.categoryPath ?? "",
|
||||||
|
unit,
|
||||||
|
quantityLabel: i.quantity === null ? "" : formatStock(i.quantity, unit),
|
||||||
|
archived: !ty,
|
||||||
|
stock: ty
|
||||||
|
? {
|
||||||
|
current: formatStock(Math.max(0, ty.current), ty.unit),
|
||||||
|
target: formatStock(ty.targetQuantity, ty.unit),
|
||||||
|
low: ty.level.low,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (i.kind === "product") {
|
||||||
|
const p = products.get(String(i.productId));
|
||||||
|
const packages = i.quantity ?? QUANTITY_SCALE;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
label: p?.label ?? "—",
|
||||||
|
detail: p ? `${p.packageLabel} · ${p.productTypeName}` : "",
|
||||||
|
unit: t.packageUnit,
|
||||||
|
quantityLabel: `${formatQuantity(packages)} ${t.packageUnit}`,
|
||||||
|
archived: !p || p.archived,
|
||||||
|
stock: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
label: i.text,
|
||||||
|
detail: "",
|
||||||
|
unit: i.unit,
|
||||||
|
quantityLabel: i.quantity === null ? "" : formatQuantity(i.quantity, i.unit || undefined),
|
||||||
|
archived: false,
|
||||||
|
stock: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildPlan(
|
||||||
|
userId: string,
|
||||||
|
m: Membership,
|
||||||
|
items: ItemLean[],
|
||||||
|
products: Map<string, ProductView>,
|
||||||
|
types: Map<string, ProductTypeView>,
|
||||||
|
): Promise<ShoppingPlanView> {
|
||||||
|
const planItems = items
|
||||||
|
.filter((i) => i.kind !== "text")
|
||||||
|
.map((i) => {
|
||||||
|
const p = i.productId ? products.get(String(i.productId)) : undefined;
|
||||||
|
const typeId = i.kind === "product" ? (p?.productTypeId ?? "") : String(i.productTypeId);
|
||||||
|
return {
|
||||||
|
id: String(i._id),
|
||||||
|
productTypeId: typeId,
|
||||||
|
quantity: requiredQuantity(i, p?.inTypeUnit ?? null),
|
||||||
|
productId: i.kind === "product" ? String(i.productId) : null,
|
||||||
|
bulky: types.get(typeId)?.bulky ?? false,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((i) => i.productTypeId);
|
||||||
|
const typeIds = [...new Set(planItems.map((i) => i.productTypeId))].map(
|
||||||
|
(x) => new Types.ObjectId(x),
|
||||||
|
);
|
||||||
|
const [offers, stores, household, accounts] = await Promise.all([
|
||||||
|
loadTypeOffers(m.householdId, typeIds, { activeOnly: true }),
|
||||||
|
StoreModel.find({ householdId: m.householdId }).sort({ createdAt: 1, _id: 1 }).lean<
|
||||||
|
{
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
branch: string;
|
||||||
|
kind: StoreKind;
|
||||||
|
distanceM: number | null;
|
||||||
|
carryDifficulty: number;
|
||||||
|
deliveryFeeMinor: number | null;
|
||||||
|
freeDeliveryMinMinor: number | null;
|
||||||
|
mealCardAccepted: boolean;
|
||||||
|
mealCardProviders: string[];
|
||||||
|
archivedAt: Date | null;
|
||||||
|
}[]
|
||||||
|
>(),
|
||||||
|
HouseholdModel.findById(m.householdId).select({ settings: 1 }).lean(),
|
||||||
|
listAccounts(userId, String(m.householdId)),
|
||||||
|
]);
|
||||||
|
const mealCards = (accounts?.accounts ?? [])
|
||||||
|
.filter(
|
||||||
|
(a) =>
|
||||||
|
a.type === "meal_card" &&
|
||||||
|
!a.archived &&
|
||||||
|
a.mealCardProvider &&
|
||||||
|
a.balanceMinor !== null &&
|
||||||
|
a.balanceMinor > 0,
|
||||||
|
)
|
||||||
|
.map((a) => ({ provider: a.mealCardProvider!, balanceMinor: a.balanceMinor! }));
|
||||||
|
const active = stores.filter((s) => !s.archivedAt);
|
||||||
|
const threshold = household?.settings?.extraStoreSavingThresholdMinor ?? 10_000;
|
||||||
|
const result = planShopping({
|
||||||
|
items: planItems,
|
||||||
|
offers,
|
||||||
|
stores: active.map((s) => ({
|
||||||
|
id: String(s._id),
|
||||||
|
kind: s.kind,
|
||||||
|
distanceM: s.distanceM,
|
||||||
|
carryDifficulty: s.carryDifficulty,
|
||||||
|
deliveryFeeMinor: s.deliveryFeeMinor,
|
||||||
|
freeDeliveryMinMinor: s.freeDeliveryMinMinor,
|
||||||
|
mealCardProviders: s.mealCardAccepted ? s.mealCardProviders : [],
|
||||||
|
})),
|
||||||
|
mealCards,
|
||||||
|
extraStoreThresholdMinor: threshold,
|
||||||
|
});
|
||||||
|
// Planda geçen ürünlerin etiketleri (listede olmayan muadil markalar dahil).
|
||||||
|
const used = new Set<string>();
|
||||||
|
for (const o of [result.single, result.recommended])
|
||||||
|
for (const g of o?.stores ?? []) for (const l of g.lines) used.add(l.productId);
|
||||||
|
for (const l of Object.values(result.cheapest)) used.add(l.productId);
|
||||||
|
const missing = [...used].filter((id) => !products.has(id));
|
||||||
|
if (missing.length) {
|
||||||
|
const extra = await listProducts(userId, String(m.householdId), { includeArchived: true });
|
||||||
|
for (const p of extra?.products ?? []) products.set(p.id, p);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
stores: Object.fromEntries(
|
||||||
|
stores.map((s, i) => [
|
||||||
|
String(s._id),
|
||||||
|
{
|
||||||
|
id: String(s._id),
|
||||||
|
label: storeLabel(s),
|
||||||
|
kind: s.kind,
|
||||||
|
slot: seriesSlot(i),
|
||||||
|
mealCard: s.mealCardAccepted,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
products: Object.fromEntries(
|
||||||
|
[...used].flatMap((id) => {
|
||||||
|
const p = products.get(id);
|
||||||
|
return p
|
||||||
|
? [[id, { label: p.label, packageLabel: p.packageLabel, typeUnit: p.typeUnit }]]
|
||||||
|
: [];
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
thresholdMinor: threshold,
|
||||||
|
mealCardBalanceMinor: mealCards.reduce((a, c) => a + c.balanceMinor, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getShoppingList(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
): Promise<ShoppingListDetail | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const list = await loadList(m, listId);
|
||||||
|
if (!list) return null;
|
||||||
|
const [stock, productList, others, labels] = await Promise.all([
|
||||||
|
getStock(userId, householdId),
|
||||||
|
listProducts(userId, householdId, { includeArchived: true }),
|
||||||
|
ShoppingListModel.find({
|
||||||
|
householdId: m.householdId,
|
||||||
|
deletedAt: null,
|
||||||
|
status: "active",
|
||||||
|
_id: { $ne: list._id },
|
||||||
|
})
|
||||||
|
.select({ name: 1 })
|
||||||
|
.sort({ updatedAt: -1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; name: string }[]>(),
|
||||||
|
loadLabels(m.householdId, [list]),
|
||||||
|
]);
|
||||||
|
if (!stock || !productList) return null;
|
||||||
|
const types = new Map(stock.items.map((x) => [x.id, x]));
|
||||||
|
const products = new Map(productList.products.map((p) => [p.id, p]));
|
||||||
|
// Arşivlenmiş ürün tiplerinin adı/birimi (getStock yalnızca aktifleri döner).
|
||||||
|
const archivedTypeIds = list.items
|
||||||
|
.filter((i) => i.productTypeId && !types.has(String(i.productTypeId)))
|
||||||
|
.map((i) => i.productTypeId!);
|
||||||
|
const archivedTypes = new Map(
|
||||||
|
(
|
||||||
|
await ProductTypeModel.find({ householdId: m.householdId, _id: { $in: archivedTypeIds } })
|
||||||
|
.select({ name: 1, unit: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; name: string; unit: string }[]>()
|
||||||
|
).map((x) => [String(x._id), { name: x.name, unit: x.unit }]),
|
||||||
|
);
|
||||||
|
const items = list.items
|
||||||
|
.map((i) => itemView(i, types, products, archivedTypes))
|
||||||
|
.sort((a, b) => Number(a.checked) - Number(b.checked));
|
||||||
|
const isActive = list.status === "active";
|
||||||
|
const canWrite = hasHouseholdRole(m.role, "member") && isActive;
|
||||||
|
const [suggestions, plan] = await Promise.all([
|
||||||
|
isActive ? pendingSuggestions(m, stock.suggestions) : Promise.resolve([]),
|
||||||
|
buildPlan(userId, m, list.items, products, types),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
list: summary(list, labels),
|
||||||
|
items,
|
||||||
|
suggestions,
|
||||||
|
plan,
|
||||||
|
options: {
|
||||||
|
types: stock.items
|
||||||
|
.map((x) => ({ id: x.id, name: x.name, unit: x.unit, path: x.categoryPath }))
|
||||||
|
.sort((a, b) => compareTr(a.name, b.name)),
|
||||||
|
products: productList.products
|
||||||
|
.filter((p) => !p.archived && !p.conversionMissing && types.has(p.productTypeId))
|
||||||
|
.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
label: p.label,
|
||||||
|
packageLabel: p.packageLabel,
|
||||||
|
productTypeId: p.productTypeId,
|
||||||
|
typeName: p.productTypeName,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
otherLists: others.map((o) => ({ id: String(o._id), name: o.name })),
|
||||||
|
canWrite,
|
||||||
|
report: list.completion
|
||||||
|
? {
|
||||||
|
completion: list.completion,
|
||||||
|
summary: compareShopping(
|
||||||
|
list.completion.rows.map((r) => ({
|
||||||
|
match: r.match,
|
||||||
|
plan: r.plan,
|
||||||
|
actual:
|
||||||
|
r.amountMinor === null
|
||||||
|
? null
|
||||||
|
: { productId: r.productId, packages: r.packages, amountMinor: r.amountMinor },
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Yazma: listeler ---
|
||||||
|
|
||||||
|
export async function writableList(actorId: string, householdId: string, listId: string) {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return { error: fail(tr.common.unauthorized) } as const;
|
||||||
|
const list = await loadList(m, listId);
|
||||||
|
if (!list) return { error: fail(tr.common.notFound) } as const;
|
||||||
|
if (list.status !== "active") return { error: fail(t.validation.notActive) } as const;
|
||||||
|
return { error: null, m, list, actor: new Types.ObjectId(actorId) } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createShoppingList(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: ShoppingListForm,
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const doc = await ShoppingListModel.create({
|
||||||
|
householdId: m.householdId,
|
||||||
|
name: input.name,
|
||||||
|
note: input.note,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
return ok({ id: String(doc._id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateShoppingList(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
input: ShoppingListForm,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: r.list._id },
|
||||||
|
{ $set: { name: input.name, note: input.note, updatedBy: r.actor } },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste silme (soft delete, audit). Tamamlanmış liste de silinebilir. */
|
||||||
|
export async function deleteShoppingList(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const list = await loadList(m, listId);
|
||||||
|
if (!list) return fail(tr.common.notFound);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: list._id },
|
||||||
|
{ $set: { deletedAt: new Date(), updatedBy: actor } },
|
||||||
|
);
|
||||||
|
await writeAudit({
|
||||||
|
action: "shoppingList.delete",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: m.householdId,
|
||||||
|
targetType: "shoppingList",
|
||||||
|
targetId: listId,
|
||||||
|
diff: { name: list.name, status: list.status, items: list.items.length },
|
||||||
|
});
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Yazma: kalemler ---
|
||||||
|
|
||||||
|
/** Ürün tipi / ürün aktif mi; aynı kalem listede alınmamış olarak var mı? */
|
||||||
|
async function checkItemRef(
|
||||||
|
m: Membership,
|
||||||
|
list: ListLean,
|
||||||
|
input: Pick<ShoppingItemForm, "kind" | "productTypeId" | "productId">,
|
||||||
|
): Promise<ActionResult<never> | null> {
|
||||||
|
if (input.kind === "type") {
|
||||||
|
const ok = await ProductTypeModel.exists({
|
||||||
|
_id: new Types.ObjectId(input.productTypeId!),
|
||||||
|
householdId: m.householdId,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
if (!ok) return fail(t.validation.productType, { productTypeId: t.validation.productType });
|
||||||
|
}
|
||||||
|
if (input.kind === "product") {
|
||||||
|
const ok = await ProductModel.exists({
|
||||||
|
_id: new Types.ObjectId(input.productId!),
|
||||||
|
householdId: m.householdId,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
if (!ok) return fail(t.validation.product, { productId: t.validation.product });
|
||||||
|
}
|
||||||
|
const key = refKey({
|
||||||
|
kind: input.kind,
|
||||||
|
productTypeId: input.productTypeId ? new Types.ObjectId(input.productTypeId) : null,
|
||||||
|
productId: input.productId ? new Types.ObjectId(input.productId) : null,
|
||||||
|
});
|
||||||
|
if (key && list.items.some((i) => !i.checked && refKey(i) === key))
|
||||||
|
return fail(t.validation.duplicate);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addShoppingItem(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
input: ShoppingItemForm,
|
||||||
|
source: ShoppingItemSource = "manual",
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const bad = await checkItemRef(r.m, r.list, input);
|
||||||
|
if (bad) return bad;
|
||||||
|
const _id = new Types.ObjectId();
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: r.list._id },
|
||||||
|
{
|
||||||
|
$push: {
|
||||||
|
items: {
|
||||||
|
_id,
|
||||||
|
kind: input.kind,
|
||||||
|
productTypeId: input.productTypeId ? new Types.ObjectId(input.productTypeId) : null,
|
||||||
|
productId: input.productId ? new Types.ObjectId(input.productId) : null,
|
||||||
|
text: input.text,
|
||||||
|
quantity: input.quantity,
|
||||||
|
unit: input.unit,
|
||||||
|
note: input.note,
|
||||||
|
source,
|
||||||
|
addedBy: r.actor,
|
||||||
|
addedAt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
$set: { updatedBy: r.actor },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return ok({ id: String(_id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateShoppingItem(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
itemId: string,
|
||||||
|
input: ShoppingItemUpdate,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const item = r.list.items.find((i) => String(i._id) === itemId);
|
||||||
|
if (!item) return fail(tr.common.notFound);
|
||||||
|
if (item.kind === "text" && !input.text)
|
||||||
|
return fail(t.validation.text, { text: t.validation.text });
|
||||||
|
const set: Record<string, unknown> = {
|
||||||
|
"items.$.quantity": input.quantity,
|
||||||
|
"items.$.note": input.note,
|
||||||
|
updatedBy: r.actor,
|
||||||
|
};
|
||||||
|
if (item.kind === "text") {
|
||||||
|
set["items.$.text"] = input.text;
|
||||||
|
set["items.$.unit"] = input.unit;
|
||||||
|
}
|
||||||
|
await ShoppingListModel.updateOne({ _id: r.list._id, "items._id": item._id }, { $set: set });
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeShoppingItem(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
itemId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const id = toObjectId(itemId);
|
||||||
|
if (!id || !r.list.items.some((i) => i._id.equals(id))) return fail(tr.common.notFound);
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: r.list._id },
|
||||||
|
{ $pull: { items: { _id: id } }, $set: { updatedBy: r.actor } },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alışveriş modunda sepete alındı / geri alındı. */
|
||||||
|
export async function setShoppingItemChecked(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
itemId: string,
|
||||||
|
checked: boolean,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const id = toObjectId(itemId);
|
||||||
|
if (!id) return fail(tr.common.notFound);
|
||||||
|
const res = await ShoppingListModel.updateOne(
|
||||||
|
{ _id: r.list._id, "items._id": id },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
"items.$.checked": checked,
|
||||||
|
"items.$.checkedBy": checked ? r.actor : null,
|
||||||
|
"items.$.checkedAt": checked ? new Date() : null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return res.matchedCount ? ok(undefined) : fail(tr.common.notFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kalemi başka bir aktif listeye taşır. */
|
||||||
|
export async function moveShoppingItem(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
itemId: string,
|
||||||
|
targetListId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const target = await writableList(actorId, householdId, targetListId);
|
||||||
|
if (target.error) return target.error;
|
||||||
|
if (target.list._id.equals(r.list._id)) return fail(t.validation.sameList);
|
||||||
|
const item = r.list.items.find((i) => String(i._id) === itemId);
|
||||||
|
if (!item) return fail(tr.common.notFound);
|
||||||
|
const key = refKey(item);
|
||||||
|
if (key && target.list.items.some((i) => !i.checked && refKey(i) === key))
|
||||||
|
return fail(t.validation.duplicateIn(target.list.name));
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: target.list._id },
|
||||||
|
{
|
||||||
|
$push: { items: { ...item, checked: false, checkedBy: null, checkedAt: null } },
|
||||||
|
$set: { updatedBy: r.actor },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: r.list._id },
|
||||||
|
{ $pull: { items: { _id: item._id } }, $set: { updatedBy: r.actor } },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Eşik önerileri ---
|
||||||
|
|
||||||
|
/** Önerileri onayla: önerilen miktarla ürün tipi kalemi olarak eklenir. */
|
||||||
|
export async function approveSuggestions(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
productTypeIds: string[],
|
||||||
|
): Promise<ActionResult<{ added: number }>> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const stock = await getStock(actorId, householdId);
|
||||||
|
if (!stock) return fail(tr.common.unauthorized);
|
||||||
|
const pending = new Map(
|
||||||
|
(await pendingSuggestions(r.m, stock.suggestions)).map((x) => [x.productTypeId, x]),
|
||||||
|
);
|
||||||
|
const items = productTypeIds
|
||||||
|
.map((id) => pending.get(id))
|
||||||
|
.filter((x): x is SuggestionView => Boolean(x))
|
||||||
|
.map((x) => ({
|
||||||
|
_id: new Types.ObjectId(),
|
||||||
|
kind: "type" as const,
|
||||||
|
productTypeId: new Types.ObjectId(x.productTypeId),
|
||||||
|
productId: null,
|
||||||
|
text: "",
|
||||||
|
quantity: x.suggestedQuantity > 0 ? x.suggestedQuantity : null,
|
||||||
|
unit: "",
|
||||||
|
note: "",
|
||||||
|
source: "suggestion" as const,
|
||||||
|
addedBy: r.actor,
|
||||||
|
addedAt: new Date(),
|
||||||
|
}));
|
||||||
|
if (!items.length) return fail(t.validation.suggestionGone);
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: r.list._id },
|
||||||
|
{ $push: { items: { $each: items } }, $set: { updatedBy: r.actor } },
|
||||||
|
);
|
||||||
|
return ok({ added: items.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Öneriyi reddet: stok yenilenene (yeni alım) kadar gösterilmez. */
|
||||||
|
export async function dismissSuggestion(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
productTypeId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const id = toObjectId(productTypeId);
|
||||||
|
const res = id
|
||||||
|
? await ProductTypeModel.updateOne(
|
||||||
|
{ _id: id, householdId: m.householdId, archivedAt: null },
|
||||||
|
{ $set: { suggestionDismissedAt: new Date() } },
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
return res?.matchedCount ? ok(undefined) : fail(tr.common.notFound);
|
||||||
|
}
|
||||||
@@ -78,9 +78,12 @@ function Stat({ label, children }: { label: string; children: React.ReactNode })
|
|||||||
export function ProductDetailView({
|
export function ProductDetailView({
|
||||||
data,
|
data,
|
||||||
categories,
|
categories,
|
||||||
|
products,
|
||||||
}: {
|
}: {
|
||||||
data: ProductTypeDetail;
|
data: ProductTypeDetail;
|
||||||
categories: StockOption[];
|
categories: StockOption[];
|
||||||
|
/** Marka/paket bölümü (Faz 12). */
|
||||||
|
products?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [pending, setPending] = useState<Pending>(null);
|
const [pending, setPending] = useState<Pending>(null);
|
||||||
const close = () => setPending(null);
|
const close = () => setPending(null);
|
||||||
@@ -281,6 +284,8 @@ export function ProductDetailView({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{products}
|
||||||
|
|
||||||
<section className="flex flex-col gap-2" aria-labelledby="stock-movements-title">
|
<section className="flex flex-col gap-2" aria-labelledby="stock-movements-title">
|
||||||
<h2 id="stock-movements-title" className="text-base font-medium">
|
<h2 id="stock-movements-title" className="text-base font-medium">
|
||||||
{t.detail.movements}
|
{t.detail.movements}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { PageHeader } from "@/components/page-header";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import { formatUnitPrice } from "@/shared/prices";
|
||||||
import { formatStock } from "@/shared/stock";
|
import { formatStock } from "@/shared/stock";
|
||||||
import type { StockAnalysis, StockAnalysisRow } from "../service";
|
import type { StockAnalysis, StockAnalysisRow } from "../service";
|
||||||
|
|
||||||
@@ -72,6 +74,7 @@ function Num({ value, unit, tone }: { value: number | null; unit: string; tone?:
|
|||||||
export function StockAnalysisView({ data }: { data: StockAnalysis }) {
|
export function StockAnalysisView({ data }: { data: StockAnalysis }) {
|
||||||
const groups = new Map<string, StockAnalysisRow[]>();
|
const groups = new Map<string, StockAnalysisRow[]>();
|
||||||
for (const r of data.rows) groups.set(r.categoryPath, [...(groups.get(r.categoryPath) ?? []), r]);
|
for (const r of data.rows) groups.set(r.categoryPath, [...(groups.get(r.categoryPath) ?? []), r]);
|
||||||
|
const totalCost = data.rows.reduce((sum, r) => sum + (r.avgMonthlyCostMinor ?? 0), 0);
|
||||||
const first = data.months[0]!;
|
const first = data.months[0]!;
|
||||||
const last = data.months.at(-1)!;
|
const last = data.months.at(-1)!;
|
||||||
|
|
||||||
@@ -91,6 +94,7 @@ export function StockAnalysisView({ data }: { data: StockAnalysis }) {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{totalCost > 0 ? <p className="text-sm">{t.totalCost(formatMoney(totalCost))}</p> : null}
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{t.trend(data.months.length)}: {monthShort(first)} – {monthShort(last)}
|
{t.trend(data.months.length)}: {monthShort(first)} – {monthShort(last)}
|
||||||
@@ -106,10 +110,11 @@ export function StockAnalysisView({ data }: { data: StockAnalysis }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-xl border bg-card">
|
<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">
|
<div className="hidden grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_repeat(5,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.product}</span>
|
||||||
<span>{t.trend(data.months.length)}</span>
|
<span>{t.trend(data.months.length)}</span>
|
||||||
<span className="text-right">{t.avgUsed}</span>
|
<span className="text-right">{t.avgUsed}</span>
|
||||||
|
<span className="text-right">{t.avgCost}</span>
|
||||||
<span className="text-right">{t.avgWasted}</span>
|
<span className="text-right">{t.avgWasted}</span>
|
||||||
<span className="text-right">{t.wastePct}</span>
|
<span className="text-right">{t.wastePct}</span>
|
||||||
<span className="text-right">{t.thisMonth}</span>
|
<span className="text-right">{t.thisMonth}</span>
|
||||||
@@ -125,7 +130,7 @@ export function StockAnalysisView({ data }: { data: StockAnalysis }) {
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={r.id}
|
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))]"
|
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(5,minmax(0,0.7fr))]"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/stok/${r.id}`}
|
href={`/stok/${r.id}`}
|
||||||
@@ -140,6 +145,29 @@ export function StockAnalysisView({ data }: { data: StockAnalysis }) {
|
|||||||
</span>
|
</span>
|
||||||
<Num value={r.avgMonthlyUsed} unit={r.unit} />
|
<Num value={r.avgMonthlyUsed} unit={r.unit} />
|
||||||
</p>
|
</p>
|
||||||
|
<p className="flex justify-between gap-2 md:block md:text-right">
|
||||||
|
<span className="text-xs text-muted-foreground md:hidden">
|
||||||
|
{t.avgCost}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"tabular-nums",
|
||||||
|
r.avgMonthlyCostMinor === null && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
r.unitPrice
|
||||||
|
? t.costHint(
|
||||||
|
formatUnitPrice(r.unitPrice.minor, r.unit),
|
||||||
|
formatDate(`${r.unitPrice.date}T12:00:00+03:00`),
|
||||||
|
)
|
||||||
|
: t.noPrice
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{r.avgMonthlyCostMinor === null
|
||||||
|
? t.noData
|
||||||
|
: formatMoney(r.avgMonthlyCostMinor)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
<p className="flex justify-between gap-2 md:block md:text-right">
|
<p className="flex justify-between gap-2 md:block md:text-right">
|
||||||
<span className="text-xs text-muted-foreground md:hidden">
|
<span className="text-xs text-muted-foreground md:hidden">
|
||||||
{t.avgWasted}
|
{t.avgWasted}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -73,6 +74,7 @@ export function ProductTypeFormDialog({
|
|||||||
step: item ? quantityInput(item.step) : "1",
|
step: item ? quantityInput(item.step) : "1",
|
||||||
countEveryDays: item?.countEveryDays ? String(item.countEveryDays) : "",
|
countEveryDays: item?.countEveryDays ? String(item.countEveryDays) : "",
|
||||||
note: item?.note ?? "",
|
note: item?.note ?? "",
|
||||||
|
bulky: item?.bulky ?? false,
|
||||||
initialQuantity: "",
|
initialQuantity: "",
|
||||||
});
|
});
|
||||||
const [errors, setErrors] = useState<Errors>({});
|
const [errors, setErrors] = useState<Errors>({});
|
||||||
@@ -93,6 +95,7 @@ export function ProductTypeFormDialog({
|
|||||||
step: v.step,
|
step: v.step,
|
||||||
countEveryDays: v.countEveryDays,
|
countEveryDays: v.countEveryDays,
|
||||||
note: v.note,
|
note: v.note,
|
||||||
|
bulky: v.bulky,
|
||||||
initialQuantity: item ? "" : v.initialQuantity,
|
initialQuantity: item ? "" : v.initialQuantity,
|
||||||
});
|
});
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -285,6 +288,17 @@ export function ProductTypeFormDialog({
|
|||||||
<FieldDescription className="-mt-2">
|
<FieldDescription className="-mt-2">
|
||||||
{f.stepHint} {f.countEveryDaysHint}
|
{f.stepHint} {f.countEveryDaysHint}
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox
|
||||||
|
id="pt-bulky"
|
||||||
|
checked={v.bulky}
|
||||||
|
onCheckedChange={(x) => set("bulky", x === true)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<FieldLabel htmlFor="pt-bulky">{f.bulky}</FieldLabel>
|
||||||
|
<FieldDescription>{f.bulkyHint}</FieldDescription>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="pt-note">{f.note}</FieldLabel>
|
<FieldLabel htmlFor="pt-note">{f.note}</FieldLabel>
|
||||||
<Input id="pt-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
<Input id="pt-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
FolderTree,
|
FolderTree,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
|
Tags,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -164,6 +165,12 @@ export function StockView({ data, options }: { data: Data; options: StockOption[
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href="/stok/urunler">
|
||||||
|
<Tags />
|
||||||
|
{tr.products.catalogLink}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href="/stok/analiz">
|
<Link href="/stok/analiz">
|
||||||
<ChartColumn />
|
<ChartColumn />
|
||||||
@@ -209,7 +216,12 @@ export function StockView({ data, options }: { data: Data; options: StockOption[
|
|||||||
<CardTitle className="text-base">{t.suggestions.title}</CardTitle>
|
<CardTitle className="text-base">{t.suggestions.title}</CardTitle>
|
||||||
<CardDescription>{t.suggestions.description}</CardDescription>
|
<CardDescription>{t.suggestions.description}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
{data.suggestions.length ? (
|
||||||
|
<Link href="/alisveris" className="text-sm text-primary hover:underline">
|
||||||
|
{t.suggestions.toShopping}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
{data.suggestions.length === 0 ? (
|
{data.suggestions.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{t.suggestions.empty}</p>
|
<p className="text-sm text-muted-foreground">{t.suggestions.empty}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@/features/categories/tree";
|
} from "@/features/categories/tree";
|
||||||
import { hasHouseholdRole } from "@/features/households/roles";
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
import { resolveMembership, type Membership } from "@/features/households/service";
|
import { resolveMembership, type Membership } from "@/features/households/service";
|
||||||
|
import { latestTypeUnitPrices } from "@/features/prices/service";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
import { writeAudit } from "@/lib/audit";
|
import { writeAudit } from "@/lib/audit";
|
||||||
@@ -24,6 +25,7 @@ import { StockCountModel } from "@/models/stock-count";
|
|||||||
import { StockMovementModel } from "@/models/stock-movement";
|
import { StockMovementModel } from "@/models/stock-movement";
|
||||||
import { todayIstanbul } from "@/shared/dates";
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
import { shiftMonth } from "@/shared/budget";
|
import { shiftMonth } from "@/shared/budget";
|
||||||
|
import { QUANTITY_SCALE } from "@/shared/quantity";
|
||||||
import type {
|
import type {
|
||||||
ProductTypeForm,
|
ProductTypeForm,
|
||||||
StockCategoryForm,
|
StockCategoryForm,
|
||||||
@@ -85,6 +87,7 @@ type ProductLean = {
|
|||||||
step: number;
|
step: number;
|
||||||
countEveryDays: number | null;
|
countEveryDays: number | null;
|
||||||
note: string;
|
note: string;
|
||||||
|
bulky?: boolean;
|
||||||
archivedAt: Date | null;
|
archivedAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
};
|
};
|
||||||
@@ -130,6 +133,7 @@ export type ProductTypeView = {
|
|||||||
step: number;
|
step: number;
|
||||||
countEveryDays: number | null;
|
countEveryDays: number | null;
|
||||||
note: string;
|
note: string;
|
||||||
|
bulky: boolean;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
current: number;
|
current: number;
|
||||||
level: StockLevel;
|
level: StockLevel;
|
||||||
@@ -329,6 +333,7 @@ function toView(p: ProductLean, moves: MoveLean[], ctx: ViewContext): ProductTyp
|
|||||||
step: p.step,
|
step: p.step,
|
||||||
countEveryDays: p.countEveryDays,
|
countEveryDays: p.countEveryDays,
|
||||||
note: p.note,
|
note: p.note,
|
||||||
|
bulky: Boolean(p.bulky),
|
||||||
archived: Boolean(p.archivedAt),
|
archived: Boolean(p.archivedAt),
|
||||||
current,
|
current,
|
||||||
level,
|
level,
|
||||||
@@ -805,6 +810,7 @@ const productFields = (input: ProductTypeForm) => ({
|
|||||||
step: input.step,
|
step: input.step,
|
||||||
countEveryDays: input.countEveryDays,
|
countEveryDays: input.countEveryDays,
|
||||||
note: input.note,
|
note: input.note,
|
||||||
|
bulky: input.bulky,
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function createProductType(
|
export async function createProductType(
|
||||||
@@ -1242,6 +1248,10 @@ export type StockAnalysisRow = {
|
|||||||
avgMonthlyWasted: number | null;
|
avgMonthlyWasted: number | null;
|
||||||
/** Fire / (kullanım + fire), %. */
|
/** Fire / (kullanım + fire), %. */
|
||||||
wastePct: number | null;
|
wastePct: number | null;
|
||||||
|
/** Son bilinen birim fiyat (ürün tipi biriminde, kesirli kuruş) ve tarihi. */
|
||||||
|
unitPrice: { minor: number; date: string } | null;
|
||||||
|
/** Aylık ortalama maliyet tahmini: ortalama tüketim × son birim fiyat (kuruş). */
|
||||||
|
avgMonthlyCostMinor: number | null;
|
||||||
/** Bu ay (bugüne kadar). */
|
/** Bu ay (bugüne kadar). */
|
||||||
thisMonth: MonthUsage;
|
thisMonth: MonthUsage;
|
||||||
};
|
};
|
||||||
@@ -1264,10 +1274,11 @@ export async function getStockAnalysis(
|
|||||||
shiftMonth(thisMonth, i - ANALYSIS_MONTHS),
|
shiftMonth(thisMonth, i - ANALYSIS_MONTHS),
|
||||||
);
|
);
|
||||||
const m = (await resolveMembership(userId, householdId, "viewer"))!;
|
const m = (await resolveMembership(userId, householdId, "viewer"))!;
|
||||||
const moves = await loadMoves(
|
const ids = data.items.map((i) => new Types.ObjectId(i.id));
|
||||||
m.householdId,
|
const [moves, prices] = await Promise.all([
|
||||||
data.items.map((i) => new Types.ObjectId(i.id)),
|
loadMoves(m.householdId, ids),
|
||||||
);
|
latestTypeUnitPrices(m.householdId, ids, data.today),
|
||||||
|
]);
|
||||||
const rows = data.items.map((item): StockAnalysisRow => {
|
const rows = data.items.map((item): StockAnalysisRow => {
|
||||||
const list = moves.get(item.id) ?? [];
|
const list = moves.get(item.id) ?? [];
|
||||||
const [usage, current] = [monthlyUsage(list, months), monthlyUsage(list, [thisMonth])[0]!];
|
const [usage, current] = [monthlyUsage(list, months), monthlyUsage(list, [thisMonth])[0]!];
|
||||||
@@ -1277,6 +1288,8 @@ export async function getStockAnalysis(
|
|||||||
const sum = (k: "used" | "wasted") => full.reduce((s, u) => s + u[k], 0);
|
const sum = (k: "used" | "wasted") => full.reduce((s, u) => s + u[k], 0);
|
||||||
const used = sum("used");
|
const used = sum("used");
|
||||||
const wasted = sum("wasted");
|
const wasted = sum("wasted");
|
||||||
|
const price = prices.get(item.id) ?? null;
|
||||||
|
const avgUsed = full.length ? used / full.length : null;
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
@@ -1287,6 +1300,11 @@ export async function getStockAnalysis(
|
|||||||
avgMonthlyUsed: full.length ? roundForDisplay(used / full.length) : null,
|
avgMonthlyUsed: full.length ? roundForDisplay(used / full.length) : null,
|
||||||
avgMonthlyWasted: full.length ? roundForDisplay(wasted / full.length) : null,
|
avgMonthlyWasted: full.length ? roundForDisplay(wasted / full.length) : null,
|
||||||
wastePct: used + wasted > 0 ? Math.round((wasted * 100) / (used + wasted)) : null,
|
wastePct: used + wasted > 0 ? Math.round((wasted * 100) / (used + wasted)) : null,
|
||||||
|
unitPrice: price ? { minor: price.unitPriceMinor, date: price.date } : null,
|
||||||
|
avgMonthlyCostMinor:
|
||||||
|
price && avgUsed !== null
|
||||||
|
? Math.round((avgUsed * price.unitPriceMinor) / QUANTITY_SCALE)
|
||||||
|
: null,
|
||||||
thisMonth: current,
|
thisMonth: current,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { getHouseholdActionContext } from "@/lib/dal";
|
||||||
|
import { storeFormSchema } from "@/shared/schemas/store";
|
||||||
|
import { createStore, setStoreArchived, updateStore } 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yeni (id boş) veya mevcut mağaza. */
|
||||||
|
export async function saveStoreAction(storeId: unknown, input: unknown) {
|
||||||
|
if (storeId) {
|
||||||
|
const id = idSchema.safeParse(storeId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(storeFormSchema, input, (u, h, d) => updateStore(u, h, id.data, d));
|
||||||
|
}
|
||||||
|
return run(storeFormSchema, input, createStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStoreArchivedAction(storeId: unknown, archived: unknown) {
|
||||||
|
const id = idSchema.safeParse(storeId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.boolean(), archived, (u, h, a) => setStoreArchived(u, h, id.data, a));
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
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 { MEAL_CARD_PROVIDERS, type MealCardProvider } from "@/shared/accounts";
|
||||||
|
import { formatMoneyInput } from "@/shared/money";
|
||||||
|
import { quantityInput } from "@/shared/stock";
|
||||||
|
import { effortScore, STORE_KINDS, type StoreKind } from "@/shared/stores";
|
||||||
|
import { saveStoreAction } from "../actions";
|
||||||
|
import type { StoreView } from "../service";
|
||||||
|
|
||||||
|
const t = tr.stores;
|
||||||
|
type Errors = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
/** Yeni mağaza / düzenle. Online mağazada uzaklık yerine teslimat ücretleri. */
|
||||||
|
export function StoreFormDialog({
|
||||||
|
store,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
store: StoreView | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [v, setV] = useState({
|
||||||
|
name: store?.name ?? "",
|
||||||
|
kind: (store?.kind ?? "market") as StoreKind,
|
||||||
|
branch: store?.branch ?? "",
|
||||||
|
distanceKm: store?.distanceM != null ? quantityInput(store.distanceM) : "",
|
||||||
|
carryDifficulty: String(store?.carryDifficulty ?? 2),
|
||||||
|
deliveryFee: store?.deliveryFeeMinor != null ? formatMoneyInput(store.deliveryFeeMinor) : "",
|
||||||
|
freeDeliveryMin:
|
||||||
|
store?.freeDeliveryMinMinor != null ? formatMoneyInput(store.freeDeliveryMinMinor) : "",
|
||||||
|
mealCardAccepted: store?.mealCardAccepted ?? false,
|
||||||
|
mealCardProviders: (store?.mealCardProviders ?? []) as MealCardProvider[],
|
||||||
|
note: store?.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 }));
|
||||||
|
const online = v.kind === "online";
|
||||||
|
const f = t.fields;
|
||||||
|
|
||||||
|
// Önizleme: girilen uzaklık ve taşıma puanıyla zahmet skoru.
|
||||||
|
const meters = Number(v.distanceKm.replace(",", ".")) * 1000;
|
||||||
|
const preview = effortScore({
|
||||||
|
kind: v.kind,
|
||||||
|
distanceM: Number.isFinite(meters) && v.distanceKm.trim() ? meters : null,
|
||||||
|
carryDifficulty: Number(v.carryDifficulty),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const r = await saveStoreAction(store?.id ?? null, {
|
||||||
|
...v,
|
||||||
|
carryDifficulty: v.carryDifficulty,
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.saved);
|
||||||
|
onClose();
|
||||||
|
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleProvider = (p: MealCardProvider, on: boolean) =>
|
||||||
|
set(
|
||||||
|
"mealCardProviders",
|
||||||
|
on ? [...v.mealCardProviders, p] : v.mealCardProviders.filter((x) => x !== p),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={store ? t.editTitle : t.createTitle}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="store-form" disabled={busy}>
|
||||||
|
{tr.common.save}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="store-form" onSubmit={submit} noValidate>
|
||||||
|
<FieldGroup>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field data-invalid={Boolean(errors.name)} className="col-span-2 sm:col-span-1">
|
||||||
|
<FieldLabel htmlFor="st-name">{f.name}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="st-name"
|
||||||
|
value={v.name}
|
||||||
|
placeholder={f.namePlaceholder}
|
||||||
|
onChange={(e) => set("name", e.target.value)}
|
||||||
|
aria-invalid={Boolean(errors.name)}
|
||||||
|
autoFocus={!store}
|
||||||
|
/>
|
||||||
|
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field className="col-span-2 sm:col-span-1">
|
||||||
|
<FieldLabel htmlFor="st-branch">{f.branch}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="st-branch"
|
||||||
|
value={v.branch}
|
||||||
|
placeholder={f.branchPlaceholder}
|
||||||
|
onChange={(e) => set("branch", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="st-kind">{f.kind}</FieldLabel>
|
||||||
|
<Select value={v.kind} onValueChange={(x) => set("kind", x as StoreKind)}>
|
||||||
|
<SelectTrigger id="st-kind" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STORE_KINDS.map((k) => (
|
||||||
|
<SelectItem key={k} value={k}>
|
||||||
|
{t.kinds[k]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{online ? (
|
||||||
|
<>
|
||||||
|
<Field data-invalid={Boolean(errors.deliveryFee)}>
|
||||||
|
<FieldLabel htmlFor="st-fee">{f.deliveryFee} (₺)</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="st-fee"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={v.deliveryFee}
|
||||||
|
onChange={(e) => set("deliveryFee", e.target.value)}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.deliveryFee)}
|
||||||
|
/>
|
||||||
|
{errors.deliveryFee ? <FieldError>{errors.deliveryFee}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={Boolean(errors.freeDeliveryMin)}>
|
||||||
|
<FieldLabel htmlFor="st-free">{f.freeDeliveryMin} (₺)</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="st-free"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={v.freeDeliveryMin}
|
||||||
|
onChange={(e) => set("freeDeliveryMin", e.target.value)}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.freeDeliveryMin)}
|
||||||
|
/>
|
||||||
|
{errors.freeDeliveryMin ? (
|
||||||
|
<FieldError>{errors.freeDeliveryMin}</FieldError>
|
||||||
|
) : null}
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Field data-invalid={Boolean(errors.distanceKm)}>
|
||||||
|
<FieldLabel htmlFor="st-distance">{f.distance}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="st-distance"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={v.distanceKm}
|
||||||
|
onChange={(e) => set("distanceKm", e.target.value)}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.distanceKm)}
|
||||||
|
/>
|
||||||
|
{errors.distanceKm ? <FieldError>{errors.distanceKm}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field className={online ? "col-span-2" : undefined}>
|
||||||
|
<FieldLabel htmlFor="st-carry">{f.carry}</FieldLabel>
|
||||||
|
<Select value={v.carryDifficulty} onValueChange={(x) => set("carryDifficulty", x)}>
|
||||||
|
<SelectTrigger id="st-carry" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<SelectItem key={n} value={String(n)}>
|
||||||
|
{n} · {t.carryLevels[n]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<FieldDescription className="-mt-2">
|
||||||
|
{online ? null : `${f.distanceHint} `}
|
||||||
|
{f.carryHint} <span className="font-medium">{t.effortValue(preview)}</span>
|
||||||
|
</FieldDescription>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox
|
||||||
|
id="st-meal"
|
||||||
|
checked={v.mealCardAccepted}
|
||||||
|
onCheckedChange={(x) => set("mealCardAccepted", x === true)}
|
||||||
|
/>
|
||||||
|
<FieldLabel htmlFor="st-meal">{f.mealCard}</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
{v.mealCardAccepted ? (
|
||||||
|
<Field data-invalid={Boolean(errors.mealCardProviders)}>
|
||||||
|
<FieldLabel>{f.mealCardProviders}</FieldLabel>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{MEAL_CARD_PROVIDERS.map((p) => (
|
||||||
|
<label key={p} className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={v.mealCardProviders.includes(p)}
|
||||||
|
onCheckedChange={(x) => toggleProvider(p, x === true)}
|
||||||
|
/>
|
||||||
|
{tr.accounts.providers[p]}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{errors.mealCardProviders ? (
|
||||||
|
<FieldError>{errors.mealCardProviders}</FieldError>
|
||||||
|
) : null}
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="st-note">{f.note}</FieldLabel>
|
||||||
|
<Input id="st-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,275 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
ArchiveRestore,
|
||||||
|
EllipsisVertical,
|
||||||
|
Globe,
|
||||||
|
MapPin,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
ShoppingBasket,
|
||||||
|
Store,
|
||||||
|
Utensils,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import {
|
||||||
|
formatDistance,
|
||||||
|
sortStores,
|
||||||
|
STORE_SORTS,
|
||||||
|
type StoreKind,
|
||||||
|
type StoreSort,
|
||||||
|
} from "@/shared/stores";
|
||||||
|
import { setStoreArchivedAction } from "../actions";
|
||||||
|
import type { StoreView } from "../service";
|
||||||
|
import { StoreFormDialog } from "./store-dialog";
|
||||||
|
|
||||||
|
const t = tr.stores;
|
||||||
|
|
||||||
|
const KIND_ICON: Record<StoreKind, typeof Store> = {
|
||||||
|
market: Store,
|
||||||
|
online: Globe,
|
||||||
|
bazaar: ShoppingBasket,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Zahmet çubuğu: 0–100, düzey etiketiyle (renk tek başına anlam taşımaz). */
|
||||||
|
function EffortMeter({ store }: { store: StoreView }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2" title={t.effortHint}>
|
||||||
|
<span className="w-14 shrink-0 text-xs text-muted-foreground">{t.effort}</span>
|
||||||
|
<div
|
||||||
|
className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-muted"
|
||||||
|
role="meter"
|
||||||
|
aria-label={t.effortValue(store.effort)}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={store.effort}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full rounded-full",
|
||||||
|
store.effortLevel === "high"
|
||||||
|
? "bg-expense"
|
||||||
|
: store.effortLevel === "medium"
|
||||||
|
? "bg-amber-500"
|
||||||
|
: "bg-income",
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.max(store.effort, 2)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-20 shrink-0 text-right text-xs tabular-nums">
|
||||||
|
{store.effort} · {t.effortLevels[store.effortLevel]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /magazalar: sıralama (ad, en yakın, en uzak, en zahmetli), kartlar, ekle/düzenle/arşivle. */
|
||||||
|
export function StoresView({ stores, canWrite }: { stores: StoreView[]; canWrite: boolean }) {
|
||||||
|
const [sort, setSort] = useState<StoreSort>("name");
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<StoreView | "new" | null>(null);
|
||||||
|
const [archiving, setArchiving] = useState<StoreView | null>(null);
|
||||||
|
|
||||||
|
const visible = useMemo(
|
||||||
|
() =>
|
||||||
|
sortStores(
|
||||||
|
stores.filter((s) => showArchived || !s.archived),
|
||||||
|
sort,
|
||||||
|
),
|
||||||
|
[stores, sort, showArchived],
|
||||||
|
);
|
||||||
|
const hasArchived = stores.some((s) => s.archived);
|
||||||
|
|
||||||
|
async function setArchived(store: StoreView, archived: boolean) {
|
||||||
|
const r = await setStoreArchivedAction(store.id, archived);
|
||||||
|
if (r.ok) toast.success(archived ? t.toast.archived : t.toast.unarchived);
|
||||||
|
else toast.error(r.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Select value={sort} onValueChange={(x) => setSort(x as StoreSort)}>
|
||||||
|
<SelectTrigger className="w-44" aria-label={t.sort}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STORE_SORTS.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{t.sorts[s]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{hasArchived ? (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox checked={showArchived} onCheckedChange={(x) => setShowArchived(x === true)} />
|
||||||
|
{t.showArchived}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
{canWrite ? (
|
||||||
|
<Button size="sm" className="ml-auto" onClick={() => setEditing("new")}>
|
||||||
|
<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="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{visible.map((s) => {
|
||||||
|
const Icon = KIND_ICON[s.kind];
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={s.id}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-3 rounded-xl border bg-card p-4",
|
||||||
|
s.archived && "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||||
|
<Icon className="size-4.5 text-muted-foreground" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">{s.name}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{[t.kindShort[s.kind], s.branch].filter(Boolean).join(" · ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canWrite ? (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="icon-sm" variant="ghost" aria-label={t.actions.more}>
|
||||||
|
<EllipsisVertical />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem disabled={s.archived} onSelect={() => setEditing(s)}>
|
||||||
|
<Pencil />
|
||||||
|
{t.actions.edit}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{s.archived ? (
|
||||||
|
<DropdownMenuItem onSelect={() => setArchived(s, false)}>
|
||||||
|
<ArchiveRestore />
|
||||||
|
{t.actions.unarchive}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem variant="destructive" onSelect={() => setArchiving(s)}>
|
||||||
|
<Archive />
|
||||||
|
{t.actions.archive}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||||
|
{s.kind === "online" ? (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{s.deliveryFeeMinor
|
||||||
|
? t.delivery(formatMoney(s.deliveryFeeMinor))
|
||||||
|
: t.freeDelivery}
|
||||||
|
{s.deliveryFeeMinor && s.freeDeliveryMinMinor
|
||||||
|
? ` · ${t.freeDeliveryFrom(formatMoney(s.freeDeliveryMinMinor))}`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 tabular-nums">
|
||||||
|
<MapPin className="size-3.5 text-muted-foreground" aria-hidden />
|
||||||
|
{s.distanceM !== null ? formatDistance(s.distanceM) : t.distanceUnknown}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t.fields.carry}: {s.carryDifficulty} · {t.carryLevels[s.carryDifficulty]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<EffortMeter store={s} />
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{s.archived ? <Badge variant="secondary">{t.archived}</Badge> : null}
|
||||||
|
{s.mealCardAccepted ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="h-auto max-w-full gap-1 text-left whitespace-normal"
|
||||||
|
>
|
||||||
|
<Utensils className="size-3" aria-hidden />
|
||||||
|
{t.mealCardBadge}:{" "}
|
||||||
|
{s.mealCardProviders.map((p) => tr.accounts.providers[p]).join(", ")}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{s.note ? <p className="text-xs text-muted-foreground">{s.note}</p> : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<StoreFormDialog
|
||||||
|
store={editing === "new" ? null : editing}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AlertDialog open={archiving !== null} onOpenChange={(o) => !o && setArchiving(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t.archiveTitle}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{archiving ? t.archiveDescription(archiving.label) : null}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
const s = archiving;
|
||||||
|
setArchiving(null);
|
||||||
|
if (s) await setArchived(s, true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.actions.archive}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { AuditLogModel } from "@/models/audit-log";
|
||||||
|
import { storeFormSchema, type StoreFormInput } from "@/shared/schemas/store";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import { createStore, listStores, setStoreArchived, updateStore } from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const form = (over: Partial<StoreFormInput> = {}) =>
|
||||||
|
storeFormSchema.parse({
|
||||||
|
name: "Macrocenter",
|
||||||
|
kind: "market",
|
||||||
|
branch: "Caddebostan",
|
||||||
|
distanceKm: "4,5",
|
||||||
|
carryDifficulty: "3",
|
||||||
|
deliveryFee: "",
|
||||||
|
freeDeliveryMin: "",
|
||||||
|
mealCardAccepted: false,
|
||||||
|
mealCardProviders: [],
|
||||||
|
note: "",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mağazalar", () => {
|
||||||
|
it("seed: zahmet skoru hesaplanır; izolasyon", async () => {
|
||||||
|
const s = (await listStores(ayse, cakir))!;
|
||||||
|
expect(s.stores).toHaveLength(8);
|
||||||
|
const carrefour = s.stores.find((x) => x.name === "CarrefourSA")!;
|
||||||
|
// 3,8 km → 9,5; taşıma 4 → 37,5 → 47.
|
||||||
|
expect(carrefour).toMatchObject({ effort: 47, effortLevel: "medium", mealCardAccepted: true });
|
||||||
|
const getir = s.stores.find((x) => x.kind === "online")!;
|
||||||
|
expect(getir).toMatchObject({ distanceM: null, effort: 0, deliveryFeeMinor: 39_90 });
|
||||||
|
expect(s.stores.find((x) => x.name === "Metro Toptancı")!.effortLevel).toBe("high");
|
||||||
|
expect(await listStores(fatma, cakir)).toBeNull();
|
||||||
|
expect((await listStores(zeynep, cakir))!.canWrite).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("şema: online mağazada uzaklık yok; yemek kartı seçilince sağlayıcı zorunlu", () => {
|
||||||
|
expect(form({ kind: "online", deliveryFee: "29,90", distanceKm: "3" })).toMatchObject({
|
||||||
|
distanceM: null,
|
||||||
|
deliveryFeeMinor: 29_90,
|
||||||
|
});
|
||||||
|
expect(form({ deliveryFee: "29,90" })).toMatchObject({
|
||||||
|
distanceM: 4_500,
|
||||||
|
deliveryFeeMinor: null,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
storeFormSchema.safeParse({ ...form(), mealCardAccepted: true, mealCardProviders: [] })
|
||||||
|
.success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
storeFormSchema.safeParse({
|
||||||
|
name: "X",
|
||||||
|
kind: "market",
|
||||||
|
branch: "",
|
||||||
|
distanceKm: "abc",
|
||||||
|
carryDifficulty: "6",
|
||||||
|
deliveryFee: "",
|
||||||
|
freeDeliveryMin: "",
|
||||||
|
mealCardAccepted: false,
|
||||||
|
mealCardProviders: [],
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("oluştur, aynı ad + şube reddi, düzenle, arşiv denetimi", async () => {
|
||||||
|
const r = await createStore(ayse, cakir, form());
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const id = r.ok ? r.data.id : "";
|
||||||
|
expect((await createStore(ayse, cakir, form({ name: "macrocenter" }))).ok).toBe(false);
|
||||||
|
// Farklı şube serbest.
|
||||||
|
expect((await createStore(ayse, cakir, form({ branch: "Ataşehir" }))).ok).toBe(true);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await updateStore(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
id,
|
||||||
|
form({ mealCardAccepted: true, mealCardProviders: ["pluxee", "pluxee"] }),
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(true);
|
||||||
|
let s = (await listStores(ayse, cakir))!.stores.find((x) => x.id === id)!;
|
||||||
|
expect(s.mealCardProviders).toEqual(["pluxee"]);
|
||||||
|
expect((await createStore(zeynep, cakir, form({ name: "İzleyici" }))).ok).toBe(false);
|
||||||
|
|
||||||
|
expect((await setStoreArchived(ayse, cakir, id, true)).ok).toBe(true);
|
||||||
|
expect(await AuditLogModel.exists({ action: "store.archive", targetId: id })).toBeTruthy();
|
||||||
|
expect((await updateStore(ayse, cakir, id, form())).ok).toBe(false);
|
||||||
|
// Arşivliyken aynı ad + şube yeniden eklenebilir; arşivden çıkarma çakışırsa reddedilir.
|
||||||
|
expect((await createStore(ayse, cakir, form())).ok).toBe(true);
|
||||||
|
expect((await setStoreArchived(ayse, cakir, id, false)).ok).toBe(false);
|
||||||
|
s = (await listStores(ayse, cakir))!.stores.find((x) => x.id === id)!;
|
||||||
|
expect(s.archived).toBe(true);
|
||||||
|
// Başka hanenin kimliğiyle işlem yok.
|
||||||
|
expect((await setStoreArchived(fatma, cakir, id, true)).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { resolveMembership } from "@/features/households/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { writeAudit } from "@/lib/audit";
|
||||||
|
import { toObjectId } from "@/lib/object-id";
|
||||||
|
import { StoreModel } from "@/models/store";
|
||||||
|
import type { MealCardProvider } from "@/shared/accounts";
|
||||||
|
import type { StoreForm } from "@/shared/schemas/store";
|
||||||
|
import { foldTr } from "@/shared/search";
|
||||||
|
import {
|
||||||
|
effortLevel,
|
||||||
|
effortScore,
|
||||||
|
sortStores,
|
||||||
|
type EffortLevel,
|
||||||
|
type StoreKind,
|
||||||
|
} from "@/shared/stores";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mağazalar (Bölüm 6.11). Hane bazlı, hane içinde ortak. Okuma: viewer; yazma: member.
|
||||||
|
* Zahmet skoru okuma sırasında hesaplanır (saf motor: shared/stores).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.stores;
|
||||||
|
|
||||||
|
type StoreLean = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
kind: StoreKind;
|
||||||
|
branch: string;
|
||||||
|
distanceM: number | null;
|
||||||
|
carryDifficulty: number;
|
||||||
|
deliveryFeeMinor: number | null;
|
||||||
|
freeDeliveryMinMinor: number | null;
|
||||||
|
mealCardAccepted: boolean;
|
||||||
|
mealCardProviders: MealCardProvider[];
|
||||||
|
note: string;
|
||||||
|
archivedAt: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreView = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: StoreKind;
|
||||||
|
branch: string;
|
||||||
|
/** "Migros · Kadıköy". */
|
||||||
|
label: string;
|
||||||
|
distanceM: number | null;
|
||||||
|
carryDifficulty: number;
|
||||||
|
deliveryFeeMinor: number | null;
|
||||||
|
freeDeliveryMinMinor: number | null;
|
||||||
|
mealCardAccepted: boolean;
|
||||||
|
mealCardProviders: MealCardProvider[];
|
||||||
|
note: string;
|
||||||
|
archived: boolean;
|
||||||
|
effort: number;
|
||||||
|
effortLevel: EffortLevel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const storeLabel = (s: { name: string; branch: string }) =>
|
||||||
|
s.branch ? `${s.name} · ${s.branch}` : s.name;
|
||||||
|
|
||||||
|
function toView(s: StoreLean): StoreView {
|
||||||
|
const effort = effortScore(s);
|
||||||
|
return {
|
||||||
|
id: String(s._id),
|
||||||
|
name: s.name,
|
||||||
|
kind: s.kind,
|
||||||
|
branch: s.branch,
|
||||||
|
label: storeLabel(s),
|
||||||
|
distanceM: s.distanceM,
|
||||||
|
carryDifficulty: s.carryDifficulty,
|
||||||
|
deliveryFeeMinor: s.deliveryFeeMinor,
|
||||||
|
freeDeliveryMinMinor: s.freeDeliveryMinMinor,
|
||||||
|
mealCardAccepted: s.mealCardAccepted,
|
||||||
|
mealCardProviders: s.mealCardProviders,
|
||||||
|
note: s.note,
|
||||||
|
archived: Boolean(s.archivedAt),
|
||||||
|
effort,
|
||||||
|
effortLevel: effortLevel(effort),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listStores(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<{ stores: StoreView[]; canWrite: boolean } | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const rows = await StoreModel.find({ householdId: m.householdId }).lean<StoreLean[]>();
|
||||||
|
return {
|
||||||
|
stores: sortStores(rows.map(toView), "name"),
|
||||||
|
canWrite: hasHouseholdRole(m.role, "member"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aynı ad + şube (Türkçe katlanmış) aktif mağazalarda tektir. */
|
||||||
|
async function isDuplicate(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
input: Pick<StoreForm, "name" | "branch">,
|
||||||
|
except?: Types.ObjectId,
|
||||||
|
) {
|
||||||
|
const rows = await StoreModel.find({
|
||||||
|
householdId,
|
||||||
|
nameKey: foldTr(input.name),
|
||||||
|
archivedAt: null,
|
||||||
|
...(except ? { _id: { $ne: except } } : {}),
|
||||||
|
})
|
||||||
|
.select({ branch: 1 })
|
||||||
|
.lean<{ branch: string }[]>();
|
||||||
|
return rows.some((r) => foldTr(r.branch) === foldTr(input.branch));
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = (input: StoreForm) => ({ ...input, nameKey: foldTr(input.name) });
|
||||||
|
|
||||||
|
export async function createStore(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: StoreForm,
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
if (await isDuplicate(m.householdId, input))
|
||||||
|
return fail(t.validation.duplicate, { name: t.validation.duplicate });
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const doc = await StoreModel.create({
|
||||||
|
householdId: m.householdId,
|
||||||
|
...fields(input),
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
return ok({ id: String(doc._id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStore(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
storeId: string,
|
||||||
|
input: StoreForm,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const id = toObjectId(storeId);
|
||||||
|
const current = id
|
||||||
|
? await StoreModel.findOne({ _id: id, householdId: m.householdId }).lean()
|
||||||
|
: null;
|
||||||
|
if (!current) return fail(tr.common.notFound);
|
||||||
|
if (current.archivedAt) return fail(t.validation.archived);
|
||||||
|
if (await isDuplicate(m.householdId, input, current._id))
|
||||||
|
return fail(t.validation.duplicate, { name: t.validation.duplicate });
|
||||||
|
await StoreModel.updateOne(
|
||||||
|
{ _id: current._id, householdId: m.householdId },
|
||||||
|
{ $set: { ...fields(input), updatedBy: new Types.ObjectId(actorId) } },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStoreArchived(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
storeId: string,
|
||||||
|
archived: boolean,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const id = toObjectId(storeId);
|
||||||
|
const current = id
|
||||||
|
? await StoreModel.findOne({ _id: id, householdId: m.householdId }).lean<StoreLean>()
|
||||||
|
: null;
|
||||||
|
if (!current) return fail(tr.common.notFound);
|
||||||
|
if (!archived && (await isDuplicate(m.householdId, current, current._id)))
|
||||||
|
return fail(t.validation.duplicate);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await StoreModel.updateOne(
|
||||||
|
{ _id: current._id, householdId: m.householdId },
|
||||||
|
{ $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } },
|
||||||
|
);
|
||||||
|
await writeAudit({
|
||||||
|
action: archived ? "store.archive" : "store.unarchive",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: m.householdId,
|
||||||
|
targetType: "store",
|
||||||
|
targetId: storeId,
|
||||||
|
diff: { name: storeLabel(current) },
|
||||||
|
});
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paylaşılan veriler için hafif senkron: sayfa yeniden odaklanınca ve belirli aralıkla sunucu
|
||||||
|
* bileşenlerini yeniler (aile üyeleri aynı listeyi kullanır). Sekme gizliyken yenilemez.
|
||||||
|
*/
|
||||||
|
export function useAutoRefresh(intervalMs: number) {
|
||||||
|
const router = useRouter();
|
||||||
|
useEffect(() => {
|
||||||
|
const refresh = () => {
|
||||||
|
if (document.visibilityState === "visible") router.refresh();
|
||||||
|
};
|
||||||
|
const timer = window.setInterval(refresh, intervalMs);
|
||||||
|
window.addEventListener("focus", refresh);
|
||||||
|
document.addEventListener("visibilitychange", refresh);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
window.removeEventListener("focus", refresh);
|
||||||
|
document.removeEventListener("visibilitychange", refresh);
|
||||||
|
};
|
||||||
|
}, [router, intervalMs]);
|
||||||
|
}
|
||||||
+452
-1
@@ -66,6 +66,7 @@ export const tr = {
|
|||||||
contacts: "Cari hesaplar",
|
contacts: "Cari hesaplar",
|
||||||
savings: "Birikim",
|
savings: "Birikim",
|
||||||
stock: "Ev stoğu",
|
stock: "Ev stoğu",
|
||||||
|
stores: "Mağazalar",
|
||||||
shopping: "Alışveriş",
|
shopping: "Alışveriş",
|
||||||
reports: "Raporlar",
|
reports: "Raporlar",
|
||||||
settings: "Ayarlar",
|
settings: "Ayarlar",
|
||||||
@@ -980,7 +981,11 @@ export const tr = {
|
|||||||
analysis: {
|
analysis: {
|
||||||
title: "Tüketim analizi",
|
title: "Tüketim analizi",
|
||||||
description:
|
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.",
|
"Ürün tipi başına aylık tüketim, fire ve tahmini maliyet. Tüketim: girilen tüketimler ve sayımda çıkan eksikler. Maliyet: aylık ortalama tüketim × son bilinen birim fiyat.",
|
||||||
|
avgCost: "Aylık ort. maliyet",
|
||||||
|
costHint: (price: string, date: string) => `Son birim fiyat ${price} (${date})`,
|
||||||
|
noPrice: "Fiyat yok",
|
||||||
|
totalCost: (total: string) => `Fiyatı bilinen ürünlerde aylık tahmini maliyet: ${total}`,
|
||||||
back: "Ev stoğuna dön",
|
back: "Ev stoğuna dön",
|
||||||
product: "Ürün tipi",
|
product: "Ürün tipi",
|
||||||
avgUsed: "Aylık ort. tüketim",
|
avgUsed: "Aylık ort. tüketim",
|
||||||
@@ -1007,6 +1012,7 @@ export const tr = {
|
|||||||
title: "Alınması önerilenler",
|
title: "Alınması önerilenler",
|
||||||
description: "Kritik eşiğin altına düşen ürünler ve hedefe tamamlamak için gereken miktar.",
|
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.",
|
empty: "Eşiğin altına düşen ürün yok.",
|
||||||
|
toShopping: "Alışveriş listelerinde onayla veya reddet",
|
||||||
suggested: (q: string) => `Öneri: ${q}`,
|
suggested: (q: string) => `Öneri: ${q}`,
|
||||||
wasteReduced: (purchases: number, pct: number) =>
|
wasteReduced: (purchases: number, pct: number) =>
|
||||||
`Son ${purchases} alımda %${pct} fire, miktar azaltıldı.`,
|
`Son ${purchases} alımda %${pct} fire, miktar azaltıldı.`,
|
||||||
@@ -1050,6 +1056,9 @@ export const tr = {
|
|||||||
stepHint: "Hızlı azaltma düğmesinin düşeceği miktar.",
|
stepHint: "Hızlı azaltma düğmesinin düşeceği miktar.",
|
||||||
countEveryDays: "Sayım sıklığı (gün)",
|
countEveryDays: "Sayım sıklığı (gün)",
|
||||||
countEveryDaysHint: "Boş bırakılırsa kategoriden devralınır.",
|
countEveryDaysHint: "Boş bırakılırsa kategoriden devralınır.",
|
||||||
|
bulky: "Ağır / hacimli",
|
||||||
|
bulkyHint:
|
||||||
|
"Su, deterjan gibi taşıması zor ürünler. Akıllı öneri bunları yakın ya da online mağazaya yönlendirir.",
|
||||||
note: "Not",
|
note: "Not",
|
||||||
initialQuantity: "Evdeki mevcut miktar",
|
initialQuantity: "Evdeki mevcut miktar",
|
||||||
initialHint: "Tahmini veya ambalaj üzerinden. Boş bırakılırsa 0 kabul edilir.",
|
initialHint: "Tahmini veya ambalaj üzerinden. Boş bırakılırsa 0 kabul edilir.",
|
||||||
@@ -1196,6 +1205,448 @@ export const tr = {
|
|||||||
open: "Ev stoğuna git",
|
open: "Ev stoğuna git",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
stores: {
|
||||||
|
title: "Mağazalar",
|
||||||
|
description:
|
||||||
|
"Alışveriş yaptığınız marketler, online platformlar ve pazarlar. Uzaklık ve taşıma güçlüğünden zahmet skoru hesaplanır; alışveriş önerisi bunu dikkate alır.",
|
||||||
|
new: "Yeni mağaza",
|
||||||
|
createTitle: "Yeni mağaza",
|
||||||
|
editTitle: "Mağazayı düzenle",
|
||||||
|
empty: "Henüz mağaza yok. “Yeni mağaza” ile ekleyin.",
|
||||||
|
readOnly: "İzleyici rolündesiniz; mağazaları değiştiremezsiniz.",
|
||||||
|
sort: "Sırala",
|
||||||
|
sorts: {
|
||||||
|
name: "Ada göre",
|
||||||
|
nearest: "En yakın",
|
||||||
|
farthest: "En uzak",
|
||||||
|
effort: "En zahmetli",
|
||||||
|
},
|
||||||
|
showArchived: "Arşivi göster",
|
||||||
|
archived: "Arşivli",
|
||||||
|
kinds: {
|
||||||
|
market: "Fiziksel market",
|
||||||
|
online: "Online platform",
|
||||||
|
bazaar: "Pazar",
|
||||||
|
},
|
||||||
|
kindShort: {
|
||||||
|
market: "Market",
|
||||||
|
online: "Online",
|
||||||
|
bazaar: "Pazar",
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
name: "Ad",
|
||||||
|
namePlaceholder: "ör. Migros, A101, Semt pazarı",
|
||||||
|
kind: "Tür",
|
||||||
|
branch: "Şube",
|
||||||
|
branchPlaceholder: "ör. Kadıköy",
|
||||||
|
distance: "Eve uzaklık (km)",
|
||||||
|
distanceHint: "ör. 1,5. Online mağazada gerekmez.",
|
||||||
|
carry: "Taşıma güçlüğü",
|
||||||
|
carryHint: "1 = kolay (kapının önü), 5 = çok zor (uzak, yokuş, araç gerekir).",
|
||||||
|
deliveryFee: "Teslimat ücreti",
|
||||||
|
freeDeliveryMin: "Ücretsiz teslimat alt limiti",
|
||||||
|
mealCard: "Yemek kartı geçerli",
|
||||||
|
mealCardProviders: "Geçerli yemek kartları",
|
||||||
|
note: "Not",
|
||||||
|
},
|
||||||
|
carryLevels: {
|
||||||
|
1: "Kolay",
|
||||||
|
2: "Rahat",
|
||||||
|
3: "Orta",
|
||||||
|
4: "Zor",
|
||||||
|
5: "Çok zor",
|
||||||
|
} as Record<number, string>,
|
||||||
|
effort: "Zahmet",
|
||||||
|
effortLevels: { low: "Düşük", medium: "Orta", high: "Yüksek" },
|
||||||
|
effortValue: (score: number) => `Zahmet ${score}/100`,
|
||||||
|
effortHint:
|
||||||
|
"Zahmet skoru 0–100: uzaklık (20 km'ye kadar) ve taşıma güçlüğü eşit ağırlıkla. Online mağazada uzaklık sayılmaz.",
|
||||||
|
distanceUnknown: "Uzaklık girilmedi",
|
||||||
|
delivery: (fee: string) => `Teslimat ${fee}`,
|
||||||
|
freeDeliveryFrom: (min: string) => `${min} üzeri ücretsiz`,
|
||||||
|
freeDelivery: "Ücretsiz teslimat",
|
||||||
|
mealCardBadge: "Yemek kartı",
|
||||||
|
actions: {
|
||||||
|
edit: "Düzenle",
|
||||||
|
archive: "Arşivle",
|
||||||
|
unarchive: "Arşivden çıkar",
|
||||||
|
more: "Mağaza işlemleri",
|
||||||
|
},
|
||||||
|
archiveTitle: "Mağaza arşivlensin mi?",
|
||||||
|
archiveDescription: (name: string) =>
|
||||||
|
`“${name}” yeni fiyat ve alışverişlerde seçilemez. Geçmiş kayıtları silinmez.`,
|
||||||
|
toast: {
|
||||||
|
saved: "Mağaza kaydedildi.",
|
||||||
|
archived: "Mağaza arşivlendi.",
|
||||||
|
unarchived: "Mağaza arşivden çıkarıldı.",
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
distance: "Geçerli bir uzaklık girin (ör. 2,5)",
|
||||||
|
carry: "1 ile 5 arasında seçin",
|
||||||
|
providers: "En az bir yemek kartı seçin",
|
||||||
|
duplicate: "Bu ad ve şubeyle bir mağaza zaten var.",
|
||||||
|
archived: "Mağaza arşivli.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
products: {
|
||||||
|
title: "Ürünler",
|
||||||
|
description:
|
||||||
|
"Marka ve paket düzeyinde ürünler (ör. “Ariel Matik 5 kg”). Her ürün bir ürün tipine bağlıdır; stok ürün tipinde tutulur, fiyatlar üründe.",
|
||||||
|
catalogLink: "Ürün kataloğu",
|
||||||
|
back: "Ev stoğuna dön",
|
||||||
|
new: "Yeni ürün",
|
||||||
|
createTitle: "Yeni ürün",
|
||||||
|
editTitle: "Ürünü düzenle",
|
||||||
|
sectionTitle: "Ürünler (marka / paket)",
|
||||||
|
sectionDescription:
|
||||||
|
"Bu ürün tipinin markaları ve paketleri. Fiyatlar ve birim fiyat karşılaştırması bu ürünler üzerinden yapılır.",
|
||||||
|
empty: "Henüz ürün yok.",
|
||||||
|
emptyType: "Bu ürün tipine henüz marka/paket eklenmedi.",
|
||||||
|
search: "Marka, tanım veya barkod ara",
|
||||||
|
noMatch: "Aramaya uyan ürün yok.",
|
||||||
|
showArchived: "Arşivi göster",
|
||||||
|
archived: "Arşivli",
|
||||||
|
barcode: (code: string) => `Barkod ${code}`,
|
||||||
|
images: (n: number) => `${n} görsel`,
|
||||||
|
conversionMissing: "Dönüşüm gerekli",
|
||||||
|
conversionMissingHint:
|
||||||
|
"Ürün tipinin birimi değişti; bu paketin karşılığını girmeden fiyatı karşılaştırılamaz.",
|
||||||
|
fields: {
|
||||||
|
productType: "Ürün tipi",
|
||||||
|
brand: "Marka",
|
||||||
|
brandPlaceholder: "ör. Ariel, Fairy, Yayla",
|
||||||
|
name: "Tanım",
|
||||||
|
namePlaceholder: "ör. Matik Dağ Esintisi",
|
||||||
|
barcode: "Barkod",
|
||||||
|
barcodeHint: "İsteğe bağlı, 8–14 rakam.",
|
||||||
|
packageQuantity: "Paket miktarı",
|
||||||
|
packageUnit: "Paket birimi",
|
||||||
|
typeQuantity: (unit: string) => `Bir paket kaç ${unit}?`,
|
||||||
|
typeQuantityHint: (pkg: string, unit: string) =>
|
||||||
|
`${pkg} ürün tipinin birimine (${unit}) kendiliğinden çevrilemiyor; ör. 5 kg = 50 yıkama.`,
|
||||||
|
autoConversion: (pkg: string) => `Bir paket = ${pkg}`,
|
||||||
|
images: "Ürün görselleri",
|
||||||
|
addImage: "Görsel ekle",
|
||||||
|
imagesHint: "Ambalajın önü, arkası veya raf etiketi",
|
||||||
|
note: "Not",
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
edit: "Düzenle",
|
||||||
|
archive: "Arşivle",
|
||||||
|
unarchive: "Arşivden çıkar",
|
||||||
|
more: "Ürün işlemleri",
|
||||||
|
},
|
||||||
|
archiveTitle: "Ürün arşivlensin mi?",
|
||||||
|
archiveDescription: (name: string) =>
|
||||||
|
`“${name}” yeni fiyat ve alışveriş listelerinde seçilemez. Görselleri ve geçmişi silinmez.`,
|
||||||
|
toast: {
|
||||||
|
saved: "Ürün kaydedildi.",
|
||||||
|
archived: "Ürün arşivlendi.",
|
||||||
|
unarchived: "Ürün arşivden çıkarıldı.",
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
productType: "Aktif bir ürün tipi seçin",
|
||||||
|
barcode: "Barkod 8–14 rakam olmalı",
|
||||||
|
barcodeTaken: "Bu barkod başka bir üründe kayıtlı.",
|
||||||
|
packageQuantity: "Geçerli bir paket miktarı girin (ör. 5 veya 0,5)",
|
||||||
|
packageUnit: "Paket birimi 1–20 karakter olmalı",
|
||||||
|
typeQuantity: "Geçerli bir miktar girin",
|
||||||
|
conversionRequired: (pkgUnit: string, typeUnit: string) =>
|
||||||
|
`${pkgUnit} → ${typeUnit} dönüşümünü girin (bir paket kaç ${typeUnit}?).`,
|
||||||
|
archived: "Ürün arşivli.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shopping: {
|
||||||
|
title: "Alışveriş listeleri",
|
||||||
|
description:
|
||||||
|
"Aile üyeleri aynı listeleri kullanır. Stokta azalan ürünler öneri olarak gelir; akıllı öneri en uygun mağaza ve markayı seçer.",
|
||||||
|
readOnly: "Görüntüleme yetkiniz var; listeleri değiştiremezsiniz.",
|
||||||
|
newList: "Yeni liste",
|
||||||
|
createTitle: "Yeni alışveriş listesi",
|
||||||
|
editTitle: "Listeyi düzenle",
|
||||||
|
active: "Aktif listeler",
|
||||||
|
completed: "Tamamlanan alışverişler",
|
||||||
|
emptyActive: "Aktif liste yok. Yeni bir liste açın.",
|
||||||
|
itemCount: (n: number) => `${n} kalem`,
|
||||||
|
checkedCount: (done: number, total: number) => `${done}/${total} alındı`,
|
||||||
|
updated: (when: string) => `Güncellendi: ${when}`,
|
||||||
|
completedAt: (when: string) => `Tamamlandı: ${when}`,
|
||||||
|
emptyList: "Liste boş.",
|
||||||
|
suggestionsBanner: (n: number) =>
|
||||||
|
`Stokta eşiğin altına düşen ${n} ürün var. Bir listeyi açıp öneriyi onaylayın ya da reddedin.`,
|
||||||
|
fields: {
|
||||||
|
name: "Liste adı",
|
||||||
|
namePlaceholder: "ör. Haftalık market, Ay başı toptan",
|
||||||
|
note: "Not",
|
||||||
|
kind: "Kalem türü",
|
||||||
|
productType: "Ürün tipi",
|
||||||
|
product: "Ürün (marka/paket)",
|
||||||
|
text: "Kalem",
|
||||||
|
textPlaceholder: "ör. Doğum günü mumu",
|
||||||
|
quantity: "Miktar",
|
||||||
|
packages: "Paket adedi",
|
||||||
|
quantityHint: "Boş bırakılırsa en az bir paket.",
|
||||||
|
unit: "Birim",
|
||||||
|
unitPlaceholder: "ör. adet",
|
||||||
|
itemNote: "Not",
|
||||||
|
itemNotePlaceholder: "ör. laktozsuz olsun",
|
||||||
|
},
|
||||||
|
kinds: { type: "Ürün tipi", product: "Belirli marka", text: "Serbest metin" },
|
||||||
|
kindHints: {
|
||||||
|
type: "Markayı akıllı öneri seçer.",
|
||||||
|
product: "Yalnızca bu ürün önerilir.",
|
||||||
|
text: "Stokla ve fiyatlarla bağlanmaz.",
|
||||||
|
},
|
||||||
|
packageUnit: "paket",
|
||||||
|
stockLine: (current: string, target: string) => `Evde ${current} / hedef ${target}`,
|
||||||
|
fromSuggestion: "Stok önerisi",
|
||||||
|
archivedRef: "Arşivlendi",
|
||||||
|
actions: {
|
||||||
|
more: "Diğer işlemler",
|
||||||
|
addItem: "Kalem ekle",
|
||||||
|
edit: "Düzenle",
|
||||||
|
editList: "Listeyi düzenle",
|
||||||
|
deleteList: "Listeyi sil",
|
||||||
|
remove: "Listeden çıkar",
|
||||||
|
moveTo: "Başka listeye taşı",
|
||||||
|
goShopping: "Alışverişe çık",
|
||||||
|
back: "Alışveriş listeleri",
|
||||||
|
backToList: "Listeye dön",
|
||||||
|
},
|
||||||
|
editItemTitle: "Kalemi düzenle",
|
||||||
|
addItemTitle: "Kalem ekle",
|
||||||
|
moveTitle: "Başka listeye taşı",
|
||||||
|
moveDescription: (name: string) => `"${name}" seçilen aktif listeye taşınır.`,
|
||||||
|
moveTarget: "Hedef liste",
|
||||||
|
noOtherLists: "Taşınabilecek başka aktif liste yok.",
|
||||||
|
deleteTitle: "Liste silinsin mi?",
|
||||||
|
deleteDescription: (name: string) => `"${name}" listesi ve kalemleri silinir.`,
|
||||||
|
suggestions: {
|
||||||
|
title: "Stok önerileri",
|
||||||
|
description: "Eşiğin altına düşen ürünler. Onaylananlar önerilen miktarla listeye eklenir.",
|
||||||
|
addAll: "Tümünü ekle",
|
||||||
|
add: "Ekle",
|
||||||
|
dismiss: "Reddet",
|
||||||
|
dismissHint: "Stok yenilenene (yeni alım girilene) kadar önerilmez.",
|
||||||
|
line: (current: string, target: string, pct: number | null) =>
|
||||||
|
`Evde ${current} / hedef ${target}${pct === null ? "" : ` (%${pct})`}`,
|
||||||
|
suggested: (q: string) => `Önerilen: ${q}`,
|
||||||
|
},
|
||||||
|
plan: {
|
||||||
|
title: "Akıllı mağaza önerisi",
|
||||||
|
description:
|
||||||
|
"Son bilinen fiyatlarla. Uzaklık ve taşıma güçlüğü, online teslimat ücreti ve yemek kartı bakiyesi hesaba katılır.",
|
||||||
|
recommended: "Önerilen dağılım",
|
||||||
|
single: "Tek mağaza",
|
||||||
|
singleBest: (store: string, total: string) => `Tek mağaza en iyi seçenek: ${store}, ${total}`,
|
||||||
|
split: (parts: string, total: string) => `Önerilen dağılım: ${parts}: ${total}`,
|
||||||
|
part: (store: string, n: number) => `${store} (${n} ürün)`,
|
||||||
|
saving: (amount: string) => `${amount} tasarruf`,
|
||||||
|
singleMissing: (n: number) => `Tek mağazada ${n} kalem bulunmuyor.`,
|
||||||
|
threshold: (amount: string) =>
|
||||||
|
`Ek mağaza ancak ${amount} üstü tasarruf sağlarsa önerilir (hane ayarı).`,
|
||||||
|
mealCard: (amount: string) => `Yemek kartıyla ödenebilir: ${amount}`,
|
||||||
|
mealCardBalance: (amount: string) => `Yemek kartı bakiyeniz: ${amount}`,
|
||||||
|
delivery: (amount: string) => `Teslimat ${amount}`,
|
||||||
|
freeDelivery: "Teslimat ücretsiz",
|
||||||
|
subtotal: "Ara toplam",
|
||||||
|
total: "Tahmini toplam",
|
||||||
|
line: (packages: string, price: string) => `${packages} × ${price}`,
|
||||||
|
unpriced: "Fiyatı bilinmeyenler",
|
||||||
|
unpricedHint: "Bu kalemler için mağaza fiyatı girilmemiş.",
|
||||||
|
cheapestAt: (store: string, unitPrice: string) =>
|
||||||
|
`En ucuz birim fiyat: ${store}, ${unitPrice}`,
|
||||||
|
empty: "Öneri için listede fiyatı bilinen ürün yok.",
|
||||||
|
textItems: "Serbest metin kalemleri öneriye katılmaz.",
|
||||||
|
missing: "Bu planda alınamayan",
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
title: "Alışveriş modu",
|
||||||
|
progress: (done: number, total: number) => `${done}/${total} sepette`,
|
||||||
|
other: "Diğer",
|
||||||
|
otherHint: "Fiyatı bilinmeyen ve serbest metin kalemleri",
|
||||||
|
inCart: "Sepette",
|
||||||
|
allDone: "Hepsi sepette.",
|
||||||
|
planChoice: "Plan",
|
||||||
|
check: (label: string) => `${label}: sepete alındı olarak işaretle`,
|
||||||
|
refreshed: "Liste güncellendi",
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
saved: "Liste kaydedildi.",
|
||||||
|
deleted: "Liste silindi.",
|
||||||
|
itemAdded: "Kalem eklendi.",
|
||||||
|
itemSaved: "Kalem güncellendi.",
|
||||||
|
itemRemoved: "Kalem listeden çıkarıldı.",
|
||||||
|
moved: "Kalem taşındı.",
|
||||||
|
suggestionsAdded: (n: number) => `${n} öneri listeye eklendi.`,
|
||||||
|
dismissed: "Öneri reddedildi.",
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
quantity: "Geçerli bir miktar girin (ör. 2 veya 1,5)",
|
||||||
|
productType: "Aktif bir ürün tipi seçin",
|
||||||
|
product: "Aktif bir ürün seçin",
|
||||||
|
text: "Kalem adını yazın",
|
||||||
|
duplicate: "Bu ürün listede zaten var; miktarını düzenleyin.",
|
||||||
|
duplicateIn: (name: string) => `Bu ürün "${name}" listesinde zaten var.`,
|
||||||
|
notActive: "Bu liste tamamlandı; değiştirilemez.",
|
||||||
|
sameList: "Kalem zaten bu listede.",
|
||||||
|
suggestionGone: "Öneri artık geçerli değil (listede ya da stok yenilenmiş).",
|
||||||
|
amount: "Geçerli bir tutar girin (ör. 89,90)",
|
||||||
|
noLines: "Alınan en az bir ürün seçin",
|
||||||
|
duplicateLine: "Bir liste kalemi yalnızca bir kez girilebilir",
|
||||||
|
duplicateStore: "Mağaza iki kez girilmiş",
|
||||||
|
account: "Her mağaza için ödeme hesabı seçin",
|
||||||
|
targetList: "Alınmayanlar için hedef liste seçin",
|
||||||
|
futureDate: "Gelecek tarihli alışveriş girilemez",
|
||||||
|
itemGone: "Listede olmayan bir kalem seçilmiş; sayfayı yenileyin",
|
||||||
|
substituteType: "Muadil aynı ürün tipinden olmalı",
|
||||||
|
textNoProduct: "Serbest metin kalemine ürün seçilemez",
|
||||||
|
conversion: (name: string) => `"${name}" ürününün tip birimine dönüşümü yok`,
|
||||||
|
fallbackCategory: "Eşlenmemiş kalemler için gider kategorisi seçin",
|
||||||
|
tooManySplits: "Bir mağazada çok fazla gider kategorisi var",
|
||||||
|
},
|
||||||
|
complete: {
|
||||||
|
action: "Alışverişi tamamla",
|
||||||
|
title: "Alışverişi tamamla",
|
||||||
|
description:
|
||||||
|
"Aldıklarınızı seçin: aynı ürün ya da muadili, paket adedi, ödenen tutar ve mağaza. Kaydedince stok, fiyatlar, mağaza başına gider ve fişler tek seferde işlenir.",
|
||||||
|
date: "Alışveriş tarihi",
|
||||||
|
bought: "Alındı",
|
||||||
|
itemsTitle: "Listedekiler",
|
||||||
|
extrasTitle: "Listede olmayanlar",
|
||||||
|
addExtra: "Listede olmayan ürün ekle",
|
||||||
|
removeExtra: "Ekstrayı çıkar",
|
||||||
|
product: "Alınan ürün",
|
||||||
|
store: "Mağaza",
|
||||||
|
packages: "Paket",
|
||||||
|
quantity: "Miktar",
|
||||||
|
generic: (unit: string) => `Marka belirtmeden (${unit})`,
|
||||||
|
amount: "Tutar (₺)",
|
||||||
|
amountHint: "Fişteki satır tutarı.",
|
||||||
|
planned: (text: string) => `Plan: ${text}`,
|
||||||
|
substitute: "Muadil",
|
||||||
|
noPlan: "Planda fiyat yoktu",
|
||||||
|
textItem: "Serbest metin: stok ve fiyata işlenmez.",
|
||||||
|
storesTitle: "Ödeme ve fiş",
|
||||||
|
storesDescription:
|
||||||
|
"Her mağaza ayrı gider işlemi olur; kalemler stok kategorisinin gider kategorisine bölünür.",
|
||||||
|
account: "Ödeme hesabı",
|
||||||
|
receipts: "Fiş görselleri",
|
||||||
|
addReceipt: "Fiş ekle",
|
||||||
|
receiptHint: "Fiş fotoğrafı; işlemin ekine kaydedilir.",
|
||||||
|
subtotal: (n: number) => `${n} kalem`,
|
||||||
|
fallbackCategory: "Eşlenmemiş kalemlerin gider kategorisi",
|
||||||
|
fallbackHint:
|
||||||
|
"Stok kategorisi bir gider kategorisine eşlenmemiş ürünler ve serbest metin kalemleri buraya yazılır.",
|
||||||
|
leftoverTitle: "Alınmayanlar",
|
||||||
|
leftoverDescription: (n: number) => `${n} kalem alınmadı.`,
|
||||||
|
leftoverActions: {
|
||||||
|
move: "Başka aktif listeye taşı",
|
||||||
|
new: "Yeni liste aç",
|
||||||
|
drop: "Bırak (listeyle kapanır)",
|
||||||
|
},
|
||||||
|
newListName: "Yeni liste adı",
|
||||||
|
newListDefault: (name: string) => `${name} – kalanlar`,
|
||||||
|
total: "Toplam",
|
||||||
|
plannedTotal: "Planlanan",
|
||||||
|
submit: "Alışverişi tamamla",
|
||||||
|
saved: "Alışveriş tamamlandı; stok, fiyatlar ve giderler işlendi.",
|
||||||
|
txDescription: (list: string) => `Alışveriş: ${list}`,
|
||||||
|
splitNote: (n: number) => `${n} kalem`,
|
||||||
|
noStores: "Mağaza yok. Önce bir mağaza ekleyin.",
|
||||||
|
},
|
||||||
|
report: {
|
||||||
|
title: "Karşılaştırma raporu",
|
||||||
|
description: (date: string) =>
|
||||||
|
`Alışveriş tarihi ${date}. Plan, tamamlama anındaki akıllı öneridir.`,
|
||||||
|
planned: "Planlanan",
|
||||||
|
actual: "Gerçekleşen",
|
||||||
|
diff: "Fark",
|
||||||
|
reasonsTitle: "Farkın nedenleri",
|
||||||
|
reasons: {
|
||||||
|
price: "Fiyat farkı (aynı ürün)",
|
||||||
|
quantity: "Miktar farkı",
|
||||||
|
substitute: "Muadil ürün",
|
||||||
|
skipped: "Alınmayanlar",
|
||||||
|
extra: "Plansız ekstralar",
|
||||||
|
unplanned: "Planda fiyatı olmayanlar",
|
||||||
|
},
|
||||||
|
matches: { same: "Aynı", substitute: "Muadil", skipped: "Alınmadı", extra: "Plansız ekstra" },
|
||||||
|
item: "Kalem",
|
||||||
|
plan: "Plan",
|
||||||
|
bought: "Alınan",
|
||||||
|
expenses: "Mağaza giderleri",
|
||||||
|
expenseLine: (account: string, receipts: number) =>
|
||||||
|
receipts ? `${account} · ${receipts} fiş` : account,
|
||||||
|
movedTo: (name: string) => `Alınmayanlar "${name}" listesine taşındı.`,
|
||||||
|
noPlanCell: "—",
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
title: "Alışveriş",
|
||||||
|
empty: "Aktif liste yok.",
|
||||||
|
open: "Listeleri aç",
|
||||||
|
suggestions: (n: number) => `${n} stok önerisi bekliyor`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
productPrices: {
|
||||||
|
add: "Fiyat gir",
|
||||||
|
addTitle: "Fiyat gir",
|
||||||
|
comparisonTitle: "Birim fiyat karşılaştırması",
|
||||||
|
comparisonDescription: (unit: string) =>
|
||||||
|
`Marka ve mağazaların son bilinen fiyatları, 1 ${unit} başına sıralı. Süresi biten kampanyada önceki normal fiyat kullanılır.`,
|
||||||
|
noPrices: "Henüz fiyat girilmedi.",
|
||||||
|
noProducts: "Fiyat girmek için önce bir ürün (marka/paket) ekleyin.",
|
||||||
|
cheapest: "En uygun",
|
||||||
|
diff: (pct: number) => `%${pct} pahalı`,
|
||||||
|
perUnit: "Birim fiyat",
|
||||||
|
packagePrice: "Paket fiyatı",
|
||||||
|
store: "Mağaza",
|
||||||
|
product: "Ürün",
|
||||||
|
date: "Tarih",
|
||||||
|
promo: "Kampanya",
|
||||||
|
promoUntil: (date: string) => `${date} tarihine kadar`,
|
||||||
|
promoEnded: "Kampanya bitti",
|
||||||
|
fellBack: "Kampanya bitti, normal fiyat",
|
||||||
|
stale: (days: number) => `${days} gün önce`,
|
||||||
|
staleHint: "90 günden eski fiyat; güncel olmayabilir.",
|
||||||
|
noConversion: "Dönüşüm yok",
|
||||||
|
sources: { manual: "Elle", receipt: "Fiş", provider: "Sağlayıcı" } as Record<string, string>,
|
||||||
|
detail: {
|
||||||
|
back: "Ürün kataloğu",
|
||||||
|
typeLink: (name: string) => `Ürün tipi: ${name}`,
|
||||||
|
currentTitle: "Mağazalardaki fiyatlar",
|
||||||
|
currentDescription: "Her mağazadaki geçerli (son) fiyat; birim fiyata göre sıralı.",
|
||||||
|
chartTitle: "Fiyat geçmişi",
|
||||||
|
chartDescription: "Mağaza başına paket fiyatı; fiyat yeni kayda kadar geçerli sayılır.",
|
||||||
|
chartLabel: (name: string) => `${name}: mağaza başına fiyat geçmişi`,
|
||||||
|
historyTitle: "Fiyat kayıtları",
|
||||||
|
deletePrice: "Fiyat kaydını sil",
|
||||||
|
deleteTitle: "Fiyat kaydı silinsin mi?",
|
||||||
|
deleteDescription: "Kayıt fiyat geçmişinden ve karşılaştırmadan çıkarılır.",
|
||||||
|
images: "Görseller",
|
||||||
|
barcode: "Barkod",
|
||||||
|
package: "Paket",
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
product: "Ürün",
|
||||||
|
store: "Mağaza",
|
||||||
|
date: "Tarih",
|
||||||
|
price: "Paket fiyatı (₺)",
|
||||||
|
isPromo: "Kampanya / indirim",
|
||||||
|
promoUntil: "Kampanya bitişi",
|
||||||
|
promoUntilHint: "İsteğe bağlı. Bitince karşılaştırmada önceki normal fiyat kullanılır.",
|
||||||
|
note: "Not",
|
||||||
|
unitPreview: (value: string) => `Birim fiyat: ${value}`,
|
||||||
|
},
|
||||||
|
toast: { saved: "Fiyat kaydedildi.", deleted: "Fiyat kaydı silindi." },
|
||||||
|
validation: {
|
||||||
|
product: "Aktif bir ürün seçin",
|
||||||
|
store: "Aktif bir mağaza seçin",
|
||||||
|
price: "Geçerli bir fiyat girin (ör. 89,90)",
|
||||||
|
promoUntil: "Kampanya bitişi fiyat tarihinden önce olamaz",
|
||||||
|
futureDate: "Gelecek tarihli fiyat girilemez",
|
||||||
|
},
|
||||||
|
},
|
||||||
prices: {
|
prices: {
|
||||||
title: "Fiyat tablosu",
|
title: "Fiyat tablosu",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { model, models, Schema, type InferSchemaType, type Model } from "mongoos
|
|||||||
import { ATTACHMENT_KINDS } from "@/shared/attachments";
|
import { ATTACHMENT_KINDS } from "@/shared/attachments";
|
||||||
|
|
||||||
/** Ek dosyanın bağlanabileceği kayıt türleri (ileride: alışveriş fişi, cari hareket…). */
|
/** Ek dosyanın bağlanabileceği kayıt türleri (ileride: alışveriş fişi, cari hareket…). */
|
||||||
export const ATTACHMENT_OWNER_TYPES = ["transaction"] as const;
|
export const ATTACHMENT_OWNER_TYPES = ["transaction", "product"] as const;
|
||||||
export type AttachmentOwnerType = (typeof ATTACHMENT_OWNER_TYPES)[number];
|
export type AttachmentOwnerType = (typeof ATTACHMENT_OWNER_TYPES)[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export const AUDIT_ACTIONS = [
|
|||||||
"stock.product.archive",
|
"stock.product.archive",
|
||||||
"stock.product.unarchive",
|
"stock.product.unarchive",
|
||||||
"stock.movement.delete",
|
"stock.movement.delete",
|
||||||
|
"store.archive",
|
||||||
|
"store.unarchive",
|
||||||
|
"product.archive",
|
||||||
|
"product.unarchive",
|
||||||
|
"price.delete",
|
||||||
|
"shoppingList.delete",
|
||||||
] as const;
|
] as const;
|
||||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ürün fiyat kaydı (Bölüm 6.11): ürün × mağaza × tarih × paket fiyatı (kuruş). Kampanya işareti
|
||||||
|
* ve geçerlilik sonu; kaynak `manual` | `receipt` | `provider:<ad>`. Hane bazlı. Birim fiyat
|
||||||
|
* okuma sırasında ürünün dönüşümüyle hesaplanır (saklanmaz).
|
||||||
|
*/
|
||||||
|
const productPriceSchema = new Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
productId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
storeId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
/** "YYYY-MM-DD" (İstanbul). */
|
||||||
|
date: { type: String, required: true },
|
||||||
|
priceMinor: { type: Number, required: true, min: 1 },
|
||||||
|
isPromo: { type: Boolean, default: false },
|
||||||
|
promoUntil: { type: String, default: null },
|
||||||
|
source: { type: String, required: true, match: /^(manual|receipt|provider:[a-z0-9-]{1,40})$/ },
|
||||||
|
note: { type: String, default: "", maxlength: 300 },
|
||||||
|
deletedAt: { type: Date, default: null },
|
||||||
|
seedRef: { type: String, default: null },
|
||||||
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
},
|
||||||
|
{ timestamps: true, collection: "productPrices" },
|
||||||
|
);
|
||||||
|
|
||||||
|
productPriceSchema.index({ householdId: 1, productId: 1, deletedAt: 1, date: 1, createdAt: 1 });
|
||||||
|
productPriceSchema.index({ householdId: 1, storeId: 1, date: -1 });
|
||||||
|
productPriceSchema.index(
|
||||||
|
{ householdId: 1, seedRef: 1 },
|
||||||
|
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ProductPrice = InferSchemaType<typeof productPriceSchema>;
|
||||||
|
|
||||||
|
export const ProductPriceModel: Model<ProductPrice> =
|
||||||
|
(models.ProductPrice as Model<ProductPrice> | undefined) ??
|
||||||
|
model<ProductPrice>("ProductPrice", productPriceSchema);
|
||||||
@@ -22,6 +22,13 @@ const productTypeSchema = new Schema(
|
|||||||
/** Sayım sıklığı (gün); null ise kategoriden devralınır (Faz 11b). */
|
/** Sayım sıklığı (gün); null ise kategoriden devralınır (Faz 11b). */
|
||||||
countEveryDays: { type: Number, default: null, min: 1, max: 365 },
|
countEveryDays: { type: Number, default: null, min: 1, max: 365 },
|
||||||
note: { type: String, default: "", maxlength: 300 },
|
note: { type: String, default: "", maxlength: 300 },
|
||||||
|
/** Ağır/hacimli (su, deterjan): akıllı öneri zahmetli fiziksel mağazadan uzak tutar (Faz 13). */
|
||||||
|
bulky: { type: Boolean, default: false },
|
||||||
|
/**
|
||||||
|
* Eşik önerisi reddedildi: bu tarihten sonra alım hareketi gelene (stok yenilenene) kadar
|
||||||
|
* öneri gösterilmez (Faz 13).
|
||||||
|
*/
|
||||||
|
suggestionDismissedAt: { type: Date, default: null },
|
||||||
archivedAt: { type: Date, default: null },
|
archivedAt: { type: Date, default: null },
|
||||||
seedRef: { type: String, default: null },
|
seedRef: { type: String, default: null },
|
||||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ürün (marka/varyant, Bölüm 6.11): "Ariel Matik 5 kg / 50 yıkama". Bir ürün tipine bağlıdır;
|
||||||
|
* fiyatlar (Faz 12b) bu seviyede tutulur. Paket miktarı binde bir. `typeQuantity`: paket birimi
|
||||||
|
* ürün tipinin birimine kendiliğinden çevrilemiyorsa bir paketin ürün tipi birimindeki karşılığı
|
||||||
|
* (zorunlu); çevrilebiliyorsa null. Görseller `attachments` (ownerType "product").
|
||||||
|
*/
|
||||||
|
const productSchema = new Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
productTypeId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
brand: { type: String, required: true, trim: true, maxlength: 60 },
|
||||||
|
/** Tanım (ör. "Matik Dağ Esintisi"). */
|
||||||
|
name: { type: String, default: "", trim: true, maxlength: 120 },
|
||||||
|
/** Arama anahtarı: marka + tanım (Türkçe katlanmış). */
|
||||||
|
searchKey: { type: String, required: true },
|
||||||
|
barcode: { type: String, default: null },
|
||||||
|
packageQuantity: { type: Number, required: true, min: 1 },
|
||||||
|
packageUnit: { type: String, required: true, trim: true, maxlength: 20 },
|
||||||
|
typeQuantity: { type: Number, default: null, min: 1 },
|
||||||
|
note: { type: String, default: "", maxlength: 300 },
|
||||||
|
imageCount: { type: Number, default: 0 },
|
||||||
|
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: "products" },
|
||||||
|
);
|
||||||
|
|
||||||
|
productSchema.index({ householdId: 1, productTypeId: 1, archivedAt: 1 });
|
||||||
|
productSchema.index(
|
||||||
|
{ householdId: 1, barcode: 1 },
|
||||||
|
{ partialFilterExpression: { barcode: { $type: "string" } } },
|
||||||
|
);
|
||||||
|
productSchema.index(
|
||||||
|
{ householdId: 1, seedRef: 1 },
|
||||||
|
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Product = InferSchemaType<typeof productSchema>;
|
||||||
|
|
||||||
|
export const ProductModel: Model<Product> =
|
||||||
|
(models.Product as Model<Product> | undefined) ?? model<Product>("Product", productSchema);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
|
import {
|
||||||
|
SHOPPING_ITEM_KINDS,
|
||||||
|
SHOPPING_ITEM_SOURCES,
|
||||||
|
SHOPPING_LIST_STATUSES,
|
||||||
|
} from "@/shared/shopping";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alışveriş kalemi (Bölüm 6.12): ürün tipi (genel), belirli ürün (marka/paket) ya da serbest metin.
|
||||||
|
* Miktar binde bir: ürün tipinde tipin biriminde, üründe paket adedi, serbest metinde `unit` ile.
|
||||||
|
* `checked`: alışveriş modunda sepete alındı.
|
||||||
|
*/
|
||||||
|
const shoppingItemSchema = new Schema(
|
||||||
|
{
|
||||||
|
kind: { type: String, enum: SHOPPING_ITEM_KINDS, required: true },
|
||||||
|
productTypeId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
productId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
text: { type: String, default: "", trim: true, maxlength: 120 },
|
||||||
|
/** Boşsa "1 paket" kabul edilir. */
|
||||||
|
quantity: { type: Number, default: null, min: 0 },
|
||||||
|
/** Serbest metin kaleminin birimi. */
|
||||||
|
unit: { type: String, default: "", trim: true, maxlength: 20 },
|
||||||
|
note: { type: String, default: "", trim: true, maxlength: 200 },
|
||||||
|
source: { type: String, enum: SHOPPING_ITEM_SOURCES, default: "manual" },
|
||||||
|
checked: { type: Boolean, default: false },
|
||||||
|
checkedBy: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
checkedAt: { type: Date, default: null },
|
||||||
|
addedBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
addedAt: { type: Date, default: () => new Date() },
|
||||||
|
},
|
||||||
|
{ _id: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Alışveriş listesi; hane içinde ortak. Birden çok aktif liste olabilir. Silme: soft delete. */
|
||||||
|
const shoppingListSchema = new Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
name: { type: String, required: true, trim: true, maxlength: 80 },
|
||||||
|
status: { type: String, enum: SHOPPING_LIST_STATUSES, default: "active" },
|
||||||
|
note: { type: String, default: "", trim: true, maxlength: 300 },
|
||||||
|
items: { type: [shoppingItemSchema], default: [] },
|
||||||
|
completedAt: { type: Date, default: null },
|
||||||
|
/** Tamamlama anındaki karşılaştırma görüntüsü (ShoppingCompletion; etiketler sabitlenir). */
|
||||||
|
completion: { type: Schema.Types.Mixed, default: null },
|
||||||
|
deletedAt: { 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: "shoppingLists" },
|
||||||
|
);
|
||||||
|
|
||||||
|
shoppingListSchema.index({ householdId: 1, deletedAt: 1, status: 1, updatedAt: -1 });
|
||||||
|
shoppingListSchema.index(
|
||||||
|
{ householdId: 1, seedRef: 1 },
|
||||||
|
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ShoppingList = InferSchemaType<typeof shoppingListSchema>;
|
||||||
|
|
||||||
|
export const ShoppingListModel: Model<ShoppingList> =
|
||||||
|
(models.ShoppingList as Model<ShoppingList> | undefined) ??
|
||||||
|
model<ShoppingList>("ShoppingList", shoppingListSchema);
|
||||||
@@ -21,6 +21,8 @@ const stockMovementSchema = new Schema(
|
|||||||
initial: { type: Boolean, default: false },
|
initial: { type: Boolean, default: false },
|
||||||
/** Sayım oturumu (Faz 11b). */
|
/** Sayım oturumu (Faz 11b). */
|
||||||
countId: { type: Schema.Types.ObjectId, default: null },
|
countId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
/** Alışverişi tamamlamadan gelen alım (Faz 13b). */
|
||||||
|
shoppingListId: { type: Schema.Types.ObjectId, default: null },
|
||||||
deletedAt: { type: Date, default: null },
|
deletedAt: { type: Date, default: null },
|
||||||
seedRef: { type: String, default: null },
|
seedRef: { type: String, default: null },
|
||||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
|
import { MEAL_CARD_PROVIDERS } from "@/shared/accounts";
|
||||||
|
import { STORE_KINDS } from "@/shared/stores";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mağaza (Bölüm 6.11): fiziksel market, online platform veya pazar. Hane bazlı (hane içinde
|
||||||
|
* ortak). Uzaklık metre; teslimat ücretleri kuruş. Zahmet skoru okuma sırasında hesaplanır.
|
||||||
|
*/
|
||||||
|
const storeSchema = new Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
name: { type: String, required: true, trim: true, maxlength: 80 },
|
||||||
|
nameKey: { type: String, required: true },
|
||||||
|
kind: { type: String, enum: STORE_KINDS, required: true },
|
||||||
|
branch: { type: String, default: "", maxlength: 80 },
|
||||||
|
/** Eve uzaklık (metre); online mağazada null. */
|
||||||
|
distanceM: { type: Number, default: null, min: 0 },
|
||||||
|
/** Taşıma güçlüğü 1 (kolay) – 5 (çok zor). */
|
||||||
|
carryDifficulty: { type: Number, default: 1, min: 1, max: 5 },
|
||||||
|
/** Online: teslimat ücreti ve ücretsiz teslimat alt limiti (kuruş). */
|
||||||
|
deliveryFeeMinor: { type: Number, default: null, min: 0 },
|
||||||
|
freeDeliveryMinMinor: { type: Number, default: null, min: 0 },
|
||||||
|
mealCardAccepted: { type: Boolean, default: false },
|
||||||
|
mealCardProviders: { type: [{ type: String, enum: MEAL_CARD_PROVIDERS }], default: [] },
|
||||||
|
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: "stores" },
|
||||||
|
);
|
||||||
|
|
||||||
|
storeSchema.index({ householdId: 1, archivedAt: 1, nameKey: 1 });
|
||||||
|
storeSchema.index(
|
||||||
|
{ householdId: 1, seedRef: 1 },
|
||||||
|
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Store = InferSchemaType<typeof storeSchema>;
|
||||||
|
|
||||||
|
export const StoreModel: Model<Store> =
|
||||||
|
(models.Store as Model<Store> | undefined) ?? model<Store>("Store", storeSchema);
|
||||||
@@ -76,6 +76,8 @@ const transactionSchema = new Schema(
|
|||||||
reconciledAccountIds: { type: [Schema.Types.ObjectId], default: [] },
|
reconciledAccountIds: { type: [Schema.Types.ObjectId], default: [] },
|
||||||
/** Mutabakat düzeltme işlemi: kategorisiz; düzenlenemez, yalnızca silinebilir. */
|
/** Mutabakat düzeltme işlemi: kategorisiz; düzenlenemez, yalnızca silinebilir. */
|
||||||
isAdjustment: { type: Boolean, default: false },
|
isAdjustment: { type: Boolean, default: false },
|
||||||
|
/** Alışverişi tamamlamadan gelen mağaza gideri (Faz 13b). */
|
||||||
|
shoppingListId: { type: Schema.Types.ObjectId, default: null },
|
||||||
deletedAt: { type: Date, default: null },
|
deletedAt: { type: Date, default: null },
|
||||||
/** Seed verisinin idempotent yüklenmesi için sabit anahtar. */
|
/** Seed verisinin idempotent yüklenmesi için sabit anahtar. */
|
||||||
seedRef: { type: String, default: null },
|
seedRef: { type: String, default: null },
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
effectivePrice,
|
||||||
|
formatUnitPrice,
|
||||||
|
PRICE_SOURCE_PATTERN,
|
||||||
|
rankOffers,
|
||||||
|
seriesSlot,
|
||||||
|
unitPrice,
|
||||||
|
unitPriceBase,
|
||||||
|
} from "./prices";
|
||||||
|
|
||||||
|
const r = (
|
||||||
|
date: string,
|
||||||
|
priceMinor: number,
|
||||||
|
isPromo = false,
|
||||||
|
promoUntil: string | null = null,
|
||||||
|
) => ({
|
||||||
|
date,
|
||||||
|
priceMinor,
|
||||||
|
isPromo,
|
||||||
|
promoUntil,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("birim fiyat", () => {
|
||||||
|
it("paket fiyatı / ürün tipi birimindeki miktar; yuvarlama yalnızca gösterimde", () => {
|
||||||
|
// Ariel 5 kg = 33 yıkama, 519,90 TL → 15,754… TL/yıkama.
|
||||||
|
const u = unitPrice(519_90, 33_000);
|
||||||
|
expect(u).toBeCloseTo(1575.4545, 3);
|
||||||
|
expect(formatUnitPrice(u, "yıkama")).toMatch(/15,75\/yıkama$/);
|
||||||
|
// 500 g beyaz peynir 210 TL, ürün tipi g olsaydı: 0,42 TL/g → 420 TL/kg.
|
||||||
|
expect(formatUnitPrice(unitPrice(210_00, 500_000), "g")).toMatch(/420,00\/kg$/);
|
||||||
|
expect(unitPriceBase("ml")).toEqual({ unit: "lt", factor: 1000 });
|
||||||
|
expect(unitPriceBase("adet")).toEqual({ unit: "adet", factor: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kaynak biçimi", () => {
|
||||||
|
expect(
|
||||||
|
["manual", "receipt", "provider:marketfiyati"].every((s) => PRICE_SOURCE_PATTERN.test(s)),
|
||||||
|
).toBe(true);
|
||||||
|
expect(PRICE_SOURCE_PATTERN.test("provider:")).toBe(false);
|
||||||
|
expect(PRICE_SOURCE_PATTERN.test("scrape")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("geçerli fiyat", () => {
|
||||||
|
const today = "2026-11-06";
|
||||||
|
|
||||||
|
it("son kayıt; süresi biten kampanyada önceki normal fiyata dönülür", () => {
|
||||||
|
expect(effectivePrice([], today)).toBeNull();
|
||||||
|
const normal = effectivePrice([r("2026-10-01", 100_00), r("2026-11-01", 110_00)], today)!;
|
||||||
|
expect(normal).toMatchObject({ record: { priceMinor: 110_00 }, fellBack: false, ageDays: 5 });
|
||||||
|
const ended = effectivePrice(
|
||||||
|
[r("2026-10-01", 100_00), r("2026-10-20", 80_00, true, "2026-10-31")],
|
||||||
|
today,
|
||||||
|
)!;
|
||||||
|
expect(ended).toMatchObject({
|
||||||
|
record: { priceMinor: 100_00 },
|
||||||
|
fellBack: true,
|
||||||
|
promoEnded: false,
|
||||||
|
});
|
||||||
|
const active = effectivePrice(
|
||||||
|
[r("2026-10-01", 100_00), r("2026-11-02", 80_00, true, "2026-11-10")],
|
||||||
|
today,
|
||||||
|
)!;
|
||||||
|
expect(active).toMatchObject({ record: { priceMinor: 80_00 }, fellBack: false });
|
||||||
|
// Dönülecek normal fiyat yoksa kampanya fiyatı işaretle.
|
||||||
|
const only = effectivePrice([r("2026-10-20", 80_00, true, "2026-10-31")], today)!;
|
||||||
|
expect(only).toMatchObject({ fellBack: false, promoEnded: true });
|
||||||
|
expect(effectivePrice([r("2026-07-01", 1)], today)!.stale).toBe(true);
|
||||||
|
expect(effectivePrice([r("2026-09-01", 1)], today)!.stale).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sıralama", () => {
|
||||||
|
it("birim fiyata göre artan, eşitler en uygun, hesaplanamayan sonda", () => {
|
||||||
|
const ranked = rankOffers([
|
||||||
|
{ id: "a", unitPriceMinor: 1_500 },
|
||||||
|
{ id: "b", unitPriceMinor: null },
|
||||||
|
{ id: "c", unitPriceMinor: 1_200 },
|
||||||
|
{ id: "d", unitPriceMinor: 1_200 },
|
||||||
|
{ id: "e", unitPriceMinor: 1_800 },
|
||||||
|
]);
|
||||||
|
expect(ranked.map((o) => [o.id, o.rank, o.cheapest, o.diffPct])).toEqual([
|
||||||
|
["c", 1, true, 0],
|
||||||
|
["d", 2, true, 0],
|
||||||
|
["a", 3, false, 25],
|
||||||
|
["e", 4, false, 50],
|
||||||
|
["b", null, false, null],
|
||||||
|
]);
|
||||||
|
expect([seriesSlot(0), seriesSlot(7), seriesSlot(8)]).toEqual([1, 8, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { daysBetween } from "./budget";
|
||||||
|
import { formatMoney } from "./format";
|
||||||
|
import { QUANTITY_SCALE } from "./quantity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ürün fiyatı ve birim fiyat motoru (Bölüm 6.11). Saf fonksiyonlar.
|
||||||
|
*
|
||||||
|
* Fiyat paket fiyatıdır (kuruş). Birim fiyat, ürün tipinin biriminde 1 birimin fiyatıdır:
|
||||||
|
* birim fiyat = paket fiyatı × 1000 / (paketin ürün tipi birimindeki miktarı, binde bir)
|
||||||
|
* Kesirli kuruş olarak tutulur; yuvarlama yalnızca gösterimde. Gösterimde g → kg, ml → lt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PRICE_SOURCE_PATTERN = /^(manual|receipt|provider:[a-z0-9-]{1,40})$/;
|
||||||
|
export type PriceSource = "manual" | "receipt" | `provider:${string}`;
|
||||||
|
|
||||||
|
/** Bu kadar günden eski fiyat "eski" sayılır. */
|
||||||
|
export const PRICE_STALE_DAYS = 90;
|
||||||
|
|
||||||
|
export function unitPrice(priceMinor: number, packageInTypeUnit: number): number {
|
||||||
|
return (priceMinor * QUANTITY_SCALE) / packageInTypeUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gösterim birimi: küçük birimler büyüğe (g → kg, ml → lt). */
|
||||||
|
export function unitPriceBase(typeUnit: string): { unit: string; factor: number } {
|
||||||
|
if (typeUnit === "g") return { unit: "kg", factor: 1000 };
|
||||||
|
if (typeUnit === "ml") return { unit: "lt", factor: 1000 };
|
||||||
|
return { unit: typeUnit, factor: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4250 kuruş/kg → "₺42,50/kg"; g biriminde 4,25 kuruş/g → "₺42,50/kg". */
|
||||||
|
export function formatUnitPrice(unitPriceMinor: number, typeUnit: string): string {
|
||||||
|
const base = unitPriceBase(typeUnit);
|
||||||
|
return `${formatMoney(Math.round(unitPriceMinor * base.factor))}/${base.unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PriceRecordLike = {
|
||||||
|
date: string;
|
||||||
|
priceMinor: number;
|
||||||
|
isPromo: boolean;
|
||||||
|
/** Kampanya geçerlilik sonu (dahil); yoksa süresiz. */
|
||||||
|
promoUntil: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const promoEnded = (r: PriceRecordLike, today: string) =>
|
||||||
|
r.isPromo && r.promoUntil !== null && r.promoUntil < today;
|
||||||
|
|
||||||
|
export type EffectivePrice<T> = {
|
||||||
|
record: T;
|
||||||
|
/** Son kayıt süresi biten kampanyaydı; önceki normal fiyata dönüldü. */
|
||||||
|
fellBack: boolean;
|
||||||
|
/** Geçerli fiyat süresi biten kampanya (dönülecek normal fiyat yok). */
|
||||||
|
promoEnded: boolean;
|
||||||
|
ageDays: number;
|
||||||
|
stale: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geçerli fiyat: tarih (ve giriş) sırasındaki son kayıt. Son kayıt süresi biten kampanyaysa,
|
||||||
|
* ondan önceki son kampanyasız (veya hâlâ geçerli kampanyalı) kayda dönülür; yoksa kampanya
|
||||||
|
* fiyatı işaretle kullanılır. `records` eskiden yeniye sıralı olmalı.
|
||||||
|
*/
|
||||||
|
export function effectivePrice<T extends PriceRecordLike>(
|
||||||
|
records: readonly T[],
|
||||||
|
today: string,
|
||||||
|
): EffectivePrice<T> | null {
|
||||||
|
const latest = records.at(-1);
|
||||||
|
if (!latest) return null;
|
||||||
|
let record = latest;
|
||||||
|
let fellBack = false;
|
||||||
|
if (promoEnded(latest, today)) {
|
||||||
|
const prev = [...records].reverse().find((r) => !promoEnded(r, today));
|
||||||
|
if (prev) {
|
||||||
|
record = prev;
|
||||||
|
fellBack = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ageDays = record.date <= today ? daysBetween(record.date, today) - 1 : 0;
|
||||||
|
return {
|
||||||
|
record,
|
||||||
|
fellBack,
|
||||||
|
promoEnded: promoEnded(record, today),
|
||||||
|
ageDays,
|
||||||
|
stale: ageDays > PRICE_STALE_DAYS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Offer = { unitPriceMinor: number | null };
|
||||||
|
|
||||||
|
export type RankedOffer<T> = T & {
|
||||||
|
rank: number | null;
|
||||||
|
cheapest: boolean;
|
||||||
|
/** En uygundan yüzde fark (tamsayı); en uygunda 0. */
|
||||||
|
diffPct: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Birim fiyata göre sıralar (artan); birim fiyatı hesaplanamayanlar (dönüşüm eksik) sonda.
|
||||||
|
* En düşük birim fiyatlı tüm teklifler (eşitlik dahil) "en uygun".
|
||||||
|
*/
|
||||||
|
export function rankOffers<T extends Offer>(offers: readonly T[]): RankedOffer<T>[] {
|
||||||
|
const priced = offers.filter((o) => o.unitPriceMinor !== null);
|
||||||
|
const min = priced.length ? Math.min(...priced.map((o) => o.unitPriceMinor!)) : null;
|
||||||
|
const sorted = [...offers].sort((a, b) => {
|
||||||
|
if (a.unitPriceMinor === null || b.unitPriceMinor === null)
|
||||||
|
return a.unitPriceMinor === null ? (b.unitPriceMinor === null ? 0 : 1) : -1;
|
||||||
|
return a.unitPriceMinor - b.unitPriceMinor;
|
||||||
|
});
|
||||||
|
let rank = 0;
|
||||||
|
return sorted.map((o) => {
|
||||||
|
if (o.unitPriceMinor === null || min === null)
|
||||||
|
return { ...o, rank: null, cheapest: false, diffPct: null };
|
||||||
|
rank++;
|
||||||
|
return {
|
||||||
|
...o,
|
||||||
|
rank,
|
||||||
|
cheapest: Math.abs(o.unitPriceMinor - min) < 1e-9,
|
||||||
|
diffPct: min > 0 ? Math.round(((o.unitPriceMinor - min) * 100) / min) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grafik/legend için mağaza renk yuvası (1–8): mağazanın hanedeki ekleniş sırasına göre sabit. */
|
||||||
|
export const SERIES_SLOTS = 8;
|
||||||
|
export const seriesSlot = (storeIndex: number) => (storeIndex % SERIES_SLOTS) + 1;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { autoConvertible, BARCODE_PATTERN, describePackage, packageInTypeUnit } from "./products";
|
||||||
|
|
||||||
|
const p = (
|
||||||
|
packageQuantity: number,
|
||||||
|
packageUnit: string,
|
||||||
|
typeUnit: string,
|
||||||
|
typeQuantity: number | null = null,
|
||||||
|
) => ({
|
||||||
|
packageQuantity,
|
||||||
|
packageUnit,
|
||||||
|
typeUnit,
|
||||||
|
typeQuantity,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("paketin ürün tipi birimindeki karşılığı", () => {
|
||||||
|
it("aynı birim ve aynı boyut kendiliğinden", () => {
|
||||||
|
expect(packageInTypeUnit(p(5_000, "kg", "kg"))).toBe(5_000);
|
||||||
|
expect(packageInTypeUnit(p(500_000, "g", "kg"))).toBe(500);
|
||||||
|
expect(packageInTypeUnit(p(1_500, "lt", "ml"))).toBe(1_500_000);
|
||||||
|
expect(packageInTypeUnit(p(40_000, "tablet", "tablet"))).toBe(40_000);
|
||||||
|
expect(autoConvertible("g", "kg")).toBe(true);
|
||||||
|
expect(autoConvertible("kg", "lt")).toBe(false);
|
||||||
|
expect(autoConvertible("paket", "adet")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("farklı boyut veya özel birimde elle dönüşüm, yoksa null", () => {
|
||||||
|
expect(packageInTypeUnit(p(5_000, "kg", "yıkama", 50_000))).toBe(50_000);
|
||||||
|
expect(packageInTypeUnit(p(1_000, "paket", "tablet", 40_000))).toBe(40_000);
|
||||||
|
expect(packageInTypeUnit(p(5_000, "kg", "yıkama"))).toBeNull();
|
||||||
|
// Kendiliğinden çevrilebiliyorsa elle girilen yok sayılır.
|
||||||
|
expect(packageInTypeUnit(p(500_000, "g", "kg", 999))).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paket açıklaması ve barkod", () => {
|
||||||
|
expect(describePackage(p(5_000, "kg", "yıkama", 50_000))).toBe("5 kg · 50 yıkama");
|
||||||
|
expect(describePackage(p(500_000, "g", "kg"))).toBe("500 g · 0,5 kg");
|
||||||
|
expect(describePackage(p(2_000, "kg", "kg"))).toBe("2 kg");
|
||||||
|
expect(BARCODE_PATTERN.test("8690000000001")).toBe(true);
|
||||||
|
expect(BARCODE_PATTERN.test("123")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { convertQuantity, formatQuantity, UNITS, type Unit } from "./quantity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ürün (marka/varyant) motoru (Bölüm 6.11). Bir paketin ürün tipinin birimindeki karşılığı:
|
||||||
|
* - paket birimi ürün tipinin birimiyle aynıysa paket miktarı,
|
||||||
|
* - aynı boyuttaysa (kg↔g, lt↔ml) çevrilmiş miktar,
|
||||||
|
* - değilse elle girilen dönüşüm ("5 kg = 50 yıkama", "1 paket = 40 tablet"); yoksa null
|
||||||
|
* (kayıtta zorunlu; ürün tipinin birimi sonradan değişirse "dönüşüm gerekli" uyarısı).
|
||||||
|
* Miktarlar binde bir.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const isStandard = (u: string): u is Unit => Object.hasOwn(UNITS, u);
|
||||||
|
|
||||||
|
/** Paket birimi ürün tipinin birimine kendiliğinden çevrilebiliyor mu? */
|
||||||
|
export function autoConvertible(packageUnit: string, typeUnit: string): boolean {
|
||||||
|
if (packageUnit === typeUnit) return true;
|
||||||
|
return (
|
||||||
|
isStandard(packageUnit) &&
|
||||||
|
isStandard(typeUnit) &&
|
||||||
|
UNITS[packageUnit].dimension === UNITS[typeUnit].dimension
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bir paketin ürün tipinin birimindeki miktarı (çevrilemiyorsa null). */
|
||||||
|
export function packageInTypeUnit(input: {
|
||||||
|
packageQuantity: number;
|
||||||
|
packageUnit: string;
|
||||||
|
typeUnit: string;
|
||||||
|
/** Elle girilen dönüşüm: bir paket = bu kadar ürün tipi birimi. */
|
||||||
|
typeQuantity: number | null;
|
||||||
|
}): number | null {
|
||||||
|
const { packageQuantity, packageUnit, typeUnit } = input;
|
||||||
|
if (packageUnit === typeUnit) return packageQuantity;
|
||||||
|
if (autoConvertible(packageUnit, typeUnit))
|
||||||
|
return convertQuantity(packageQuantity, packageUnit as Unit, typeUnit as Unit);
|
||||||
|
return input.typeQuantity && input.typeQuantity > 0 ? input.typeQuantity : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "5 kg", dönüşüm varsa "5 kg · 50 yıkama". */
|
||||||
|
export function describePackage(input: {
|
||||||
|
packageQuantity: number;
|
||||||
|
packageUnit: string;
|
||||||
|
typeUnit: string;
|
||||||
|
typeQuantity: number | null;
|
||||||
|
}): string {
|
||||||
|
const pkg = formatQuantity(input.packageQuantity, input.packageUnit);
|
||||||
|
if (input.packageUnit === input.typeUnit) return pkg;
|
||||||
|
const inType = packageInTypeUnit(input);
|
||||||
|
return inType === null ? pkg : `${pkg} · ${formatQuantity(inType, input.typeUnit)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Barkod: 8–14 rakam (EAN-8, UPC-A, EAN-13, GTIN-14). */
|
||||||
|
export const BARCODE_PATTERN = /^\d{8,14}$/;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "../../i18n/tr";
|
||||||
|
import { parseMoney } from "../money";
|
||||||
|
|
||||||
|
const v = tr.validation;
|
||||||
|
const s = tr.productPrices.validation;
|
||||||
|
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date);
|
||||||
|
const id = (message: string) => z.string().regex(/^[a-f0-9]{24}$/i, message);
|
||||||
|
|
||||||
|
/** Elle fiyat girişi (kaynak: manual). Paket fiyatı TL; kampanyada geçerlilik sonu isteğe bağlı. */
|
||||||
|
export const productPriceFormSchema = z
|
||||||
|
.object({
|
||||||
|
productId: id(s.product),
|
||||||
|
storeId: id(s.store),
|
||||||
|
date: ymd,
|
||||||
|
price: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((x, ctx) => {
|
||||||
|
const minor = parseMoney(x);
|
||||||
|
if (minor === null || minor <= 0) {
|
||||||
|
ctx.addIssue({ code: "custom", message: s.price });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return minor;
|
||||||
|
}),
|
||||||
|
isPromo: z.boolean(),
|
||||||
|
promoUntil: z.preprocess((x) => (x === "" || x === undefined ? null : x), ymd.nullable()),
|
||||||
|
note: z.string().trim().max(300).default(""),
|
||||||
|
})
|
||||||
|
.superRefine((x, ctx) => {
|
||||||
|
if (x.isPromo && x.promoUntil && x.promoUntil < x.date)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["promoUntil"], message: s.promoUntil });
|
||||||
|
})
|
||||||
|
.transform(({ price, ...x }) => ({
|
||||||
|
...x,
|
||||||
|
priceMinor: price,
|
||||||
|
promoUntil: x.isPromo ? x.promoUntil : null,
|
||||||
|
}));
|
||||||
|
export type ProductPriceFormInput = z.input<typeof productPriceFormSchema>;
|
||||||
|
export type ProductPriceForm = z.output<typeof productPriceFormSchema>;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "../../i18n/tr";
|
||||||
|
import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments";
|
||||||
|
import { BARCODE_PATTERN } from "../products";
|
||||||
|
import { parseQuantity } from "../quantity";
|
||||||
|
|
||||||
|
const v = tr.validation;
|
||||||
|
const s = tr.products.validation;
|
||||||
|
|
||||||
|
const quantity = (message: string) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((x, ctx) => {
|
||||||
|
const q = parseQuantity(x);
|
||||||
|
if (q === null || q <= 0) {
|
||||||
|
ctx.addIssue({ code: "custom", message });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return q;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ürün (marka/varyant). Dönüşümün zorunlu olup olmadığı ürün tipinin birimine bağlı olduğundan
|
||||||
|
* serviste denetlenir; burada yalnızca biçim.
|
||||||
|
*/
|
||||||
|
export const productFormSchema = z.object({
|
||||||
|
productTypeId: z.string().regex(/^[a-f0-9]{24}$/i, s.productType),
|
||||||
|
brand: z.string().trim().min(1, tr.common.required).max(60, v.nameMax),
|
||||||
|
name: z.string().trim().max(120).default(""),
|
||||||
|
barcode: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((x) => x.replace(/\s/g, ""))
|
||||||
|
.refine((x) => x === "" || BARCODE_PATTERN.test(x), s.barcode)
|
||||||
|
.transform((x) => (x === "" ? null : x)),
|
||||||
|
packageQuantity: quantity(s.packageQuantity),
|
||||||
|
packageUnit: z.string().trim().min(1, s.packageUnit).max(20, s.packageUnit),
|
||||||
|
typeQuantity: z.preprocess(
|
||||||
|
(x) => (x === undefined || x === null || String(x).trim() === "" ? null : x),
|
||||||
|
quantity(s.typeQuantity).nullable(),
|
||||||
|
),
|
||||||
|
note: z.string().trim().max(300).default(""),
|
||||||
|
imageIds: z.array(z.string().regex(/^[a-f0-9]{24}$/i)).max(MAX_ATTACHMENTS_PER_RECORD),
|
||||||
|
});
|
||||||
|
export type ProductFormInput = z.input<typeof productFormSchema>;
|
||||||
|
export type ProductForm = z.output<typeof productFormSchema>;
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "../../i18n/tr";
|
||||||
|
import { parseMoney } from "../money";
|
||||||
|
import { parseQuantity } from "../quantity";
|
||||||
|
|
||||||
|
const s = tr.shopping.validation;
|
||||||
|
const id = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||||
|
const optionalId = z
|
||||||
|
.string()
|
||||||
|
.regex(/^([a-f0-9]{24})?$/i)
|
||||||
|
.transform((x) => (x === "" ? null : x));
|
||||||
|
|
||||||
|
/** Boş → null (1 paket / belirtilmemiş); dolu → binde bir pozitif tamsayı. */
|
||||||
|
const optionalQuantity = z.preprocess(
|
||||||
|
(x) => (x === undefined || x === null || String(x).trim() === "" ? null : x),
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((x, ctx) => {
|
||||||
|
const q = parseQuantity(x);
|
||||||
|
if (q === null || q <= 0) {
|
||||||
|
ctx.addIssue({ code: "custom", message: s.quantity });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return q;
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const shoppingListFormSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, tr.common.required).max(80, tr.validation.nameMax),
|
||||||
|
note: z.string().trim().max(300).default(""),
|
||||||
|
});
|
||||||
|
export type ShoppingListFormInput = z.input<typeof shoppingListFormSchema>;
|
||||||
|
export type ShoppingListForm = z.output<typeof shoppingListFormSchema>;
|
||||||
|
|
||||||
|
/** Kalem: ürün tipi, belirli ürün (marka/paket) ya da serbest metin. */
|
||||||
|
export const shoppingItemFormSchema = z
|
||||||
|
.object({
|
||||||
|
kind: z.enum(["type", "product", "text"]),
|
||||||
|
productTypeId: optionalId.default(""),
|
||||||
|
productId: optionalId.default(""),
|
||||||
|
text: z.string().trim().max(120).default(""),
|
||||||
|
quantity: optionalQuantity.default(null),
|
||||||
|
unit: z.string().trim().max(20).default(""),
|
||||||
|
note: z.string().trim().max(200).default(""),
|
||||||
|
})
|
||||||
|
.superRefine((x, ctx) => {
|
||||||
|
if (x.kind === "type" && !x.productTypeId)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["productTypeId"], message: s.productType });
|
||||||
|
if (x.kind === "product" && !x.productId)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["productId"], message: s.product });
|
||||||
|
if (x.kind === "text" && !x.text)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["text"], message: s.text });
|
||||||
|
})
|
||||||
|
.transform((x) => ({
|
||||||
|
...x,
|
||||||
|
productTypeId: x.kind === "type" ? x.productTypeId : null,
|
||||||
|
productId: x.kind === "product" ? x.productId : null,
|
||||||
|
text: x.kind === "text" ? x.text : "",
|
||||||
|
unit: x.kind === "text" ? x.unit : "",
|
||||||
|
}));
|
||||||
|
export type ShoppingItemFormInput = z.input<typeof shoppingItemFormSchema>;
|
||||||
|
export type ShoppingItemForm = z.output<typeof shoppingItemFormSchema>;
|
||||||
|
|
||||||
|
/** Kalem düzenleme: miktar ve not (serbest metinde metin ve birim de). */
|
||||||
|
export const shoppingItemUpdateSchema = z.object({
|
||||||
|
quantity: optionalQuantity.default(null),
|
||||||
|
text: z.string().trim().max(120).default(""),
|
||||||
|
unit: z.string().trim().max(20).default(""),
|
||||||
|
note: z.string().trim().max(200).default(""),
|
||||||
|
});
|
||||||
|
export type ShoppingItemUpdate = z.output<typeof shoppingItemUpdateSchema>;
|
||||||
|
|
||||||
|
export const shoppingItemRefSchema = z.object({ listId: id, itemId: id });
|
||||||
|
|
||||||
|
const positiveQuantity = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((x, ctx) => {
|
||||||
|
const q = parseQuantity(x);
|
||||||
|
if (q === null || q <= 0) {
|
||||||
|
ctx.addIssue({ code: "custom", message: s.quantity });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return q;
|
||||||
|
});
|
||||||
|
|
||||||
|
const amount = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((x, ctx) => {
|
||||||
|
const minor = parseMoney(x);
|
||||||
|
if (minor === null || minor <= 0) {
|
||||||
|
ctx.addIssue({ code: "custom", message: s.amount });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return minor;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alışverişi tamamla. Satır: liste kalemi (itemId) ya da plansız ekstra (itemId boş). Ürün
|
||||||
|
* kaleminde/ekstrada ürün zorunlu; serbest metin kaleminde ürün yok. Tutar satırın ödenen tutarı.
|
||||||
|
*/
|
||||||
|
export const shoppingCompletionSchema = z
|
||||||
|
.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, tr.validation.date),
|
||||||
|
lines: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
itemId: optionalId.default(""),
|
||||||
|
productId: optionalId.default(""),
|
||||||
|
storeId: id,
|
||||||
|
packages: positiveQuantity,
|
||||||
|
amount,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1, s.noLines)
|
||||||
|
.max(200),
|
||||||
|
stores: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
storeId: id,
|
||||||
|
accountId: z.string().regex(/^[a-f0-9]{24}$/i, s.account),
|
||||||
|
attachmentIds: z.array(id).max(10).default([]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.max(10),
|
||||||
|
/** Gider kategorisine eşlenmemiş stok kategorilerindeki kalemler için. */
|
||||||
|
fallbackCategoryId: optionalId.default(""),
|
||||||
|
leftover: z.object({
|
||||||
|
action: z.enum(["move", "new", "drop"]),
|
||||||
|
targetListId: optionalId.default(""),
|
||||||
|
newListName: z.string().trim().max(80).default(""),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.superRefine((x, ctx) => {
|
||||||
|
const items = x.lines.map((l) => l.itemId).filter(Boolean);
|
||||||
|
if (new Set(items).size !== items.length)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["lines"], message: s.duplicateLine });
|
||||||
|
x.lines.forEach((l, i) => {
|
||||||
|
if (!l.itemId && !l.productId)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["lines", i, "productId"], message: s.product });
|
||||||
|
});
|
||||||
|
const stores = new Set(x.stores.map((st) => st.storeId));
|
||||||
|
if (stores.size !== x.stores.length)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["stores"], message: s.duplicateStore });
|
||||||
|
for (const l of x.lines)
|
||||||
|
if (!stores.has(l.storeId)) {
|
||||||
|
ctx.addIssue({ code: "custom", path: ["stores"], message: s.account });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (x.leftover.action === "move" && !x.leftover.targetListId)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["leftover", "targetListId"], message: s.targetList });
|
||||||
|
if (x.leftover.action === "new" && !x.leftover.newListName)
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["leftover", "newListName"],
|
||||||
|
message: tr.common.required,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
export type ShoppingCompletionInput = z.input<typeof shoppingCompletionSchema>;
|
||||||
|
export type ShoppingCompletionForm = z.output<typeof shoppingCompletionSchema>;
|
||||||
@@ -77,6 +77,8 @@ export const productTypeFormSchema = z.object({
|
|||||||
step: quantity(),
|
step: quantity(),
|
||||||
countEveryDays,
|
countEveryDays,
|
||||||
note: z.string().trim().max(300).default(""),
|
note: z.string().trim().max(300).default(""),
|
||||||
|
/** Ağır/hacimli: akıllı mağaza önerisinde taşıma cezası. */
|
||||||
|
bulky: z.boolean().default(false),
|
||||||
/** Yalnızca yeni ürün tipinde: evdeki başlangıç miktarı. */
|
/** Yalnızca yeni ürün tipinde: evdeki başlangıç miktarı. */
|
||||||
initialQuantity: optionalQuantity.default(null),
|
initialQuantity: optionalQuantity.default(null),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "../../i18n/tr";
|
||||||
|
import { MEAL_CARD_PROVIDERS } from "../accounts";
|
||||||
|
import { parseMoney } from "../money";
|
||||||
|
import { parseQuantity } from "../quantity";
|
||||||
|
import { STORE_KINDS } from "../stores";
|
||||||
|
|
||||||
|
const v = tr.validation;
|
||||||
|
const s = tr.stores.validation;
|
||||||
|
|
||||||
|
/** Boş → null; dolu → kuruş (≥ 0). */
|
||||||
|
const optionalMoney = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((x, ctx) => {
|
||||||
|
if (x === "") return null;
|
||||||
|
const minor = parseMoney(x);
|
||||||
|
if (minor === null || minor < 0) {
|
||||||
|
ctx.addIssue({ code: "custom", message: v.money });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return minor;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** "2,5" km → 2500 m (3 ondalık = metre). Boş → null. */
|
||||||
|
const distance = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((x, ctx) => {
|
||||||
|
if (x === "") return null;
|
||||||
|
const m = parseQuantity(x);
|
||||||
|
if (m === null || m > 1_000_000) {
|
||||||
|
ctx.addIssue({ code: "custom", message: s.distance });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const storeFormSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax),
|
||||||
|
kind: z.enum(STORE_KINDS),
|
||||||
|
branch: z.string().trim().max(80).default(""),
|
||||||
|
distanceKm: distance,
|
||||||
|
carryDifficulty: z.coerce.number().int().min(1, s.carry).max(5, s.carry),
|
||||||
|
deliveryFee: optionalMoney,
|
||||||
|
freeDeliveryMin: optionalMoney,
|
||||||
|
mealCardAccepted: z.boolean(),
|
||||||
|
mealCardProviders: z.array(z.enum(MEAL_CARD_PROVIDERS)).max(MEAL_CARD_PROVIDERS.length),
|
||||||
|
note: z.string().trim().max(300).default(""),
|
||||||
|
})
|
||||||
|
.superRefine((x, ctx) => {
|
||||||
|
if (x.mealCardAccepted && x.mealCardProviders.length === 0)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["mealCardProviders"], message: s.providers });
|
||||||
|
})
|
||||||
|
.transform((x) => {
|
||||||
|
const online = x.kind === "online";
|
||||||
|
return {
|
||||||
|
name: x.name,
|
||||||
|
kind: x.kind,
|
||||||
|
branch: x.branch,
|
||||||
|
distanceM: online ? null : x.distanceKm,
|
||||||
|
carryDifficulty: x.carryDifficulty,
|
||||||
|
deliveryFeeMinor: online ? x.deliveryFee : null,
|
||||||
|
freeDeliveryMinMinor: online ? x.freeDeliveryMin : null,
|
||||||
|
mealCardAccepted: x.mealCardAccepted,
|
||||||
|
mealCardProviders: x.mealCardAccepted ? [...new Set(x.mealCardProviders)] : [],
|
||||||
|
note: x.note,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
export type StoreFormInput = z.input<typeof storeFormSchema>;
|
||||||
|
export type StoreForm = z.output<typeof storeFormSchema>;
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { compareShopping, matchKind, type ComparisonRow } from "./shopping-compare";
|
||||||
|
|
||||||
|
const plan = (productId: string, packages: number, priceMinor: number) => ({
|
||||||
|
productId,
|
||||||
|
packages: packages * 1000,
|
||||||
|
priceMinor,
|
||||||
|
costMinor: packages * priceMinor,
|
||||||
|
});
|
||||||
|
const actual = (productId: string | null, packages: number, amountMinor: number) => ({
|
||||||
|
productId,
|
||||||
|
packages: packages * 1000,
|
||||||
|
amountMinor,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("matchKind", () => {
|
||||||
|
it("ürün tipi kalemi: planlanan marka aynı, başka marka muadil, plan yoksa aynı", () => {
|
||||||
|
const item = { kind: "type" as const, productId: null };
|
||||||
|
expect(matchKind(item, plan("a", 1, 100), actual("a", 1, 100))).toBe("same");
|
||||||
|
expect(matchKind(item, plan("a", 1, 100), actual("b", 1, 100))).toBe("substitute");
|
||||||
|
expect(matchKind(item, null, actual("b", 1, 100))).toBe("same");
|
||||||
|
expect(matchKind(item, plan("a", 1, 100), null)).toBe("skipped");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ürün kalemi başka ürünle muadil; serbest metin alındıysa aynı; listede yoksa ekstra", () => {
|
||||||
|
expect(matchKind({ kind: "product", productId: "a" }, null, actual("c", 1, 100))).toBe(
|
||||||
|
"substitute",
|
||||||
|
);
|
||||||
|
expect(matchKind({ kind: "text", productId: null }, null, actual(null, 1, 100))).toBe("same");
|
||||||
|
expect(matchKind(null, null, actual("x", 1, 100))).toBe("extra");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("compareShopping", () => {
|
||||||
|
it("fark nedenlere ayrışır ve toplamı farka eşittir", () => {
|
||||||
|
const rows: ComparisonRow[] = [
|
||||||
|
// Aynı ürün: 2 paket planlandı (100), 3 alındı ve paket 110 → fiyat +30, miktar +100.
|
||||||
|
{ match: "same", plan: plan("a", 2, 100), actual: actual("a", 3, 330) },
|
||||||
|
// Muadil: plan 200, gerçek 180.
|
||||||
|
{ match: "substitute", plan: plan("b", 1, 200), actual: actual("b2", 1, 180) },
|
||||||
|
// Alınmadı: −50.
|
||||||
|
{ match: "skipped", plan: plan("c", 1, 50), actual: null },
|
||||||
|
// Fiyatı bilinmeyen kalem alındı: +40.
|
||||||
|
{ match: "same", plan: null, actual: actual("d", 1, 40) },
|
||||||
|
// Ekstra: +75.
|
||||||
|
{ match: "extra", plan: null, actual: actual("e", 1, 75) },
|
||||||
|
];
|
||||||
|
const s = compareShopping(rows);
|
||||||
|
expect(s.plannedMinor).toBe(200 + 200 + 50);
|
||||||
|
expect(s.actualMinor).toBe(330 + 180 + 40 + 75);
|
||||||
|
expect(s.reasons).toEqual({
|
||||||
|
price: 30,
|
||||||
|
quantity: 100,
|
||||||
|
substitute: -20,
|
||||||
|
skipped: -50,
|
||||||
|
extra: 75,
|
||||||
|
unplanned: 40,
|
||||||
|
});
|
||||||
|
const sum = Object.values(s.reasons).reduce((a, b) => a + b, 0);
|
||||||
|
expect(sum).toBe(s.diffMinor);
|
||||||
|
expect(s.counts).toEqual({ same: 2, substitute: 1, skipped: 1, extra: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kesirli paket: plan fiyatıyla beklenen yuvarlanır, toplam yine tam", () => {
|
||||||
|
const s = compareShopping([
|
||||||
|
{
|
||||||
|
match: "same",
|
||||||
|
plan: plan("a", 1, 999),
|
||||||
|
actual: { productId: "a", packages: 1500, amountMinor: 1500 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(s.reasons.price + s.reasons.quantity).toBe(s.diffMinor);
|
||||||
|
expect(s.reasons.quantity).toBe(1499 - 999);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Alışveriş sonrası karşılaştırma (Bölüm 6.12). Saf fonksiyonlar.
|
||||||
|
*
|
||||||
|
* Her liste kalemi için: aynı (planlanan ürün alındı), muadil (aynı ürün tipinde başka marka/paket),
|
||||||
|
* alınmadı; listede olmayanlar plansız ekstra. Planlanan tutar, tamamlama anındaki akıllı önerinin
|
||||||
|
* (önerilen dağılım) kalem maliyetidir; fiyatı bilinmeyen kalemin planı yoktur.
|
||||||
|
*
|
||||||
|
* Fark (gerçek − plan) nedenlere ayrıştırılır ve toplamları farka tam eşittir:
|
||||||
|
* - fiyat: aynı ürünün paket fiyatı farkı × alınan paket,
|
||||||
|
* - miktar: (alınan − planlanan paket) × planlanan paket fiyatı,
|
||||||
|
* - muadil: muadil kalemlerde gerçek − plan,
|
||||||
|
* - alınmayan: alınmayan planlı kalemlerin planı (eksi),
|
||||||
|
* - ekstra: plansız ekstraların tutarı,
|
||||||
|
* - fiyatsız: planı olmayan (fiyatı bilinmeyen) kalemlerin tutarı.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MATCH_KINDS = ["same", "substitute", "skipped", "extra"] as const;
|
||||||
|
export type MatchKind = (typeof MATCH_KINDS)[number];
|
||||||
|
|
||||||
|
/** Liste kalemi veya ekstra için gerçekleşen alım. Paket adedi binde bir. */
|
||||||
|
export type ActualPurchase = { productId: string | null; packages: number; amountMinor: number };
|
||||||
|
|
||||||
|
/** Akıllı önerinin kalem için planı. */
|
||||||
|
export type PlannedPurchase = {
|
||||||
|
productId: string;
|
||||||
|
packages: number;
|
||||||
|
priceMinor: number;
|
||||||
|
costMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kalemin eşleşmesi. Ürün kalemi: aynı ürün → aynı. Ürün tipi kalemi: planlanan markayla aynı
|
||||||
|
* ürün (ya da plan yoksa herhangi bir ürün) → aynı; başka marka/paket → muadil. Serbest metin
|
||||||
|
* kalemi alındıysa aynı.
|
||||||
|
*/
|
||||||
|
export function matchKind(
|
||||||
|
item: { kind: "type" | "product" | "text"; productId: string | null } | null,
|
||||||
|
plan: PlannedPurchase | null,
|
||||||
|
actual: ActualPurchase | null,
|
||||||
|
): MatchKind {
|
||||||
|
if (!item) return "extra";
|
||||||
|
if (!actual) return "skipped";
|
||||||
|
if (item.kind === "text") return "same";
|
||||||
|
const expected = item.kind === "product" ? item.productId : (plan?.productId ?? null);
|
||||||
|
return expected === null || expected === actual.productId ? "same" : "substitute";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ComparisonRow = {
|
||||||
|
match: MatchKind;
|
||||||
|
plan: PlannedPurchase | null;
|
||||||
|
actual: ActualPurchase | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComparisonReasons = {
|
||||||
|
price: number;
|
||||||
|
quantity: number;
|
||||||
|
substitute: number;
|
||||||
|
skipped: number;
|
||||||
|
extra: number;
|
||||||
|
unplanned: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComparisonSummary = {
|
||||||
|
plannedMinor: number;
|
||||||
|
actualMinor: number;
|
||||||
|
diffMinor: number;
|
||||||
|
reasons: ComparisonReasons;
|
||||||
|
counts: Record<MatchKind, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function compareShopping(rows: readonly ComparisonRow[]): ComparisonSummary {
|
||||||
|
const reasons: ComparisonReasons = {
|
||||||
|
price: 0,
|
||||||
|
quantity: 0,
|
||||||
|
substitute: 0,
|
||||||
|
skipped: 0,
|
||||||
|
extra: 0,
|
||||||
|
unplanned: 0,
|
||||||
|
};
|
||||||
|
const counts: Record<MatchKind, number> = { same: 0, substitute: 0, skipped: 0, extra: 0 };
|
||||||
|
let plannedMinor = 0;
|
||||||
|
let actualMinor = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
counts[r.match]++;
|
||||||
|
if (r.plan) plannedMinor += r.plan.costMinor;
|
||||||
|
if (r.actual) actualMinor += r.actual.amountMinor;
|
||||||
|
switch (r.match) {
|
||||||
|
case "extra":
|
||||||
|
reasons.extra += r.actual?.amountMinor ?? 0;
|
||||||
|
break;
|
||||||
|
case "skipped":
|
||||||
|
reasons.skipped -= r.plan?.costMinor ?? 0;
|
||||||
|
break;
|
||||||
|
case "substitute":
|
||||||
|
if (r.plan) reasons.substitute += r.actual!.amountMinor - r.plan.costMinor;
|
||||||
|
else reasons.unplanned += r.actual!.amountMinor;
|
||||||
|
break;
|
||||||
|
case "same": {
|
||||||
|
const a = r.actual!;
|
||||||
|
if (!r.plan || a.productId !== r.plan.productId) {
|
||||||
|
// Plansız (fiyatı bilinmeyen) ya da serbest metin.
|
||||||
|
if (r.plan) reasons.substitute += a.amountMinor - r.plan.costMinor;
|
||||||
|
else reasons.unplanned += a.amountMinor;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Paket adedi binde bir: plan fiyatıyla beklenen = paket × fiyat.
|
||||||
|
const expectedAtPlanPrice = Math.round((a.packages * r.plan.priceMinor) / 1000);
|
||||||
|
reasons.price += a.amountMinor - expectedAtPlanPrice;
|
||||||
|
reasons.quantity += expectedAtPlanPrice - r.plan.costMinor;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { plannedMinor, actualMinor, diffMinor: actualMinor - plannedMinor, reasons, counts };
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
BULKY_PENALTY_PER_POINT,
|
||||||
|
planShopping,
|
||||||
|
VISIT_PENALTY_PER_POINT,
|
||||||
|
type PlanInput,
|
||||||
|
type PlanItem,
|
||||||
|
type PlanOffer,
|
||||||
|
type PlanStore,
|
||||||
|
} from "./shopping-plan";
|
||||||
|
import { effortScore } from "./stores";
|
||||||
|
|
||||||
|
const store = (id: string, over: Partial<PlanStore> = {}): PlanStore => ({
|
||||||
|
id,
|
||||||
|
kind: "market",
|
||||||
|
distanceM: 0,
|
||||||
|
carryDifficulty: 1,
|
||||||
|
deliveryFeeMinor: null,
|
||||||
|
freeDeliveryMinMinor: null,
|
||||||
|
mealCardProviders: [],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = (
|
||||||
|
id: string,
|
||||||
|
type: string,
|
||||||
|
quantity = 1000,
|
||||||
|
over: Partial<PlanItem> = {},
|
||||||
|
): PlanItem => ({
|
||||||
|
id,
|
||||||
|
productTypeId: type,
|
||||||
|
quantity,
|
||||||
|
productId: null,
|
||||||
|
bulky: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const offer = (
|
||||||
|
storeId: string,
|
||||||
|
type: string,
|
||||||
|
priceMinor: number,
|
||||||
|
over: Partial<PlanOffer> = {},
|
||||||
|
): PlanOffer => ({
|
||||||
|
productId: `${type}-p`,
|
||||||
|
productTypeId: type,
|
||||||
|
storeId,
|
||||||
|
priceMinor,
|
||||||
|
inTypeUnit: 1000,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const base = (over: Partial<PlanInput>): PlanInput => ({
|
||||||
|
items: [],
|
||||||
|
offers: [],
|
||||||
|
stores: [],
|
||||||
|
mealCards: [],
|
||||||
|
extraStoreThresholdMinor: 10_000,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const storesOf = (o: { stores: { storeId: string }[] } | null) => o?.stores.map((s) => s.storeId);
|
||||||
|
|
||||||
|
describe("planShopping", () => {
|
||||||
|
it("fiyatı olmayan kalemler ayrılır; hiç fiyat yoksa plan yok", () => {
|
||||||
|
const r = planShopping(base({ items: [item("i1", "t1")], stores: [store("A")] }));
|
||||||
|
expect(r.recommended).toBeNull();
|
||||||
|
expect(r.single).toBeNull();
|
||||||
|
expect(r.unpriced).toEqual(["i1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paket sayısı ihtiyaca göre yukarı yuvarlanır; mağazada en az harcamalı paket seçilir", () => {
|
||||||
|
const r = planShopping(
|
||||||
|
base({
|
||||||
|
items: [item("i1", "t1", 2500)],
|
||||||
|
stores: [store("A")],
|
||||||
|
offers: [
|
||||||
|
offer("A", "t1", 3000, { productId: "small", inTypeUnit: 1000 }),
|
||||||
|
// Birim fiyatı daha ucuz ama 5 birimlik paket: harcama 12.000 > 3 × 3.000.
|
||||||
|
offer("A", "t1", 12_000, { productId: "big", inTypeUnit: 5000 }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const line = r.recommended!.stores[0]!.lines[0]!;
|
||||||
|
expect(line).toMatchObject({ productId: "small", packages: 3, costMinor: 9000 });
|
||||||
|
// En ucuz birim fiyat bilgisi büyük paketi gösterir.
|
||||||
|
expect(r.cheapest.i1!.productId).toBe("big");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("belirli marka istenen kalemde yalnızca o ürünün fiyatı kullanılır", () => {
|
||||||
|
const r = planShopping(
|
||||||
|
base({
|
||||||
|
items: [item("i1", "t1", 1000, { productId: "ariel" })],
|
||||||
|
stores: [store("A"), store("B")],
|
||||||
|
offers: [
|
||||||
|
offer("A", "t1", 5000, { productId: "ariel" }),
|
||||||
|
offer("B", "t1", 3000, { productId: "omo" }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(storesOf(r.recommended)).toEqual(["A"]);
|
||||||
|
expect(r.recommended!.totalMinor).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tasarruf eşiği aşılmazsa tek mağazada toplar", () => {
|
||||||
|
const r = planShopping(
|
||||||
|
base({
|
||||||
|
items: [item("i1", "t1"), item("i2", "t2")],
|
||||||
|
stores: [store("A"), store("B")],
|
||||||
|
offers: [
|
||||||
|
offer("A", "t1", 10_000),
|
||||||
|
offer("A", "t2", 10_000),
|
||||||
|
offer("B", "t1", 9_000),
|
||||||
|
offer("B", "t2", 10_500),
|
||||||
|
],
|
||||||
|
extraStoreThresholdMinor: 10_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// A: 200 TL, B: 195 TL tek başına; ikisi birden 190 TL (5 TL fark < 100 TL eşik).
|
||||||
|
expect(storesOf(r.single)).toEqual(["B"]);
|
||||||
|
expect(storesOf(r.recommended)).toEqual(["B"]);
|
||||||
|
expect(r.savingMinor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tasarruf eşiği aşılırsa ek mağaza önerilir", () => {
|
||||||
|
const r = planShopping(
|
||||||
|
base({
|
||||||
|
items: [item("i1", "t1"), item("i2", "t2"), item("i3", "t3")],
|
||||||
|
stores: [store("A"), store("B")],
|
||||||
|
offers: [
|
||||||
|
offer("A", "t1", 50_000),
|
||||||
|
offer("A", "t2", 50_000),
|
||||||
|
offer("A", "t3", 50_000),
|
||||||
|
offer("B", "t1", 30_000),
|
||||||
|
offer("B", "t2", 60_000),
|
||||||
|
offer("B", "t3", 60_000),
|
||||||
|
],
|
||||||
|
extraStoreThresholdMinor: 10_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(storesOf(r.single)).toEqual(["A"]);
|
||||||
|
expect(r.recommended!.stores.map((s) => [s.storeId, s.lines.length])).toEqual([
|
||||||
|
["A", 2],
|
||||||
|
["B", 1],
|
||||||
|
]);
|
||||||
|
expect(r.recommended!.totalMinor).toBe(130_000);
|
||||||
|
expect(r.savingMinor).toBe(20_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tek mağaza her şeyi karşılamıyorsa en az mağazalı eksiksiz plan önerilir", () => {
|
||||||
|
const r = planShopping(
|
||||||
|
base({
|
||||||
|
items: [item("i1", "t1"), item("i2", "t2")],
|
||||||
|
stores: [store("A"), store("B")],
|
||||||
|
offers: [offer("A", "t1", 1000), offer("B", "t2", 1000)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(r.single!.missing).toHaveLength(1);
|
||||||
|
expect(r.recommended!.missing).toEqual([]);
|
||||||
|
expect(r.recommended!.stores).toHaveLength(2);
|
||||||
|
expect(r.savingMinor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uzaklık ve taşıma güçlüğü ziyaret cezası olarak puana eklenir", () => {
|
||||||
|
const far = store("FAR", { distanceM: 20_000, carryDifficulty: 5 });
|
||||||
|
const near = store("NEAR");
|
||||||
|
const r = planShopping(
|
||||||
|
base({
|
||||||
|
items: [item("i1", "t1")],
|
||||||
|
stores: [far, near],
|
||||||
|
offers: [offer("FAR", "t1", 10_000), offer("NEAR", "t1", 13_000)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// FAR 30 TL ucuz ama zahmet 100 → 50 TL ceza.
|
||||||
|
expect(effortScore(far) * VISIT_PENALTY_PER_POINT).toBe(5000);
|
||||||
|
expect(storesOf(r.recommended)).toEqual(["NEAR"]);
|
||||||
|
expect(storesOf(r.single)).toEqual(["NEAR"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hacimli kalemler zahmetli fiziksel mağaza yerine online'a yönelir", () => {
|
||||||
|
const hard = store("HARD", { distanceM: 10_000, carryDifficulty: 5 });
|
||||||
|
const online = store("ONLINE", { kind: "online", distanceM: null, deliveryFeeMinor: 0 });
|
||||||
|
const items = [item("water", "w", 1000, { bulky: true }), item("bread", "b")];
|
||||||
|
const offers = [
|
||||||
|
offer("HARD", "w", 10_000),
|
||||||
|
offer("ONLINE", "w", 10_500),
|
||||||
|
offer("HARD", "b", 1000),
|
||||||
|
];
|
||||||
|
const bulky = planShopping(
|
||||||
|
base({ items, stores: [hard, online], offers, extraStoreThresholdMinor: 0 }),
|
||||||
|
);
|
||||||
|
const line = bulky.recommended!.stores.find((s) => s.storeId === "ONLINE")?.lines[0];
|
||||||
|
expect(line?.itemId).toBe("water");
|
||||||
|
// Aynı kalem hacimli değilse 5 TL ucuz olan HARD seçilir.
|
||||||
|
const plain = planShopping(
|
||||||
|
base({
|
||||||
|
items: [item("water", "w"), item("bread", "b")],
|
||||||
|
stores: [hard, online],
|
||||||
|
offers,
|
||||||
|
extraStoreThresholdMinor: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(storesOf(plain.recommended)).toEqual(["HARD"]);
|
||||||
|
expect(effortScore(hard) * BULKY_PENALTY_PER_POINT).toBeGreaterThan(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("online teslimat ücreti ücretsiz teslimat limitinin altında eklenir", () => {
|
||||||
|
const online = store("ON", {
|
||||||
|
kind: "online",
|
||||||
|
distanceM: null,
|
||||||
|
deliveryFeeMinor: 3000,
|
||||||
|
freeDeliveryMinMinor: 50_000,
|
||||||
|
});
|
||||||
|
const shop = store("SHOP");
|
||||||
|
const offers = [offer("ON", "t1", 10_000), offer("SHOP", "t1", 11_000)];
|
||||||
|
const small = planShopping(base({ items: [item("i1", "t1")], stores: [online, shop], offers }));
|
||||||
|
// 100 TL + 30 TL teslimat > 110 TL.
|
||||||
|
expect(storesOf(small.recommended)).toEqual(["SHOP"]);
|
||||||
|
const big = planShopping(
|
||||||
|
base({ items: [item("i1", "t1", 5000)], stores: [online, shop], offers }),
|
||||||
|
);
|
||||||
|
// 500 TL ≥ limit: teslimat ücretsiz, online 50 TL ucuz.
|
||||||
|
expect(storesOf(big.recommended)).toEqual(["ON"]);
|
||||||
|
expect(big.recommended!.stores[0]!.deliveryFeeMinor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yemek kartı bakiyesi varsa fiyatı yakın kalemler kart geçen mağazaya yönelir", () => {
|
||||||
|
const meal = store("MEAL", { mealCardProviders: ["multinet"] });
|
||||||
|
const other = store("OTHER");
|
||||||
|
const offers = [offer("MEAL", "t1", 10_300), offer("OTHER", "t1", 10_000)];
|
||||||
|
const items = [item("i1", "t1")];
|
||||||
|
const without = planShopping(base({ items, stores: [meal, other], offers }));
|
||||||
|
expect(storesOf(without.recommended)).toEqual(["OTHER"]);
|
||||||
|
const withCard = planShopping(
|
||||||
|
base({
|
||||||
|
items,
|
||||||
|
stores: [meal, other],
|
||||||
|
offers,
|
||||||
|
mealCards: [{ provider: "multinet", balanceMinor: 50_000 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(storesOf(withCard.recommended)).toEqual(["MEAL"]);
|
||||||
|
expect(withCard.recommended!.mealCardMinor).toBe(10_300);
|
||||||
|
// Kart sağlayıcısı mağazada geçmiyorsa öncelik yok.
|
||||||
|
const wrong = planShopping(
|
||||||
|
base({
|
||||||
|
items,
|
||||||
|
stores: [meal, other],
|
||||||
|
offers,
|
||||||
|
mealCards: [{ provider: "sodexo", balanceMinor: 50_000 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(storesOf(wrong.recommended)).toEqual(["OTHER"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yemek kartı bakiyesi mağazalar arasında bir kez harcanır", () => {
|
||||||
|
const a = store("A", { mealCardProviders: ["multinet"] });
|
||||||
|
const b = store("B", { mealCardProviders: ["multinet"] });
|
||||||
|
const r = planShopping(
|
||||||
|
base({
|
||||||
|
items: [item("i1", "t1"), item("i2", "t2")],
|
||||||
|
stores: [a, b],
|
||||||
|
offers: [offer("A", "t1", 30_000), offer("B", "t2", 20_000)],
|
||||||
|
mealCards: [{ provider: "multinet", balanceMinor: 40_000 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(r.recommended!.mealCardMinor).toBe(40_000);
|
||||||
|
expect(r.recommended!.stores.map((s) => s.mealCardMinor).sort()).toEqual([10_000, 30_000]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { effortScore, type StoreKind } from "./stores";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Akıllı mağaza önerisi (Bölüm 6.12). Saf fonksiyon; son bilinen fiyatlarla listedeki her kalemi
|
||||||
|
* bir mağazaya ve markaya dağıtır.
|
||||||
|
*
|
||||||
|
* Kalem maliyeti: ihtiyacı karşılayan paket sayısı × paket fiyatı (gerçek harcama). Bir mağazada
|
||||||
|
* birden çok marka/paket varsa en az harcamalı olan, eşitlikte birim fiyatı düşük olan seçilir.
|
||||||
|
*
|
||||||
|
* Planın puanı (küçük olan iyi) = harcama + teslimat ücretleri + zahmet cezaları − yemek kartı payı:
|
||||||
|
* - Ziyaret cezası: fiziksel mağaza başına zahmet skoru × ZİYARET_KATSAYISI (online: 0).
|
||||||
|
* - Hacimli kalem cezası: fiziksel mağazada hacimli kalem başına zahmet skoru × HACİMLİ_KATSAYI.
|
||||||
|
* - Online teslimat: ara toplam ücretsiz teslimat alt limitinin altındaysa (limit yoksa her
|
||||||
|
* zaman) teslimat ücreti eklenir.
|
||||||
|
* - Yemek kartı önceliği: yemek kartı geçen mağazalarda kartla ödenebilecek tutarın
|
||||||
|
* MEAL_CARD_PREFERENCE_PCT'si puandan düşülür; böylece fiyatı yakın kalemler karta yönelir.
|
||||||
|
*
|
||||||
|
* Seçim: tüm fiyatlı kalemleri karşılayan en az mağazalı plandan başlanır; bir mağaza daha
|
||||||
|
* eklemek ancak puanı eşikten (hane ayarı) fazla düşürüyorsa kabul edilir.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Ziyaret cezası: zahmet puanı başına kuruş (zahmet 100 → 50 TL). */
|
||||||
|
export const VISIT_PENALTY_PER_POINT = 50;
|
||||||
|
/** Hacimli kalem cezası: zahmet puanı başına kuruş (zahmet 100 → kalem başına 15 TL). */
|
||||||
|
export const BULKY_PENALTY_PER_POINT = 15;
|
||||||
|
/** Yemek kartıyla ödenebilecek tutarın puandan düşülen yüzdesi. */
|
||||||
|
export const MEAL_CARD_PREFERENCE_PCT = 10;
|
||||||
|
/** Bir planda en fazla bu kadar mağaza. */
|
||||||
|
export const MAX_PLAN_STORES = 4;
|
||||||
|
|
||||||
|
export type PlanStore = {
|
||||||
|
id: string;
|
||||||
|
kind: StoreKind;
|
||||||
|
distanceM: number | null;
|
||||||
|
carryDifficulty: number;
|
||||||
|
deliveryFeeMinor: number | null;
|
||||||
|
freeDeliveryMinMinor: number | null;
|
||||||
|
/** Yemek kartı geçmiyorsa boş. */
|
||||||
|
mealCardProviders: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanOffer = {
|
||||||
|
productId: string;
|
||||||
|
productTypeId: string;
|
||||||
|
storeId: string;
|
||||||
|
/** Paket fiyatı (kuruş). */
|
||||||
|
priceMinor: number;
|
||||||
|
/** Bir paketin ürün tipi birimindeki miktarı (binde bir). */
|
||||||
|
inTypeUnit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanItem = {
|
||||||
|
id: string;
|
||||||
|
productTypeId: string;
|
||||||
|
/** Gereken miktar, ürün tipi biriminde (binde bir). */
|
||||||
|
quantity: number;
|
||||||
|
/** Belirli marka/ürün istenmişse yalnızca o ürünün fiyatları. */
|
||||||
|
productId: string | null;
|
||||||
|
bulky: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanMealCard = { provider: string; balanceMinor: number };
|
||||||
|
|
||||||
|
export type PlanInput = {
|
||||||
|
items: readonly PlanItem[];
|
||||||
|
offers: readonly PlanOffer[];
|
||||||
|
stores: readonly PlanStore[];
|
||||||
|
mealCards: readonly PlanMealCard[];
|
||||||
|
/** Ek mağaza eşiği (kuruş). */
|
||||||
|
extraStoreThresholdMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanLine = {
|
||||||
|
itemId: string;
|
||||||
|
productId: string;
|
||||||
|
storeId: string;
|
||||||
|
packages: number;
|
||||||
|
priceMinor: number;
|
||||||
|
costMinor: number;
|
||||||
|
/** Ürün tipi biriminde 1 birimin fiyatı (kesirli kuruş). */
|
||||||
|
unitPriceMinor: number;
|
||||||
|
bulkyPenaltyMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanStoreGroup = {
|
||||||
|
storeId: string;
|
||||||
|
lines: PlanLine[];
|
||||||
|
subtotalMinor: number;
|
||||||
|
deliveryFeeMinor: number;
|
||||||
|
visitPenaltyMinor: number;
|
||||||
|
/** Bu mağazada yemek kartıyla ödenebilecek tutar. */
|
||||||
|
mealCardMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanOption = {
|
||||||
|
stores: PlanStoreGroup[];
|
||||||
|
/** Harcama + teslimat. */
|
||||||
|
totalMinor: number;
|
||||||
|
/** Zahmet cezaları (ziyaret + hacimli). */
|
||||||
|
penaltyMinor: number;
|
||||||
|
mealCardMinor: number;
|
||||||
|
/** Karşılaştırma puanı (kesirli olabilir). */
|
||||||
|
score: number;
|
||||||
|
/** Bu planda alınamayan (fiyatlı) kalemler. */
|
||||||
|
missing: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanResult = {
|
||||||
|
/** Tek mağazanın en iyisi (hepsini karşılayan yoksa en çok kalemi karşılayan). */
|
||||||
|
single: PlanOption | null;
|
||||||
|
/** Önerilen dağılım; tek mağaza en iyiyse onunla aynı. */
|
||||||
|
recommended: PlanOption | null;
|
||||||
|
/** Tek mağazaya göre harcama farkı (pozitif = tasarruf); karşılaştırılamıyorsa null. */
|
||||||
|
savingMinor: number | null;
|
||||||
|
/** Hiçbir mağazada fiyatı bilinmeyen kalemler. */
|
||||||
|
unpriced: string[];
|
||||||
|
/** Kalem başına birim fiyatı en düşük teklif (mağaza ve marka). */
|
||||||
|
cheapest: Record<string, PlanLine>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const unitOf = (o: PlanOffer) => (o.priceMinor * 1000) / o.inTypeUnit;
|
||||||
|
|
||||||
|
/** Bir teklifle kalemi karşılamak: paket sayısı ve harcama. */
|
||||||
|
function lineFor(item: PlanItem, o: PlanOffer, store: PlanStore): PlanLine {
|
||||||
|
const packages = Math.max(1, Math.ceil(item.quantity / o.inTypeUnit));
|
||||||
|
const physical = store.kind !== "online";
|
||||||
|
return {
|
||||||
|
itemId: item.id,
|
||||||
|
productId: o.productId,
|
||||||
|
storeId: o.storeId,
|
||||||
|
packages,
|
||||||
|
priceMinor: o.priceMinor,
|
||||||
|
costMinor: packages * o.priceMinor,
|
||||||
|
unitPriceMinor: unitOf(o),
|
||||||
|
bulkyPenaltyMinor: item.bulky && physical ? effortScore(store) * BULKY_PENALTY_PER_POINT : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const better = (a: PlanLine, b: PlanLine) =>
|
||||||
|
a.costMinor !== b.costMinor ? a.costMinor < b.costMinor : a.unitPriceMinor < b.unitPriceMinor;
|
||||||
|
|
||||||
|
/** Mağazalardaki yemek kartı payı: kart bakiyeleri bir kez harcanır, büyük ara toplam önce. */
|
||||||
|
function allocateMealCards(
|
||||||
|
groups: PlanStoreGroup[],
|
||||||
|
stores: Map<string, PlanStore>,
|
||||||
|
cards: readonly PlanMealCard[],
|
||||||
|
) {
|
||||||
|
const left = cards.map((c) => ({ ...c }));
|
||||||
|
const order = [...groups].sort((a, b) => b.subtotalMinor - a.subtotalMinor);
|
||||||
|
for (const g of order) {
|
||||||
|
const accepted = new Set(stores.get(g.storeId)!.mealCardProviders);
|
||||||
|
let need = g.subtotalMinor + g.deliveryFeeMinor;
|
||||||
|
for (const c of left) {
|
||||||
|
if (need <= 0) break;
|
||||||
|
if (!accepted.has(c.provider) || c.balanceMinor <= 0) continue;
|
||||||
|
const use = Math.min(need, c.balanceMinor);
|
||||||
|
c.balanceMinor -= use;
|
||||||
|
need -= use;
|
||||||
|
g.mealCardMinor += use;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function* subsets(n: number, k: number, start = 0, acc: number[] = []): Generator<number[]> {
|
||||||
|
if (acc.length === k) {
|
||||||
|
yield [...acc];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let i = start; i <= n - (k - acc.length); i++) {
|
||||||
|
acc.push(i);
|
||||||
|
yield* subsets(n, k, i + 1, acc);
|
||||||
|
acc.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planShopping(input: PlanInput): PlanResult {
|
||||||
|
const storeById = new Map(input.stores.map((s) => [s.id, s]));
|
||||||
|
// Kalem × mağaza en iyi satır.
|
||||||
|
const best = new Map<string, Map<string, PlanLine>>();
|
||||||
|
const cheapest: Record<string, PlanLine> = {};
|
||||||
|
for (const item of input.items) {
|
||||||
|
const byStore = new Map<string, PlanLine>();
|
||||||
|
for (const o of input.offers) {
|
||||||
|
if (o.inTypeUnit <= 0 || o.productTypeId !== item.productTypeId) continue;
|
||||||
|
if (item.productId && o.productId !== item.productId) continue;
|
||||||
|
const store = storeById.get(o.storeId);
|
||||||
|
if (!store) continue;
|
||||||
|
const line = lineFor(item, o, store);
|
||||||
|
const prev = byStore.get(o.storeId);
|
||||||
|
if (!prev || better(line, prev)) byStore.set(o.storeId, line);
|
||||||
|
const c = cheapest[item.id];
|
||||||
|
if (!c || line.unitPriceMinor < c.unitPriceMinor) cheapest[item.id] = line;
|
||||||
|
}
|
||||||
|
best.set(item.id, byStore);
|
||||||
|
}
|
||||||
|
const priced = input.items.filter((i) => best.get(i.id)!.size > 0);
|
||||||
|
const unpriced = input.items.filter((i) => best.get(i.id)!.size === 0).map((i) => i.id);
|
||||||
|
const candidates = input.stores.filter((s) => priced.some((i) => best.get(i.id)!.has(s.id)));
|
||||||
|
|
||||||
|
if (priced.length === 0 || candidates.length === 0)
|
||||||
|
return { single: null, recommended: null, savingMinor: null, unpriced, cheapest };
|
||||||
|
|
||||||
|
function evaluate(chosen: PlanStore[]): PlanOption {
|
||||||
|
const groups = new Map<string, PlanStoreGroup>();
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const item of priced) {
|
||||||
|
const options = best.get(item.id)!;
|
||||||
|
let pick: PlanLine | null = null;
|
||||||
|
for (const s of chosen) {
|
||||||
|
const line = options.get(s.id);
|
||||||
|
if (!line) continue;
|
||||||
|
if (
|
||||||
|
!pick ||
|
||||||
|
line.costMinor + line.bulkyPenaltyMinor < pick.costMinor + pick.bulkyPenaltyMinor ||
|
||||||
|
(line.costMinor + line.bulkyPenaltyMinor === pick.costMinor + pick.bulkyPenaltyMinor &&
|
||||||
|
line.unitPriceMinor < pick.unitPriceMinor)
|
||||||
|
)
|
||||||
|
pick = line;
|
||||||
|
}
|
||||||
|
if (!pick) {
|
||||||
|
missing.push(item.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const g = groups.get(pick.storeId) ?? {
|
||||||
|
storeId: pick.storeId,
|
||||||
|
lines: [],
|
||||||
|
subtotalMinor: 0,
|
||||||
|
deliveryFeeMinor: 0,
|
||||||
|
visitPenaltyMinor: 0,
|
||||||
|
mealCardMinor: 0,
|
||||||
|
};
|
||||||
|
g.lines.push(pick);
|
||||||
|
g.subtotalMinor += pick.costMinor;
|
||||||
|
groups.set(pick.storeId, g);
|
||||||
|
}
|
||||||
|
const list = [...groups.values()];
|
||||||
|
for (const g of list) {
|
||||||
|
const s = storeById.get(g.storeId)!;
|
||||||
|
if (s.kind === "online") {
|
||||||
|
const fee = s.deliveryFeeMinor ?? 0;
|
||||||
|
const free = s.freeDeliveryMinMinor;
|
||||||
|
g.deliveryFeeMinor = free !== null && g.subtotalMinor >= free ? 0 : fee;
|
||||||
|
} else g.visitPenaltyMinor = effortScore(s) * VISIT_PENALTY_PER_POINT;
|
||||||
|
}
|
||||||
|
allocateMealCards(list, storeById, input.mealCards);
|
||||||
|
const totalMinor = list.reduce((a, g) => a + g.subtotalMinor + g.deliveryFeeMinor, 0);
|
||||||
|
const penaltyMinor = list.reduce(
|
||||||
|
(a, g) => a + g.visitPenaltyMinor + g.lines.reduce((b, l) => b + l.bulkyPenaltyMinor, 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const mealCardMinor = list.reduce((a, g) => a + g.mealCardMinor, 0);
|
||||||
|
return {
|
||||||
|
stores: list.sort(
|
||||||
|
(a, b) => b.lines.length - a.lines.length || b.subtotalMinor - a.subtotalMinor,
|
||||||
|
),
|
||||||
|
totalMinor,
|
||||||
|
penaltyMinor,
|
||||||
|
mealCardMinor,
|
||||||
|
score: totalMinor + penaltyMinor - (mealCardMinor * MEAL_CARD_PREFERENCE_PCT) / 100,
|
||||||
|
missing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boyut başına en iyi tam (eksiksiz) plan; tek mağazada eksiksiz yoksa en çok kalemi karşılayan.
|
||||||
|
const bestBySize: (PlanOption | null)[] = [];
|
||||||
|
let single: PlanOption | null = null;
|
||||||
|
const maxK = Math.min(MAX_PLAN_STORES, candidates.length);
|
||||||
|
for (let k = 1; k <= maxK; k++) {
|
||||||
|
let top: PlanOption | null = null;
|
||||||
|
for (const idx of subsets(candidates.length, k)) {
|
||||||
|
const option = evaluate(idx.map((i) => candidates[i]!));
|
||||||
|
// Kullanılmayan mağaza içeren alt küme daha küçük boyutta zaten değerlendirildi.
|
||||||
|
if (option.stores.length < k) continue;
|
||||||
|
if (
|
||||||
|
k === 1 &&
|
||||||
|
(!single ||
|
||||||
|
option.missing.length < single.missing.length ||
|
||||||
|
(option.missing.length === single.missing.length && option.score < single.score))
|
||||||
|
)
|
||||||
|
single = option;
|
||||||
|
if (option.missing.length === 0 && (!top || option.score < top.score)) top = option;
|
||||||
|
}
|
||||||
|
bestBySize[k] = top;
|
||||||
|
}
|
||||||
|
|
||||||
|
let k = bestBySize.findIndex((o, i) => i > 0 && o !== null);
|
||||||
|
let recommended: PlanOption | null = k > 0 ? bestBySize[k]! : null;
|
||||||
|
// Tüm alt kümeler sınırı aştıysa (kalemler çok dağınık): en iyi eksikli plan.
|
||||||
|
if (!recommended) recommended = single;
|
||||||
|
else
|
||||||
|
while (k + 1 <= maxK) {
|
||||||
|
const next = bestBySize[k + 1];
|
||||||
|
if (!next || recommended.score - next.score <= input.extraStoreThresholdMinor) break;
|
||||||
|
recommended = next;
|
||||||
|
k++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparable =
|
||||||
|
single && recommended && single.missing.length === 0 && recommended.missing.length === 0;
|
||||||
|
return {
|
||||||
|
single,
|
||||||
|
recommended,
|
||||||
|
savingMinor: comparable ? single!.totalMinor - recommended!.totalMinor : null,
|
||||||
|
unpriced,
|
||||||
|
cheapest,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { requiredQuantity, suggestionHidden } from "./shopping";
|
||||||
|
|
||||||
|
describe("requiredQuantity", () => {
|
||||||
|
it("ürün tipi kaleminde girilen miktar; boşsa 0", () => {
|
||||||
|
expect(requiredQuantity({ kind: "type", quantity: 2500 }, null)).toBe(2500);
|
||||||
|
expect(requiredQuantity({ kind: "type", quantity: null }, null)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ürün kaleminde paket adedi × paketin tip karşılığı; boşsa 1 paket", () => {
|
||||||
|
// 2 paket × 50 yıkama
|
||||||
|
expect(requiredQuantity({ kind: "product", quantity: 2000 }, 50_000)).toBe(100_000);
|
||||||
|
expect(requiredQuantity({ kind: "product", quantity: null }, 500)).toBe(500);
|
||||||
|
expect(requiredQuantity({ kind: "product", quantity: 2000 }, null)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("suggestionHidden", () => {
|
||||||
|
const d = (s: string) => new Date(s);
|
||||||
|
it("reddedilmemişse görünür", () => {
|
||||||
|
expect(suggestionHidden(null, null)).toBe(false);
|
||||||
|
});
|
||||||
|
it("redden sonra alım yoksa gizli, alım gelince yeniden görünür", () => {
|
||||||
|
expect(suggestionHidden(d("2026-11-01"), null)).toBe(true);
|
||||||
|
expect(suggestionHidden(d("2026-11-01"), d("2026-10-20"))).toBe(true);
|
||||||
|
expect(suggestionHidden(d("2026-11-01"), d("2026-11-03"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { QUANTITY_SCALE } from "./quantity";
|
||||||
|
|
||||||
|
/** Alışveriş listeleri (Bölüm 6.12): sabitler ve saf yardımcılar. */
|
||||||
|
|
||||||
|
export const SHOPPING_LIST_STATUSES = ["active", "completed"] as const;
|
||||||
|
export type ShoppingListStatus = (typeof SHOPPING_LIST_STATUSES)[number];
|
||||||
|
|
||||||
|
export const SHOPPING_ITEM_KINDS = ["type", "product", "text"] as const;
|
||||||
|
export type ShoppingItemKind = (typeof SHOPPING_ITEM_KINDS)[number];
|
||||||
|
|
||||||
|
export const SHOPPING_ITEM_SOURCES = ["manual", "suggestion"] as const;
|
||||||
|
export type ShoppingItemSource = (typeof SHOPPING_ITEM_SOURCES)[number];
|
||||||
|
|
||||||
|
/** Aile üyeleri aynı listeyi kullanır: sayfa odaklanınca ve bu aralıkla yenilenir. */
|
||||||
|
export const SHOPPING_REFRESH_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Planlayıcı için gereken miktar (ürün tipi biriminde, binde bir):
|
||||||
|
* - ürün tipi kalemi: girilen miktar; boşsa 0 (en az bir paket alınır),
|
||||||
|
* - ürün kalemi: paket adedi × paketin tip birimindeki karşılığı.
|
||||||
|
*/
|
||||||
|
export function requiredQuantity(
|
||||||
|
item: { kind: ShoppingItemKind; quantity: number | null },
|
||||||
|
inTypeUnit: number | null,
|
||||||
|
): number {
|
||||||
|
if (item.kind === "product") {
|
||||||
|
if (!inTypeUnit) return 0;
|
||||||
|
const packages = item.quantity ?? QUANTITY_SCALE;
|
||||||
|
return Math.round((packages * inTypeUnit) / QUANTITY_SCALE);
|
||||||
|
}
|
||||||
|
return item.quantity ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reddedilen eşik önerisi, reddedildikten sonra alım hareketi girilene (stok yenilenene) kadar
|
||||||
|
* gizli kalır; sonra ürün yine eşiğin altına düşerse öneri yeniden çıkar.
|
||||||
|
*/
|
||||||
|
export function suggestionHidden(dismissedAt: Date | null, lastPurchaseAt: Date | null): boolean {
|
||||||
|
if (!dismissedAt) return false;
|
||||||
|
return !lastPurchaseAt || lastPurchaseAt.getTime() <= dismissedAt.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alınmayan kalemler: başka aktif listeye taşı, yeni liste aç ya da bırak. */
|
||||||
|
export const LEFTOVER_ACTIONS = ["move", "new", "drop"] as const;
|
||||||
|
export type LeftoverAction = (typeof LEFTOVER_ACTIONS)[number];
|
||||||
|
|
||||||
|
/** Tamamlanan listede saklanan görüntü (etiketler tamamlama anındaki hâliyle). */
|
||||||
|
export type CompletionRow = {
|
||||||
|
itemId: string | null;
|
||||||
|
match: "same" | "substitute" | "skipped" | "extra";
|
||||||
|
/** Kalem adı (liste kalemi ya da ekstra ürün). */
|
||||||
|
label: string;
|
||||||
|
/** Alınan: ürün etiketi ve paketi; serbest metinde boş. */
|
||||||
|
productId: string | null;
|
||||||
|
productLabel: string;
|
||||||
|
storeId: string | null;
|
||||||
|
storeLabel: string;
|
||||||
|
/** Paket adedi (binde bir). */
|
||||||
|
packages: number;
|
||||||
|
/** Alınan miktar metni ("4 paket", markasızda "2,5 kg"); alınmadıysa boş. */
|
||||||
|
quantityLabel?: string;
|
||||||
|
amountMinor: number | null;
|
||||||
|
plan: {
|
||||||
|
productId: string;
|
||||||
|
productLabel: string;
|
||||||
|
storeId: string;
|
||||||
|
storeLabel: string;
|
||||||
|
packages: number;
|
||||||
|
priceMinor: number;
|
||||||
|
costMinor: number;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompletionStore = {
|
||||||
|
storeId: string;
|
||||||
|
label: string;
|
||||||
|
accountId: string;
|
||||||
|
accountName: string;
|
||||||
|
transactionId: string;
|
||||||
|
amountMinor: number;
|
||||||
|
attachmentCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShoppingCompletion = {
|
||||||
|
date: string;
|
||||||
|
completedBy: string;
|
||||||
|
rows: CompletionRow[];
|
||||||
|
stores: CompletionStore[];
|
||||||
|
movedTo: { listId: string; name: string } | null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { effortLevel, effortScore, formatDistance, sortStores, type StoreKind } from "./stores";
|
||||||
|
|
||||||
|
const s = (name: string, kind: StoreKind, distanceM: number | null, carryDifficulty: number) => ({
|
||||||
|
name,
|
||||||
|
branch: "",
|
||||||
|
kind,
|
||||||
|
distanceM,
|
||||||
|
carryDifficulty,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("zahmet skoru", () => {
|
||||||
|
it("uzaklık ve taşıma eşit ağırlıklı; online uzaklık saymaz", () => {
|
||||||
|
expect(effortScore(s("A", "market", 0, 1))).toBe(0);
|
||||||
|
expect(effortScore(s("A", "market", 20_000, 5))).toBe(100);
|
||||||
|
expect(effortScore(s("A", "market", 50_000, 5))).toBe(100);
|
||||||
|
expect(effortScore(s("A", "market", 2_000, 3))).toBe(30);
|
||||||
|
expect(effortScore(s("A", "bazaar", 1_000, 4))).toBe(40);
|
||||||
|
expect(effortScore(s("A", "online", 30_000, 1))).toBe(0);
|
||||||
|
expect(effortScore(s("A", "market", null, 2))).toBe(13);
|
||||||
|
expect([effortLevel(0), effortLevel(33), effortLevel(34), effortLevel(67)]).toEqual([
|
||||||
|
"low",
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sıralama", () => {
|
||||||
|
const list = [
|
||||||
|
s("Migros", "market", 1_200, 2),
|
||||||
|
s("Pazar", "bazaar", 600, 4),
|
||||||
|
s("Getir", "online", null, 1),
|
||||||
|
s("BİM", "market", 300, 2),
|
||||||
|
s("Metro", "market", 9_000, 3),
|
||||||
|
s("Bakkal", "market", null, 1),
|
||||||
|
];
|
||||||
|
const names = (by: Parameters<typeof sortStores>[1]) => sortStores(list, by).map((x) => x.name);
|
||||||
|
|
||||||
|
it("en yakın / en uzak: online ve uzaklığı bilinmeyenler sonda", () => {
|
||||||
|
expect(names("nearest")).toEqual(["BİM", "Pazar", "Migros", "Metro", "Bakkal", "Getir"]);
|
||||||
|
expect(names("farthest")).toEqual(["Metro", "Migros", "Pazar", "BİM", "Bakkal", "Getir"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("en zahmetli ve ada göre (Türkçe)", () => {
|
||||||
|
expect(names("effort")).toEqual(["Metro", "Pazar", "Migros", "BİM", "Bakkal", "Getir"]);
|
||||||
|
expect(names("name")).toEqual(["Bakkal", "BİM", "Getir", "Metro", "Migros", "Pazar"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uzaklık gösterimi", () => {
|
||||||
|
expect(formatDistance(800)).toBe("800 m");
|
||||||
|
expect(formatDistance(2_500)).toBe("2,5 km");
|
||||||
|
expect(formatDistance(12_000)).toBe("12 km");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { compareTr } from "./search";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mağaza motoru (Bölüm 6.11). Saf fonksiyonlar.
|
||||||
|
*
|
||||||
|
* Zahmet skoru (0–100): uzaklık ve taşıma güçlüğünün eşit ağırlıklı bileşimi.
|
||||||
|
* uzaklık payı = min(uzaklık, 20 km) / 20 km × 50 (online mağazada 0: kapıya teslim)
|
||||||
|
* taşıma payı = (taşıma puanı − 1) / 4 × 50 (1 = kolay … 5 = çok zor)
|
||||||
|
* Faz 13'te akıllı öneri bu skoru maliyet cezasına çevirir.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const STORE_KINDS = ["market", "online", "bazaar"] as const;
|
||||||
|
export type StoreKind = (typeof STORE_KINDS)[number];
|
||||||
|
|
||||||
|
/** Uzaklık metre cinsinden tamsayı saklanır (2,5 km → 2500). */
|
||||||
|
export const EFFORT_MAX_DISTANCE_M = 20_000;
|
||||||
|
export const CARRY_MIN = 1;
|
||||||
|
export const CARRY_MAX = 5;
|
||||||
|
|
||||||
|
export type StoreEffortInput = {
|
||||||
|
kind: StoreKind;
|
||||||
|
/** Metre; bilinmiyorsa null. */
|
||||||
|
distanceM: number | null;
|
||||||
|
/** 1–5. */
|
||||||
|
carryDifficulty: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function effortScore(s: StoreEffortInput): number {
|
||||||
|
const distance =
|
||||||
|
s.kind === "online" ? 0 : Math.min(Math.max(0, s.distanceM ?? 0), EFFORT_MAX_DISTANCE_M);
|
||||||
|
const carry = Math.min(CARRY_MAX, Math.max(CARRY_MIN, s.carryDifficulty));
|
||||||
|
return Math.round(
|
||||||
|
(distance / EFFORT_MAX_DISTANCE_M) * 50 + ((carry - CARRY_MIN) / (CARRY_MAX - CARRY_MIN)) * 50,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EffortLevel = "low" | "medium" | "high";
|
||||||
|
|
||||||
|
export function effortLevel(score: number): EffortLevel {
|
||||||
|
if (score < 34) return "low";
|
||||||
|
if (score < 67) return "medium";
|
||||||
|
return "high";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STORE_SORTS = ["name", "nearest", "farthest", "effort"] as const;
|
||||||
|
export type StoreSort = (typeof STORE_SORTS)[number];
|
||||||
|
|
||||||
|
type Sortable = StoreEffortInput & { name: string; branch: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sıralama. "En yakın/en uzak" yalnızca uzaklığı bilinen fiziksel mağazaları sıralar; online ve
|
||||||
|
* uzaklığı bilinmeyenler sonda (ada göre). "En zahmetli": skor büyükten küçüğe.
|
||||||
|
*/
|
||||||
|
export function sortStores<T extends Sortable>(stores: readonly T[], by: StoreSort): T[] {
|
||||||
|
const byName = (a: T, b: T) => compareTr(a.name, b.name) || compareTr(a.branch, b.branch);
|
||||||
|
const known = (s: T) => s.kind !== "online" && s.distanceM !== null;
|
||||||
|
return [...stores].sort((a, b) => {
|
||||||
|
switch (by) {
|
||||||
|
case "name":
|
||||||
|
return byName(a, b);
|
||||||
|
case "effort":
|
||||||
|
return effortScore(b) - effortScore(a) || byName(a, b);
|
||||||
|
case "nearest":
|
||||||
|
case "farthest": {
|
||||||
|
if (known(a) !== known(b)) return known(a) ? -1 : 1;
|
||||||
|
if (!known(a)) return byName(a, b);
|
||||||
|
const d = a.distanceM! - b.distanceM!;
|
||||||
|
return (by === "nearest" ? d : -d) || byName(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2500 → "2,5 km"; 800 → "800 m". */
|
||||||
|
export function formatDistance(meters: number): string {
|
||||||
|
if (meters < 1000) return `${meters} m`;
|
||||||
|
const km = meters / 1000;
|
||||||
|
return `${km.toLocaleString("tr-TR", { maximumFractionDigits: 1 })} km`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user