From d52f41ab0c1a54e75c532a5d4f9028e2edc2ddb2 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 6 Nov 2026 11:48:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20ma=C4=9Fazalar=20sayfas=C4=B1,=20?= =?UTF-8?q?=C3=BCr=C3=BCn=20katalo=C4=9Fu=20ve=20=C3=BCr=C3=BCn=20tipi=20d?= =?UTF-8?q?etay=C4=B1nda=20=C3=BCr=C3=BCnler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 + src/app/(app)/magazalar/page.tsx | 25 ++ src/app/(app)/stok/[id]/page.tsx | 23 +- src/app/(app)/stok/urunler/page.tsx | 19 ++ src/components/layout/nav-config.ts | 4 +- .../components/attachment-picker.tsx | 16 +- .../products/components/product-dialog.tsx | 261 +++++++++++++++++ .../products/components/product-list.tsx | 184 ++++++++++++ .../products/components/products-catalog.tsx | 110 +++++++ .../products/components/products-section.tsx | 59 ++++ .../stock/components/product-detail-view.tsx | 5 + src/features/stock/components/stock-view.tsx | 7 + .../stores/components/store-dialog.tsx | 239 +++++++++++++++ .../stores/components/stores-view.tsx | 275 ++++++++++++++++++ 14 files changed, 1228 insertions(+), 8 deletions(-) create mode 100644 src/app/(app)/magazalar/page.tsx create mode 100644 src/app/(app)/stok/urunler/page.tsx create mode 100644 src/features/products/components/product-dialog.tsx create mode 100644 src/features/products/components/product-list.tsx create mode 100644 src/features/products/components/products-catalog.tsx create mode 100644 src/features/products/components/products-section.tsx create mode 100644 src/features/stores/components/store-dialog.tsx create mode 100644 src/features/stores/components/stores-view.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index e659bfd..0c5055c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ## [Yayınlanmamış] +### 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 Faz 11: ev stoğu (11a ve 11b alt fazları). diff --git a/src/app/(app)/magazalar/page.tsx b/src/app/(app)/magazalar/page.tsx new file mode 100644 index 0000000..345be28 --- /dev/null +++ b/src/app/(app)/magazalar/page.tsx @@ -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 ( + <> + + {!data.canWrite ? ( +

{tr.stores.readOnly}

