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:
@@ -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,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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user