更新:優化配方詳情彈窗 UI 與一般修正
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { Wand2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -36,6 +37,7 @@ export default function ProductDialog({
|
||||
}: ProductDialogProps) {
|
||||
const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({
|
||||
code: "",
|
||||
barcode: "",
|
||||
name: "",
|
||||
category_id: "",
|
||||
brand: "",
|
||||
@@ -52,6 +54,7 @@ export default function ProductDialog({
|
||||
if (product) {
|
||||
setData({
|
||||
code: product.code,
|
||||
barcode: product.barcode || "",
|
||||
name: product.name,
|
||||
category_id: product.categoryId.toString(),
|
||||
brand: product.brand || "",
|
||||
@@ -99,6 +102,11 @@ export default function ProductDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const generateRandomBarcode = () => {
|
||||
const randomDigits = Math.floor(Math.random() * 9000000000) + 1000000000;
|
||||
setData("barcode", randomDigits.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
@@ -159,6 +167,32 @@ export default function ProductDialog({
|
||||
{errors.code && <p className="text-sm text-red-500">{errors.code}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="barcode">
|
||||
條碼編號 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="barcode"
|
||||
value={data.barcode}
|
||||
onChange={(e) => setData("barcode", e.target.value)}
|
||||
placeholder="輸入條碼或自動生成"
|
||||
className={`flex-1 ${errors.barcode ? "border-red-500" : ""}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={generateRandomBarcode}
|
||||
title="隨機生成條碼"
|
||||
className="shrink-0 button-outlined-primary"
|
||||
>
|
||||
<Wand2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{errors.barcode && <p className="text-sm text-red-500">{errors.barcode}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="brand">品牌</Label>
|
||||
<Input
|
||||
|
||||
@@ -74,11 +74,7 @@ export default function ProductTable({
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead>
|
||||
<button onClick={() => onSort("code")} className="flex items-center hover:text-gray-900">
|
||||
商品代號 <SortIcon field="code" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[150px]">條碼編號</TableHead>
|
||||
<TableHead>
|
||||
<button onClick={() => onSort("name")} className="flex items-center hover:text-gray-900">
|
||||
商品名稱 <SortIcon field="name" />
|
||||
@@ -112,12 +108,15 @@ export default function ProductTable({
|
||||
{startIndex + index}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm text-gray-700">
|
||||
{product.code}
|
||||
{product.barcode || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{product.name}</span>
|
||||
{product.brand && <span className="text-xs text-gray-400">{product.brand}</span>}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-grey-0">{product.name}</span>
|
||||
{product.brand && <Badge variant="secondary" className="text-[10px] h-4 px-1 bg-gray-100 text-gray-500 border-none">{product.brand}</Badge>}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 font-mono">代號: {product.code}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -281,7 +281,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">確認刪除</AlertDialogAction>
|
||||
<AlertDialogAction onClick={handleDelete} className="button-filled-error">確認刪除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
@@ -317,7 +317,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handlePost} className="bg-primary-600 hover:bg-primary-700">確認過帳</AlertDialogAction>
|
||||
<AlertDialogAction onClick={handlePost} className="button-filled-primary">確認過帳</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
@@ -259,14 +259,14 @@ export default function Show({ doc }: any) {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{item.batch_number || '-'}</TableCell>
|
||||
<TableCell className="text-right font-medium">{item.system_qty.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{item.system_qty.toFixed(0)}</TableCell>
|
||||
<TableCell className="text-right px-1 py-3">
|
||||
{isCompleted ? (
|
||||
<span className="font-semibold mr-2">{item.counted_qty}</span>
|
||||
) : (
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
step="1"
|
||||
value={formItem.counted_qty ?? ''}
|
||||
onChange={(e) => updateItem(index, 'counted_qty', e.target.value)}
|
||||
onWheel={(e: any) => e.target.blur()}
|
||||
@@ -284,7 +284,7 @@ export default function Show({ doc }: any) {
|
||||
: 'text-red-600'
|
||||
}`}>
|
||||
{formItem.counted_qty !== '' && formItem.counted_qty !== null
|
||||
? diff.toFixed(2)
|
||||
? diff.toFixed(0)
|
||||
: '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface Category {
|
||||
export interface Product {
|
||||
id: string;
|
||||
code: string;
|
||||
barcode?: string;
|
||||
name: string;
|
||||
categoryId: number;
|
||||
category?: Category;
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar, AlertCircle } from 'lucide-react';
|
||||
import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar } from 'lucide-react';
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router, useForm } from "@inertiajs/react";
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import { toast } from "sonner";
|
||||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
@@ -90,12 +90,17 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
|
||||
const [bomItems, setBomItems] = useState<BomItem[]>([]);
|
||||
|
||||
// 多配方支援
|
||||
const [recipes, setRecipes] = useState<any[]>([]);
|
||||
const [selectedRecipeId, setSelectedRecipeId] = useState<string>("");
|
||||
|
||||
const { data, setData, processing, errors } = useForm({
|
||||
product_id: "",
|
||||
warehouse_id: "",
|
||||
output_quantity: "",
|
||||
output_batch_number: "",
|
||||
output_box_count: "",
|
||||
// 移除 Box Count UI
|
||||
// 移除相關邏輯
|
||||
production_date: new Date().toISOString().split('T')[0],
|
||||
expiry_date: "",
|
||||
remark: "",
|
||||
@@ -244,34 +249,116 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
})));
|
||||
}, [bomItems]);
|
||||
|
||||
// 自動產生成品批號(當選擇商品或日期變動時)
|
||||
// 應用配方到表單 (獨立函式)
|
||||
const applyRecipe = (recipe: any) => {
|
||||
if (!recipe || !recipe.items) return;
|
||||
|
||||
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
|
||||
// 自動帶入配方標準產量
|
||||
setData('output_quantity', String(yieldQty));
|
||||
const ratio = 1;
|
||||
|
||||
const newBomItems: BomItem[] = recipe.items.map((item: any) => {
|
||||
const baseQty = parseFloat(item.quantity || "0");
|
||||
const calculatedQty = (baseQty * ratio).toFixed(4); // 保持精度
|
||||
|
||||
return {
|
||||
inventory_id: "",
|
||||
quantity_used: String(calculatedQty),
|
||||
unit_id: String(item.unit_id),
|
||||
ui_warehouse_id: selectedWarehouse || "", // 自動帶入目前選擇的倉庫
|
||||
ui_product_id: String(item.product_id),
|
||||
ui_product_name: item.product_name,
|
||||
ui_batch_number: "",
|
||||
ui_available_qty: 0,
|
||||
ui_input_quantity: String(calculatedQty),
|
||||
ui_selected_unit: 'base',
|
||||
ui_base_unit_name: item.unit_name,
|
||||
ui_base_unit_id: item.unit_id,
|
||||
ui_conversion_rate: 1,
|
||||
};
|
||||
});
|
||||
setBomItems(newBomItems);
|
||||
|
||||
// 若有選倉庫,預先載入庫存資料以供選擇
|
||||
if (selectedWarehouse) {
|
||||
fetchWarehouseInventory(selectedWarehouse);
|
||||
}
|
||||
|
||||
toast.success(`已自動載入配方: ${recipe.name}`, {
|
||||
description: `標準產量: ${yieldQty} 份`
|
||||
});
|
||||
};
|
||||
|
||||
// 當手動切換配方時
|
||||
useEffect(() => {
|
||||
if (!selectedRecipeId) return;
|
||||
const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId);
|
||||
if (targetRecipe) {
|
||||
applyRecipe(targetRecipe);
|
||||
}
|
||||
}, [selectedRecipeId]);
|
||||
|
||||
// 自動產生成品批號與載入配方
|
||||
useEffect(() => {
|
||||
if (!data.product_id) return;
|
||||
|
||||
// 1. 自動產生成品批號
|
||||
const product = products.find(p => String(p.id) === data.product_id);
|
||||
if (!product) return;
|
||||
if (product) {
|
||||
const datePart = data.production_date;
|
||||
const dateFormatted = datePart.replace(/-/g, '');
|
||||
const originCountry = 'TW';
|
||||
const warehouseId = selectedWarehouse || (warehouses.length > 0 ? String(warehouses[0].id) : '1');
|
||||
|
||||
const datePart = data.production_date; // YYYY-MM-DD
|
||||
const dateFormatted = datePart.replace(/-/g, '');
|
||||
const originCountry = 'TW';
|
||||
fetch(`/api/warehouses/${warehouseId}/inventory/batches/${product.id}?originCountry=${originCountry}&arrivalDate=${datePart}`)
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
const seq = result.nextSequence || '01';
|
||||
const suggested = `${product.code}-${originCountry}-${dateFormatted}-${seq}`;
|
||||
setData('output_batch_number', suggested);
|
||||
})
|
||||
.catch(() => {
|
||||
const suggested = `${product.code}-${originCountry}-${dateFormatted}-01`;
|
||||
setData('output_batch_number', suggested);
|
||||
});
|
||||
}
|
||||
|
||||
// 呼叫 API 取得下一組流水號
|
||||
// 複用庫存批號 API,但這裡可能沒有選 warehouse,所以用第一個預設
|
||||
const warehouseId = selectedWarehouse || (warehouses.length > 0 ? String(warehouses[0].id) : '1');
|
||||
// 2. 自動載入配方列表
|
||||
const fetchRecipes = async () => {
|
||||
try {
|
||||
// 改為抓取所有配方
|
||||
const res = await fetch(route('api.production.recipes.by-product', data.product_id));
|
||||
const recipesData = await res.json();
|
||||
|
||||
fetch(`/api/warehouses/${warehouseId}/inventory/batches/${product.id}?originCountry=${originCountry}&arrivalDate=${datePart}`)
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
const seq = result.nextSequence || '01';
|
||||
const suggested = `${product.code}-${originCountry}-${dateFormatted}-${seq}`;
|
||||
setData('output_batch_number', suggested);
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback:若 API 失敗,使用預設 01
|
||||
const suggested = `${product.code}-${originCountry}-${dateFormatted}-01`;
|
||||
setData('output_batch_number', suggested);
|
||||
});
|
||||
}, [data.product_id, data.production_date]);
|
||||
if (Array.isArray(recipesData) && recipesData.length > 0) {
|
||||
setRecipes(recipesData);
|
||||
// 預設選取最新的 (第一個)
|
||||
const latest = recipesData[0];
|
||||
setSelectedRecipeId(String(latest.id));
|
||||
} else {
|
||||
// 若無配方
|
||||
setRecipes([]);
|
||||
setSelectedRecipeId("");
|
||||
setBomItems([]); // 清空 BOM
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch recipes", e);
|
||||
setRecipes([]);
|
||||
setBomItems([]);
|
||||
}
|
||||
};
|
||||
fetchRecipes();
|
||||
}, [data.product_id]);
|
||||
|
||||
// 當生產數量變動時,如果是從配方載入的,則按比例更新用量
|
||||
useEffect(() => {
|
||||
if (bomItems.length > 0 && data.output_quantity) {
|
||||
// 這個部位比較複雜,因為使用者可能已經手動修改過或是選了批號
|
||||
// 目前先保持簡單:如果使用者改了產出量,我們不強行覆蓋已經選好批號的明細,避免困擾
|
||||
// 但如果是剛載入(inventory_id 為空),可以考慮連動?暫時先不處理以維護操作穩定性
|
||||
}
|
||||
}, [data.output_quantity]);
|
||||
|
||||
// 提交表單
|
||||
const submit = (status: 'draft' | 'completed') => {
|
||||
@@ -286,12 +373,9 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
if (bomItems.length === 0) missingFields.push('原物料明細');
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
toast.error(
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-bold">請填寫必要欄位</span>
|
||||
<span className="text-sm">缺漏:{missingFields.join('、')}</span>
|
||||
</div>
|
||||
);
|
||||
toast.error("請填寫必要欄位", {
|
||||
description: `缺漏:${missingFields.join('、')}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -313,12 +397,9 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
}, {
|
||||
onError: (errors) => {
|
||||
const errorCount = Object.keys(errors).length;
|
||||
toast.error(
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-bold">建立失敗,請檢查表單</span>
|
||||
<span className="text-sm">共有 {errorCount} 個欄位有誤,請修正後再試</span>
|
||||
</div>
|
||||
);
|
||||
toast.error("建立失敗,請檢查表單", {
|
||||
description: `共有 ${errorCount} 個欄位有誤,請修正後再試`
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -331,7 +412,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
return (
|
||||
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
|
||||
<Head title="建立生產單" />
|
||||
<Toaster position="top-right" />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
<div className="mb-6">
|
||||
<Link href={route('production-orders.index')}>
|
||||
@@ -394,6 +475,28 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
className="w-full h-9"
|
||||
/>
|
||||
{errors.product_id && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>}
|
||||
|
||||
{/* 配方選擇 (放在成品商品底下) */}
|
||||
{recipes.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<Label className="text-xs font-medium text-grey-2">使用配方</Label>
|
||||
<span className="text-[10px] text-blue-500">
|
||||
切換將重置明細
|
||||
</span>
|
||||
</div>
|
||||
<SearchableSelect
|
||||
value={selectedRecipeId}
|
||||
onValueChange={setSelectedRecipeId}
|
||||
options={recipes.map(r => ({
|
||||
label: `${r.name} (${r.code})`,
|
||||
value: String(r.id),
|
||||
}))}
|
||||
placeholder="選擇配方"
|
||||
className="w-full h-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
@@ -420,15 +523,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
{errors.output_batch_number && <p className="text-red-500 text-xs mt-1">{errors.output_batch_number}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">箱數(選填)</Label>
|
||||
<Input
|
||||
value={data.output_box_count}
|
||||
onChange={(e) => setData('output_box_count', e.target.value)}
|
||||
placeholder="例如: 10"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">生產日期 *</Label>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/Components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Loader2, Package, Calendar, Clock, BookOpen } from "lucide-react";
|
||||
|
||||
interface RecipeDetailModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
recipe: any | null; // Detailed recipe object with items
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function RecipeDetailModal({ isOpen, onClose, recipe, isLoading }: RecipeDetailModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto p-0 gap-0">
|
||||
<DialogHeader className="p-6 pb-4 border-b pr-12">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<DialogTitle className="text-xl font-bold text-gray-900">
|
||||
配方明細
|
||||
</DialogTitle>
|
||||
{recipe && (
|
||||
<Badge variant={recipe.is_active ? "default" : "secondary"} className="text-xs font-normal">
|
||||
{recipe.is_active ? "啟用中" : "已停用"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 現代化元數據條 */}
|
||||
{recipe && (
|
||||
<div className="flex flex-wrap items-center gap-6 pt-2 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-gray-400" />
|
||||
<span className="font-medium text-gray-700">{recipe.code}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-gray-400" />
|
||||
<span>建立於 {new Date(recipe.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-gray-400" />
|
||||
<span>更新於 {new Date(recipe.updated_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="bg-gray-50/50 p-6 min-h-[300px]">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary-main" />
|
||||
</div>
|
||||
) : recipe ? (
|
||||
<div className="space-y-6">
|
||||
{/* 基本資訊區塊 */}
|
||||
<div className="border rounded-md overflow-hidden bg-white shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50/50 hover:bg-gray-50/50">
|
||||
<TableHead className="w-[150px]">欄位</TableHead>
|
||||
<TableHead>內容</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium text-gray-700">配方名稱</TableCell>
|
||||
<TableCell className="text-gray-900 font-medium">{recipe.name}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium text-gray-700">對應成品</TableCell>
|
||||
<TableCell className="text-gray-900 font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{recipe.product?.name || '-'}</span>
|
||||
<span className="text-gray-400 text-xs bg-gray-100 px-1.5 py-0.5 rounded">{recipe.product?.code}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium text-gray-700">標準產量</TableCell>
|
||||
<TableCell className="text-gray-900 font-medium">
|
||||
{Number(recipe.yield_quantity).toLocaleString()} {recipe.product?.base_unit?.name || '份'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{recipe.description && (
|
||||
<TableRow>
|
||||
<TableCell className="font-medium text-gray-700 align-top pt-3">備註說明</TableCell>
|
||||
<TableCell className="text-gray-600 leading-relaxed py-3">
|
||||
{recipe.description}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* BOM 表格區塊 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-900 flex items-center gap-2 px-1 mb-3">
|
||||
<Package className="w-4 h-4 text-primary-main" />
|
||||
原物料清單 (BOM)
|
||||
</h3>
|
||||
<div className="border rounded-md overflow-hidden bg-white shadow-sm">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50/50">
|
||||
<TableRow>
|
||||
<TableHead>原物料名稱 / 料號</TableHead>
|
||||
<TableHead className="text-right">標準用量</TableHead>
|
||||
<TableHead>單位</TableHead>
|
||||
<TableHead>備註</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recipe.items?.length > 0 ? (
|
||||
recipe.items.map((item: any, index: number) => (
|
||||
<TableRow key={index} className="hover:bg-gray-50/50">
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-gray-900">{item.product?.name || 'Unknown'}</span>
|
||||
<span className="text-xs text-gray-400">{item.product?.code}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium text-gray-900">
|
||||
{Number(item.quantity).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-600">
|
||||
{item.unit?.name || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">
|
||||
{item.remark || '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-24 text-center text-gray-500">
|
||||
此配方尚未設定原物料
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 text-center text-gray-500">無法載入配方資料</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Search, RotateCcw, Pencil, Trash2, BookOpen } from 'lucide-react';
|
||||
import { Plus, Search, RotateCcw, Pencil, Trash2, BookOpen, Eye } from 'lucide-react';
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router, Link } from "@inertiajs/react";
|
||||
@@ -15,6 +15,8 @@ import { Label } from "@/Components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
import { RecipeDetailModal } from "./Components/RecipeDetailModal";
|
||||
import axios from 'axios';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -59,6 +61,11 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
||||
const [search, setSearch] = useState(filters.search || "");
|
||||
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
|
||||
|
||||
// View Modal State
|
||||
const [viewRecipe, setViewRecipe] = useState<any | null>(null);
|
||||
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
||||
const [isViewLoading, setIsViewLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSearch(filters.search || "");
|
||||
setPerPage(filters.per_page || "10");
|
||||
@@ -95,6 +102,20 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleView = async (id: number) => {
|
||||
setIsViewModalOpen(true);
|
||||
setIsViewLoading(true);
|
||||
setViewRecipe(null);
|
||||
try {
|
||||
const response = await axios.get(route('recipes.show', id));
|
||||
setViewRecipe(response.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load recipe details", error);
|
||||
} finally {
|
||||
setIsViewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes")}>
|
||||
<Head title="配方管理" />
|
||||
@@ -171,7 +192,7 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
||||
<TableHead className="text-right">標準產量</TableHead>
|
||||
<TableHead className="text-center w-[100px]">狀態</TableHead>
|
||||
<TableHead className="w-[150px]">更新時間</TableHead>
|
||||
<TableHead className="text-center w-[120px]">操作</TableHead>
|
||||
<TableHead className="text-center w-[150px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -221,6 +242,17 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Can permission="recipes.view">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary"
|
||||
title="查看明細"
|
||||
onClick={() => handleView(recipe.id)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Can>
|
||||
<Can permission="recipes.edit">
|
||||
<Link href={route('recipes.edit', recipe.id)}>
|
||||
<Button
|
||||
@@ -296,6 +328,13 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
||||
<Pagination links={recipes.links} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RecipeDetailModal
|
||||
isOpen={isViewModalOpen}
|
||||
onClose={() => setIsViewModalOpen(false)}
|
||||
recipe={viewRecipe}
|
||||
isLoading={isViewLoading}
|
||||
/>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
|
||||
@@ -130,7 +130,7 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
|
||||
<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">
|
||||
<span className="text-3xl font-bold text-primary-main">
|
||||
{totals.available_stock.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user