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

This commit is contained in:
2026-01-21 17:19:36 +08:00
parent fc20c6d813
commit 1ae21febb5
17 changed files with 1753 additions and 33 deletions

View File

@@ -129,6 +129,21 @@ export default function AuthenticatedLayout({
},
],
},
{
id: "production-management",
label: "生產管理",
icon: <Boxes className="h-5 w-5" />,
permission: "production_orders.view",
children: [
{
id: "production-order-list",
label: "生產工單",
icon: <Package className="h-4 w-4" />,
route: "/production-orders",
permission: "production_orders.view",
},
],
},
{
id: "finance-management",
label: "財務管理",

View File

@@ -0,0 +1,442 @@
/**
* 建立生產工單頁面
* 動態 BOM 表單:選擇倉庫 → 選擇原物料 → 選擇批號 → 輸入用量
*/
import { useState, useEffect } from "react";
import { Factory, Plus, Trash2, ArrowLeft, Save, AlertTriangle, Calendar } from 'lucide-react';
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, useForm } from "@inertiajs/react";
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";
interface Product {
id: number;
name: string;
code: string;
base_unit?: { id: number; name: string } | null;
}
interface Warehouse {
id: number;
name: string;
}
interface Unit {
id: number;
name: string;
}
interface InventoryOption {
id: number;
product_id: number;
product_name: string;
product_code: string;
batch_number: string;
box_number: string | null;
quantity: number;
arrival_date: string | null;
expiry_date: string | null;
unit_name: string | null;
}
interface BomItem {
inventory_id: string;
quantity_used: string;
unit_id: string;
// 顯示用
product_name?: string;
batch_number?: string;
available_qty?: number;
}
interface Props {
products: Product[];
warehouses: Warehouse[];
units: Unit[];
}
export default function ProductionCreate({ products, warehouses, units }: Props) {
const [selectedWarehouse, setSelectedWarehouse] = useState<string>("");
const [inventoryOptions, setInventoryOptions] = useState<InventoryOption[]>([]);
const [isLoadingInventory, setIsLoadingInventory] = useState(false);
const [bomItems, setBomItems] = useState<BomItem[]>([]);
const { data, setData, processing, errors } = useForm({
product_id: "",
warehouse_id: "",
output_quantity: "",
output_batch_number: "",
output_box_count: "",
production_date: new Date().toISOString().split('T')[0],
expiry_date: "",
remark: "",
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
});
// 當選擇倉庫時,載入該倉庫的可用庫存
useEffect(() => {
if (selectedWarehouse) {
setIsLoadingInventory(true);
fetch(route('api.production.warehouses.inventories', selectedWarehouse))
.then(res => res.json())
.then((inventories: InventoryOption[]) => {
setInventoryOptions(inventories);
setIsLoadingInventory(false);
})
.catch(() => setIsLoadingInventory(false));
} else {
setInventoryOptions([]);
}
}, [selectedWarehouse]);
// 同步 warehouse_id 到 form data
useEffect(() => {
setData('warehouse_id', selectedWarehouse);
}, [selectedWarehouse]);
// 新增 BOM 項目
const addBomItem = () => {
setBomItems([...bomItems, {
inventory_id: "",
quantity_used: "",
unit_id: "",
}]);
};
// 移除 BOM 項目
const removeBomItem = (index: number) => {
setBomItems(bomItems.filter((_, i) => i !== index));
};
// 更新 BOM 項目
const updateBomItem = (index: number, field: keyof BomItem, value: string) => {
const updated = [...bomItems];
updated[index] = { ...updated[index], [field]: value };
// 如果選擇了庫存,自動填入顯示資訊
if (field === 'inventory_id' && value) {
const inv = inventoryOptions.find(i => String(i.id) === value);
if (inv) {
updated[index].product_name = inv.product_name;
updated[index].batch_number = inv.batch_number;
updated[index].available_qty = inv.quantity;
}
}
setBomItems(updated);
};
// 產生成品批號建議
const generateBatchNumber = () => {
if (!data.product_id) return;
const product = products.find(p => String(p.id) === data.product_id);
if (!product) return;
const date = data.production_date.replace(/-/g, '');
const suggested = `${product.code}-TW-${date}-01`;
setData('output_batch_number', suggested);
};
// 提交表單
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// 轉換 BOM items 格式
const formattedItems = bomItems
.filter(item => item.inventory_id && item.quantity_used)
.map(item => ({
inventory_id: parseInt(item.inventory_id),
quantity_used: parseFloat(item.quantity_used),
unit_id: item.unit_id ? parseInt(item.unit_id) : null,
}));
// 使用 router.post 提交完整資料
router.post(route('production-orders.store'), {
...data,
items: formattedItems,
});
};
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
<Head title="建立生產單" />
<div className="container mx-auto p-6 max-w-4xl">
<div className="flex items-center gap-4 mb-6">
<Button
variant="ghost"
onClick={() => router.get(route('production-orders.index'))}
className="p-2"
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Factory className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
使
</p>
</div>
</div>
<form onSubmit={handleSubmit}>
{/* 成品資訊 */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-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 h-9"
/>
{errors.product_id && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<Input
type="number"
step="0.01"
value={data.output_quantity}
onChange={(e) => setData('output_quantity', e.target.value)}
placeholder="例如: 50"
className="h-9"
/>
{errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<div className="flex gap-2">
<Input
value={data.output_batch_number}
onChange={(e) => setData('output_batch_number', e.target.value)}
placeholder="例如: AB-TW-20260121-01"
className="h-9 font-mono"
/>
<Button
type="button"
variant="outline"
onClick={generateBatchNumber}
disabled={!data.product_id}
className="h-9 button-outlined-primary shrink-0"
>
</Button>
</div>
{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>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={data.production_date}
onChange={(e) => setData('production_date', e.target.value)}
className="h-9 pl-9"
/>
</div>
{errors.production_date && <p className="text-red-500 text-xs mt-1">{errors.production_date}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={data.expiry_date}
onChange={(e) => setData('expiry_date', e.target.value)}
className="h-9 pl-9"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<SearchableSelect
value={selectedWarehouse}
onValueChange={setSelectedWarehouse}
options={warehouses.map(w => ({
label: w.name,
value: String(w.id),
}))}
placeholder="選擇倉庫"
className="w-full h-9"
/>
{errors.warehouse_id && <p className="text-red-500 text-xs mt-1">{errors.warehouse_id}</p>}
</div>
</div>
<div className="mt-4 space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<Textarea
value={data.remark}
onChange={(e) => setData('remark', e.target.value)}
placeholder="生產備註..."
rows={2}
className="resize-none"
/>
</div>
</div>
{/* BOM 原物料明細 */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">使 (BOM)</h2>
<Button
type="button"
variant="outline"
onClick={addBomItem}
disabled={!selectedWarehouse}
className="gap-2 button-filled-primary text-white"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{!selectedWarehouse && (
<div className="text-center py-8 text-gray-500">
<AlertTriangle className="h-8 w-8 mx-auto mb-2 text-yellow-500" />
</div>
)}
{selectedWarehouse && isLoadingInventory && (
<div className="text-center py-8 text-gray-500">
...
</div>
)}
{selectedWarehouse && !isLoadingInventory && bomItems.length === 0 && (
<div className="text-center py-8 text-gray-500">
<Factory className="h-8 w-8 mx-auto mb-2 text-gray-300" />
BOM
</div>
)}
{bomItems.length > 0 && (
<div className="space-y-4">
{bomItems.map((item, index) => (
<div
key={index}
className="grid grid-cols-1 md:grid-cols-12 gap-3 items-end p-4 bg-gray-50/50 border border-gray-100 rounded-lg relative group"
>
<div className="md:col-span-5 space-y-1">
<Label className="text-xs font-medium text-grey-2"> ()</Label>
<SearchableSelect
value={item.inventory_id}
onValueChange={(v) => updateBomItem(index, 'inventory_id', v)}
options={inventoryOptions.map(inv => ({
label: `${inv.product_name} - ${inv.batch_number} (庫存: ${inv.quantity})`,
value: String(inv.id),
}))}
placeholder="選擇原物料與批號"
className="w-full h-9"
/>
</div>
<div className="md:col-span-3 space-y-1">
<Label className="text-xs font-medium text-grey-2">使</Label>
<div className="relative">
<Input
type="number"
step="0.0001"
value={item.quantity_used}
onChange={(e) => updateBomItem(index, 'quantity_used', e.target.value)}
placeholder="0.00"
className="h-9 pr-12"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-400 pointer-events-none">
</div>
</div>
{item.available_qty && (
<p className="text-xs text-gray-400 mt-1">: {item.available_qty.toLocaleString()}</p>
)}
</div>
<div className="md:col-span-3 space-y-1">
<Label className="text-xs font-medium text-grey-2">/</Label>
<SearchableSelect
value={item.unit_id}
onValueChange={(v) => updateBomItem(index, 'unit_id', v)}
options={units.map(u => ({
label: u.name,
value: String(u.id),
}))}
placeholder="選擇單位"
className="w-full h-9"
/>
</div>
<div className="md:col-span-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => removeBomItem(index)}
className="button-outlined-error h-9 w-full md:w-9 p-0"
title="移除此項目"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
</div>
{/* 提交按鈕 */}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => router.get(route('production-orders.index'))}
className="h-10 px-6"
>
</Button>
<Button
type="submit"
disabled={processing || bomItems.length === 0}
className="gap-2 button-filled-primary h-10 px-8"
>
<Save className="h-4 w-4" />
{processing ? '處理中...' : '建立生產單'}
</Button>
</div>
</form>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,287 @@
/**
* 生產工單管理主頁面
*/
import { useState, useEffect } from "react";
import { Plus, Factory, Search, RotateCcw, Eye } 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 { Can } from "@/Components/Permission/Can";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Badge } from "@/Components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
interface ProductionOrder {
id: number;
code: string;
product: { id: number; name: string; code: string } | null;
warehouse: { id: number; name: string } | null;
user: { id: number; name: string } | null;
output_batch_number: string;
output_quantity: number;
production_date: string;
status: 'draft' | 'completed' | 'cancelled';
created_at: string;
}
interface Props {
productionOrders: {
data: ProductionOrder[];
links: any[];
total: number;
from: number;
to: number;
};
filters: {
search?: string;
status?: string;
per_page?: string;
};
}
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
draft: { label: "草稿", variant: "secondary" },
completed: { label: "已完成", variant: "default" },
cancelled: { label: "已取消", variant: "destructive" },
};
export default function ProductionIndex({ productionOrders, filters }: Props) {
const [search, setSearch] = useState(filters.search || "");
const [status, setStatus] = useState<string>(filters.status || "all");
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
useEffect(() => {
setSearch(filters.search || "");
setStatus(filters.status || "all");
setPerPage(filters.per_page || "10");
}, [filters]);
const handleFilter = () => {
router.get(
route('production-orders.index'),
{
search,
status: status === 'all' ? undefined : status,
per_page: perPage,
},
{ preserveState: true, replace: true }
);
};
const handleReset = () => {
setSearch("");
setStatus("all");
router.get(route('production-orders.index'));
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(
route("production-orders.index"),
{ ...filters, per_page: value },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
const handleNavigateToCreate = () => {
router.get(route('production-orders.create'));
};
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrders")}>
<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">
<Factory className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
使
</p>
</div>
<div className="flex gap-2">
<Can permission="production_orders.create">
<Button
onClick={handleNavigateToCreate}
className="gap-2 button-filled-primary"
>
<Plus className="h-4 w-4" />
</Button>
</Can>
</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-8 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 className="md:col-span-4 space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="選擇狀態" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="draft">稿</SelectItem>
<SelectItem value="completed"></SelectItem>
<SelectItem value="cancelled"></SelectItem>
</SelectContent>
</Select>
</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-[150px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center w-[100px]"></TableHead>
<TableHead className="text-center w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{productionOrders.data.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="h-32 text-center text-gray-500">
<div className="flex flex-col items-center justify-center gap-2">
<Factory className="h-10 w-10 text-gray-300" />
<p></p>
</div>
</TableCell>
</TableRow>
) : (
productionOrders.data.map((order) => (
<TableRow key={order.id}>
<TableCell className="font-medium text-gray-900">
{order.code}
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium text-gray-900">{order.product?.name || '-'}</span>
<span className="text-gray-400 text-xs">
{order.product?.code || '-'}
</span>
</div>
</TableCell>
<TableCell>
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs font-mono">
{order.output_batch_number}
</code>
</TableCell>
<TableCell className="text-right font-medium">
{order.output_quantity.toLocaleString()}
</TableCell>
<TableCell className="text-gray-600">
{order.warehouse?.name || '-'}
</TableCell>
<TableCell className="text-gray-600">
{order.production_date}
</TableCell>
<TableCell className="text-center">
<Badge variant={statusConfig[order.status]?.variant || "secondary"} className="font-normal capitalize">
{statusConfig[order.status]?.label || order.status}
</Badge>
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center">
<Link href={route('production-orders.show', order.id)}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary h-8"
>
<Eye className="h-4 w-4 mr-1" />
</Button>
</Link>
</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={productionOrders.links} />
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,254 @@
/**
* 生產工單詳情頁面
* 含追溯資訊:成品批號 → 原物料批號 → 來源採購單
*/
import { Factory, ArrowLeft, Package, Calendar, User, Warehouse, FileText, Link2 } from 'lucide-react';
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, Link } from "@inertiajs/react";
import { getBreadcrumbs } from "@/utils/breadcrumb";
import { Badge } from "@/Components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
interface ProductionOrderItem {
id: number;
quantity_used: number;
unit?: { id: number; name: string } | null;
inventory: {
id: number;
batch_number: string;
box_number: string | null;
arrival_date: string | null;
origin_country: string | null;
product: { id: number; name: string; code: string } | null;
source_purchase_order?: {
id: number;
code: string;
vendor?: { id: number; name: string } | null;
} | null;
} | null;
}
interface ProductionOrder {
id: number;
code: string;
product: { id: number; name: string; code: string; base_unit?: { name: string } | null } | null;
warehouse: { id: number; name: string } | null;
user: { id: number; name: string } | null;
output_batch_number: string;
output_box_count: string | null;
output_quantity: number;
production_date: string;
expiry_date: string | null;
status: 'draft' | 'completed' | 'cancelled';
remark: string | null;
created_at: string;
items: ProductionOrderItem[];
}
interface Props {
productionOrder: ProductionOrder;
}
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
draft: { label: "草稿", variant: "secondary" },
completed: { label: "已完成", variant: "default" },
cancelled: { label: "已取消", variant: "destructive" },
};
export default function ProductionShow({ productionOrder }: Props) {
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersShow")}>
<Head title={`生產單 ${productionOrder.code}`} />
<div className="container mx-auto p-6 max-w-4xl">
<div className="flex items-center gap-4 mb-6">
<Button
variant="ghost"
onClick={() => router.get(route('production-orders.index'))}
className="p-2"
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex-1">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Factory className="h-6 w-6 text-primary-main" />
{productionOrder.code}
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
<Badge variant={statusConfig[productionOrder.status]?.variant || "secondary"}>
{statusConfig[productionOrder.status]?.label || productionOrder.status}
</Badge>
</div>
{/* 成品資訊 */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Package className="h-5 w-5 text-gray-500" />
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6">
<div className="space-y-1">
<p className="text-xs font-medium text-grey-2"></p>
<p className="font-medium text-grey-0">
{productionOrder.product?.name || '-'}
<span className="text-gray-400 ml-2 text-sm font-normal">
({productionOrder.product?.code || '-'})
</span>
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-grey-2"></p>
<p className="font-mono font-medium text-primary-main">
{productionOrder.output_batch_number}
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-grey-2"></p>
<p className="font-medium text-grey-0">
{productionOrder.output_quantity.toLocaleString()}
{productionOrder.product?.base_unit?.name && (
<span className="text-gray-400 ml-1 font-normal">{productionOrder.product.base_unit.name}</span>
)}
{productionOrder.output_box_count && (
<span className="text-gray-400 ml-2 font-normal">({productionOrder.output_box_count} )</span>
)}
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-grey-2"></p>
<div className="flex items-center gap-2">
<Warehouse className="h-4 w-4 text-gray-400" />
<p className="font-medium text-grey-0">{productionOrder.warehouse?.name || '-'}</p>
</div>
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-grey-2"></p>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-gray-400" />
<p className="font-medium text-grey-0">{productionOrder.production_date}</p>
</div>
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-grey-2"></p>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-gray-400" />
<p className="font-medium text-grey-0">{productionOrder.expiry_date || '-'}</p>
</div>
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-grey-2"></p>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-gray-400" />
<p className="font-medium text-grey-0">{productionOrder.user?.name || '-'}</p>
</div>
</div>
</div>
{productionOrder.remark && (
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="flex items-start gap-2">
<FileText className="h-4 w-4 text-gray-400 mt-1" />
<div>
<p className="text-sm text-gray-500"></p>
<p className="text-gray-700">{productionOrder.remark}</p>
</div>
</div>
</div>
)}
</div>
{/* 原物料使用明細 (BOM) */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Link2 className="h-5 w-5 text-gray-500" />
使 (BOM) -
</h2>
{productionOrder.items.length === 0 ? (
<p className="text-center text-gray-500 py-8"></p>
) : (
<div className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-grey-2">
</TableHead>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-grey-2">
</TableHead>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-grey-2">
</TableHead>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-grey-2">
</TableHead>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-grey-2">
使
</TableHead>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-grey-2">
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{productionOrder.items.map((item) => (
<TableRow key={item.id} className="hover:bg-gray-50/50">
<TableCell className="px-4 py-4 text-sm">
<div className="font-medium text-grey-0">{item.inventory?.product?.name || '-'}</div>
<div className="text-gray-400 text-xs">
{item.inventory?.product?.code || '-'}
</div>
</TableCell>
<TableCell className="px-4 py-4 text-sm font-mono text-primary-main">
{item.inventory?.batch_number || '-'}
{item.inventory?.box_number && (
<span className="text-gray-300 ml-1">#{item.inventory.box_number}</span>
)}
</TableCell>
<TableCell className="px-4 py-4 text-sm text-grey-1">
{item.inventory?.origin_country || '-'}
</TableCell>
<TableCell className="px-4 py-4 text-sm text-grey-1">
{item.inventory?.arrival_date || '-'}
</TableCell>
<TableCell className="px-4 py-4 text-sm font-medium text-grey-0">
{item.quantity_used.toLocaleString()}
{item.unit?.name && (
<span className="text-gray-400 ml-1 font-normal text-xs">{item.unit.name}</span>
)}
</TableCell>
<TableCell className="px-4 py-4 text-sm">
{item.inventory?.source_purchase_order ? (
<div className="flex flex-col">
<Link
href={route('purchase-orders.show', item.inventory.source_purchase_order.id)}
className="text-primary-main hover:underline font-medium"
>
{item.inventory.source_purchase_order.code}
</Link>
{item.inventory.source_purchase_order.vendor && (
<span className="text-[11px] text-gray-400 mt-0.5">
{item.inventory.source_purchase_order.vendor.name}
</span>
)}
</div>
) : (
<span className="text-gray-400">-</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -148,7 +148,9 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
return {
productId: item.productId,
quantity: finalQuantity
quantity: finalQuantity,
batchNumber: item.batchNumber,
expiryDate: item.expiryDate
};
})
}, {
@@ -307,8 +309,8 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
</TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
{/* <TableHead className="w-[180px]">效期</TableHead>
<TableHead className="w-[220px]">進貨編號</TableHead> */}
<TableHead className="w-[180px]"></TableHead>
<TableHead className="w-[220px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
@@ -395,37 +397,40 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
</TableCell>
{/* 效期 */}
{/* <TableCell>
<div className="relative">
<TableCell>
<div className="relative">
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={item.expiryDate || ""}
onChange={(e) =>
handleUpdateItem(item.tempId, {
expiryDate: e.target.value,
})
}
className="border-gray-300 pl-9"
/>
</div>
</TableCell>
{/* 批號 */}
<TableCell>
<Input
type="date"
value={item.expiryDate}
value={item.batchNumber || ""}
onChange={(e) =>
handleUpdateItem(item.tempId, {
expiryDate: e.target.value,
batchNumber: e.target.value,
})
}
className="border-gray-300"
placeholder="系統自動生成"
/>
</div>
</TableCell> */}
{/* 批號 */}
{/* <TableCell>
<Input
value={item.batchNumber}
onChange={(e) =>
handleBatchNumberChange(item.tempId, e.target.value)
}
className="border-gray-300"
placeholder="系統自動生成"
/>
{errors[`item-${index}-batch`] && (
<p className="text-xs text-red-500 mt-1">
{errors[`item-${index}-batch`]}
</p>
)}
</TableCell> */}
{errors[`item-${index}-batch`] && (
<p className="text-xs text-red-500 mt-1">
{errors[`item-${index}-batch`]}
</p>
)}
</TableCell>
{/* 刪除按鈕 */}
<TableCell>

View File

@@ -151,6 +151,8 @@ export interface InboundItem {
largeUnit?: string;
conversionRate?: number;
selectedUnit?: 'base' | 'large';
batchNumber?: string;
expiryDate?: string;
}
/**

View File

@@ -22,6 +22,20 @@ export const BREADCRUMB_MAP: Record<string, BreadcrumbItemType[]> = {
{ label: "採購管理" },
{ label: "管理採購單", href: "/purchase-orders", isPage: true }
],
productionOrders: [
{ label: "生產管理" },
{ label: "生產工單", href: "/production-orders", isPage: true }
],
productionOrdersCreate: [
{ label: "生產管理" },
{ label: "生產工單", href: "/production-orders" },
{ label: "建立生產單", isPage: true }
],
productionOrdersShow: [
{ label: "生產管理" },
{ label: "生產工單", href: "/production-orders" },
{ label: "詳情", isPage: true }
],
};
/**