haixunMaster/app/(dashboard)/products/page.tsx

441 lines
16 KiB
TypeScript
Raw Normal View History

2026-06-21 12:50:31 +00:00
"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>
);
}