feat(生產/庫存): 實作生產管理模組與批號追溯功能
This commit is contained in:
442
resources/js/Pages/Production/Create.tsx
Normal file
442
resources/js/Pages/Production/Create.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
287
resources/js/Pages/Production/Index.tsx
Normal file
287
resources/js/Pages/Production/Index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
254
resources/js/Pages/Production/Show.tsx
Normal file
254
resources/js/Pages/Production/Show.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user