2026-01-26 14:59:24 +08:00
|
|
|
/**
|
|
|
|
|
* 編輯配方頁面
|
|
|
|
|
*/
|
|
|
|
|
|
2026-02-04 13:08:05 +08:00
|
|
|
import { useEffect } from "react";
|
2026-01-26 14:59:24 +08:00
|
|
|
import { BookOpen, Plus, Trash2, ArrowLeft, Save } from 'lucide-react';
|
|
|
|
|
import { Button } from "@/Components/ui/button";
|
|
|
|
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
2026-02-04 13:08:05 +08:00
|
|
|
import { Head, useForm, Link } from "@inertiajs/react";
|
2026-01-26 14:59:24 +08:00
|
|
|
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;
|
2026-02-04 13:08:05 +08:00
|
|
|
ui_unit_name?: string;
|
2026-01-26 14:59:24 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-02-04 13:08:05 +08:00
|
|
|
ui_product_code: item.product?.code,
|
|
|
|
|
ui_unit_name: item.unit?.name
|
2026-01-26 14:59:24 +08:00
|
|
|
})) 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);
|
2026-02-04 13:08:05 +08:00
|
|
|
const unit = units.find(u => u.id === product.base_unit_id);
|
|
|
|
|
newItems[index].ui_unit_name = unit?.name;
|
2026-01-26 14:59:24 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setData('items', newItems);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
2026-02-04 13:08:05 +08:00
|
|
|
put(route('recipes.update', recipe.id));
|
2026-01-26 14:59:24 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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"
|
2026-02-05 11:45:08 +08:00
|
|
|
step="any"
|
2026-01-26 14:59:24 +08:00
|
|
|
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"
|
2026-02-05 11:45:08 +08:00
|
|
|
step="any"
|
2026-01-26 14:59:24 +08:00
|
|
|
value={item.quantity}
|
|
|
|
|
onChange={(e) => updateItem(index, 'quantity', e.target.value)}
|
|
|
|
|
placeholder="數量"
|
2026-02-05 11:45:08 +08:00
|
|
|
className="text-right"
|
2026-01-26 14:59:24 +08:00
|
|
|
/>
|
|
|
|
|
</TableCell>
|
2026-02-04 13:08:05 +08:00
|
|
|
<TableCell className="align-middle">
|
|
|
|
|
<div className="text-sm text-gray-700 bg-gray-50 px-3 py-2 rounded-md border border-gray-100 min-h-[38px] flex items-center">
|
|
|
|
|
{item.ui_unit_name || (units.find(u => String(u.id) === item.unit_id)?.name) || '-'}
|
|
|
|
|
</div>
|
2026-01-26 14:59:24 +08:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|