feat: 修正庫存與撥補單邏輯並整合文件
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

1. 修復倉庫統計數據加總與樣式。
2. 修正可用庫存計算邏輯(排除不可銷售倉庫)。
3. 撥補單商品列表加入批號與效期顯示。
4. 修正撥補單儲存邏輯以支援精確批號轉移。
5. 整合 FEATURES.md 至 README.md。
This commit is contained in:
2026-01-26 14:59:24 +08:00
parent b0848a6bb8
commit 106de4e945
81 changed files with 4118 additions and 1023 deletions

View File

@@ -20,22 +20,22 @@ export interface Category {
}
export interface Product {
id: number;
id: string;
code: string;
name: string;
category_id: number;
categoryId: number;
category?: Category;
brand?: string;
specification?: string;
base_unit_id: number;
base_unit?: Unit;
large_unit_id?: number;
large_unit?: Unit;
conversion_rate?: number;
purchase_unit_id?: number;
purchase_unit?: Unit;
created_at: string;
updated_at: string;
baseUnitId: number;
baseUnit?: Unit;
largeUnitId?: number;
largeUnit?: Unit;
conversionRate?: number;
purchaseUnitId?: number;
purchaseUnit?: Unit;
createdAt?: string;
updatedAt?: string;
}
interface PageProps {
@@ -163,7 +163,7 @@ export default function ProductManagement({ products, categories, units, filters
setIsDialogOpen(true);
};
const handleDeleteProduct = (id: number) => {
const handleDeleteProduct = (id: string) => {
router.delete(route('products.destroy', id), {
onSuccess: () => {
// Toast handled by flash message

View File

@@ -53,18 +53,18 @@ interface InventoryOption {
}
interface BomItem {
// Backend required
inventory_id: string; // The selected inventory record ID (Specific Batch)
quantity_used: string; // The converted final quantity (Base Unit)
unit_id: string; // The unit ID (Base Unit ID usually)
// 後端必填
inventory_id: string; // 所選庫存記錄 ID特定批號
quantity_used: string; // 轉換後的最終數量(基本單位)
unit_id: string; // 單位 ID通常為基本單位 ID
// UI State
ui_warehouse_id: string; // Source Warehouse
ui_product_id: string; // Filter for batch list
ui_input_quantity: string; // User typed quantity
ui_selected_unit: 'base' | 'large'; // User selected unit
// UI 狀態
ui_warehouse_id: string; // 來源倉庫
ui_product_id: string; // 批號列表篩選
ui_input_quantity: string; // 使用者輸入數量
ui_selected_unit: 'base' | 'large'; // 使用者選擇單位
// UI Helpers / Cache
// UI 輔助 / 快取
ui_product_name?: string;
ui_batch_number?: string;
ui_available_qty?: number;
@@ -83,8 +83,8 @@ interface Props {
}
export default function ProductionCreate({ products, warehouses }: Props) {
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // Output Warehouse
// Cache map: warehouse_id -> inventories
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // 產出倉庫
// 快取對照表:warehouse_id -> inventories
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({});
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({});
@@ -102,7 +102,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
});
// Helper to fetch warehouse data
// 獲取倉庫資料的輔助函式
const fetchWarehouseInventory = async (warehouseId: string) => {
if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return;

View File

@@ -52,18 +52,18 @@ interface InventoryOption {
}
interface BomItem {
// Backend required
// 後端必填
inventory_id: string;
quantity_used: string;
unit_id: string;
// UI State
ui_warehouse_id: string; // Source Warehouse
// UI 狀態
ui_warehouse_id: string; // 來源倉庫
ui_product_id: string;
ui_input_quantity: string;
ui_selected_unit: 'base' | 'large';
// UI Helpers / Cache
// UI 輔助 / 快取
ui_product_name?: string;
ui_batch_number?: string;
ui_available_qty?: number;
@@ -134,13 +134,13 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(
productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : ""
); // Output Warehouse
); // 產出倉庫
// Cache map: warehouse_id -> inventories
// 快取對照表:warehouse_id -> inventories
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({});
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({});
// Helper to fetch warehouse data
// 獲取倉庫資料的輔助函式
const fetchWarehouseInventory = async (warehouseId: string) => {
if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return;
@@ -168,7 +168,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
ui_input_quantity: String(item.quantity_used), // 假設已存的資料是基本單位
ui_selected_unit: 'base',
// UI Helpers
// UI 輔助
ui_product_name: item.inventory?.product?.name,
ui_batch_number: item.inventory?.batch_number,
ui_available_qty: item.inventory?.quantity,
@@ -600,7 +600,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
currentOptions.map(inv => [inv.product_id, { label: inv.product_name, value: String(inv.product_id) }])
).values());
// Fallback for initial state before fetch
// 在獲取前初始狀態的備案
const displayProductOptions = uniqueProductOptions.length > 0 ? uniqueProductOptions : (item.ui_product_name ? [{ label: item.ui_product_name, value: item.ui_product_id }] : []);
const batchOptions = currentOptions
@@ -610,7 +610,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
value: String(inv.id)
}));
// Fallback
// 備案
const displayBatchOptions = batchOptions.length > 0 ? batchOptions : (item.inventory_id && item.ui_batch_number ? [{ label: item.ui_batch_number, value: item.inventory_id }] : []);

View File

@@ -0,0 +1,320 @@
/**
* 新增配方頁面
*/
import { useState, useEffect } from "react";
import { BookOpen, Plus, Trash2, ArrowLeft, Save } from 'lucide-react';
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, useForm, Link } from "@inertiajs/react";
import { toast } from "sonner";
import { getBreadcrumbs } from "@/utils/breadcrumb";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Textarea } from "@/Components/ui/textarea";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
interface Product {
id: number;
name: string;
code: string;
base_unit_id?: number;
large_unit_id?: number;
}
interface Unit {
id: number;
name: string;
}
interface RecipeItem {
product_id: string;
quantity: string;
unit_id: string;
remark: string;
// UI Helpers
ui_product_name?: string;
ui_product_code?: string;
}
interface Props {
products: Product[];
units: Unit[];
}
export default function RecipeCreate({ products, units }: Props) {
const { data, setData, post, processing, errors } = useForm({
product_id: "",
code: "",
name: "",
description: "",
yield_quantity: "1",
items: [] as RecipeItem[],
});
// 自動產生配方名稱 (當選擇商品時)
useEffect(() => {
if (data.product_id && !data.name) {
const product = products.find(p => String(p.id) === data.product_id);
if (product) {
setData(d => ({ ...d, name: `${product.name} 標準配方` }));
}
}
// 自動產生代號 (簡易版)
if (data.product_id && !data.code) {
const product = products.find(p => String(p.id) === data.product_id);
if (product) {
setData(d => ({ ...d, code: `REC-${product.code}` }));
}
}
}, [data.product_id]);
const addItem = () => {
setData('items', [
...data.items,
{ product_id: "", quantity: "1", unit_id: "", remark: "" }
]);
};
const removeItem = (index: number) => {
setData('items', data.items.filter((_, i) => i !== index));
};
const updateItem = (index: number, field: keyof RecipeItem, value: string) => {
const newItems = [...data.items];
newItems[index] = { ...newItems[index], [field]: value };
// Auto-fill unit when product selected
if (field === 'product_id') {
const product = products.find(p => String(p.id) === value);
if (product) {
newItems[index].ui_product_name = product.name;
newItems[index].ui_product_code = product.code;
// Default to base unit
if (product.base_unit_id) {
newItems[index].unit_id = String(product.base_unit_id);
}
}
}
setData('items', newItems);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
post(route('recipes.store'), {
onSuccess: () => {
toast.success("配方已建立");
},
onError: (errors) => {
toast.error("儲存失敗,請檢查欄位");
}
});
};
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes", [{ label: "新增", isPage: true }])}>
<Head title="新增配方" />
<div className="container mx-auto p-6 max-w-7xl">
<div className="mb-6">
<Link href={route('recipes.index')}>
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-6"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<BookOpen className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
<Button
onClick={handleSubmit}
disabled={processing}
className="button-filled-primary gap-2"
>
<Save className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左側:基本資料 */}
<div className="lg:col-span-1 space-y-6">
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<SearchableSelect
value={data.product_id}
onValueChange={(v) => setData('product_id', v)}
options={products.map(p => ({
label: `${p.name} (${p.code})`,
value: String(p.id),
}))}
placeholder="選擇商品"
className="w-full"
/>
{errors.product_id && <p className="text-red-500 text-xs">{errors.product_id}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<Input
value={data.code}
onChange={(e) => setData('code', e.target.value)}
placeholder="例如: REC-P001"
/>
{errors.code && <p className="text-red-500 text-xs">{errors.code}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<Input
value={data.name}
onChange={(e) => setData('name', e.target.value)}
placeholder="例如: 草莓冰標準配方"
/>
{errors.name && <p className="text-red-500 text-xs">{errors.name}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<div className="flex items-center gap-2">
<Input
type="number"
value={data.yield_quantity}
onChange={(e) => setData('yield_quantity', e.target.value)}
placeholder="1"
/>
<span className="text-sm text-gray-500"></span>
</div>
{errors.yield_quantity && <p className="text-red-500 text-xs">{errors.yield_quantity}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<Textarea
value={data.description}
onChange={(e) => setData('description', e.target.value)}
placeholder="備註說明..."
rows={3}
/>
</div>
</div>
</div>
</div>
{/* 右側:配方明細 */}
<div className="lg:col-span-2">
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 h-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold"> (BOM)</h2>
<Button
type="button"
variant="outline"
onClick={addItem}
className="gap-2 button-filled-primary text-white"
>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[35%]"></TableHead>
<TableHead className="w-[20%]"></TableHead>
<TableHead className="w-[20%]"></TableHead>
<TableHead className="w-[20%]"></TableHead>
<TableHead className="w-[5%]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-gray-500">
</TableCell>
</TableRow>
) : (
data.items.map((item, index) => (
<TableRow key={index}>
<TableCell className="align-top">
<SearchableSelect
value={item.product_id}
onValueChange={(v) => updateItem(index, 'product_id', v)}
options={products.map(p => ({
label: `${p.name} (${p.code})`,
value: String(p.id)
}))}
placeholder="選擇原料"
className="w-full"
/>
</TableCell>
<TableCell className="align-top">
<Input
type="number"
step="0.0001"
value={item.quantity}
onChange={(e) => updateItem(index, 'quantity', e.target.value)}
placeholder="數量"
/>
</TableCell>
<TableCell className="align-top">
<SearchableSelect
value={item.unit_id}
onValueChange={(v) => updateItem(index, 'unit_id', v)}
options={units.map(u => ({
label: u.name,
value: String(u.id)
}))}
placeholder="單位"
className="w-full"
/>
</TableCell>
<TableCell className="align-top">
<Input
value={item.remark}
onChange={(e) => updateItem(index, 'remark', e.target.value)}
placeholder="備註"
/>
</TableCell>
<TableCell className="align-top">
<Button
variant="ghost"
size="sm"
onClick={() => removeItem(index)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,344 @@
/**
* 編輯配方頁面
*/
import { useState, useEffect } from "react";
import { BookOpen, Plus, Trash2, ArrowLeft, Save } from 'lucide-react';
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, useForm, Link } from "@inertiajs/react";
import { toast } from "sonner";
import { getBreadcrumbs } from "@/utils/breadcrumb";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Textarea } from "@/Components/ui/textarea";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
interface Product {
id: number;
name: string;
code: string;
base_unit_id?: number;
large_unit_id?: number;
}
interface Unit {
id: number;
name: string;
}
// Backend Model Structure
interface RecipeItemModel {
id: number;
product_id: number;
quantity: number;
unit_id: number;
remark: string | null;
product?: Product;
unit?: Unit;
}
interface RecipeModel {
id: number;
product_id: number;
code: string;
name: string;
description: string | null;
yield_quantity: number;
items: RecipeItemModel[];
product?: Product;
}
// Form State Structure
interface RecipeItemForm {
product_id: string;
quantity: string;
unit_id: string;
remark: string;
// UI Helpers
ui_product_name?: string;
ui_product_code?: string;
}
interface Props {
recipe: RecipeModel;
products: Product[];
units: Unit[];
}
export default function RecipeEdit({ recipe, products, units }: Props) {
const { data, setData, put, processing, errors } = useForm({
product_id: String(recipe.product_id),
code: recipe.code,
name: recipe.name,
description: recipe.description || "",
yield_quantity: String(recipe.yield_quantity),
items: recipe.items.map(item => ({
product_id: String(item.product_id),
quantity: String(item.quantity),
unit_id: String(item.unit_id),
remark: item.remark || "",
ui_product_name: item.product?.name,
ui_product_code: item.product?.code
})) as RecipeItemForm[],
});
// 自動產生配方名稱 (當選擇商品時) - 僅在名稱為空時觸發,避免覆蓋舊資料
useEffect(() => {
if (data.product_id && !data.name) {
const product = products.find(p => String(p.id) === data.product_id);
if (product) {
setData(d => ({ ...d, name: `${product.name} 標準配方` }));
}
}
}, [data.product_id]);
const addItem = () => {
setData('items', [
...data.items,
{ product_id: "", quantity: "1", unit_id: "", remark: "" }
]);
};
const removeItem = (index: number) => {
setData('items', data.items.filter((_, i) => i !== index));
};
const updateItem = (index: number, field: keyof RecipeItemForm, value: string) => {
const newItems = [...data.items];
newItems[index] = { ...newItems[index], [field]: value };
// Auto-fill unit when product selected
if (field === 'product_id') {
const product = products.find(p => String(p.id) === value);
if (product) {
newItems[index].ui_product_name = product.name;
newItems[index].ui_product_code = product.code;
// Default to base unit if not set
if (product.base_unit_id) {
newItems[index].unit_id = String(product.base_unit_id);
}
}
}
setData('items', newItems);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
put(route('recipes.update', recipe.id), {
onSuccess: () => {
toast.success("配方已更新");
},
onError: (errors) => {
toast.error("儲存失敗,請檢查欄位");
}
});
};
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes", [{ label: "編輯", isPage: true }])}>
<Head title="編輯配方" />
<div className="container mx-auto p-6 max-w-7xl">
<div className="mb-6">
<Link href={route('recipes.index')}>
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-6"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<BookOpen className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
{recipe.name} ({recipe.code})
</p>
</div>
<Button
onClick={handleSubmit}
disabled={processing}
className="button-filled-primary gap-2"
>
<Save className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左側:基本資料 */}
<div className="lg:col-span-1 space-y-6">
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<SearchableSelect
value={data.product_id}
onValueChange={(v) => setData('product_id', v)}
options={products.map(p => ({
label: `${p.name} (${p.code})`,
value: String(p.id),
}))}
placeholder="選擇商品"
className="w-full"
/>
{errors.product_id && <p className="text-red-500 text-xs">{errors.product_id}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<Input
value={data.code}
onChange={(e) => setData('code', e.target.value)}
placeholder="例如: REC-P001"
/>
{errors.code && <p className="text-red-500 text-xs">{errors.code}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<Input
value={data.name}
onChange={(e) => setData('name', e.target.value)}
placeholder="例如: 草莓冰標準配方"
/>
{errors.name && <p className="text-red-500 text-xs">{errors.name}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<div className="flex items-center gap-2">
<Input
type="number"
value={data.yield_quantity}
onChange={(e) => setData('yield_quantity', e.target.value)}
placeholder="1"
/>
<span className="text-sm text-gray-500"></span>
</div>
{errors.yield_quantity && <p className="text-red-500 text-xs">{errors.yield_quantity}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<Textarea
value={data.description}
onChange={(e) => setData('description', e.target.value)}
placeholder="備註說明..."
rows={3}
/>
</div>
</div>
</div>
</div>
{/* 右側:配方明細 */}
<div className="lg:col-span-2">
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 h-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold"> (BOM)</h2>
<Button
type="button"
variant="outline"
onClick={addItem}
className="gap-2 button-filled-primary text-white"
>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[35%]"></TableHead>
<TableHead className="w-[20%]"></TableHead>
<TableHead className="w-[20%]"></TableHead>
<TableHead className="w-[20%]"></TableHead>
<TableHead className="w-[5%]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-gray-500">
</TableCell>
</TableRow>
) : (
data.items.map((item, index) => (
<TableRow key={index}>
<TableCell className="align-top">
<SearchableSelect
value={item.product_id}
onValueChange={(v) => updateItem(index, 'product_id', v)}
options={products.map(p => ({
label: `${p.name} (${p.code})`,
value: String(p.id)
}))}
placeholder="選擇原料"
className="w-full"
/>
</TableCell>
<TableCell className="align-top">
<Input
type="number"
step="0.0001"
value={item.quantity}
onChange={(e) => updateItem(index, 'quantity', e.target.value)}
placeholder="數量"
/>
</TableCell>
<TableCell className="align-top">
<SearchableSelect
value={item.unit_id}
onValueChange={(v) => updateItem(index, 'unit_id', v)}
options={units.map(u => ({
label: u.name,
value: String(u.id)
}))}
placeholder="單位"
className="w-full"
/>
</TableCell>
<TableCell className="align-top">
<Input
value={item.remark}
onChange={(e) => updateItem(index, 'remark', e.target.value)}
placeholder="備註"
/>
</TableCell>
<TableCell className="align-top">
<Button
variant="ghost"
size="sm"
onClick={() => removeItem(index)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,263 @@
/**
* 配方管理主頁面
*/
import { useState, useEffect } from "react";
import { Plus, Search, RotateCcw, Pencil, Trash2, BookOpen } from 'lucide-react';
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, Link } from "@inertiajs/react";
import Pagination from "@/Components/shared/Pagination";
import { getBreadcrumbs } from "@/utils/breadcrumb";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
interface Recipe {
id: number;
code: string;
name: string;
product_id: number;
product?: { id: number; name: string; code: string };
yield_quantity: number;
is_active: boolean;
description: string;
updated_at: string;
}
interface Props {
recipes: {
data: Recipe[];
links: any[];
total: number;
from: number;
to: number;
};
filters: {
search?: string;
per_page?: string;
sort_field?: string;
sort_direction?: string;
};
}
export default function RecipeIndex({ recipes, filters }: Props) {
const [search, setSearch] = useState(filters.search || "");
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
useEffect(() => {
setSearch(filters.search || "");
setPerPage(filters.per_page || "10");
}, [filters]);
const handleFilter = () => {
router.get(
route('recipes.index'),
{
search,
per_page: perPage,
},
{ preserveState: true, replace: true }
);
};
const handleReset = () => {
setSearch("");
router.get(route('recipes.index'));
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(
route("recipes.index"),
{ ...filters, per_page: value },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
const handleDelete = (id: number) => {
if (confirm("確定要刪除此配方嗎?")) {
router.delete(route('recipes.destroy', id));
}
};
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes")}>
<Head title="配方管理" />
<div className="container mx-auto p-6 max-w-7xl">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<BookOpen className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
<div className="flex gap-2">
<Link href={route('recipes.create')}>
<Button className="gap-2 button-filled-primary">
<Plus className="h-4 w-4" />
</Button>
</Link>
</div>
</div>
{/* 篩選區塊 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 mb-6 overflow-hidden">
<div className="p-5">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
<div className="md:col-span-12 space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="搜尋配方代號、名稱、產品名稱..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10 h-9 block"
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
/>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end px-5 py-4 bg-gray-50/50 border-t border-gray-100 gap-3">
<Button
variant="outline"
onClick={handleReset}
className="button-outlined-primary h-9 gap-2"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
onClick={handleFilter}
className="button-filled-primary h-9 px-6 gap-2"
>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
{/* 配方列表 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[120px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-center w-[100px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="text-center w-[120px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recipes.data.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-32 text-center text-gray-500">
<div className="flex flex-col items-center justify-center gap-2">
<BookOpen className="h-10 w-10 text-gray-300" />
<p></p>
</div>
</TableCell>
</TableRow>
) : (
recipes.data.map((recipe) => (
<TableRow key={recipe.id}>
<TableCell className="font-medium text-gray-900">
{recipe.code}
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium text-gray-900">{recipe.name}</span>
{recipe.description && (
<span className="text-gray-400 text-xs truncate max-w-[200px]">
{recipe.description}
</span>
)}
</div>
</TableCell>
<TableCell>
{recipe.product ? (
<div className="flex flex-col">
<span className="font-medium">{recipe.product.name}</span>
<span className="text-xs text-gray-400">{recipe.product.code}</span>
</div>
) : '-'}
</TableCell>
<TableCell className="text-right font-medium">
{recipe.yield_quantity}
</TableCell>
<TableCell className="text-center">
<Badge variant={recipe.is_active ? "default" : "secondary"}>
{recipe.is_active ? "啟用" : "停用"}
</Badge>
</TableCell>
<TableCell className="text-gray-500 text-sm">
{new Date(recipe.updated_at).toLocaleDateString()}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
<Link href={route('recipes.edit', recipe.id)}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="h-4 w-4" />
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(recipe.id)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
title="刪除"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 分頁 */}
<div className="mt-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span></span>
<SearchableSelect
value={perPage}
onValueChange={handlePerPageChange}
options={[
{ label: "10", value: "10" },
{ label: "20", value: "20" },
{ label: "50", value: "50" },
{ label: "100", value: "100" }
]}
className="w-[100px] h-8"
showSearch={false}
/>
<span></span>
</div>
<div className="w-full sm:w-auto flex justify-center sm:justify-end">
<Pagination links={recipes.links} />
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -255,4 +255,4 @@ export default function ProductionShow({ productionOrder }: Props) {
</div>
</AuthenticatedLayout>
);
}
}

View File

@@ -13,20 +13,20 @@ import Pagination from "@/Components/shared/Pagination";
import { SearchableSelect } from "@/Components/ui/searchable-select";
export interface Vendor {
id: number;
id: string;
code: string;
name: string;
short_name?: string;
tax_id?: string;
shortName?: string;
taxId?: string;
owner?: string;
contact_name?: string;
contactName?: string;
tel?: string;
phone?: string;
email?: string;
address?: string;
remark?: string;
created_at: string;
updated_at: string;
createdAt?: string;
updatedAt?: string;
}
interface PageProps {
@@ -126,7 +126,7 @@ export default function VendorManagement({ vendors, filters }: PageProps) {
router.get(route("vendors.show", vendor.id));
};
const handleDeleteVendor = (id: number) => {
const handleDeleteVendor = (id: string) => {
router.delete(route('vendors.destroy', id));
};

View File

@@ -13,6 +13,7 @@ import Pagination from "@/Components/shared/Pagination";
import { toast } from "sonner";
import { getBreadcrumbs } from "@/utils/breadcrumb";
import { Can } from "@/Components/Permission/Can";
import { Card, CardContent } from "@/Components/ui/card";
interface PageProps {
warehouses: {
@@ -22,12 +23,16 @@ interface PageProps {
last_page: number;
total: number;
};
totals: {
available_stock: number;
book_stock: number;
};
filters: {
search?: string;
};
}
export default function WarehouseIndex({ warehouses, filters }: PageProps) {
export default function WarehouseIndex({ warehouses, totals, filters }: PageProps) {
// 篩選狀態
const [searchTerm, setSearchTerm] = useState(filters.search || "");
@@ -119,6 +124,31 @@ export default function WarehouseIndex({ warehouses, filters }: PageProps) {
</p>
</div>
{/* 統計區塊 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<Card className="shadow-sm">
<CardContent className="p-6">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-500 mb-1"></span>
<span className="text-3xl font-bold text-blue-600">
{totals.available_stock.toLocaleString()}
</span>
</div>
</CardContent>
</Card>
<Card className="shadow-sm">
<CardContent className="p-6">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-500 mb-1"></span>
<span className="text-3xl font-bold text-gray-700">
{totals.book_stock.toLocaleString()}
</span>
</div>
</CardContent>
</Card>
</div>
{/* 工具列 */}
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between">