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>
|
||
);
|
||
} |