UI優化: 全系統狀態標籤 (StatusBadge) 統一化重構完成 (Phase 3 & 4)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m8s

This commit is contained in:
2026-02-13 13:16:05 +08:00
56 changed files with 3343 additions and 429 deletions

View File

@@ -1,9 +1,9 @@
import { Badge } from "@/Components/ui/badge";
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
export type GoodsReceiptStatus = 'processing' | 'completed' | 'cancelled';
export const GOODS_RECEIPT_STATUS_CONFIG: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" | "success" | "warning" }> = {
processing: { label: "處理中", variant: "warning" },
export const GOODS_RECEIPT_STATUS_CONFIG: Record<string, { label: string; variant: StatusVariant }> = {
processing: { label: "處理中", variant: "info" },
completed: { label: "已完成", variant: "success" },
cancelled: { label: "已取消", variant: "destructive" },
};
@@ -19,28 +19,9 @@ export default function GoodsReceiptStatusBadge({
}: GoodsReceiptStatusBadgeProps) {
const config = GOODS_RECEIPT_STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
// Apply custom styling based on variant mapping if not using standard badge variants
let badgeClass = "";
switch (config.variant) {
case "success":
badgeClass = "bg-green-100 text-green-800 hover:bg-green-200 border-green-200";
break;
case "warning":
badgeClass = "bg-yellow-100 text-yellow-800 hover:bg-yellow-200 border-yellow-200";
break;
case "destructive":
badgeClass = "bg-red-100 text-red-800 hover:bg-red-200 border-red-200";
break;
default:
badgeClass = "bg-gray-100 text-gray-800 hover:bg-gray-200 border-gray-200";
}
return (
<Badge
variant="outline"
className={`${className} font-medium px-2.5 py-0.5 rounded-full border ${badgeClass}`}
>
<StatusBadge variant={config.variant} className={className}>
{config.label}
</Badge>
</StatusBadge>
);
}

View File

