feat: 修正 BOM 單位顯示與完工入庫彈窗 UI 統一規範
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 生產工單狀態標籤組件
|
||||
*/
|
||||
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { ProductionOrderStatus, STATUS_CONFIG } from "@/constants/production-order";
|
||||
|
||||
interface ProductionOrderStatusBadgeProps {
|
||||
status: ProductionOrderStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ProductionOrderStatusBadge({
|
||||
status,
|
||||
className,
|
||||
}: ProductionOrderStatusBadgeProps) {
|
||||
const config = STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
|
||||
|
||||
const getStatusStyles = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return 'bg-gray-100 text-gray-600 border-gray-200';
|
||||
case 'pending':
|
||||
return 'bg-blue-50 text-blue-600 border-blue-200';
|
||||
case 'approved':
|
||||
return 'bg-primary text-primary-foreground border-transparent';
|
||||
case 'in_progress':
|
||||
return 'bg-amber-50 text-amber-600 border-amber-200';
|
||||
case 'completed':
|
||||
return 'bg-primary text-primary-foreground border-transparent transition-all shadow-sm';
|
||||
case 'cancelled':
|
||||
return 'bg-destructive text-destructive-foreground border-transparent';
|
||||
default:
|
||||
return 'bg-gray-50 text-gray-500 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${className} ${getStatusStyles(status)} font-bold px-2.5 py-0.5 rounded-full border shadow-none`}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 生產工單狀態流程條組件
|
||||
*/
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
import { ProductionOrderStatus, PRODUCTION_ORDER_STATUS } from "@/constants/production-order";
|
||||
|
||||
interface ProductionStatusProgressBarProps {
|
||||
currentStatus: ProductionOrderStatus;
|
||||
}
|
||||
|
||||
// 流程步驟定義
|
||||
const FLOW_STEPS: { key: ProductionOrderStatus; label: string }[] = [
|
||||
{ key: PRODUCTION_ORDER_STATUS.DRAFT, label: "草稿" },
|
||||
{ key: PRODUCTION_ORDER_STATUS.PENDING, label: "簽核中" },
|
||||
{ key: PRODUCTION_ORDER_STATUS.APPROVED, label: "已核准" },
|
||||
{ key: PRODUCTION_ORDER_STATUS.IN_PROGRESS, label: "製作中" },
|
||||
{ key: PRODUCTION_ORDER_STATUS.COMPLETED, label: "製作完成" },
|
||||
];
|
||||
|
||||
export function ProductionStatusProgressBar({ currentStatus }: ProductionStatusProgressBarProps) {
|
||||
// 對於已作廢狀態,我們顯示到它作廢前的最後一個有效狀態(通常顯示到核准後或簽核中)
|
||||
// 這裡我們比照採購單邏輯,如果已作廢,可能停在最後一個有效位置
|
||||
const effectiveStatus = currentStatus === PRODUCTION_ORDER_STATUS.CANCELLED ? PRODUCTION_ORDER_STATUS.PENDING : currentStatus;
|
||||
|
||||
// 找到當前狀態在流程中的位置
|
||||
const currentIndex = FLOW_STEPS.findIndex((step) => step.key === effectiveStatus);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border shadow-sm p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-6">生產工單處理進度</h3>
|
||||
<div className="relative px-4">
|
||||
{/* 進度條背景 */}
|
||||
<div className="absolute top-5 left-8 right-8 h-0.5 bg-gray-100" />
|
||||
|
||||
{/* 進度條進度 */}
|
||||
{currentIndex >= 0 && (
|
||||
<div
|
||||
className="absolute top-5 left-8 h-0.5 bg-primary transition-all duration-500"
|
||||
style={{
|
||||
width: `${(currentIndex / (FLOW_STEPS.length - 1)) * 100}%`,
|
||||
maxWidth: "calc(100% - 4rem)"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 步驟標記 */}
|
||||
<div className="relative flex justify-between">
|
||||
{FLOW_STEPS.map((step, index) => {
|
||||
const isCompleted = index < currentIndex;
|
||||
const isCurrent = index === currentIndex;
|
||||
const isRejectedAtThisStep = currentStatus === PRODUCTION_ORDER_STATUS.CANCELLED && step.key === PRODUCTION_ORDER_STATUS.PENDING;
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex flex-col items-center flex-1">
|
||||
{/* 圓點 */}
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center border-2 z-10 transition-all duration-300 ${isRejectedAtThisStep
|
||||
? "bg-red-500 border-red-500 text-white"
|
||||
: isCompleted
|
||||
? "bg-primary border-primary text-white"
|
||||
: isCurrent
|
||||
? "bg-white border-primary text-primary ring-4 ring-primary/10 font-bold"
|
||||
: "bg-white border-gray-200 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{isCompleted && !isRejectedAtThisStep ? (
|
||||
<Check className="h-5 w-5" />
|
||||
) : (
|
||||
<span className="text-sm">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 標籤 */}
|
||||
<div className="mt-3 text-center">
|
||||
<p
|
||||
className={`text-xs whitespace-nowrap transition-colors ${isRejectedAtThisStep
|
||||
? "text-red-600 font-bold"
|
||||
: isCompleted || isCurrent
|
||||
? "text-gray-900 font-bold"
|
||||
: "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{isRejectedAtThisStep ? "已作廢" : step.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 生產工單完工入庫 - 選擇倉庫彈窗
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Warehouse as WarehouseIcon, Calendar as CalendarIcon, Tag, X, CheckCircle2 } from "lucide-react";
|
||||
|
||||
interface Warehouse {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface WarehouseSelectionModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (data: {
|
||||
warehouseId: number;
|
||||
batchNumber: string;
|
||||
expiryDate: string;
|
||||
}) => void;
|
||||
warehouses: Warehouse[];
|
||||
processing?: boolean;
|
||||
// 新增商品資訊以利產生批號
|
||||
productCode?: string;
|
||||
productId?: number;
|
||||
}
|
||||
|
||||
export default function WarehouseSelectionModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
warehouses,
|
||||
processing = false,
|
||||
productCode,
|
||||
productId,
|
||||
}: WarehouseSelectionModalProps) {
|
||||
const [selectedId, setSelectedId] = React.useState<number | null>(null);
|
||||
const [batchNumber, setBatchNumber] = React.useState<string>("");
|
||||
const [expiryDate, setExpiryDate] = React.useState<string>("");
|
||||
|
||||
// 當開啟時,嘗試產生成品批號 (若有資訊)
|
||||
React.useEffect(() => {
|
||||
if (isOpen && productCode && productId) {
|
||||
const today = new Date().toISOString().split('T')[0].replace(/-/g, '');
|
||||
const originCountry = 'TW';
|
||||
|
||||
// 先放一個預設值,實際序號由後端在儲存時再次確認或提供 API
|
||||
fetch(`/api/warehouses/${selectedId || warehouses[0]?.id || 1}/inventory/batches/${productId}?originCountry=${originCountry}&arrivalDate=${new Date().toISOString().split('T')[0]}`)
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
const seq = result.nextSequence || '01';
|
||||
setBatchNumber(`${productCode}-${originCountry}-${today}-${seq}`);
|
||||
})
|
||||
.catch(() => {
|
||||
setBatchNumber(`${productCode}-${originCountry}-${today}-01`);
|
||||
});
|
||||
}
|
||||
}, [isOpen, productCode, productId]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedId && batchNumber) {
|
||||
onConfirm({
|
||||
warehouseId: selectedId,
|
||||
batchNumber,
|
||||
expiryDate
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-primary-main">
|
||||
<WarehouseIcon className="h-5 w-5" />
|
||||
選擇完工入庫倉庫
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-6 space-y-6">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
||||
<WarehouseIcon className="h-3 w-3" />
|
||||
完工入庫倉庫 *
|
||||
</Label>
|
||||
<SearchableSelect
|
||||
options={warehouses.map(w => ({ value: w.id.toString(), label: w.name }))}
|
||||
value={selectedId?.toString() || ""}
|
||||
onValueChange={(val) => setSelectedId(parseInt(val))}
|
||||
placeholder="請選擇倉庫..."
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
||||
<Tag className="h-3 w-3" />
|
||||
成品批號 *
|
||||
</Label>
|
||||
<Input
|
||||
value={batchNumber}
|
||||
onChange={(e) => setBatchNumber(e.target.value)}
|
||||
placeholder="輸入成品批號"
|
||||
className="h-9 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
||||
<CalendarIcon className="h-3 w-3" />
|
||||
成品效期 (選填)
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={expiryDate}
|
||||
onChange={(e) => setExpiryDate(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={processing}
|
||||
className="gap-2 button-outlined-error"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedId || !batchNumber || processing}
|
||||
className="gap-2 button-filled-primary"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{processing ? "處理中..." : "確認完工入庫"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Check, ChevronsUpDown, X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -36,6 +36,8 @@ interface SearchableSelectProps {
|
||||
searchThreshold?: number;
|
||||
/** 強制控制是否顯示搜尋框。若設定此值,則忽略 searchThreshold */
|
||||
showSearch?: boolean;
|
||||
/** 是否可清除選取 */
|
||||
isClearable?: boolean;
|
||||
}
|
||||
|
||||
export function SearchableSelect({
|
||||
@@ -49,6 +51,7 @@ export function SearchableSelect({
|
||||
className,
|
||||
searchThreshold = 10,
|
||||
showSearch,
|
||||
isClearable = false,
|
||||
}: SearchableSelectProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
@@ -86,7 +89,18 @@ export function SearchableSelect({
|
||||
<span className="truncate">
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 text-grey-2" />
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isClearable && value && !disabled && (
|
||||
<X
|
||||
className="h-4 w-4 text-grey-3 hover:text-grey-1 transition-colors pointer-events-auto cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onValueChange("");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ChevronsUpDown className="h-4 w-4 opacity-50 text-grey-2" />
|
||||
</div>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar } from 'lucide-react';
|
||||
import { Trash2, Plus, ArrowLeft, Save, Factory } from "lucide-react";
|
||||
import { formatQuantity } from "@/lib/utils";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router, useForm } from "@inertiajs/react";
|
||||
import { router, useForm, Head, Link } from "@inertiajs/react";
|
||||
import { toast } from "sonner";
|
||||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
import { Link } from "@inertiajs/react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||
|
||||
interface Product {
|
||||
@@ -84,7 +84,7 @@ interface Props {
|
||||
units: Unit[];
|
||||
}
|
||||
|
||||
export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
export default function Create({ products, warehouses, units }: Props) {
|
||||
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // 產出倉庫
|
||||
// 快取對照表:product_id -> inventories across warehouses
|
||||
const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
||||
@@ -100,11 +100,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
product_id: "",
|
||||
warehouse_id: "",
|
||||
output_quantity: "",
|
||||
output_batch_number: "",
|
||||
// 移除 Box Count UI
|
||||
// 移除相關邏輯
|
||||
production_date: new Date().toISOString().split('T')[0],
|
||||
expiry_date: "",
|
||||
// 移除成品批號、生產日期、有效日期,這些欄位改在完工時處理或自動記錄
|
||||
remark: "",
|
||||
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
|
||||
});
|
||||
@@ -184,13 +180,14 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊
|
||||
// 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊 (保留數量)
|
||||
if (field === 'ui_warehouse_id') {
|
||||
item.inventory_id = "";
|
||||
item.quantity_used = "";
|
||||
item.unit_id = "";
|
||||
item.ui_input_quantity = "";
|
||||
item.ui_selected_unit = "base";
|
||||
// 不重置數量
|
||||
// item.quantity_used = "";
|
||||
// item.ui_input_quantity = "";
|
||||
// item.ui_selected_unit = "base";
|
||||
|
||||
// 清除某些 cache
|
||||
delete item.ui_batch_number;
|
||||
delete item.ui_available_qty;
|
||||
@@ -215,6 +212,11 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
// 預設單位
|
||||
item.ui_selected_unit = 'base';
|
||||
item.unit_id = String(inv.base_unit_id || '');
|
||||
|
||||
// 不重置數量,但如果原本沒數量可以從庫存帶入 (選填,通常配方已帶入則保留配方)
|
||||
if (!item.ui_input_quantity) {
|
||||
item.ui_input_quantity = formatQuantity(inv.quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,27 +300,6 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
useEffect(() => {
|
||||
if (!data.product_id) return;
|
||||
|
||||
// 1. 自動產生成品批號
|
||||
const product = products.find(p => String(p.id) === data.product_id);
|
||||
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');
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 自動載入配方列表
|
||||
const fetchRecipes = async () => {
|
||||
try {
|
||||
@@ -362,9 +343,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
const missingFields = [];
|
||||
if (!data.product_id) missingFields.push('成品商品');
|
||||
if (!data.output_quantity) missingFields.push('生產數量');
|
||||
if (!data.output_batch_number) missingFields.push('成品批號');
|
||||
if (!data.production_date) missingFields.push('生產日期');
|
||||
if (!selectedWarehouse) missingFields.push('入庫倉庫');
|
||||
if (!selectedWarehouse) missingFields.push('預計入庫倉庫');
|
||||
if (bomItems.length === 0) missingFields.push('原物料明細');
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
@@ -387,6 +366,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
// 使用 router.post 提交完整資料
|
||||
router.post(route('production-orders.store'), {
|
||||
...data,
|
||||
warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null,
|
||||
items: formattedItems,
|
||||
status: status,
|
||||
}, {
|
||||
@@ -430,25 +410,14 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
建立新的生產排程,選擇原物料並記錄產出
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => submit('draft')}
|
||||
disabled={processing}
|
||||
variant="outline"
|
||||
className="button-outlined-primary"
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
儲存草稿
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => submit('completed')}
|
||||
disabled={processing}
|
||||
className="button-filled-primary"
|
||||
>
|
||||
<Factory className="mr-2 h-4 w-4" />
|
||||
建立工單
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => submit('draft')}
|
||||
disabled={processing}
|
||||
className="gap-2 button-filled-primary"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
儲存工單 (草稿)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -499,56 +468,16 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={data.output_quantity}
|
||||
value={Number(data.output_quantity) === 0 ? '' : formatQuantity(data.output_quantity)}
|
||||
onChange={(e) => setData('output_quantity', e.target.value)}
|
||||
placeholder="例如: 50"
|
||||
className="h-9"
|
||||
className="h-9 font-mono"
|
||||
/>
|
||||
{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>
|
||||
<Input
|
||||
value={data.output_batch_number}
|
||||
onChange={(e) => setData('output_batch_number', e.target.value)}
|
||||
placeholder="選擇商品後自動產生"
|
||||
className="h-9 font-mono"
|
||||
/>
|
||||
{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>
|
||||
<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>
|
||||
<Label className="text-xs font-medium text-grey-2">預計入庫倉庫 *</Label>
|
||||
<SearchableSelect
|
||||
value={selectedWarehouse}
|
||||
onValueChange={setSelectedWarehouse}
|
||||
@@ -602,12 +531,12 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50/50">
|
||||
<TableHead className="w-[20%]">商品 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[20%]">來源倉庫 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[25%]">批號 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[15%]">數量 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[15%]">單位</TableHead>
|
||||
<TableHead className="w-[5%]"></TableHead>
|
||||
<TableHead className="w-[18%]">商品 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[15%]">來源倉庫 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[30%]">批號 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[12%]">數量 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[10%]">單位</TableHead>
|
||||
<TableHead className="w-[10%]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -618,7 +547,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
value: String(p.id)
|
||||
}));
|
||||
|
||||
// 2. 來源倉庫選項 (根據商品库庫存過濾)
|
||||
// 2. 來源倉庫選項 (根據商品庫存過濾)
|
||||
const currentInventories = productInventoryMap[item.ui_product_id] || [];
|
||||
const filteredWarehouseOptions = Array.from(new Map(
|
||||
currentInventories.map((inv: InventoryOption) => [inv.warehouse_id, { label: inv.warehouse_name, value: String(inv.warehouse_id) }])
|
||||
@@ -629,12 +558,13 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
? filteredWarehouseOptions
|
||||
: (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []);
|
||||
|
||||
// 3. 批號選項 (根據商品與倉庫過濾)
|
||||
// 3. 批號選項 (利用 sublabel 顯示詳細資訊,保持選中後簡潔)
|
||||
const batchOptions = currentInventories
|
||||
.filter((inv: InventoryOption) => String(inv.warehouse_id) === item.ui_warehouse_id)
|
||||
.map((inv: InventoryOption) => ({
|
||||
label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`,
|
||||
value: String(inv.id)
|
||||
label: inv.batch_number,
|
||||
value: String(inv.id),
|
||||
sublabel: `(存:${inv.quantity} | 效:${inv.expiry_date || '無'})`
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -678,11 +608,16 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
/>
|
||||
{item.inventory_id && (() => {
|
||||
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
|
||||
if (selectedInv) return (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
有效日期: {selectedInv.expiry_date || '無'} | 庫存: {selectedInv.quantity}
|
||||
</div>
|
||||
);
|
||||
if (selectedInv) {
|
||||
const isInsufficient = selectedInv.quantity < parseFloat(item.ui_input_quantity || '0');
|
||||
return (
|
||||
<div className={`text-xs mt-1 ${isInsufficient ? 'text-red-500 font-bold animate-pulse' : 'text-gray-500'}`}>
|
||||
有效日期: {selectedInv.expiry_date || '無'} |
|
||||
庫存: {formatQuantity(selectedInv.quantity)}
|
||||
{isInsufficient && ' (庫存不足!)'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</TableCell>
|
||||
@@ -692,7 +627,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={item.ui_input_quantity}
|
||||
value={formatQuantity(item.ui_input_quantity)}
|
||||
onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)}
|
||||
placeholder="0"
|
||||
className="h-9 text-right"
|
||||
@@ -700,20 +635,22 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* 4. 選擇單位 */}
|
||||
<TableCell className="align-top pt-3">
|
||||
<span className="text-sm">{item.ui_base_unit_name}</span>
|
||||
{/* 4. 單位 */}
|
||||
<TableCell className="align-top">
|
||||
<div className="h-9 flex items-center px-1 text-sm text-gray-600 font-medium">
|
||||
{item.ui_base_unit_name || '-'}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
|
||||
|
||||
<TableCell className="align-top">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeBomItem(index)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50 p-2"
|
||||
className="button-outlined-error"
|
||||
title="刪除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar } from 'lucide-react';
|
||||
import { Trash2, Plus, ArrowLeft, Save, Factory } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { formatQuantity } from "@/lib/utils";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router, useForm, Link } from "@inertiajs/react";
|
||||
import { router, useForm, Head, Link } from "@inertiajs/react";
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
@@ -107,10 +108,6 @@ interface ProductionOrder {
|
||||
product_id: number;
|
||||
warehouse_id: number | null;
|
||||
output_quantity: number;
|
||||
output_batch_number: string;
|
||||
output_box_count: string | null;
|
||||
production_date: string;
|
||||
expiry_date: string | null;
|
||||
remark: string | null;
|
||||
status: string;
|
||||
items: ProductionOrderItem[];
|
||||
@@ -126,18 +123,9 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function ProductionEdit({ productionOrder, products, warehouses }: Props) {
|
||||
// 日期格式轉換輔助函數
|
||||
const formatDate = (dateValue: string | null | undefined): string => {
|
||||
if (!dateValue) return '';
|
||||
// 處理可能的 ISO 格式或 YYYY-MM-DD 格式
|
||||
const date = new Date(dateValue);
|
||||
if (isNaN(date.getTime())) return dateValue;
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(
|
||||
productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : ""
|
||||
); // 產出倉庫
|
||||
); // 預計入庫倉庫
|
||||
|
||||
// 快取對照表:product_id -> inventories
|
||||
const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
||||
@@ -169,7 +157,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
// UI Initial State (復原)
|
||||
ui_warehouse_id: item.inventory?.warehouse_id ? String(item.inventory.warehouse_id) : "",
|
||||
ui_product_id: item.inventory ? String(item.inventory.product_id) : "",
|
||||
ui_input_quantity: String(item.quantity_used), // 假設已存的資料是基本單位
|
||||
ui_input_quantity: formatQuantity(item.quantity_used),
|
||||
ui_selected_unit: 'base',
|
||||
|
||||
// UI 輔助
|
||||
@@ -183,11 +171,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
const { data, setData, processing, errors } = useForm({
|
||||
product_id: String(productionOrder.product_id),
|
||||
warehouse_id: productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : "",
|
||||
output_quantity: productionOrder.output_quantity ? String(productionOrder.output_quantity) : "",
|
||||
output_batch_number: productionOrder.output_batch_number || "",
|
||||
output_box_count: productionOrder.output_box_count || "",
|
||||
production_date: formatDate(productionOrder.production_date) || new Date().toISOString().split('T')[0],
|
||||
expiry_date: formatDate(productionOrder.expiry_date),
|
||||
output_quantity: productionOrder.output_quantity ? formatQuantity(productionOrder.output_quantity) : "",
|
||||
remark: productionOrder.remark || "",
|
||||
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
|
||||
});
|
||||
@@ -210,7 +194,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
if (inv) {
|
||||
return {
|
||||
...item,
|
||||
ui_warehouse_id: String(inv.warehouse_id), // 重要:還原倉庫 ID
|
||||
ui_warehouse_id: String(inv.warehouse_id),
|
||||
ui_product_name: inv.product_name,
|
||||
ui_batch_number: inv.batch_number,
|
||||
ui_available_qty: inv.quantity,
|
||||
@@ -255,7 +239,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
const updated = [...bomItems];
|
||||
const item = { ...updated[index], [field]: value };
|
||||
|
||||
// 0. 當選擇商品變更時 (第一層)
|
||||
// 0. 當選擇商品變更時
|
||||
if (field === 'ui_product_id') {
|
||||
item.ui_warehouse_id = "";
|
||||
item.inventory_id = "";
|
||||
@@ -263,7 +247,6 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
item.unit_id = "";
|
||||
item.ui_input_quantity = "";
|
||||
item.ui_selected_unit = "base";
|
||||
// 保留基本資訊
|
||||
if (value) {
|
||||
const prod = products.find(p => String(p.id) === value);
|
||||
if (prod) {
|
||||
@@ -274,16 +257,12 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 當選擇來源倉庫變更時 (第二層)
|
||||
// 1. 當選擇來源倉庫變更時
|
||||
if (field === 'ui_warehouse_id') {
|
||||
item.inventory_id = "";
|
||||
item.quantity_used = "";
|
||||
item.unit_id = "";
|
||||
item.ui_input_quantity = "";
|
||||
item.ui_selected_unit = "base";
|
||||
}
|
||||
|
||||
// 2. 當選擇批號 (Inventory) 變更時 (第三層)
|
||||
// 2. 當選擇批號 (Inventory) 變更時
|
||||
if (field === 'inventory_id' && value) {
|
||||
const currentOptions = productInventoryMap[item.ui_product_id] || [];
|
||||
const inv = currentOptions.find(i => String(i.id) === value);
|
||||
@@ -302,6 +281,10 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
|
||||
item.ui_selected_unit = 'base';
|
||||
item.unit_id = String(inv.base_unit_id || '');
|
||||
|
||||
if (!item.ui_input_quantity) {
|
||||
item.ui_input_quantity = String(inv.quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,18 +315,13 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
})));
|
||||
}, [bomItems]);
|
||||
|
||||
// 提交表單(完成模式)
|
||||
// 提交表單(完成模式)
|
||||
// 提交表單(完成模式)
|
||||
// 提交表單
|
||||
const submit = (status: 'draft' | 'completed') => {
|
||||
// 驗證(簡單前端驗證)
|
||||
if (status === 'completed') {
|
||||
const missingFields = [];
|
||||
if (!data.product_id) missingFields.push('成品商品');
|
||||
if (!data.output_quantity) missingFields.push('生產數量');
|
||||
if (!data.output_batch_number) missingFields.push('成品批號');
|
||||
if (!data.production_date) missingFields.push('生產日期');
|
||||
if (!selectedWarehouse) missingFields.push('入庫倉庫');
|
||||
if (!selectedWarehouse) missingFields.push('預計入庫倉庫');
|
||||
if (bomItems.length === 0) missingFields.push('原物料明細');
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
@@ -384,24 +362,22 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
submit('completed');
|
||||
submit('draft');
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
|
||||
<Head title={`編輯生產單 - ${productionOrder.code}`} />
|
||||
<Toaster position="top-right" />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500">
|
||||
<div className="mb-6">
|
||||
<Link href={route('production-orders.index')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
className="gap-2 button-outlined-primary mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回生產單
|
||||
返回列表
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -409,38 +385,27 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
<div>
|
||||
<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>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => submit('draft')}
|
||||
disabled={processing}
|
||||
variant="outline"
|
||||
className="button-outlined-primary"
|
||||
className="gap-2 button-filled-primary"
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
儲存草稿
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => submit('completed')}
|
||||
disabled={processing}
|
||||
className="button-filled-primary"
|
||||
>
|
||||
<Factory className="mr-2 h-4 w-4" />
|
||||
儲存變更
|
||||
<Save className="h-4 w-4" />
|
||||
儲存 (草稿)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 成品資訊 */}
|
||||
<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">
|
||||
@@ -463,64 +428,16 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={data.output_quantity}
|
||||
value={Number(data.output_quantity) === 0 ? '' : formatQuantity(data.output_quantity)}
|
||||
onChange={(e) => setData('output_quantity', e.target.value)}
|
||||
placeholder="例如: 50"
|
||||
className="h-9"
|
||||
className="h-9 font-mono"
|
||||
/>
|
||||
{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>
|
||||
<Input
|
||||
value={data.output_batch_number}
|
||||
onChange={(e) => setData('output_batch_number', e.target.value)}
|
||||
placeholder="例如: AB-TW-20260122-01"
|
||||
className="h-9 font-mono"
|
||||
/>
|
||||
{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>
|
||||
<Label className="text-xs font-medium text-grey-2">預計入庫倉庫 *</Label>
|
||||
<SearchableSelect
|
||||
value={selectedWarehouse}
|
||||
onValueChange={setSelectedWarehouse}
|
||||
@@ -547,7 +464,6 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
</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>
|
||||
@@ -574,23 +490,21 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50/50">
|
||||
<TableHead className="w-[20%]">商品 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[20%]">來源倉庫 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[25%]">批號 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[18%]">商品 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[15%]">來源倉庫 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[30%]">批號 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[15%]">數量 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[15%]">單位</TableHead>
|
||||
<TableHead className="w-[5%]"></TableHead>
|
||||
<TableHead className="w-[12%]">單位</TableHead>
|
||||
<TableHead className="w-[10%]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bomItems.map((item, index) => {
|
||||
// 1. 商品選項
|
||||
const productOptions = products.map(p => ({
|
||||
label: `${p.name} (${p.code})`,
|
||||
value: String(p.id)
|
||||
}));
|
||||
|
||||
// 2. 來源倉庫選項 (根據商品库庫存過濾)
|
||||
const currentInventories = productInventoryMap[item.ui_product_id] || [];
|
||||
const filteredWarehouseOptions = Array.from(new Map(
|
||||
currentInventories.map((inv: InventoryOption) => [inv.warehouse_id, { label: inv.warehouse_name, value: String(inv.warehouse_id) }])
|
||||
@@ -600,26 +514,22 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
? filteredWarehouseOptions
|
||||
: (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []);
|
||||
|
||||
// 備案 (初始載入時)
|
||||
const displayWarehouseOptions = uniqueWarehouseOptions.length > 0
|
||||
? uniqueWarehouseOptions
|
||||
: (item.ui_warehouse_id ? [{ label: "載入中...", value: item.ui_warehouse_id }] : []);
|
||||
|
||||
// 3. 批號選項 (根據商品與倉庫過濾)
|
||||
const batchOptions = currentInventories
|
||||
.filter((inv: InventoryOption) => String(inv.warehouse_id) === item.ui_warehouse_id)
|
||||
.map((inv: InventoryOption) => ({
|
||||
label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`,
|
||||
value: String(inv.id)
|
||||
label: inv.batch_number,
|
||||
value: String(inv.id),
|
||||
sublabel: `(存:${inv.quantity} | 效:${inv.expiry_date || '無'})`
|
||||
}));
|
||||
|
||||
// 備案
|
||||
const displayBatchOptions = batchOptions.length > 0 ? batchOptions : (item.inventory_id && item.ui_batch_number ? [{ label: item.ui_batch_number, value: item.inventory_id }] : []);
|
||||
|
||||
|
||||
return (
|
||||
<TableRow key={index}>
|
||||
{/* 1. 選擇商品 */}
|
||||
<TableCell className="align-top">
|
||||
<SearchableSelect
|
||||
value={item.ui_product_id}
|
||||
@@ -630,7 +540,6 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* 2. 選擇來源倉庫 */}
|
||||
<TableCell className="align-top">
|
||||
<SearchableSelect
|
||||
value={item.ui_warehouse_id}
|
||||
@@ -646,7 +555,6 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* 3. 選擇批號 */}
|
||||
<TableCell className="align-top">
|
||||
<SearchableSelect
|
||||
value={item.inventory_id}
|
||||
@@ -658,21 +566,25 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
/>
|
||||
{item.inventory_id && (() => {
|
||||
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
|
||||
if (selectedInv) return (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
有效日期: {selectedInv.expiry_date || '無'} | 庫存: {selectedInv.quantity}
|
||||
</div>
|
||||
);
|
||||
if (selectedInv) {
|
||||
const isInsufficient = selectedInv.quantity < parseFloat(item.ui_input_quantity || '0');
|
||||
return (
|
||||
<div className={`text-xs mt-1 ${isInsufficient ? 'text-red-500 font-bold animate-pulse' : 'text-gray-500'}`}>
|
||||
有效日期: {selectedInv.expiry_date || '無'} |
|
||||
庫存: {formatQuantity(selectedInv.quantity)}
|
||||
{isInsufficient && ' (庫存不足!)'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</TableCell>
|
||||
|
||||
{/* 3. 輸入數量 */}
|
||||
<TableCell className="align-top">
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={item.ui_input_quantity}
|
||||
value={formatQuantity(item.ui_input_quantity)}
|
||||
onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)}
|
||||
placeholder="0"
|
||||
className="h-9 text-right"
|
||||
@@ -680,20 +592,20 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* 4. 選擇單位 */}
|
||||
<TableCell className="align-top pt-3">
|
||||
<span className="text-sm">{item.ui_base_unit_name}</span>
|
||||
<TableCell className="align-top">
|
||||
<div className="h-9 flex items-center px-1 text-sm text-gray-600 font-medium">
|
||||
{item.ui_base_unit_name || '-'}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
|
||||
|
||||
<TableCell className="align-top">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeBomItem(index)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50 p-2"
|
||||
className="button-outlined-error"
|
||||
title="刪除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Factory, Search, Eye, Pencil, Trash2 } from 'lucide-react';
|
||||
import { formatQuantity } from "@/lib/utils";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router, Link } from "@inertiajs/react";
|
||||
@@ -20,8 +21,9 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||
import ProductionOrderStatusBadge from "@/Components/ProductionOrder/ProductionOrderStatusBadge";
|
||||
import { formatDate } from "@/lib/date";
|
||||
|
||||
interface ProductionOrder {
|
||||
id: number;
|
||||
@@ -32,7 +34,7 @@ interface ProductionOrder {
|
||||
output_batch_number: string;
|
||||
output_quantity: number;
|
||||
production_date: string;
|
||||
status: 'draft' | 'completed' | 'cancelled';
|
||||
status: 'draft' | 'pending' | 'approved' | 'in_progress' | 'completed' | 'cancelled';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -51,11 +53,15 @@ interface Props {
|
||||
};
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||
draft: { label: "草稿", variant: "secondary" },
|
||||
completed: { label: "已完成", variant: "default" },
|
||||
cancelled: { label: "已取消", variant: "destructive" },
|
||||
};
|
||||
const statusOptions = [
|
||||
{ value: 'all', label: '全部狀態' },
|
||||
{ value: 'draft', label: '草稿' },
|
||||
{ value: 'pending', label: '審核中' },
|
||||
{ value: 'approved', label: '已核准' },
|
||||
{ value: 'in_progress', label: '製作中' },
|
||||
{ value: 'completed', label: '已完成' },
|
||||
{ value: 'cancelled', label: '已取消' },
|
||||
];
|
||||
|
||||
export default function ProductionIndex({ productionOrders, filters }: Props) {
|
||||
const [search, setSearch] = useState(filters.search || "");
|
||||
@@ -154,10 +160,11 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
|
||||
<SelectValue placeholder="選擇狀態" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部狀態</SelectItem>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="completed">已完成</SelectItem>
|
||||
<SelectItem value="cancelled">已取消</SelectItem>
|
||||
{statusOptions.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -230,18 +237,16 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{order.output_quantity.toLocaleString()}
|
||||
{formatQuantity(order.output_quantity)}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-600">
|
||||
{order.warehouse?.name || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-600">
|
||||
{order.production_date}
|
||||
{formatDate(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>
|
||||
<ProductionOrderStatusBadge status={order.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
@@ -272,19 +277,21 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
|
||||
</Link>
|
||||
</Can>
|
||||
<Can permission="production_orders.delete">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error"
|
||||
title="刪除"
|
||||
onClick={() => {
|
||||
if (confirm('確定要刪除此生產工單嗎?')) {
|
||||
router.delete(route('production-orders.destroy', order.id));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{(order.status === 'draft' || order.status === 'cancelled') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error"
|
||||
title="刪除"
|
||||
onClick={() => {
|
||||
if (confirm('確定要刪除此生產工單嗎?')) {
|
||||
router.delete(route('production-orders.destroy', order.id));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</Can>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -3,15 +3,28 @@
|
||||
* 含追溯資訊:成品批號 → 原物料批號 → 來源採購單
|
||||
*/
|
||||
|
||||
import { Factory, ArrowLeft, Package, Calendar, User, Warehouse, FileText, Link2 } from 'lucide-react';
|
||||
import { Factory, ArrowLeft, Package, Calendar, User, Warehouse, FileText, Link2, Send, CheckCircle2, PlayCircle, Ban, ArrowRightCircle } from 'lucide-react';
|
||||
import { formatQuantity } from "@/lib/utils";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link } from "@inertiajs/react";
|
||||
import { Head, Link, useForm, router } 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";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import ProductionOrderStatusBadge from '@/Components/ProductionOrder/ProductionOrderStatusBadge';
|
||||
import { ProductionStatusProgressBar } from '@/Components/ProductionOrder/ProductionStatusProgressBar';
|
||||
import { PRODUCTION_ORDER_STATUS, ProductionOrderStatus } from '@/constants/production-order';
|
||||
import WarehouseSelectionModal from '@/Components/ProductionOrder/WarehouseSelectionModal';
|
||||
import { useState } from 'react';
|
||||
import { formatDate } from '@/lib/date';
|
||||
|
||||
interface Warehouse {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ProductionOrderItem {
|
||||
// ... (後面保持不變)
|
||||
id: number;
|
||||
quantity_used: number;
|
||||
unit?: { id: number; name: string } | null;
|
||||
@@ -22,6 +35,7 @@ interface ProductionOrderItem {
|
||||
arrival_date: string | null;
|
||||
origin_country: string | null;
|
||||
product: { id: number; name: string; code: string } | null;
|
||||
warehouse?: { id: number; name: string } | null;
|
||||
source_purchase_order?: {
|
||||
id: number;
|
||||
code: string;
|
||||
@@ -34,14 +48,16 @@ interface ProductionOrder {
|
||||
id: number;
|
||||
code: string;
|
||||
product: { id: number; name: string; code: string; base_unit?: { name: string } | null } | null;
|
||||
product_id: number;
|
||||
warehouse: { id: number; name: string } | null;
|
||||
warehouse_id: number | 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';
|
||||
status: ProductionOrderStatus;
|
||||
remark: string | null;
|
||||
created_at: string;
|
||||
items: ProductionOrderItem[];
|
||||
@@ -49,200 +65,363 @@ interface ProductionOrder {
|
||||
|
||||
interface Props {
|
||||
productionOrder: ProductionOrder;
|
||||
warehouses: Warehouse[];
|
||||
auth: {
|
||||
user: {
|
||||
id: number;
|
||||
name: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
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, warehouses, auth }: Props) {
|
||||
const [isWarehouseModalOpen, setIsWarehouseModalOpen] = useState(false);
|
||||
const { processing } = useForm({
|
||||
status: '' as ProductionOrderStatus,
|
||||
warehouse_id: null as number | null,
|
||||
});
|
||||
|
||||
const handleStatusUpdate = (newStatus: string, extraData?: {
|
||||
warehouseId?: number;
|
||||
batchNumber?: string;
|
||||
expiryDate?: string;
|
||||
}) => {
|
||||
router.patch(route('production-orders.update-status', productionOrder.id), {
|
||||
status: newStatus,
|
||||
warehouse_id: extraData?.warehouseId,
|
||||
output_batch_number: extraData?.batchNumber,
|
||||
expiry_date: extraData?.expiryDate,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsWarehouseModalOpen(false);
|
||||
},
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
const userPermissions = auth.user?.permissions || [];
|
||||
const hasPermission = (permission: string) => auth.user?.roles?.includes('super-admin') || userPermissions.includes(permission);
|
||||
|
||||
// 權限判斷
|
||||
const canApprove = hasPermission('production_orders.approve');
|
||||
const canCancel = hasPermission('production_orders.cancel');
|
||||
const canEdit = hasPermission('production_orders.edit');
|
||||
|
||||
export default function ProductionShow({ productionOrder }: Props) {
|
||||
return (
|
||||
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersShow")}>
|
||||
<Head title={`生產單 ${productionOrder.code}`} />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
|
||||
<WarehouseSelectionModal
|
||||
isOpen={isWarehouseModalOpen}
|
||||
onClose={() => setIsWarehouseModalOpen(false)}
|
||||
onConfirm={(data) => handleStatusUpdate(PRODUCTION_ORDER_STATUS.COMPLETED, data)}
|
||||
warehouses={warehouses}
|
||||
processing={processing}
|
||||
productCode={productionOrder.product?.code}
|
||||
productId={productionOrder.product?.id}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500">
|
||||
{/* Header 區塊 */}
|
||||
<div className="mb-6">
|
||||
{/* 返回按鈕 (統一規範:標題上方,mb-4) */}
|
||||
<Link href={route('production-orders.index')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
className="gap-2 button-outlined-primary mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回生產單
|
||||
返回列表
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<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">
|
||||
生產工單詳情與追溯資訊
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
<ProductionOrderStatusBadge status={productionOrder.status} />
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
建立人員:{productionOrder.user?.name || '-'} | 建立時間:{formatDate(productionOrder.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={statusConfig[productionOrder.status]?.variant || "secondary"} className="text-sm">
|
||||
{statusConfig[productionOrder.status]?.label || productionOrder.status}
|
||||
</Badge>
|
||||
|
||||
{/* 操作按鈕區 (統一規範樣式類別) */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 草稿 -> 提交審核 */}
|
||||
{productionOrder.status === PRODUCTION_ORDER_STATUS.DRAFT && (
|
||||
<>
|
||||
{canEdit && (
|
||||
<Link href={route('production-orders.edit', productionOrder.id)}>
|
||||
<Button variant="outline" className="gap-2 button-outlined-primary">
|
||||
<FileText className="h-4 w-4" />
|
||||
編輯工單
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => handleStatusUpdate(PRODUCTION_ORDER_STATUS.PENDING)}
|
||||
className="gap-2 button-filled-primary"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
送審工單
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 待審核 -> 核准 / 駁回 */}
|
||||
{productionOrder.status === PRODUCTION_ORDER_STATUS.PENDING && canApprove && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleStatusUpdate(PRODUCTION_ORDER_STATUS.DRAFT)}
|
||||
className="gap-2 button-outlined-error"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
退回草稿
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleStatusUpdate(PRODUCTION_ORDER_STATUS.APPROVED)}
|
||||
className="gap-2 button-filled-success"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
核准工單
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 已核准 -> 開始製作 */}
|
||||
{productionOrder.status === PRODUCTION_ORDER_STATUS.APPROVED && (
|
||||
<Button
|
||||
onClick={() => handleStatusUpdate(PRODUCTION_ORDER_STATUS.IN_PROGRESS)}
|
||||
className="gap-2 button-filled-primary"
|
||||
>
|
||||
<PlayCircle className="h-4 w-4" />
|
||||
開始製作 (扣除原料庫存)
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 製作中 -> 完成製作 */}
|
||||
{productionOrder.status === PRODUCTION_ORDER_STATUS.IN_PROGRESS && (
|
||||
<Button
|
||||
onClick={() => setIsWarehouseModalOpen(true)}
|
||||
className="gap-2 button-filled-primary"
|
||||
>
|
||||
<ArrowRightCircle className="h-4 w-4" />
|
||||
製作完成 (成品入庫)
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 可作廢狀態 (非已完成/已作廢/草稿之外) */}
|
||||
{!([PRODUCTION_ORDER_STATUS.COMPLETED, PRODUCTION_ORDER_STATUS.CANCELLED, PRODUCTION_ORDER_STATUS.DRAFT] as ProductionOrderStatus[]).includes(productionOrder.status) && canCancel && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (confirm('確定要作廢此生產工單嗎?此動作無法復原。')) {
|
||||
handleStatusUpdate(PRODUCTION_ORDER_STATUS.CANCELLED);
|
||||
}
|
||||
}}
|
||||
className="gap-2 button-outlined-error"
|
||||
>
|
||||
<Ban className="h-4 w-4" />
|
||||
作廢工單
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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 className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
{/* 狀態進度條 */}
|
||||
<div className="lg:col-span-3">
|
||||
<ProductionStatusProgressBar currentStatus={productionOrder.status} />
|
||||
</div>
|
||||
|
||||
{/* 成品資訊 (統一規範:bg-white rounded-xl border border-gray-200 shadow-sm p-6) */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200 h-full">
|
||||
<h2 className="text-lg font-semibold mb-6 flex items-center gap-2 text-grey-0">
|
||||
<Package className="h-5 w-5 text-primary-main" />
|
||||
成品資訊
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-8">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">成品商品</p>
|
||||
<div>
|
||||
<p className="font-bold text-grey-0 text-lg">
|
||||
{productionOrder.product?.name || '-'}
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm font-mono mt-0.5">
|
||||
{productionOrder.product?.code || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">生產批號</p>
|
||||
<p className="font-mono font-bold text-primary-main text-lg py-1 px-2 bg-primary-lightest rounded-md inline-block">
|
||||
{productionOrder.output_batch_number}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">預計/實際產量</p>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<p className="font-bold text-grey-0 text-xl">
|
||||
{formatQuantity(productionOrder.output_quantity)}
|
||||
</p>
|
||||
{productionOrder.product?.base_unit?.name && (
|
||||
<span className="text-grey-2 font-medium">{productionOrder.product.base_unit.name}</span>
|
||||
)}
|
||||
{productionOrder.output_box_count && (
|
||||
<span className="text-grey-3 ml-2 text-sm">({productionOrder.output_box_count} 箱)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">入庫倉庫</p>
|
||||
<div className="flex items-center gap-2 bg-grey-5 p-2 rounded-lg border border-grey-4">
|
||||
<Warehouse className="h-4 w-4 text-grey-3" />
|
||||
<p className="font-semibold text-grey-0">{productionOrder.warehouse?.name || (productionOrder.status === PRODUCTION_ORDER_STATUS.COMPLETED ? '系統錯誤' : '待選取')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{productionOrder.remark && (
|
||||
<div className="mt-8 pt-6 border-t border-grey-4 transition-all hover:bg-grey-5 p-2 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="h-5 w-5 text-grey-3 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider mb-1">備註資訊</p>
|
||||
<p className="text-grey-1 leading-relaxed">{productionOrder.remark}</p>
|
||||
</div>
|
||||
</div>
|
||||
</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 className="lg:col-span-1">
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200 h-full space-y-8">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-grey-0">
|
||||
<Calendar className="h-5 w-5 text-primary-main" />
|
||||
時間與人員
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-4 p-3 rounded-lg bg-primary-lightest border border-primary-light/20">
|
||||
<Calendar className="h-5 w-5 text-primary-main mt-1" />
|
||||
<div>
|
||||
<p className="text-xs font-bold text-primary-main/60 uppercase">生產日期</p>
|
||||
<p className="font-bold text-grey-0 text-lg">{formatDate(productionOrder.production_date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 p-3 rounded-lg bg-orange-50 border border-orange-100">
|
||||
<Calendar className="h-5 w-5 text-orange-600 mt-1" />
|
||||
<div>
|
||||
<p className="text-xs font-bold text-orange-900/50 uppercase">成品效期</p>
|
||||
<p className="font-bold text-orange-900 text-lg">{formatDate(productionOrder.expiry_date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 p-3 rounded-lg bg-grey-5 border border-grey-4">
|
||||
<User className="h-5 w-5 text-grey-2 mt-1" />
|
||||
<div>
|
||||
<p className="text-xs font-bold text-grey-3 uppercase">執行人員</p>
|
||||
<p className="font-bold text-grey-1 text-lg">{productionOrder.user?.name || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
{/* 原物料使用明細 (BOM) (統一規範:bg-white rounded-xl border border-gray-200 shadow-sm p-6) */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-grey-0">
|
||||
<Link2 className="h-5 w-5 text-primary-main" />
|
||||
原料耗用與追溯
|
||||
</h2>
|
||||
<Badge variant="outline" className="text-grey-3 font-medium">
|
||||
共 {productionOrder.items.length} 項物料
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{productionOrder.items.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-8">無原物料記錄</p>
|
||||
<div className="flex flex-col items-center justify-center py-16 text-grey-3 bg-grey-5 rounded-xl border-2 border-dashed border-grey-4">
|
||||
<Package className="h-10 w-10 mb-4 opacity-20 text-grey-2" />
|
||||
<p>無原物料消耗記錄</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="rounded-xl border border-grey-4 overflow-hidden shadow-sm">
|
||||
<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>
|
||||
<TableHeader className="bg-grey-5/80 backdrop-blur-sm transition-colors">
|
||||
<TableRow className="hover:bg-transparent border-b-grey-4">
|
||||
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none">原物料</TableHead>
|
||||
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none">來源倉庫</TableHead>
|
||||
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none">使用批號</TableHead>
|
||||
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none text-center">來源國家</TableHead>
|
||||
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none">使用數量</TableHead>
|
||||
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none">來源單據</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">
|
||||
<TableRow key={item.id} className="hover:bg-grey-5/80 transition-colors border-b-grey-4 last:border-0">
|
||||
<TableCell className="px-6 py-5">
|
||||
<div className="font-bold text-grey-0">{item.inventory?.product?.name || '-'}</div>
|
||||
<div className="text-grey-3 text-xs font-mono mt-1 px-1.5 py-0.5 bg-grey-5 border border-grey-4 rounded inline-block">
|
||||
{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 className="px-6 py-5">
|
||||
<div className="text-grey-0 font-medium">{item.inventory?.warehouse?.name || '-'}</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-grey-1">
|
||||
{item.inventory?.origin_country || '-'}
|
||||
<TableCell className="px-6 py-5">
|
||||
<div className="font-mono font-bold text-primary-main bg-primary-lightest border border-primary-light/10 px-2 py-1 rounded inline-flex items-center gap-2">
|
||||
{item.inventory?.batch_number || '-'}
|
||||
{item.inventory?.box_number && (
|
||||
<span className="text-primary-main/60 text-[10px] bg-white px-1 rounded shadow-sm">#{item.inventory.box_number}</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-grey-1">
|
||||
{item.inventory?.arrival_date || '-'}
|
||||
<TableCell className="px-6 py-5 text-center">
|
||||
<span className="px-3 py-1 bg-grey-5 border border-grey-4 rounded-full text-xs font-bold text-grey-2">
|
||||
{item.inventory?.origin_country || '-'}
|
||||
</span>
|
||||
</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 className="px-6 py-5">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="font-bold text-grey-0 text-base">{formatQuantity(item.quantity_used)}</span>
|
||||
{item.unit?.name && (
|
||||
<span className="text-grey-3 text-xs font-medium uppercase">{item.unit.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm">
|
||||
<TableCell className="px-6 py-5">
|
||||
{item.inventory?.source_purchase_order ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="group flex flex-col">
|
||||
<Link
|
||||
href={route('purchase-orders.show', item.inventory.source_purchase_order.id)}
|
||||
className="text-primary-main hover:underline font-medium"
|
||||
className="text-primary-main hover:text-primary-dark font-bold inline-flex items-center gap-1 group-hover:underline transition-all"
|
||||
>
|
||||
{item.inventory.source_purchase_order.code}
|
||||
<ArrowLeft className="h-3 w-3 rotate-180 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</Link>
|
||||
{item.inventory.source_purchase_order.vendor && (
|
||||
<span className="text-[11px] text-gray-400 mt-0.5">
|
||||
<span className="text-[10px] text-grey-3 font-bold uppercase tracking-tight mt-0.5 whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px]">
|
||||
{item.inventory.source_purchase_order.vendor.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
<span className="text-grey-4">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
41
resources/js/constants/production-order.ts
Normal file
41
resources/js/constants/production-order.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 生產工單狀態相關常數
|
||||
*/
|
||||
|
||||
export const PRODUCTION_ORDER_STATUS = {
|
||||
DRAFT: 'draft',
|
||||
PENDING: 'pending',
|
||||
APPROVED: 'approved',
|
||||
IN_PROGRESS: 'in_progress',
|
||||
COMPLETED: 'completed',
|
||||
CANCELLED: 'cancelled',
|
||||
} as const;
|
||||
|
||||
export type ProductionOrderStatus = typeof PRODUCTION_ORDER_STATUS[keyof typeof PRODUCTION_ORDER_STATUS];
|
||||
|
||||
export const STATUS_CONFIG: Record<ProductionOrderStatus, { label: string; variant: "default" | "secondary" | "destructive" | "outline" | "success" | "warning" | "info" }> = {
|
||||
[PRODUCTION_ORDER_STATUS.DRAFT]: {
|
||||
label: "草稿",
|
||||
variant: "outline",
|
||||
},
|
||||
[PRODUCTION_ORDER_STATUS.PENDING]: {
|
||||
label: "審核中",
|
||||
variant: "info",
|
||||
},
|
||||
[PRODUCTION_ORDER_STATUS.APPROVED]: {
|
||||
label: "已核准",
|
||||
variant: "success",
|
||||
},
|
||||
[PRODUCTION_ORDER_STATUS.IN_PROGRESS]: {
|
||||
label: "製作中",
|
||||
variant: "warning",
|
||||
},
|
||||
[PRODUCTION_ORDER_STATUS.COMPLETED]: {
|
||||
label: "製作完成",
|
||||
variant: "default",
|
||||
},
|
||||
[PRODUCTION_ORDER_STATUS.CANCELLED]: {
|
||||
label: "已作廢",
|
||||
variant: "destructive",
|
||||
},
|
||||
};
|
||||
41
resources/js/lib/date.ts
Normal file
41
resources/js/lib/date.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { format, parseISO, isValid } from "date-fns";
|
||||
|
||||
/**
|
||||
* 格式化日期字串為統一格式 (YYYY-MM-DD HH:mm:ss)
|
||||
* @param dateStr ISO 格式的日期字串
|
||||
* @param formatStr 輸出的格式字串,預設為 "yyyy-MM-dd HH:mm:ss"
|
||||
* @returns 格式化後的字串,若日期無效則回傳空字串
|
||||
*/
|
||||
export function formatDate(
|
||||
dateStr: string | null | undefined,
|
||||
formatStr?: string
|
||||
): string {
|
||||
if (!dateStr) return "-";
|
||||
|
||||
try {
|
||||
const date = parseISO(dateStr);
|
||||
if (!isValid(date)) return "-";
|
||||
|
||||
// 如果使用者有指定格式,則依指定格式輸出
|
||||
if (formatStr) {
|
||||
return format(date, formatStr);
|
||||
}
|
||||
|
||||
// 智慧判斷:如果時間是 00:00:00 (通常代表後端僅提供日期),則僅顯示日期
|
||||
const hasTime =
|
||||
date.getHours() !== 0 ||
|
||||
date.getMinutes() !== 0 ||
|
||||
date.getSeconds() !== 0;
|
||||
|
||||
return format(date, hasTime ? "yyyy-MM-dd HH:mm:ss" : "yyyy-MM-dd");
|
||||
} catch (e) {
|
||||
return "-";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期字串為僅日期格式 (YYYY-MM-DD)
|
||||
*/
|
||||
export function formatDateOnly(dateStr: string | null | undefined): string {
|
||||
return formatDate(dateStr, "yyyy-MM-dd");
|
||||
}
|
||||
@@ -4,3 +4,10 @@ import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatQuantity(value: number | string): string {
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return '0';
|
||||
// 使用 Number() 會自動去除末尾無意義的 0
|
||||
return String(Number(num.toFixed(4)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user