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:
@@ -6,6 +6,15 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
|||||||
|
|
||||||
## [Yayınlanmamış]
|
## [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
|
## [0.11.0] - 2026-11-06
|
||||||
|
|
||||||
Faz 11: ev stoğu (11a ve 11b alt fazları).
|
Faz 11: ev stoğu (11a ve 11b alt fazları).
|
||||||
|
|||||||
@@ -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 type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
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 { ProductDetailView } from "@/features/stock/components/product-detail-view";
|
||||||
import { getProductType, getStockOptions } from "@/features/stock/service";
|
import { getProductType, getStockOptions } from "@/features/stock/service";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
@@ -11,10 +13,25 @@ export const metadata: Metadata = { title: tr.stock.detail.movements };
|
|||||||
export default async function ProductTypePage({ params }: PageProps<"/stok/[id]">) {
|
export default async function ProductTypePage({ params }: PageProps<"/stok/[id]">) {
|
||||||
const { user, household } = await requireActiveHousehold("viewer");
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const [data, options] = await Promise.all([
|
const [data, options, products, types] = await Promise.all([
|
||||||
getProductType(user.id, household.id, id),
|
getProductType(user.id, household.id, id),
|
||||||
getStockOptions(user.id, household.id),
|
getStockOptions(user.id, household.id),
|
||||||
|
listProducts(user.id, household.id, { productTypeId: id, includeArchived: true }),
|
||||||
|
listProductTypeOptions(user.id, household.id),
|
||||||
]);
|
]);
|
||||||
if (!data || !options) notFound();
|
if (!data || !options || !products || !types) notFound();
|
||||||
return <ProductDetailView data={data} categories={options.categories} />;
|
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,
|
Package,
|
||||||
Settings,
|
Settings,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
|
Store,
|
||||||
Tags,
|
Tags,
|
||||||
Target,
|
Target,
|
||||||
UserCog,
|
UserCog,
|
||||||
@@ -27,7 +28,7 @@ export type NavItem = {
|
|||||||
|
|
||||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||||
export const CURRENT_PHASE = 11.2;
|
export const CURRENT_PHASE = 12.1;
|
||||||
|
|
||||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
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: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 },
|
||||||
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.1 },
|
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.1 },
|
||||||
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 },
|
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 },
|
||||||
|
{ href: "/magazalar", label: tr.nav.stores, icon: Store, phase: 12.1 },
|
||||||
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
|
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
|
||||||
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
|
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
|
||||||
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
|
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
|
||||||
|
|||||||
@@ -38,11 +38,17 @@ export function AttachmentPicker({
|
|||||||
onChange,
|
onChange,
|
||||||
allowedKinds,
|
allowedKinds,
|
||||||
onBusyChange,
|
onBusyChange,
|
||||||
|
hint,
|
||||||
|
addLabel,
|
||||||
}: {
|
}: {
|
||||||
value: AttachmentInfo[];
|
value: AttachmentInfo[];
|
||||||
onChange: (items: AttachmentInfo[]) => void;
|
onChange: (items: AttachmentInfo[]) => void;
|
||||||
allowedKinds: readonly AttachmentKind[];
|
allowedKinds: readonly AttachmentKind[];
|
||||||
onBusyChange?: (busy: boolean) => void;
|
onBusyChange?: (busy: boolean) => void;
|
||||||
|
/** Boşken gösterilen ipucu (varsayılan: fiş/fatura). */
|
||||||
|
hint?: string;
|
||||||
|
/** Düğme metni (varsayılan: "Ek ekle"). */
|
||||||
|
addLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploading, setUploading] = useState(0);
|
const [uploading, setUploading] = useState(0);
|
||||||
@@ -91,14 +97,16 @@ export function AttachmentPicker({
|
|||||||
disabled={room <= 0 || uploading > 0}
|
disabled={room <= 0 || uploading > 0}
|
||||||
>
|
>
|
||||||
{uploading > 0 ? <Loader2 className="animate-spin" /> : <Paperclip />}
|
{uploading > 0 ? <Loader2 className="animate-spin" /> : <Paperclip />}
|
||||||
{uploading > 0 ? t.uploading : t.add}
|
{uploading > 0 ? t.uploading : (addLabel ?? t.add)}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{value.length
|
{value.length
|
||||||
? t.count(value.length)
|
? t.count(value.length)
|
||||||
: allowedKinds.includes("pdf")
|
: hint
|
||||||
? t.addHint.imagePdf
|
? hint
|
||||||
: t.addHint.image}
|
: allowedKinds.includes("pdf")
|
||||||
|
? t.addHint.imagePdf
|
||||||
|
: t.addHint.image}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|||||||
@@ -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({
|
export function ProductDetailView({
|
||||||
data,
|
data,
|
||||||
categories,
|
categories,
|
||||||
|
products,
|
||||||
}: {
|
}: {
|
||||||
data: ProductTypeDetail;
|
data: ProductTypeDetail;
|
||||||
categories: StockOption[];
|
categories: StockOption[];
|
||||||
|
/** Marka/paket bölümü (Faz 12). */
|
||||||
|
products?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [pending, setPending] = useState<Pending>(null);
|
const [pending, setPending] = useState<Pending>(null);
|
||||||
const close = () => setPending(null);
|
const close = () => setPending(null);
|
||||||
@@ -281,6 +284,8 @@ export function ProductDetailView({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{products}
|
||||||
|
|
||||||
<section className="flex flex-col gap-2" aria-labelledby="stock-movements-title">
|
<section className="flex flex-col gap-2" aria-labelledby="stock-movements-title">
|
||||||
<h2 id="stock-movements-title" className="text-base font-medium">
|
<h2 id="stock-movements-title" className="text-base font-medium">
|
||||||
{t.detail.movements}
|
{t.detail.movements}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
FolderTree,
|
FolderTree,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
|
Tags,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -164,6 +165,12 @@ export function StockView({ data, options }: { data: Data; options: StockOption[
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href="/stok/urunler">
|
||||||
|
<Tags />
|
||||||
|
{tr.products.catalogLink}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href="/stok/analiz">
|
<Link href="/stok/analiz">
|
||||||
<ChartColumn />
|
<ChartColumn />
|
||||||
|
|||||||
@@ -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