+ ) : null} + + + ); +} diff --git a/src/app/(app)/stok/[id]/page.tsx b/src/app/(app)/stok/[id]/page.tsx index dfcc8c5..2b6e2ae 100644 --- a/src/app/(app)/stok/[id]/page.tsx +++ b/src/app/(app)/stok/[id]/page.tsx @@ -1,5 +1,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; +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 { getProductType, getStockOptions } from "@/features/stock/service"; import { tr } from "@/i18n/tr"; @@ -11,10 +13,25 @@ export const metadata: Metadata = { title: tr.stock.detail.movements }; export default async function ProductTypePage({ params }: PageProps<"/stok/[id]">) { const { user, household } = await requireActiveHousehold("viewer"); const { id } = await params; - const [data, options] = await Promise.all([ + const [data, options, products, types] = await Promise.all([ getProductType(user.id, household.id, id), getStockOptions(user.id, household.id), + listProducts(user.id, household.id, { productTypeId: id, includeArchived: true }), + listProductTypeOptions(user.id, household.id), ]); - if (!data || !options) notFound(); - return ; + if (!data || !options || !products || !types) notFound(); + return ( + + } + /> + ); } diff --git a/src/app/(app)/stok/urunler/page.tsx b/src/app/(app)/stok/urunler/page.tsx new file mode 100644 index 0000000..799d382 --- /dev/null +++ b/src/app/(app)/stok/urunler/page.tsx @@ -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 ; +} diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index e6ee9d1..32810ed 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -8,6 +8,7 @@ import { Package, Settings, ShoppingCart, + Store, Tags, Target, UserCog, @@ -27,7 +28,7 @@ export type NavItem = { /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** Alt fazlar ondalıkla: 6.1 = Faz 6a. */ -export const CURRENT_PHASE = 11.2; +export const CURRENT_PHASE = 12.1; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; @@ -41,6 +42,7 @@ export const MAIN_NAV: NavItem[] = [ { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 }, { href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.1 }, { href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 }, + { href: "/magazalar", label: tr.nav.stores, icon: Store, phase: 12.1 }, { href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 }, { href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 }, { href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 }, diff --git a/src/features/attachments/components/attachment-picker.tsx b/src/features/attachments/components/attachment-picker.tsx index c3b0f7d..2687fad 100644 --- a/src/features/attachments/components/attachment-picker.tsx +++ b/src/features/attachments/components/attachment-picker.tsx @@ -38,11 +38,17 @@ export function AttachmentPicker({ onChange, allowedKinds, onBusyChange, + hint, + addLabel, }: { value: AttachmentInfo[]; onChange: (items: AttachmentInfo[]) => void; allowedKinds: readonly AttachmentKind[]; 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(null); const [uploading, setUploading] = useState(0); @@ -91,14 +97,16 @@ export function AttachmentPicker({ disabled={room <= 0 || uploading > 0} > {uploading > 0 ? : } - {uploading > 0 ? t.uploading : t.add} + {uploading > 0 ? t.uploading : (addLabel ?? t.add)} {value.length ? t.count(value.length) - : allowedKinds.includes("pdf") - ? t.addHint.imagePdf - : t.addHint.image} + : hint + ? hint + : allowedKinds.includes("pdf") + ? t.addHint.imagePdf + : t.addHint.image} ; + +/** 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(product?.images ?? []); + const [uploading, setUploading] = useState(false); + const [errors, setErrors] = useState({}); + const [busy, setBusy] = useState(false); + const set = (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 ( + !o && onClose()} + title={product ? t.editTitle : t.createTitle} + footer={ + <> + + + + } + > +
+ + + {f.productType} + + {errors.productTypeId ? {errors.productTypeId} : null} + +
+ + {f.brand} + set("brand", e.target.value)} + aria-invalid={Boolean(errors.brand)} + autoFocus={!product} + /> + {errors.brand ? {errors.brand} : null} + + + {f.name} + set("name", e.target.value)} + /> + +
+
+ + {f.packageQuantity} + set("packageQuantity", e.target.value)} + className="text-right tabular-nums" + aria-invalid={Boolean(errors.packageQuantity)} + /> + {errors.packageQuantity ? {errors.packageQuantity} : null} + + + {f.packageUnit} + + {v.unitChoice === CUSTOM ? ( + set("customUnit", e.target.value)} + aria-invalid={Boolean(errors.packageUnit)} + /> + ) : null} + {errors.packageUnit ? {errors.packageUnit} : null} + +
+ {needsConversion ? ( + + {f.typeQuantity(typeUnit)} + set("typeQuantity", e.target.value)} + className="text-right tabular-nums" + aria-invalid={Boolean(errors.typeQuantity)} + /> + {f.typeQuantityHint(pkgText, typeUnit)} + {errors.typeQuantity ? {errors.typeQuantity} : null} + + ) : autoValue !== null && packageUnit !== typeUnit ? ( + + {f.autoConversion(formatQuantity(autoValue, typeUnit))} + + ) : null} + + {f.barcode} + set("barcode", e.target.value)} + className="tabular-nums" + aria-invalid={Boolean(errors.barcode)} + /> + {f.barcodeHint} + {errors.barcode ? {errors.barcode} : null} + + + {f.images} + + {errors.imageIds ? {errors.imageIds} : null} + + + {f.note} + set("note", e.target.value)} /> + + {errors.root ? {errors.root} : null} +
+
+
+ ); +} diff --git a/src/features/products/components/product-list.tsx b/src/features/products/components/product-list.tsx new file mode 100644 index 0000000..87091f4 --- /dev/null +++ b/src/features/products/components/product-list.tsx @@ -0,0 +1,184 @@ +"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(null); + const [archiving, setArchiving] = useState(null); + const [viewer, setViewer] = useState(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 ( + <> +
    + {products.map((p) => { + const cover = p.images[0]; + return ( +
  • + {cover ? ( + setViewer(p)} className="size-14" /> + ) : ( + + + + )} +
    +

    {p.label}

    +

    + {p.packageLabel} + {showType ? ( + <> + {" · "} + + {p.productTypeName} + + + ) : null} +

    +
    + {p.barcode ? {t.barcode(p.barcode)} : null} + {p.images.length > 1 ? {t.images(p.images.length)} : null} + {p.archived ? {t.archived} : null} + {p.conversionMissing ? ( + + + {t.conversionMissing} + + ) : null} +
    +
    + {canWrite ? ( + + + + + + setEditing(p)}> + + {t.actions.edit} + + + {p.archived ? ( + setArchived(p, false)}> + + {t.actions.unarchive} + + ) : ( + setArchiving(p)}> + + {t.actions.archive} + + )} + + + ) : null} +
  • + ); + })} +
+ + {editing ? ( + setEditing(null)} /> + ) : null} + !o && setViewer(null)} + /> + !o && setArchiving(null)}> + + + {t.archiveTitle} + + {archiving ? t.archiveDescription(archiving.label) : null} + + + + {tr.common.cancel} + + + + + + ); +} diff --git a/src/features/products/components/products-catalog.tsx b/src/features/products/components/products-catalog.tsx new file mode 100644 index 0000000..5c4a3a2 --- /dev/null +++ b/src/features/products/components/products-catalog.tsx @@ -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(); + 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 ( +
+ + setCreating(true)}> + + {t.new} + + ) : null + } + /> +
+
+ + setQuery(e.target.value)} + placeholder={t.search} + aria-label={t.search} + className="pl-8" + /> +
+ {products.some((p) => p.archived) ? ( + + ) : null} +
+ {products.length === 0 ? ( +

+ {t.empty} +

+ ) : groups.length === 0 ? ( +

{t.noMatch}

+ ) : ( + groups.map(([typeId, g]) => ( +
+

+ + {g.name} + +

+ +
+ )) + )} + {creating ? ( + setCreating(false)} /> + ) : null} +
+ ); +} diff --git a/src/features/products/components/products-section.tsx b/src/features/products/components/products-section.tsx new file mode 100644 index 0000000..59e5eb1 --- /dev/null +++ b/src/features/products/components/products-section.tsx @@ -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 ( +
+
+
+

+ {t.sectionTitle} +

+

{t.sectionDescription}

+
+ {canWrite && types.some((x) => x.id === productTypeId) ? ( + + ) : null} +
+ {products.length === 0 ? ( +

+ {t.emptyType} +

+ ) : ( + + )} + {creating ? ( + setCreating(false)} + /> + ) : null} +
+ ); +} diff --git a/src/features/stock/components/product-detail-view.tsx b/src/features/stock/components/product-detail-view.tsx index a957398..3f3496f 100644 --- a/src/features/stock/components/product-detail-view.tsx +++ b/src/features/stock/components/product-detail-view.tsx @@ -78,9 +78,12 @@ function Stat({ label, children }: { label: string; children: React.ReactNode }) export function ProductDetailView({ data, categories, + products, }: { data: ProductTypeDetail; categories: StockOption[]; + /** Marka/paket bölümü (Faz 12). */ + products?: React.ReactNode; }) { const [pending, setPending] = useState(null); const close = () => setPending(null); @@ -281,6 +284,8 @@ export function ProductDetailView({ + {products} +

{t.detail.movements} diff --git a/src/features/stock/components/stock-view.tsx b/src/features/stock/components/stock-view.tsx index 915f4ff..db6a611 100644 --- a/src/features/stock/components/stock-view.tsx +++ b/src/features/stock/components/stock-view.tsx @@ -9,6 +9,7 @@ import { FolderTree, Plus, Search, + Tags, } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -164,6 +165,12 @@ export function StockView({ data, options }: { data: Data; options: StockOption[ return (
+ + + + } + > +
+ +
+ + {f.name} + set("name", e.target.value)} + aria-invalid={Boolean(errors.name)} + autoFocus={!store} + /> + {errors.name ? {errors.name} : null} + + + {f.branch} + set("branch", e.target.value)} + /> + +
+ + {f.kind} + + +
+ {online ? ( + <> + + {f.deliveryFee} (₺) + set("deliveryFee", e.target.value)} + className="text-right tabular-nums" + aria-invalid={Boolean(errors.deliveryFee)} + /> + {errors.deliveryFee ? {errors.deliveryFee} : null} + + + {f.freeDeliveryMin} (₺) + set("freeDeliveryMin", e.target.value)} + className="text-right tabular-nums" + aria-invalid={Boolean(errors.freeDeliveryMin)} + /> + {errors.freeDeliveryMin ? ( + {errors.freeDeliveryMin} + ) : null} + + + ) : ( + + {f.distance} + set("distanceKm", e.target.value)} + className="text-right tabular-nums" + aria-invalid={Boolean(errors.distanceKm)} + /> + {errors.distanceKm ? {errors.distanceKm} : null} + + )} + + {f.carry} + + +
+ + {online ? null : `${f.distanceHint} `} + {f.carryHint} {t.effortValue(preview)} + + + set("mealCardAccepted", x === true)} + /> + {f.mealCard} + + {v.mealCardAccepted ? ( + + {f.mealCardProviders} +
+ {MEAL_CARD_PROVIDERS.map((p) => ( + + ))} +
+ {errors.mealCardProviders ? ( + {errors.mealCardProviders} + ) : null} +
+ ) : null} + + {f.note} + set("note", e.target.value)} /> + + {errors.root ? {errors.root} : null} +
+
+ + ); +} diff --git a/src/features/stores/components/stores-view.tsx b/src/features/stores/components/stores-view.tsx new file mode 100644 index 0000000..3691977 --- /dev/null +++ b/src/features/stores/components/stores-view.tsx @@ -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 = { + 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 ( +
+ {t.effort} +
+
+
+ + {store.effort} · {t.effortLevels[store.effortLevel]} + +
+ ); +} + +/** /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("name"); + const [showArchived, setShowArchived] = useState(false); + const [editing, setEditing] = useState(null); + const [archiving, setArchiving] = useState(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 ( +
+
+ + {hasArchived ? ( + + ) : null} + {canWrite ? ( + + ) : null} +
+ + {visible.length === 0 ? ( +

+ {t.empty} +

+ ) : ( +
    + {visible.map((s) => { + const Icon = KIND_ICON[s.kind]; + return ( +
  • +
    + + + +
    +

    {s.name}

    +

    + {[t.kindShort[s.kind], s.branch].filter(Boolean).join(" · ")} +

    +
    + {canWrite ? ( + + + + + + setEditing(s)}> + + {t.actions.edit} + + + {s.archived ? ( + setArchived(s, false)}> + + {t.actions.unarchive} + + ) : ( + setArchiving(s)}> + + {t.actions.archive} + + )} + + + ) : null} +
    +
    + {s.kind === "online" ? ( + + {s.deliveryFeeMinor + ? t.delivery(formatMoney(s.deliveryFeeMinor)) + : t.freeDelivery} + {s.deliveryFeeMinor && s.freeDeliveryMinMinor + ? ` · ${t.freeDeliveryFrom(formatMoney(s.freeDeliveryMinMinor))}` + : ""} + + ) : ( + + + {s.distanceM !== null ? formatDistance(s.distanceM) : t.distanceUnknown} + + )} + + {t.fields.carry}: {s.carryDifficulty} · {t.carryLevels[s.carryDifficulty]} + +
    + +
    + {s.archived ? {t.archived} : null} + {s.mealCardAccepted ? ( + + + {t.mealCardBadge}:{" "} + {s.mealCardProviders.map((p) => tr.accounts.providers[p]).join(", ")} + + ) : null} +
    + {s.note ?

    {s.note}

    : null} +
  • + ); + })} +
+ )} + + {editing ? ( + setEditing(null)} + /> + ) : null} + !o && setArchiving(null)}> + + + {t.archiveTitle} + + {archiving ? t.archiveDescription(archiving.label) : null} + + + + {tr.common.cancel} + + + + +
+ ); +}