@@ -4,7 +4,7 @@
*/
import { useState } from "react";
import { AlertTriangle, Edit, ChevronDown, ChevronRight, CheckCircle, Package } from "lucide-react";
import { Edit, ChevronDown, ChevronRight, Package } from "lucide-react";
import {
Table,
TableBody,
@@ -14,14 +14,14 @@ import {
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/Components/ui/collapsible";
import { WarehouseInventory, SafetyStockSetting } from "@/types/warehouse";
import { calculateProductTotalStock, getSafetyStockStatus } from "@/utils/inventory";
import { getSafetyStockStatus } from "@/utils/inventory";
import { formatDate } from "@/utils/format";
export type InventoryItemWithId = WarehouseInventory & { inventoryId: string };
@@ -74,31 +74,28 @@ export default function InventoryTable({
// 獲取狀態徽章
const getStatusBadge = (status: string) => {
switch (status) {
case "正常":
return (
<Badge className="bg-green-100 text-green-700 border-green-300">
<CheckCircle className="mr-1 h-3 w-3" />
</Badge>
);
case "接近":
return (
<Badge className="bg-yellow-100 text-yellow-700 border-yellow-300">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
);
case "低於":
return (
<Badge className="bg-red-100 text-red-700 border-red-300">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
);
default:
return null;
if (status === '正常') {
return (
<StatusBadge variant="success">
</StatusBadge>
);
}
if (status === '接近') {
return (
<StatusBadge variant="warning">
</StatusBadge>
);
}
if (status === '低於') {
return (
<StatusBadge variant="destructive">
</StatusBadge>
);
}
return null;
};
return (
@@ -108,12 +105,12 @@ export default function InventoryTable({
(sum, item) => sum + item.quantity,
0
);
// 計算安全庫存狀態
const status = group.safetySetting
? getSafetyStockStatus(totalQuantity, group.safetySetting.safetyStock)
: null;
const isLowStock = status === "低於";
const isExpanded = expandedProducts.has(group.productId);
const hasInventory = group.items.length > 0;
@@ -127,10 +124,9 @@ export default function InventoryTable({
<div className="border rounded-lg overflow-hidden">
{/* 商品標題 - 可點擊折疊 */}
<CollapsibleTrigger asChild>
<div
className={`px-4 py-3 border-b cursor-pointer hover:bg-gray-100 transition-colors ${
isLowStock ? "bg-red-50" : "bg-gray-50"
}`}
<div
className={`px-4 py-3 border-b cursor-pointer hover:bg-gray-100 transition-colors ${isLowStock ? "bg-red-50" : "bg-gray-50"
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -164,9 +160,9 @@ export default function InventoryTable({
</>
)}
{!group.safetySetting && (
<Badge variant="outline" className="text-gray-500">
<StatusBadge variant="neutral">
</Badge>
</StatusBadge>
)}
</div>
</div>

View File

@@ -7,7 +7,7 @@ import {
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { Pencil, Trash2, ArrowUpDown, ArrowUp, ArrowDown, Eye } from "lucide-react";
import {
Tooltip,
@@ -122,15 +122,15 @@ export default function ProductTable({
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="font-medium text-grey-0">{product.name}</span>
{product.brand && <Badge variant="secondary" className="text-[10px] h-4 px-1 bg-gray-100 text-gray-500 border-none">{product.brand}</Badge>}
{product.brand && <StatusBadge variant="neutral" className="text-[10px] h-4 px-1">{product.brand}</StatusBadge>}
</div>
<span className="text-xs text-gray-400 font-mono">: {product.code}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
<StatusBadge variant="neutral">
{product.category?.name || '-'}
</Badge>
</StatusBadge>
</TableCell>
<TableCell>{product.baseUnit?.name || '-'}</TableCell>
<TableCell>
@@ -163,9 +163,9 @@ export default function ProductTable({
</TableCell>
<TableCell className="text-center">
{product.is_active ? (
<Badge className="bg-green-100 text-green-700 hover:bg-green-100 border-none"></Badge>
<StatusBadge variant="success"></StatusBadge>
) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-500 hover:bg-gray-100 border-none"></Badge>
<StatusBadge variant="neutral"></StatusBadge>
)}
</TableCell>
<TableCell className="text-center">

View File

@@ -1,8 +1,4 @@
/**
* 生產工單狀態標籤組件
*/
import { Badge } from "@/Components/ui/badge";
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
import { ProductionOrderStatus, STATUS_CONFIG } from "@/constants/production-order";
interface ProductionOrderStatusBadgeProps {
@@ -16,31 +12,31 @@ export default function ProductionOrderStatusBadge({
}: ProductionOrderStatusBadgeProps) {
const config = STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
const getStatusStyles = (status: string) => {
const getVariant = (status: string): StatusVariant => {
switch (status) {
case 'draft':
return 'bg-gray-100 text-gray-600 border-gray-200';
return 'neutral';
case 'pending':
return 'bg-blue-50 text-blue-600 border-blue-200';
return 'warning';
case 'approved':
return 'bg-primary text-primary-foreground border-transparent';
return 'success';
case 'in_progress':
return 'bg-amber-50 text-amber-600 border-amber-200';
return 'info';
case 'completed':
return 'bg-primary text-primary-foreground border-transparent transition-all shadow-sm';
return 'success';
case 'cancelled':
return 'bg-destructive text-destructive-foreground border-transparent';
return 'destructive';
default:
return 'bg-gray-50 text-gray-500 border-gray-200';
return 'neutral';
}
};
return (
<Badge
variant="outline"
className={`${className} ${getStatusStyles(status)} font-bold px-2.5 py-0.5 rounded-full border shadow-none`}
<StatusBadge
variant={getVariant(status)}
className={className}
>
{config.label}
</Badge>
</StatusBadge>
);
}

View File

@@ -2,7 +2,7 @@
* 採購單狀態標籤組件
*/
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { PurchaseOrderStatus } from "@/types/purchase-order";
import { STATUS_CONFIG } from "@/constants/purchase-order";
@@ -15,14 +15,11 @@ export default function PurchaseOrderStatusBadge({
status,
className,
}: PurchaseOrderStatusBadgeProps) {
const config = STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
const config = STATUS_CONFIG[status] || { label: "未知", variant: "neutral" };
return (
<Badge
variant={config.variant}
className={`${className} font-medium px-2.5 py-0.5 rounded-full`}
>
<StatusBadge variant={config.variant} className={className}>
{config.label}
</Badge>
</StatusBadge>
);
}

View File

@@ -16,7 +16,7 @@ import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { SafetyStockSetting } from "@/types/warehouse";
import { toast } from "sonner";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
interface EditSafetyStockDialogProps {
open: boolean;
@@ -66,7 +66,7 @@ export default function EditSafetyStockDialog({
<Label></Label>
<div className="flex items-center gap-2">
<span className="font-medium">{setting.productName}</span>
<Badge variant="outline">{setting.productType}</Badge>
<StatusBadge variant="neutral">{setting.productType}</StatusBadge>
</div>
</div>

View File

@@ -2,7 +2,7 @@
* 安全庫存列表組件
*/
import { Edit, Trash2, AlertCircle, CheckCircle, AlertTriangle } from "lucide-react";
import { Trash2, Pencil } from "lucide-react";
import {
Table,
TableBody,
@@ -13,7 +13,7 @@ import {
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { SafetyStockSetting, WarehouseInventory, SafetyStockStatus } from "@/types/warehouse";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
interface SafetyStockListProps {
settings: SafetyStockSetting[];
@@ -35,29 +35,28 @@ function getSafetyStockStatus(
// 獲取狀態徽章
function getStatusBadge(status: SafetyStockStatus) {
switch (status) {
case "正常":
return (
<Badge className="bg-green-100 text-green-700 border-green-300">
<CheckCircle className="mr-1 h-3 w-3" />
</Badge>
);
case "接近":
return (
<Badge className="bg-yellow-100 text-yellow-700 border-yellow-300">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
);
case "低於":
return (
<Badge className="bg-red-100 text-red-700 border-red-300">
<AlertCircle className="mr-1 h-3 w-3" />
</Badge>
);
if (status === '正常') {
return (
<StatusBadge variant="success">
</StatusBadge>
);
}
if (status === '接近') {
return (
<StatusBadge variant="warning">
</StatusBadge>
);
}
if (status === '低於') {
return (
<StatusBadge variant="destructive">
</StatusBadge>
);
}
return null; // Should not happen if SafetyStockStatus is exhaustive
}
export default function SafetyStockList({
@@ -108,7 +107,7 @@ export default function SafetyStockList({
<TableCell className="text-grey-2">{index + 1}</TableCell>
<TableCell className="font-medium">{setting.productName}</TableCell>
<TableCell>
<Badge variant="outline">{setting.productType}</Badge>
<StatusBadge variant="neutral">{setting.productType}</StatusBadge>
</TableCell>
<TableCell>
<span className={isLowStock ? "text-red-600 font-medium" : ""}>
@@ -126,7 +125,7 @@ export default function SafetyStockList({
onClick={() => onEdit(setting)}
className="hover:bg-primary/10 hover:text-primary"
>
<Edit className="h-4 w-4 mr-1" />
<Pencil className="h-4 w-4 mr-1" />
</Button>
<Button

View File

@@ -5,7 +5,7 @@
import { useState, useEffect } from "react";
import { AlertTriangle, Trash2, Eye, ChevronDown, ChevronRight, CheckCircle, Package } from "lucide-react";
import { Trash2, Eye, ChevronDown, ChevronRight, Package, AlertTriangle } from "lucide-react";
import {
Table,
TableBody,
@@ -15,7 +15,7 @@ import {
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import {
Collapsible,
CollapsibleContent,
@@ -98,25 +98,22 @@ export default function InventoryTable({
// 獲取狀態徽章
const getStatusBadge = (status: string) => {
switch (status) {
case "正常":
return (
<Badge className="bg-green-100 text-green-700 border-green-300">
<CheckCircle className="mr-1 h-3 w-3" />
</Badge>
);
case "低於":
return (
<Badge className="bg-red-100 text-red-700 border-red-300">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
);
default:
return null;
if (status === '正常') {
return (
<StatusBadge variant="success">
</StatusBadge>
);
}
if (status === '低於') {
return (
<StatusBadge variant="destructive">
</StatusBadge>
);
}
return null;
};
return (
@@ -168,10 +165,9 @@ export default function InventoryTable({
{isVending ? '' : (hasInventory ? `${group.batches.length} 個批號` : '無庫存')}
</span>
{group.batches.some(b => b.expiryDate && new Date(b.expiryDate) < new Date()) && (
<Badge className="bg-red-50 text-red-600 border-red-200">
<AlertTriangle className="mr-1 h-3 w-3" />
<StatusBadge variant="destructive">
</Badge>
</StatusBadge>
)}
</div>
<div className="flex items-center gap-4">
@@ -199,9 +195,9 @@ export default function InventoryTable({
</div>
</>
) : (
<Badge variant="outline" className="text-gray-500">
<StatusBadge variant="neutral">
</Badge>
</StatusBadge>
)}
{onViewProduct && (
<Button

View File

@@ -18,7 +18,7 @@ import { Label } from "@/Components/ui/label";
import { Checkbox } from "@/Components/ui/checkbox";
import { SafetyStockSetting, Product } from "@/types/warehouse";
import { toast } from "sonner";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
interface AddSafetyStockDialogProps {
open: boolean;
@@ -193,7 +193,7 @@ export default function AddSafetyStockDialog({
<div className="flex-1">
<div className="font-medium">{product.name}</div>
</div>
<Badge variant="outline">{product.type}</Badge>
<StatusBadge variant="neutral">{product.type}</StatusBadge>
</div>
);
})}
@@ -223,7 +223,7 @@ export default function AddSafetyStockDialog({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium">{product.name}</span>
<Badge variant="outline">{product.type}</Badge>
<StatusBadge variant="neutral">{product.type}</StatusBadge>
</div>
</div>
<div className="flex items-center gap-2">

View File

@@ -2,7 +2,7 @@
* 安全庫存設定列表
*/
import { Trash2, Pencil, CheckCircle, Package, AlertTriangle } from "lucide-react";
import { Trash2, Pencil, Package } from "lucide-react";
import {
Table,
TableBody,
@@ -12,7 +12,7 @@ import {
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { SafetyStockSetting, WarehouseInventory } from "@/types/warehouse";
import { calculateProductTotalStock, getSafetyStockStatus } from "@/utils/inventory";
import { Can } from "@/Components/Permission/Can";
@@ -57,38 +57,35 @@ export default function SafetyStockList({
// 如果是自動帶入的品項且尚未存檔,顯示「未設定」
if (isNew) {
return (
<Badge variant="outline" className="text-gray-400 border-gray-200 font-normal">
<StatusBadge variant="neutral" className="border-gray-200 font-normal text-gray-400">
</Badge>
</StatusBadge>
);
}
const status = getSafetyStockStatus(quantity, safetyStock);
switch (status) {
case "正常":
return (
<Badge className="bg-green-100 text-green-700 border-green-300 hover:bg-green-100">
<CheckCircle className="mr-1 h-3 w-3" />
</Badge>
);
case "接近": // 數量 <= 安全庫存 * 1.2
return (
<Badge className="bg-yellow-100 text-yellow-700 border-yellow-300 hover:bg-yellow-100">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
);
case "低於": // 數量 < 安全庫存
return (
<Badge className="bg-orange-100 text-orange-700 border-orange-300 hover:bg-orange-100">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
);
default:
return null;
if (status === '正常') {
return (
<StatusBadge variant="success">
</StatusBadge>
);
}
if (status === '接近') { // 數量 <= 安全庫存 * 1.2
return (
<StatusBadge variant="warning">
</StatusBadge>
);
}
if (status === '低於') { // 數量 < 安全庫存
return (
<StatusBadge variant="destructive">
</StatusBadge>
);
}
return null;
};
return (
@@ -118,9 +115,9 @@ export default function SafetyStockList({
{setting.productName}
</TableCell>
<TableCell>
<Badge variant="outline" className="font-normal">
<StatusBadge variant="neutral">
{setting.productType}
</Badge>
</StatusBadge>
</TableCell>
<TableCell className="text-right font-semibold">
{setting.safetyStock} {setting.unit || '個'}

View File

@@ -17,7 +17,7 @@ import {
} from "lucide-react";
import { Warehouse, WarehouseStats } from "@/types/warehouse";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { Card, CardContent } from "@/Components/ui/card";
import {
Dialog,
@@ -101,13 +101,12 @@ export default function WarehouseCard({
</button>
</div>
<div className="flex gap-2 mt-1">
<Badge
variant={warehouse.type === 'quarantine' ? "secondary" : "outline"}
className={`text-xs font-normal ${warehouse.type === 'quarantine' ? 'bg-red-100 text-red-700 border-red-200' : ''}`}
<StatusBadge
variant={warehouse.type === 'quarantine' ? "destructive" : "neutral"}
>
{WAREHOUSE_TYPE_LABELS[warehouse.type || 'standard'] || '標準倉'}
{warehouse.type === 'quarantine' ? ' (不計入可用)' : ' (計入可用)'}
</Badge>
</StatusBadge>
</div>
</div>
</div>

View File

@@ -32,12 +32,20 @@ import { validateWarehouse } from "@/utils/validation";
import { toast } from "sonner";
import { SearchableSelect } from "@/Components/ui/searchable-select";
interface TransitWarehouseOption {
id: string;
name: string;
license_plate?: string;
driver_name?: string;
}
interface WarehouseDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
warehouse: Warehouse | null;
onSave: (warehouse: Omit<Warehouse, "id" | "createdAt" | "updatedAt">) => void;
onDelete?: (warehouseId: string) => void;
transitWarehouses?: TransitWarehouseOption[];
}
const WAREHOUSE_TYPE_OPTIONS: { label: string; value: WarehouseType }[] = [
@@ -55,6 +63,7 @@ export default function WarehouseDialog({
warehouse,
onSave,
onDelete,
transitWarehouses = [],
}: WarehouseDialogProps) {
const [formData, setFormData] = useState<{
code: string;
@@ -64,6 +73,7 @@ export default function WarehouseDialog({
type: WarehouseType;
license_plate: string;
driver_name: string;
default_transit_warehouse_id: string | null;
}>({
code: "",
name: "",
@@ -72,6 +82,7 @@ export default function WarehouseDialog({
type: "standard",
license_plate: "",
driver_name: "",
default_transit_warehouse_id: null,
});
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -86,6 +97,7 @@ export default function WarehouseDialog({
type: warehouse.type || "standard",
license_plate: warehouse.license_plate || "",
driver_name: warehouse.driver_name || "",
default_transit_warehouse_id: warehouse.default_transit_warehouse_id ? String(warehouse.default_transit_warehouse_id) : null,
});
} else {
setFormData({
@@ -96,6 +108,7 @@ export default function WarehouseDialog({
type: "standard",
license_plate: "",
driver_name: "",
default_transit_warehouse_id: null,
});
}
}, [warehouse, open]);
@@ -216,6 +229,32 @@ export default function WarehouseDialog({
</div>
)}
{/* 預設在途倉設定(僅非 transit 類型顯示) */}
{formData.type !== 'transit' && transitWarehouses.length > 0 && (
<div className="space-y-4 bg-blue-50 p-4 rounded-lg border border-blue-100">
<div className="border-b border-blue-200 pb-2">
<h4 className="text-sm text-blue-800 font-medium">調</h4>
</div>
<div className="space-y-2">
<Label></Label>
<p className="text-xs text-gray-500">調</p>
<SearchableSelect
value={formData.default_transit_warehouse_id || ""}
onValueChange={(val) => setFormData({ ...formData, default_transit_warehouse_id: val || null })}
options={[
{ label: "不指定", value: "" },
...transitWarehouses.map((tw) => ({
label: `${tw.name}${tw.license_plate ? ` (${tw.license_plate})` : ''}`,
value: tw.id,
})),
]}
placeholder="選擇預設在途倉"
className="h-9 bg-white"
/>
</div>
</div>
)}
{/* 區塊 B位置 */}

View File

@@ -0,0 +1,34 @@
import { Badge } from "@/Components/ui/badge";
import { cn } from "@/lib/utils";
export type StatusVariant =
| "neutral"
| "info"
| "warning"
| "success"
| "destructive";
interface StatusBadgeProps {
variant: StatusVariant;
children: React.ReactNode;
className?: string;
}
const variantStyles: Record<StatusVariant, string> = {
neutral: "bg-gray-100 text-gray-800 border-gray-200 hover:bg-gray-100", // Draft, Cancelled(sometimes), Closed
info: "bg-blue-100 text-blue-800 border-blue-200 hover:bg-blue-100", // Processing, Active
warning: "bg-amber-100 text-amber-800 border-amber-200 hover:bg-amber-100", // Pending, Review
success: "bg-green-100 text-green-800 border-green-200 hover:bg-green-100", // Completed, Approved
destructive: "bg-red-100 text-red-800 border-red-200 hover:bg-red-100", // Voided, Rejected, High Risk
};
export function StatusBadge({ variant, children, className }: StatusBadgeProps) {
return (
<Badge
variant="outline"
className={cn(variantStyles[variant], "font-medium border", className)}
>
{children}
</Badge>
);
}