416 lines
11 KiB
TypeScript
416 lines
11 KiB
TypeScript
import { useState } from "react";
|
|
import { Button } from "./ui/button";
|
|
import { Input } from "./ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "./ui/select";
|
|
import { Plus, Search } from "lucide-react";
|
|
import ProductTable from "./ProductTable";
|
|
import ProductDialog from "./ProductDialog";
|
|
|
|
export type ProductType = "raw_material" | "finished_product";
|
|
|
|
export type ProductUnit = "kg" | "g" | "l" | "ml" | "piece" | "box" | "pack" | "bottle" | "can" | "jar" | "bag" | "basin" | "container";
|
|
|
|
export interface Product {
|
|
id: string;
|
|
product_code: string; // 商品編號(系統自動生成)
|
|
name: string;
|
|
type: ProductType;
|
|
unit: ProductUnit;
|
|
barcode_value: string; // 條碼內容(預設等於商品編號)
|
|
createdAt: string;
|
|
}
|
|
|
|
export default function ProductManagement() {
|
|
const [products, setProducts] = useState<Product[]>([
|
|
// 原物料
|
|
{
|
|
id: "1",
|
|
product_code: "RM0001",
|
|
name: "紅糖",
|
|
type: "raw_material",
|
|
unit: "pack",
|
|
barcode_value: "RM0001",
|
|
createdAt: "2025-11-01",
|
|
},
|
|
{
|
|
id: "2",
|
|
product_code: "RM0002",
|
|
name: "白糖",
|
|
type: "raw_material",
|
|
unit: "pack",
|
|
barcode_value: "RM0002",
|
|
createdAt: "2025-11-01",
|
|
},
|
|
{
|
|
id: "3",
|
|
product_code: "RM0003",
|
|
name: "砂糖",
|
|
type: "raw_material",
|
|
unit: "pack",
|
|
barcode_value: "RM0003",
|
|
createdAt: "2025-11-02",
|
|
},
|
|
{
|
|
id: "4",
|
|
product_code: "RM0004",
|
|
name: "全脂牛奶-小",
|
|
type: "raw_material",
|
|
unit: "bottle",
|
|
barcode_value: "RM0004",
|
|
createdAt: "2025-11-02",
|
|
},
|
|
{
|
|
id: "5",
|
|
product_code: "RM0005",
|
|
name: "煉乳",
|
|
type: "raw_material",
|
|
unit: "can",
|
|
barcode_value: "RM0005",
|
|
createdAt: "2025-11-03",
|
|
},
|
|
{
|
|
id: "6",
|
|
product_code: "RM0006",
|
|
name: "椰奶",
|
|
type: "raw_material",
|
|
unit: "can",
|
|
barcode_value: "RM0006",
|
|
createdAt: "2025-11-03",
|
|
},
|
|
{
|
|
id: "7",
|
|
product_code: "RM0007",
|
|
name: "鮮奶油",
|
|
type: "raw_material",
|
|
unit: "can",
|
|
barcode_value: "RM0007",
|
|
createdAt: "2025-11-04",
|
|
},
|
|
{
|
|
id: "8",
|
|
product_code: "RM0008",
|
|
name: "粉圓原料(生珍珠)",
|
|
type: "raw_material",
|
|
unit: "pack",
|
|
barcode_value: "RM0008",
|
|
createdAt: "2025-11-04",
|
|
},
|
|
{
|
|
id: "9",
|
|
product_code: "RM0009",
|
|
name: "仙草凍粉",
|
|
type: "raw_material",
|
|
unit: "pack",
|
|
barcode_value: "RM0009",
|
|
createdAt: "2025-11-05",
|
|
},
|
|
{
|
|
id: "10",
|
|
product_code: "RM0010",
|
|
name: "芋頭塊(原料)",
|
|
type: "raw_material",
|
|
unit: "kg",
|
|
barcode_value: "RM0010",
|
|
createdAt: "2025-11-05",
|
|
},
|
|
{
|
|
id: "11",
|
|
product_code: "RM0011",
|
|
name: "地瓜塊(原料)",
|
|
type: "raw_material",
|
|
unit: "kg",
|
|
barcode_value: "RM0011",
|
|
createdAt: "2025-11-06",
|
|
},
|
|
{
|
|
id: "12",
|
|
product_code: "RM0012",
|
|
name: "綠豆(乾)",
|
|
type: "raw_material",
|
|
unit: "kg",
|
|
barcode_value: "RM0012",
|
|
createdAt: "2025-11-06",
|
|
},
|
|
{
|
|
id: "13",
|
|
product_code: "RM0013",
|
|
name: "紅豆(乾)",
|
|
type: "raw_material",
|
|
unit: "kg",
|
|
barcode_value: "RM0013",
|
|
createdAt: "2025-11-07",
|
|
},
|
|
{
|
|
id: "14",
|
|
product_code: "RM0014",
|
|
name: "檸檬原汁",
|
|
type: "raw_material",
|
|
unit: "bottle",
|
|
barcode_value: "RM0014",
|
|
createdAt: "2025-11-07",
|
|
},
|
|
{
|
|
id: "15",
|
|
product_code: "RM0015",
|
|
name: "抹茶粉",
|
|
type: "raw_material",
|
|
unit: "can",
|
|
barcode_value: "RM0015",
|
|
createdAt: "2025-11-08",
|
|
},
|
|
{
|
|
id: "16",
|
|
product_code: "RM0016",
|
|
name: "可可粉",
|
|
type: "raw_material",
|
|
unit: "can",
|
|
barcode_value: "RM0016",
|
|
createdAt: "2025-11-08",
|
|
},
|
|
{
|
|
id: "17",
|
|
product_code: "RM0017",
|
|
name: "蜂蜜",
|
|
type: "raw_material",
|
|
unit: "bottle",
|
|
barcode_value: "RM0017",
|
|
createdAt: "2025-11-09",
|
|
},
|
|
{
|
|
id: "18",
|
|
product_code: "RM0018",
|
|
name: "果糖",
|
|
type: "raw_material",
|
|
unit: "can",
|
|
barcode_value: "RM0018",
|
|
createdAt: "2025-11-09",
|
|
},
|
|
// 半成品
|
|
{
|
|
id: "19",
|
|
product_code: "SF0001",
|
|
name: "粉粿原漿",
|
|
type: "finished_product",
|
|
unit: "bag",
|
|
barcode_value: "SF0001",
|
|
createdAt: "2025-11-10",
|
|
},
|
|
{
|
|
id: "20",
|
|
product_code: "SF0002",
|
|
name: "黑糖粉圓(未加糖)",
|
|
type: "finished_product",
|
|
unit: "basin",
|
|
barcode_value: "SF0002",
|
|
createdAt: "2025-11-10",
|
|
},
|
|
{
|
|
id: "21",
|
|
product_code: "SF0003",
|
|
name: "熟紅豆(未加糖)",
|
|
type: "finished_product",
|
|
unit: "basin",
|
|
barcode_value: "SF0003",
|
|
createdAt: "2025-11-11",
|
|
},
|
|
{
|
|
id: "22",
|
|
product_code: "SF0004",
|
|
name: "熟綠豆(未加糖)",
|
|
type: "finished_product",
|
|
unit: "basin",
|
|
barcode_value: "SF0004",
|
|
createdAt: "2025-11-11",
|
|
},
|
|
{
|
|
id: "23",
|
|
product_code: "SF0005",
|
|
name: "芋頭泥(無調味)",
|
|
type: "finished_product",
|
|
unit: "box",
|
|
barcode_value: "SF0005",
|
|
createdAt: "2025-11-12",
|
|
},
|
|
{
|
|
id: "24",
|
|
product_code: "SF0006",
|
|
name: "地瓜泥(無調味)",
|
|
type: "finished_product",
|
|
unit: "box",
|
|
barcode_value: "SF0006",
|
|
createdAt: "2025-11-12",
|
|
},
|
|
{
|
|
id: "25",
|
|
product_code: "SF0007",
|
|
name: "仙草原凍(整塊)",
|
|
type: "finished_product",
|
|
unit: "basin",
|
|
barcode_value: "SF0007",
|
|
createdAt: "2025-11-13",
|
|
},
|
|
]);
|
|
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
|
|
|
const filteredProducts = products.filter((product) => {
|
|
const matchesSearch =
|
|
product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
product.product_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
product.barcode_value.includes(searchTerm);
|
|
|
|
const matchesType =
|
|
typeFilter === "all" || product.type === typeFilter;
|
|
|
|
return matchesSearch && matchesType;
|
|
});
|
|
|
|
const handleAddProduct = () => {
|
|
setEditingProduct(null);
|
|
setIsDialogOpen(true);
|
|
};
|
|
|
|
const handleEditProduct = (product: Product) => {
|
|
setEditingProduct(product);
|
|
setIsDialogOpen(true);
|
|
};
|
|
|
|
// 生成新的商品編號(根據類型使用不同前綴)
|
|
const generateProductCode = (productType: ProductType): string => {
|
|
if (productType === "raw_material") {
|
|
// 原物料使用 RM 前綴 + 4 位數字
|
|
const rawMaterialProducts = products.filter(p => p.type === "raw_material");
|
|
|
|
if (rawMaterialProducts.length === 0) {
|
|
return "RM0001";
|
|
}
|
|
|
|
const maxNumber = rawMaterialProducts.reduce((max, product) => {
|
|
const match = product.product_code.match(/RM(\d{4})/);
|
|
if (match) {
|
|
const num = parseInt(match[1], 10);
|
|
return num > max ? num : max;
|
|
}
|
|
return max;
|
|
}, 0);
|
|
|
|
const nextNumber = maxNumber + 1;
|
|
return `RM${nextNumber.toString().padStart(4, "0")}`;
|
|
} else {
|
|
// 半成品使用 SF 前綴 + 4 位數字
|
|
const finishedProducts = products.filter(p => p.type === "finished_product");
|
|
|
|
if (finishedProducts.length === 0) {
|
|
return "SF0001";
|
|
}
|
|
|
|
const maxNumber = finishedProducts.reduce((max, product) => {
|
|
const match = product.product_code.match(/SF(\d{4})/);
|
|
if (match) {
|
|
const num = parseInt(match[1], 10);
|
|
return num > max ? num : max;
|
|
}
|
|
return max;
|
|
}, 0);
|
|
|
|
const nextNumber = maxNumber + 1;
|
|
return `SF${nextNumber.toString().padStart(4, "0")}`;
|
|
}
|
|
};
|
|
|
|
const handleSaveProduct = (product: Omit<Product, "id" | "createdAt" | "product_code">) => {
|
|
if (editingProduct) {
|
|
// Update existing product
|
|
setProducts(
|
|
products.map((p) =>
|
|
p.id === editingProduct.id
|
|
? { ...product, id: p.id, createdAt: p.createdAt, product_code: p.product_code }
|
|
: p
|
|
)
|
|
);
|
|
} else {
|
|
// Add new product with auto-generated product_code based on type
|
|
const product_code = generateProductCode(product.type);
|
|
const newProduct: Product = {
|
|
...product,
|
|
id: Date.now().toString(),
|
|
product_code,
|
|
createdAt: new Date().toISOString().split("T")[0],
|
|
};
|
|
setProducts([newProduct, ...products]);
|
|
}
|
|
setIsDialogOpen(false);
|
|
};
|
|
|
|
const handleDeleteProduct = (id: string) => {
|
|
setProducts(products.filter((p) => p.id !== id));
|
|
};
|
|
|
|
return (
|
|
<div className="container mx-auto p-6 max-w-7xl">
|
|
{/* Header */}
|
|
<div className="mb-6">
|
|
<h1 className="mb-2">商品建檔管理</h1>
|
|
<p className="text-gray-600">管理甜點店原物料與成品資料</p>
|
|
</div>
|
|
|
|
{/* Toolbar */}
|
|
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
|
<div className="flex flex-col md:flex-row gap-4">
|
|
{/* Search */}
|
|
<div className="flex-1 relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
|
<Input
|
|
placeholder="搜尋商品名稱、商品編號或條碼..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
|
|
{/* Type Filter */}
|
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
|
<SelectTrigger className="w-full md:w-[180px]">
|
|
<SelectValue placeholder="商品類型" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">全部類型</SelectItem>
|
|
<SelectItem value="raw_material">原物料</SelectItem>
|
|
<SelectItem value="finished_product">半成品</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* Add Button */}
|
|
<Button onClick={handleAddProduct} className="w-full md:w-auto button-filled-primary">
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
新增商品
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Product Table */}
|
|
<ProductTable
|
|
products={filteredProducts}
|
|
onEdit={handleEditProduct}
|
|
onDelete={handleDeleteProduct}
|
|
/>
|
|
|
|
{/* Product Dialog */}
|
|
<ProductDialog
|
|
open={isDialogOpen}
|
|
onOpenChange={setIsDialogOpen}
|
|
product={editingProduct}
|
|
onSave={handleSaveProduct}
|
|
/>
|
|
</div>
|
|
);
|
|
} |