441 lines
16 KiB
TypeScript
441 lines
16 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|||
|
|
import { ChevronLeft, ChevronRight, Package, Pencil, Plus, Search, Tag, Trash2 } from "lucide-react";
|
|||
|
|
import { BrandFormDialog, type BrandRow } from "@/components/product-profile/brand-form-dialog";
|
|||
|
|
import {
|
|||
|
|
ProductItemFormDialog,
|
|||
|
|
type ProductItemRow,
|
|||
|
|
} from "@/components/product-profile/product-item-form-dialog";
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|||
|
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
|||
|
|
import { EmptyState } from "@/components/layout/empty-state";
|
|||
|
|
import { PageHeader } from "@/components/layout/page-header";
|
|||
|
|
import { InlineAlert } from "@/components/ui/inline-alert";
|
|||
|
|
import { Input } from "@/components/ui/input";
|
|||
|
|
import { summarizeProductContext } from "@/lib/types/product-context";
|
|||
|
|
import { useActionFeedback } from "@/lib/use-action-feedback";
|
|||
|
|
import { parseFetchJson } from "@/lib/utils";
|
|||
|
|
|
|||
|
|
const BRANDS_PER_PAGE = 8;
|
|||
|
|
const PRODUCTS_PREVIEW = 6;
|
|||
|
|
|
|||
|
|
interface BrandWithProducts extends BrandRow {
|
|||
|
|
products: ProductItemRow[];
|
|||
|
|
productCount?: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface BrandsResponse {
|
|||
|
|
brands?: BrandWithProducts[];
|
|||
|
|
total?: number;
|
|||
|
|
page?: number;
|
|||
|
|
totalPages?: number;
|
|||
|
|
error?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default function ProductsPage() {
|
|||
|
|
const [brands, setBrands] = useState<BrandWithProducts[]>([]);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [page, setPage] = useState(1);
|
|||
|
|
const [total, setTotal] = useState(0);
|
|||
|
|
const [totalPages, setTotalPages] = useState(1);
|
|||
|
|
const [searchInput, setSearchInput] = useState("");
|
|||
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|||
|
|
const [expandedBrands, setExpandedBrands] = useState<Set<string>>(new Set());
|
|||
|
|
const [fullProducts, setFullProducts] = useState<Record<string, ProductItemRow[]>>({});
|
|||
|
|
const [loadingProducts, setLoadingProducts] = useState<string | null>(null);
|
|||
|
|
const [brandDialogOpen, setBrandDialogOpen] = useState(false);
|
|||
|
|
const [editingBrand, setEditingBrand] = useState<BrandRow | null>(null);
|
|||
|
|
const [productDialogOpen, setProductDialogOpen] = useState(false);
|
|||
|
|
const [productBrand, setProductBrand] = useState<BrandWithProducts | null>(null);
|
|||
|
|
const [editingProduct, setEditingProduct] = useState<ProductItemRow | null>(null);
|
|||
|
|
const [deletingBrandId, setDeletingBrandId] = useState<string | null>(null);
|
|||
|
|
const [deletingProductId, setDeletingProductId] = useState<string | null>(null);
|
|||
|
|
const [confirmDialog, setConfirmDialog] = useState<{
|
|||
|
|
title: string;
|
|||
|
|
description?: string;
|
|||
|
|
onConfirm: () => void | Promise<void>;
|
|||
|
|
} | null>(null);
|
|||
|
|
const { feedback, clearFeedback, showError, showSuccess } = useActionFeedback();
|
|||
|
|
|
|||
|
|
const load = useCallback(async () => {
|
|||
|
|
setLoading(true);
|
|||
|
|
const params = new URLSearchParams({
|
|||
|
|
page: String(page),
|
|||
|
|
limit: String(BRANDS_PER_PAGE),
|
|||
|
|
productLimit: "12",
|
|||
|
|
});
|
|||
|
|
if (searchQuery) params.set("q", searchQuery);
|
|||
|
|
|
|||
|
|
const res = await fetch(`/api/brands?${params}`);
|
|||
|
|
try {
|
|||
|
|
const data = await parseFetchJson<BrandsResponse>(res);
|
|||
|
|
if (!res.ok) {
|
|||
|
|
showError(data.error ?? "無法載入品牌列表", "讀取失敗");
|
|||
|
|
setBrands([]);
|
|||
|
|
setTotal(0);
|
|||
|
|
setTotalPages(1);
|
|||
|
|
} else {
|
|||
|
|
setBrands(data.brands ?? []);
|
|||
|
|
setTotal(data.total ?? 0);
|
|||
|
|
setTotalPages(data.totalPages ?? 1);
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
showError(err instanceof Error ? err.message : "伺服器回應異常", "讀取失敗");
|
|||
|
|
setBrands([]);
|
|||
|
|
setTotal(0);
|
|||
|
|
setTotalPages(1);
|
|||
|
|
}
|
|||
|
|
setLoading(false);
|
|||
|
|
}, [page, searchQuery, showError]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
load();
|
|||
|
|
}, [load]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
const timer = window.setTimeout(() => {
|
|||
|
|
setSearchQuery(searchInput.trim());
|
|||
|
|
setPage(1);
|
|||
|
|
}, 300);
|
|||
|
|
return () => window.clearTimeout(timer);
|
|||
|
|
}, [searchInput]);
|
|||
|
|
|
|||
|
|
const summaryText = useMemo(() => {
|
|||
|
|
if (total === 0) return searchQuery ? "沒有符合的品牌或產品" : "尚無品牌";
|
|||
|
|
if (searchQuery) return `找到 ${total} 個品牌`;
|
|||
|
|
return `共 ${total} 個品牌`;
|
|||
|
|
}, [total, searchQuery]);
|
|||
|
|
|
|||
|
|
function openCreateBrand() {
|
|||
|
|
setEditingBrand(null);
|
|||
|
|
setBrandDialogOpen(true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openEditBrand(brand: BrandRow) {
|
|||
|
|
setEditingBrand(brand);
|
|||
|
|
setBrandDialogOpen(true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openCreateProduct(brand: BrandWithProducts) {
|
|||
|
|
setProductBrand(brand);
|
|||
|
|
setEditingProduct(null);
|
|||
|
|
setProductDialogOpen(true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openEditProduct(brand: BrandWithProducts, product: ProductItemRow) {
|
|||
|
|
setProductBrand(brand);
|
|||
|
|
setEditingProduct(product);
|
|||
|
|
setProductDialogOpen(true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadAllProducts(brandId: string) {
|
|||
|
|
setLoadingProducts(brandId);
|
|||
|
|
const res = await fetch(`/api/brands?brandId=${brandId}&productLimit=200`);
|
|||
|
|
try {
|
|||
|
|
const data = await parseFetchJson<{ brand?: BrandWithProducts; error?: string }>(res);
|
|||
|
|
if (!res.ok || !data.brand) {
|
|||
|
|
showError(data.error ?? "無法載入產品列表", "讀取失敗");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setFullProducts((prev) => ({ ...prev, [brandId]: data.brand!.products }));
|
|||
|
|
setExpandedBrands((prev) => new Set(prev).add(brandId));
|
|||
|
|
} catch (err) {
|
|||
|
|
showError(err instanceof Error ? err.message : "伺服器回應異常", "讀取失敗");
|
|||
|
|
} finally {
|
|||
|
|
setLoadingProducts(null);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getVisibleProducts(brand: BrandWithProducts) {
|
|||
|
|
const all = fullProducts[brand.id] ?? brand.products;
|
|||
|
|
const expanded = expandedBrands.has(brand.id);
|
|||
|
|
if (expanded || all.length <= PRODUCTS_PREVIEW) return all;
|
|||
|
|
return all.slice(0, PRODUCTS_PREVIEW);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hasMoreProducts(brand: BrandWithProducts) {
|
|||
|
|
const count = brand.productCount ?? brand.products.length;
|
|||
|
|
const loaded = fullProducts[brand.id] ?? brand.products;
|
|||
|
|
if (expandedBrands.has(brand.id)) return false;
|
|||
|
|
return count > loaded.length || loaded.length > PRODUCTS_PREVIEW;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleDeleteBrand(id: string) {
|
|||
|
|
setConfirmDialog({
|
|||
|
|
title: "刪除品牌",
|
|||
|
|
description: "確定刪除此品牌?若有主題正在使用將無法刪除。",
|
|||
|
|
onConfirm: async () => {
|
|||
|
|
setDeletingBrandId(id);
|
|||
|
|
const res = await fetch(`/api/brands?id=${id}`, { method: "DELETE" });
|
|||
|
|
const data = await res.json();
|
|||
|
|
setDeletingBrandId(null);
|
|||
|
|
if (!res.ok) {
|
|||
|
|
showError(data.error ?? "無法刪除品牌", "刪除失敗");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
showSuccess("品牌已刪除");
|
|||
|
|
if (brands.length === 1 && page > 1) {
|
|||
|
|
setPage((p) => p - 1);
|
|||
|
|
} else {
|
|||
|
|
load();
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleDeleteProduct(id: string) {
|
|||
|
|
setConfirmDialog({
|
|||
|
|
title: "刪除產品",
|
|||
|
|
description: "確定刪除此產品?若有主題指定此產品將無法刪除。",
|
|||
|
|
onConfirm: async () => {
|
|||
|
|
setDeletingProductId(id);
|
|||
|
|
const res = await fetch(`/api/brands/products?id=${id}`, { method: "DELETE" });
|
|||
|
|
const data = await res.json();
|
|||
|
|
setDeletingProductId(null);
|
|||
|
|
if (!res.ok) {
|
|||
|
|
showError(data.error ?? "無法刪除產品", "刪除失敗");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setFullProducts({});
|
|||
|
|
setExpandedBrands(new Set());
|
|||
|
|
load();
|
|||
|
|
showSuccess("產品已刪除");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div>
|
|||
|
|
<PageHeader
|
|||
|
|
title="品牌與產品"
|
|||
|
|
description="一個品牌可有多個產品;置入時 AI 會依海巡標籤自動推薦對應產品"
|
|||
|
|
action={
|
|||
|
|
<Button size="sm" onClick={openCreateBrand}>
|
|||
|
|
<Plus className="h-4 w-4" />
|
|||
|
|
新增品牌
|
|||
|
|
</Button>
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
{feedback && (
|
|||
|
|
<InlineAlert
|
|||
|
|
type={feedback.type}
|
|||
|
|
title={feedback.title}
|
|||
|
|
message={feedback.message}
|
|||
|
|
onDismiss={clearFeedback}
|
|||
|
|
className="mb-4"
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|||
|
|
<div className="relative max-w-md flex-1">
|
|||
|
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|||
|
|
<Input
|
|||
|
|
value={searchInput}
|
|||
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|||
|
|
placeholder="搜尋品牌、產品或標籤…"
|
|||
|
|
className="pl-9"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<p className="text-[13px] text-muted-foreground">{summaryText}</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{loading ? (
|
|||
|
|
<div className="skeleton h-40 animate-pulse" />
|
|||
|
|
) : brands.length === 0 ? (
|
|||
|
|
<EmptyState
|
|||
|
|
icon={Package}
|
|||
|
|
title={searchQuery ? "找不到符合的結果" : "尚無品牌與產品"}
|
|||
|
|
description={
|
|||
|
|
searchQuery
|
|||
|
|
? "試試其他關鍵字,或清除搜尋建立新品牌"
|
|||
|
|
: "先建立品牌,再新增各項產品與適用標籤"
|
|||
|
|
}
|
|||
|
|
action={
|
|||
|
|
searchQuery ? (
|
|||
|
|
<Button size="sm" variant="outline" onClick={() => setSearchInput("")}>
|
|||
|
|
清除搜尋
|
|||
|
|
</Button>
|
|||
|
|
) : (
|
|||
|
|
<Button size="sm" onClick={openCreateBrand}>
|
|||
|
|
新增品牌
|
|||
|
|
</Button>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<>
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
{brands.map((brand) => {
|
|||
|
|
const visibleProducts = getVisibleProducts(brand);
|
|||
|
|
const productTotal = brand.productCount ?? brand.products.length;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Card key={brand.id}>
|
|||
|
|
<CardHeader className="pb-3">
|
|||
|
|
<div className="flex items-start justify-between gap-3">
|
|||
|
|
<div>
|
|||
|
|
<CardTitle className="text-base">{brand.name}</CardTitle>
|
|||
|
|
{brand.notes && (
|
|||
|
|
<CardDescription className="mt-1">{brand.notes}</CardDescription>
|
|||
|
|
)}
|
|||
|
|
<p className="mt-1 text-[11px] text-muted-foreground">
|
|||
|
|
{productTotal} 個產品
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex shrink-0 gap-1">
|
|||
|
|
<Button size="sm" variant="outline" onClick={() => openEditBrand(brand)}>
|
|||
|
|
<Pencil className="h-3.5 w-3.5" />
|
|||
|
|
編輯
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="ghost"
|
|||
|
|
disabled={deletingBrandId === brand.id}
|
|||
|
|
onClick={() => handleDeleteBrand(brand.id)}
|
|||
|
|
>
|
|||
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</CardHeader>
|
|||
|
|
<CardContent className="space-y-2">
|
|||
|
|
{visibleProducts.length === 0 ? (
|
|||
|
|
<p className="text-[13px] text-muted-foreground">尚無產品,請新增至少一項。</p>
|
|||
|
|
) : (
|
|||
|
|
<ul className="divide-y divide-border rounded-lg border border-border">
|
|||
|
|
{visibleProducts.map((product) => {
|
|||
|
|
const summary = summarizeProductContext(product.context);
|
|||
|
|
return (
|
|||
|
|
<li
|
|||
|
|
key={product.id}
|
|||
|
|
className="flex items-start justify-between gap-3 px-3 py-2.5"
|
|||
|
|
>
|
|||
|
|
<div className="min-w-0">
|
|||
|
|
<p className="text-[13px] font-medium">{product.label}</p>
|
|||
|
|
{summary && (
|
|||
|
|
<p className="mt-0.5 truncate text-[12px] text-muted-foreground">
|
|||
|
|
{summary}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
{product.matchTags.length > 0 && (
|
|||
|
|
<p className="mt-1 flex items-center gap-1 text-[11px] text-muted-foreground">
|
|||
|
|
<Tag className="h-3 w-3 shrink-0" />
|
|||
|
|
{product.matchTags.join("、")}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div className="flex shrink-0 gap-1">
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="ghost"
|
|||
|
|
onClick={() => openEditProduct(brand, product)}
|
|||
|
|
>
|
|||
|
|
<Pencil className="h-3.5 w-3.5" />
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="ghost"
|
|||
|
|
disabled={deletingProductId === product.id}
|
|||
|
|
onClick={() => handleDeleteProduct(product.id)}
|
|||
|
|
>
|
|||
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</li>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</ul>
|
|||
|
|
)}
|
|||
|
|
{hasMoreProducts(brand) && (
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="ghost"
|
|||
|
|
disabled={loadingProducts === brand.id}
|
|||
|
|
onClick={() => loadAllProducts(brand.id)}
|
|||
|
|
>
|
|||
|
|
{loadingProducts === brand.id
|
|||
|
|
? "載入中…"
|
|||
|
|
: `顯示全部 ${productTotal} 個產品`}
|
|||
|
|
</Button>
|
|||
|
|
)}
|
|||
|
|
<Button size="sm" variant="outline" onClick={() => openCreateProduct(brand)}>
|
|||
|
|
<Plus className="h-3.5 w-3.5" />
|
|||
|
|
新增產品
|
|||
|
|
</Button>
|
|||
|
|
</CardContent>
|
|||
|
|
</Card>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{totalPages > 1 && (
|
|||
|
|
<div className="mt-6 flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2.5">
|
|||
|
|
<p className="text-[13px] text-muted-foreground">
|
|||
|
|
第 {page} / {totalPages} 頁
|
|||
|
|
</p>
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="outline"
|
|||
|
|
disabled={page <= 1}
|
|||
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|||
|
|
>
|
|||
|
|
<ChevronLeft className="h-4 w-4" />
|
|||
|
|
上一頁
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="outline"
|
|||
|
|
disabled={page >= totalPages}
|
|||
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|||
|
|
>
|
|||
|
|
下一頁
|
|||
|
|
<ChevronRight className="h-4 w-4" />
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<BrandFormDialog
|
|||
|
|
open={brandDialogOpen}
|
|||
|
|
onOpenChange={setBrandDialogOpen}
|
|||
|
|
brand={editingBrand}
|
|||
|
|
onSaved={() => {
|
|||
|
|
setFullProducts({});
|
|||
|
|
setExpandedBrands(new Set());
|
|||
|
|
load();
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
{productBrand && (
|
|||
|
|
<ProductItemFormDialog
|
|||
|
|
open={productDialogOpen}
|
|||
|
|
onOpenChange={setProductDialogOpen}
|
|||
|
|
brandId={productBrand.id}
|
|||
|
|
brandName={productBrand.name}
|
|||
|
|
product={editingProduct}
|
|||
|
|
onSaved={() => {
|
|||
|
|
setFullProducts({});
|
|||
|
|
setExpandedBrands(new Set());
|
|||
|
|
load();
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<ConfirmDialog
|
|||
|
|
open={confirmDialog !== null}
|
|||
|
|
onOpenChange={(open) => !open && setConfirmDialog(null)}
|
|||
|
|
title={confirmDialog?.title ?? ""}
|
|||
|
|
description={confirmDialog?.description}
|
|||
|
|
confirmText="確認刪除"
|
|||
|
|
danger
|
|||
|
|
onConfirm={confirmDialog?.onConfirm ?? (() => {})}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|