feat(ui): mağazalar sayfası, ürün kataloğu ve ürün tipi detayında ürünler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 11:48:04 +03:00
parent f0b570284e
commit d52f41ab0c
14 changed files with 1228 additions and 8 deletions
+9
View File
@@ -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ı).
+25
View File
@@ -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} />
</>
);
}
+20 -3
View File
@@ -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 <ProductDetailView data={data} categories={options.categories} />;
if (!data || !options || !products || !types) notFound();
return (
<ProductDetailView
data={data}
categories={options.categories}
products={
<ProductsSection
productTypeId={data.id}
products={products.products}
types={types}
canWrite={products.canWrite}
/>
}
/>
);
}
+19
View File
@@ -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} />;
}
+3 -1
View File
@@ -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 },
@@ -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<HTMLInputElement>(null);
const [uploading, setUploading] = useState(0);
@@ -91,14 +97,16 @@ export function AttachmentPicker({
disabled={room <= 0 || uploading > 0}
>
{uploading > 0 ? <Loader2 className="animate-spin" /> : <Paperclip />}
{uploading > 0 ? t.uploading : t.add}
{uploading > 0 ? t.uploading : (addLabel ?? t.add)}
</Button>
<span className="text-xs text-muted-foreground">
{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}
</span>
<input
ref={inputRef}
@@ -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,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<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">{p.label}</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>
);
}
@@ -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<Pending>(null);
const close = () => setPending(null);
@@ -281,6 +284,8 @@ export function ProductDetailView({
</CardContent>
</Card>
{products}
<section className="flex flex-col gap-2" aria-labelledby="stock-movements-title">
<h2 id="stock-movements-title" className="text-base font-medium">
{t.detail.movements}
@@ -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 (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap justify-end gap-2">
<Button variant="ghost" size="sm" asChild>
<Link href="/stok/urunler">
<Tags />
{tr.products.catalogLink}
</Link>
</Button>
<Button variant="ghost" size="sm" asChild>
<Link href="/stok/analiz">
<ChartColumn />
@@ -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>
);
}