feat: 修正 BOM 單位顯示與完工入庫彈窗 UI 統一規範

This commit is contained in:
2026-02-12 16:30:34 +08:00
parent eb5ab58093
commit 5be4d49679
20 changed files with 1186 additions and 549 deletions

View File

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

View File

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

View File

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

View File

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