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

441 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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