更新:優化配方詳情彈窗 UI 與一般修正
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 49s

This commit is contained in:
2026-01-29 16:13:56 +08:00
parent 7619dc24f7
commit 746eeb6f01
23 changed files with 1925 additions and 79 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -22,6 +22,7 @@ export interface Category {
export interface Product {
id: string;
code: string;
barcode?: string;
name: string;
categoryId: number;
category?: Category;

View File

@@ -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>

View File

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

View File

@@ -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>
);

View File

@@ -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