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

View File

@@ -25,7 +25,8 @@ import {
ClipboardCheck,
ArrowLeftRight,
TrendingUp,
FileUp
FileUp,
Store
} from "lucide-react";
import { toast, Toaster } from "sonner";
import { useState, useEffect, useMemo, useRef } from "react";
@@ -131,6 +132,13 @@ export default function AuthenticatedLayout({
route: "/inventory/transfer-orders",
permission: "inventory_transfer.view",
},
{
id: "store-requisition",
label: "門市叫貨",
icon: <Store className="h-4 w-4" />,
route: "/store-requisitions",
permission: "store_requisitions.view",
},
],
},
{

View File

@@ -16,7 +16,7 @@ import {
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
import { Button } from "@/Components/ui/button";
interface AbnormalItem {
@@ -103,6 +103,21 @@ export default function Dashboard({ stats, abnormalItems }: Props) {
},
];
const getStatusVariant = (status: string): StatusVariant => {
switch (status) {
case 'negative': return 'destructive';
case 'low_stock': return 'warning';
case 'expiring': return 'warning';
case 'expired': return 'destructive';
default: return 'neutral';
}
};
const getStatusLabel = (status: string): string => {
const config = statusConfig[status];
return config ? config.label : status;
};
return (
<AuthenticatedLayout
breadcrumbs={[
@@ -227,25 +242,14 @@ export default function Dashboard({ stats, abnormalItems }: Props) {
<TableCell className="text-center">
<div className="flex flex-wrap items-center justify-center gap-1">
{item.statuses.map(
(status) => {
const config =
statusConfig[
status
];
if (!config)
return null;
return (
<Badge
key={status}
variant="outline"
className={
config.className
}
>
{config.label}
</Badge>
);
}
(status) => (
<StatusBadge
key={status}
variant={getStatusVariant(status)}
>
{getStatusLabel(status)}
</StatusBadge>
)
)}
</div>
</TableCell>

View File

@@ -1,6 +1,8 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm, router, Link } from '@inertiajs/react';
import { usePermission } from '@/hooks/usePermission';
import { StatusBadge } from "@/Components/shared/StatusBadge";
import {
Table,
TableBody,
@@ -11,7 +13,6 @@ import {
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Badge } from "@/Components/ui/badge";
import {
Dialog,
DialogContent,
@@ -167,13 +168,13 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
const getStatusBadge = (status: string) => {
switch (status) {
case 'draft':
return <Badge variant="secondary" className="bg-gray-100 text-gray-600 border-none">稿</Badge>;
return <StatusBadge variant="neutral">稿</StatusBadge>;
case 'posted':
return <Badge className="bg-green-100 text-green-700 border-none"></Badge>;
return <StatusBadge variant="success"></StatusBadge>;
case 'voided':
return <Badge variant="destructive" className="bg-red-100 text-red-700 border-none"></Badge>;
return <StatusBadge variant="destructive"></StatusBadge>;
default:
return <Badge variant="outline">{status}</Badge>;
return <StatusBadge variant="neutral">{status}</StatusBadge>;
}
};
@@ -257,10 +258,10 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
<TableHead className="w-[180px] font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600">調</TableHead>
<TableHead className="font-medium text-grey-600 text-center"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600 text-center"></TableHead>
<TableHead className="text-center font-medium text-grey-600"></TableHead>
</TableRow>
</TableHeader>
@@ -286,10 +287,10 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
</TableCell>
<TableCell>{doc.warehouse_name}</TableCell>
<TableCell className="text-gray-500 max-w-[200px] truncate">{doc.reason}</TableCell>
<TableCell className="text-center">{getStatusBadge(doc.status)}</TableCell>
<TableCell className="text-sm">{doc.created_by}</TableCell>
<TableCell className="text-gray-500 text-sm">{doc.created_at}</TableCell>
<TableCell className="text-gray-500 text-sm">{doc.posted_at || '-'}</TableCell>
<TableCell className="text-center">{getStatusBadge(doc.status)}</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2" onClick={(e) => e.stopPropagation()}>
{(() => {

View File

@@ -11,7 +11,7 @@ import {
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { Checkbox } from "@/Components/ui/checkbox";
import {
AlertDialog,
@@ -243,9 +243,9 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
調: {doc.doc_no}
</h1>
{isDraft ? (
<Badge variant="secondary" className="bg-blue-500 text-white border-none py-1 px-3">稿</Badge>
<StatusBadge variant="neutral" className="border-none py-1 px-3">稿</StatusBadge>
) : (
<Badge className="bg-green-500 text-white border-none py-1 px-3"></Badge>
<StatusBadge variant="success" className="border-none py-1 px-3"></StatusBadge>
)}
</div>
<p className="text-sm text-gray-500 mt-1 font-medium flex items-center gap-2">
@@ -604,6 +604,6 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
</div>
</div>
</AuthenticatedLayout>
</AuthenticatedLayout >
);
}

View File

@@ -1,7 +1,9 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, useForm, router } from '@inertiajs/react';
import { Head, Link, router, useForm } from '@inertiajs/react';
import { useState, useCallback, useEffect } from 'react';
import { usePermission } from '@/hooks/usePermission';
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { debounce } from "lodash";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import {
@@ -14,7 +16,6 @@ import {
} from '@/Components/ui/table';
import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input';
import { Badge } from '@/Components/ui/badge';
import {
Dialog,
DialogContent,
@@ -138,19 +139,19 @@ export default function Index({ docs, warehouses, filters }: any) {
const getStatusBadge = (status: string) => {
switch (status) {
case 'draft':
return <Badge variant="secondary">稿</Badge>;
return <StatusBadge variant="neutral">稿</StatusBadge>;
case 'counting':
return <Badge className="bg-blue-500 hover:bg-blue-600"></Badge>;
return <StatusBadge variant="info"></StatusBadge>;
case 'completed':
return <Badge className="bg-green-500 hover:bg-green-600"></Badge>;
return <StatusBadge variant="success"></StatusBadge>;
case 'no_adjust':
return <Badge className="bg-green-600 hover:bg-green-700"> (調)</Badge>;
return <StatusBadge variant="success"> (調)</StatusBadge>;
case 'adjusted':
return <Badge className="bg-purple-500 hover:bg-purple-600">調</Badge>;
return <StatusBadge variant="info">調</StatusBadge>; // Decided on info/blue for adjusted to match "active/done" but distinctive from pure success if needed, or stick to success? Plan said Info/Blue.
case 'cancelled':
return <Badge variant="destructive"></Badge>;
return <StatusBadge variant="destructive"></StatusBadge>;
default:
return <Badge variant="outline">{status}</Badge>;
return <StatusBadge variant="neutral">{status}</StatusBadge>;
}
};
@@ -273,11 +274,11 @@ export default function Index({ docs, warehouses, filters }: any) {
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
@@ -296,7 +297,6 @@ export default function Index({ docs, warehouses, filters }: any) {
</TableCell>
<TableCell className="font-medium text-primary-main">{doc.doc_no}</TableCell>
<TableCell>{doc.warehouse_name}</TableCell>
<TableCell>{getStatusBadge(doc.status)}</TableCell>
<TableCell className="text-gray-500 text-sm">{doc.snapshot_date}</TableCell>
<TableCell>
<span className="font-medium text-gray-700">{doc.counted_items}</span>
@@ -305,6 +305,7 @@ export default function Index({ docs, warehouses, filters }: any) {
</TableCell>
<TableCell className="text-gray-500 text-sm">{doc.completed_at || '-'}</TableCell>
<TableCell className="text-sm">{doc.created_by}</TableCell>
<TableCell>{getStatusBadge(doc.status)}</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
{/* Action Button Logic: Prefer Edit if allowed and status is active, otherwise fallback to View if allowed */}

View File

@@ -11,7 +11,7 @@ import {
} from '@/Components/ui/table';
import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input';
import { Badge } from '@/Components/ui/badge';
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { Save, Printer, Trash2, ClipboardCheck, ArrowLeft, RotateCcw } from 'lucide-react'; // Added ArrowLeft
import {
AlertDialog,
@@ -121,16 +121,16 @@ export default function Show({ doc }: any) {
: {doc.doc_no}
</h1>
{doc.status === 'completed' && (
<Badge className="bg-green-500 hover:bg-green-600"></Badge>
<StatusBadge variant="success"></StatusBadge>
)}
{doc.status === 'no_adjust' && (
<Badge className="bg-green-600 hover:bg-green-700"> (調)</Badge>
<StatusBadge variant="success"> (調)</StatusBadge>
)}
{doc.status === 'adjusted' && (
<Badge className="bg-purple-500 hover:bg-purple-600">調</Badge>
<StatusBadge variant="warning">調</StatusBadge>
)}
{doc.status === 'draft' && (
<Badge className="bg-blue-500 hover:bg-blue-600"></Badge>
<StatusBadge variant="info"></StatusBadge>
)}
</div>
<p className="text-sm text-gray-500 mt-1 font-medium">

View File

@@ -21,7 +21,7 @@ import {
TableRow,
} from '@/Components/ui/table';
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import {
@@ -395,9 +395,9 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
<TableCell className="font-medium text-primary-main">{po.code}</TableCell>
<TableCell>{po.vendor_name}</TableCell>
<TableCell className="text-center">
<Badge variant={STATUS_CONFIG[po.status]?.variant || 'outline'}>
<StatusBadge variant={STATUS_CONFIG[po.status]?.variant || 'neutral'}>
{STATUS_CONFIG[po.status]?.label || po.status}
</Badge>
</StatusBadge>
</TableCell>
<TableCell className="text-center text-gray-600">
{po.items.length}

View File

@@ -10,7 +10,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 { ArrowLeft, FileText, Package } from "lucide-react";
import Pagination from "@/Components/shared/Pagination";
import { formatDate } from "@/utils/format";
@@ -69,17 +69,18 @@ interface ShowProps extends PageProps {
export default function InventoryReportShow({ product, transactions, filters, reportFilters, warehouses }: ShowProps) {
// 類型 Badge 顏色映射
const getTypeBadgeVariant = (type: string) => {
// 類型 Badge 顏色映射
const getTypeBadgeVariant = (type: string): "success" | "destructive" | "neutral" => {
switch (type) {
case '入庫':
case '手動入庫':
case '調撥入庫':
return "default";
return "success";
case '出庫':
case '調撥出庫':
return "destructive";
default:
return "secondary";
return "neutral";
}
};
@@ -128,9 +129,9 @@ export default function InventoryReportShow({ product, transactions, filters, re
<div className="space-y-3">
<div className="flex items-center gap-3">
<h3 className="text-xl font-bold text-grey-0">{product.name}</h3>
<Badge variant="outline" className="text-sm px-2 py-0.5 bg-gray-50">
<StatusBadge variant="neutral" className="text-sm px-2 py-0.5">
{product.code}
</Badge>
</StatusBadge>
</div>
<div className="flex items-center gap-6 text-sm text-gray-500">
<span className="flex items-center gap-1.5">
@@ -212,9 +213,9 @@ export default function InventoryReportShow({ product, transactions, filters, re
{formatDate(tx.actual_time)}
</TableCell>
<TableCell>
<Badge variant={getTypeBadgeVariant(tx.type)}>
<StatusBadge variant={getTypeBadgeVariant(tx.type)}>
{tx.type}
</Badge>
</StatusBadge>
</TableCell>
<TableCell>{tx.warehouse_name}</TableCell>
<TableCell className={`text-right font-medium ${tx.quantity > 0 ? 'text-emerald-600' :

View File

@@ -20,7 +20,7 @@ import {
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { SearchableSelect } from "@/Components/ui/searchable-select";
@@ -77,31 +77,31 @@ interface Props {
categories: { id: number; name: string }[];
}
// 狀態 Badge
const statusConfig: Record<
string,
{ label: string; className: string }
> = {
normal: {
label: "正常",
className: "bg-green-100 text-green-800 border-green-200",
},
negative: {
label: "負庫存",
className: "bg-red-100 text-red-800 border-red-200",
},
low_stock: {
label: "低庫存",
className: "bg-amber-100 text-amber-800 border-amber-200",
},
expiring: {
label: "即將過期",
className: "bg-yellow-100 text-yellow-800 border-yellow-200",
},
expired: {
label: "已過期",
className: "bg-red-100 text-red-800 border-red-200",
},
// 狀態與樣式映射
const getStatusVariant = (status: string): StatusVariant => {
switch (status) {
case 'negative':
case 'expired':
return 'destructive';
case 'low_stock':
case 'expiring':
return 'warning';
case 'normal':
return 'success';
default:
return 'neutral';
}
};
const getStatusLabel = (status: string): string => {
switch (status) {
case 'normal': return "正常";
case 'negative': return "負庫存";
case 'low_stock': return "低庫存";
case 'expiring': return "即將過期";
case 'expired': return "已過期";
default: return status;
}
};
// 狀態篩選選項
@@ -512,25 +512,14 @@ export default function StockQueryIndex({
<TableCell className="text-center">
<div className="flex flex-wrap items-center justify-center gap-1">
{item.statuses.map(
(status) => {
const config =
statusConfig[
status
];
if (!config)
return null;
return (
<Badge
key={status}
variant="outline"
className={
config.className
}
>
{config.label}
</Badge>
);
}
(status) => (
<StatusBadge
key={status}
variant={getStatusVariant(status)}
>
{getStatusLabel(status)}
</StatusBadge>
)
)}
</div>
</TableCell>

View File

@@ -4,6 +4,7 @@ import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react";
import { debounce } from "lodash";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import {
Table,
TableBody,
@@ -14,7 +15,6 @@ import {
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Badge } from "@/Components/ui/badge";
import {
Dialog,
DialogContent,
@@ -160,13 +160,15 @@ export default function Index({ warehouses, orders, filters }: any) {
const getStatusBadge = (status: string) => {
switch (status) {
case 'draft':
return <Badge variant="secondary">稿</Badge>;
return <StatusBadge variant="neutral">稿</StatusBadge>;
case 'dispatched':
return <StatusBadge variant="info"></StatusBadge>;
case 'completed':
return <Badge className="bg-green-500 hover:bg-green-600"></Badge>;
return <StatusBadge variant="success"></StatusBadge>;
case 'voided':
return <Badge variant="destructive"></Badge>;
return <StatusBadge variant="destructive"></StatusBadge>;
default:
return <Badge variant="outline">{status}</Badge>;
return <StatusBadge variant="neutral">{status}</StatusBadge>;
}
};
@@ -287,12 +289,12 @@ export default function Index({ warehouses, orders, filters }: any) {
<TableRow>
<TableHead className="w-[50px] text-center font-medium text-gray-600">#</TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="text-center font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="text-center font-medium text-gray-600"></TableHead>
<TableHead className="text-center font-medium text-gray-600"></TableHead>
</TableRow>
</TableHeader>
@@ -314,12 +316,12 @@ export default function Index({ warehouses, orders, filters }: any) {
{(orders.current_page - 1) * orders.per_page + index + 1}
</TableCell>
<TableCell className="font-medium text-primary-main">{order.doc_no}</TableCell>
<TableCell className="text-center">{getStatusBadge(order.status)}</TableCell>
<TableCell className="text-gray-700">{order.from_warehouse_name}</TableCell>
<TableCell className="text-gray-700">{order.to_warehouse_name}</TableCell>
<TableCell className="text-gray-500 text-sm">{order.created_at}</TableCell>
<TableCell className="text-gray-500 text-sm">{order.posted_at || '-'}</TableCell>
<TableCell className="text-sm">{order.created_by}</TableCell>
<TableCell className="text-center">{getStatusBadge(order.status)}</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2" onClick={(e) => e.stopPropagation()}>
{(() => {

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, Link } from "@inertiajs/react";
import { Head, router, Link, usePage } from "@inertiajs/react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
@@ -12,7 +12,7 @@ import {
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { Checkbox } from "@/Components/ui/checkbox";
import {
Dialog,
@@ -32,27 +32,86 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog";
import { Plus, Save, Trash2, ArrowLeft, CheckCircle, Package, ArrowLeftRight, Printer, Search } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Plus, Save, Trash2, ArrowLeft, CheckCircle, Package, ArrowLeftRight, Printer, Search, Truck, PackageCheck } from "lucide-react";
import { toast } from "sonner";
import axios from "axios";
import { Can } from '@/Components/Permission/Can';
import { usePermission } from '@/hooks/usePermission';
import TransferImportDialog from '@/Components/Transfer/TransferImportDialog';
export default function Show({ order }: any) {
interface TransitWarehouse {
id: string;
name: string;
license_plate: string | null;
driver_name: string | null;
}
export default function Show({ order, transitWarehouses = [] }: { order: any; transitWarehouses?: TransitWarehouse[] }) {
const { can } = usePermission();
const { url } = usePage();
// 解析 URL query 參數,判斷使用者從哪裡來
const backNav = useMemo(() => {
const params = new URLSearchParams(url.split('?')[1] || '');
const from = params.get('from');
if (from === 'requisition') {
const fromId = params.get('from_id');
const fromDoc = params.get('from_doc') || '';
return {
href: route('store-requisitions.show', [fromId!]),
label: `返回叫貨單: ${decodeURIComponent(fromDoc)}`,
breadcrumbs: [
{ label: '商品與庫存管理', href: '#' },
{ label: '門市叫貨申請', href: route('store-requisitions.index') },
{ label: `叫貨單: ${decodeURIComponent(fromDoc)}`, href: route('store-requisitions.show', [fromId!]) },
{ label: `調撥單: ${order.doc_no}`, href: route('inventory.transfer.show', [order.id]), isPage: true },
],
};
}
return {
href: route('inventory.transfer.index'),
label: '返回調撥單列表',
breadcrumbs: [
{ label: '商品與庫存管理', href: '#' },
{ label: '庫存調撥', href: route('inventory.transfer.index') },
{ label: `調撥單: ${order.doc_no}`, href: route('inventory.transfer.show', [order.id]), isPage: true },
],
};
}, [url, order]);
const [items, setItems] = useState(order.items || []);
const [remarks, setRemarks] = useState(order.remarks || "");
// 狀態初始化
const [transitWarehouseId, setTransitWarehouseId] = useState<string | null>(order.transit_warehouse_id || null);
const [isSaving, setIsSaving] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false);
const [isReceiveDialogOpen, setIsReceiveDialogOpen] = useState(false);
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
// 判斷是否有在途倉流程 (包含前端暫選的)
const hasTransit = !!transitWarehouseId;
// 取得選中的在途倉資訊
const selectedTransitWarehouse = transitWarehouses.find(w => w.id === transitWarehouseId);
// 當 order prop 變動時 (例如匯入後 router.reload),同步更新內部狀態
useEffect(() => {
if (order) {
setItems(order.items || []);
setRemarks(order.remarks || "");
setTransitWarehouseId(order.transit_warehouse_id || null);
}
}, [order]);
@@ -74,7 +133,6 @@ export default function Show({ order }: any) {
const loadInventory = async () => {
setLoadingInventory(true);
try {
// Fetch inventory from SOURCE warehouse
const response = await axios.get(route('api.warehouses.inventories', order.from_warehouse_id));
setAvailableInventory(response.data);
} catch (error) {
@@ -122,8 +180,8 @@ export default function Show({ order }: any) {
batch_number: inv.batch_number,
expiry_date: inv.expiry_date,
unit: inv.unit_name,
quantity: 1, // Default 1
max_quantity: inv.quantity, // Max available
quantity: 1,
max_quantity: inv.quantity,
notes: "",
});
addedCount++;
@@ -155,6 +213,7 @@ export default function Show({ order }: any) {
await router.put(route('inventory.transfer.update', [order.id]), {
items: items,
remarks: remarks,
transit_warehouse_id: transitWarehouseId || '',
}, {
onSuccess: () => { },
onError: () => toast.error("儲存失敗,請檢查輸入"),
@@ -164,21 +223,42 @@ export default function Show({ order }: any) {
}
};
// 確認出貨 / 確認過帳(無在途倉)
// 確認出貨 / 確認過帳(無在途倉)
const handlePost = () => {
router.put(route('inventory.transfer.update', [order.id]), {
action: 'post'
action: 'post',
transit_warehouse_id: transitWarehouseId || '',
items: items,
remarks: remarks,
}, {
onSuccess: () => {
setIsPostDialogOpen(false);
},
onError: (errors) => {
const message = Object.values(errors).join('\n') || "過帳失敗,請檢查輸入或庫存狀態";
const message = Object.values(errors).join('\n') || "操作失敗,請檢查輸入或庫存狀態";
toast.error(message);
setIsPostDialogOpen(false);
}
});
};
// 確認收貨
const handleReceive = () => {
router.put(route('inventory.transfer.update', [order.id]), {
action: 'receive'
}, {
onSuccess: () => {
setIsReceiveDialogOpen(false);
},
onError: (errors) => {
const message = Object.values(errors).join('\n') || "收貨失敗";
toast.error(message);
setIsReceiveDialogOpen(false);
}
});
};
const handleDelete = () => {
router.delete(route('inventory.transfer.destroy', [order.id]), {
onSuccess: () => {
@@ -188,28 +268,44 @@ export default function Show({ order }: any) {
};
const canEdit = can('inventory_transfer.edit');
const isReadOnly = order.status !== 'draft' || !canEdit;
const isReadOnly = (order.status !== 'draft' || !canEdit);
const isVending = order.to_warehouse_type === 'vending';
// 狀態 Badge 渲染
const renderStatusBadge = () => {
const statusConfig: Record<string, { variant: "success" | "warning" | "neutral" | "destructive" | "info", label: string }> = {
completed: { variant: 'success', label: '已完成' },
dispatched: { variant: 'warning', label: '配送中' },
draft: { variant: 'neutral', label: '草稿' },
voided: { variant: 'destructive', label: '已作廢' },
};
const config = statusConfig[order.status] || { variant: 'neutral', label: order.status };
return <StatusBadge variant={config.variant}>{config.label}</StatusBadge>;
};
// 過帳時庫存欄標題
const stockColumnTitle = () => {
if (order.status === 'completed' || order.status === 'dispatched') return '出貨時庫存';
return '可用庫存';
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '商品與庫存管理', href: '#' },
{ label: '庫存調撥', href: route('inventory.transfer.index') },
{ label: `調撥單: ${order.doc_no}`, href: route('inventory.transfer.show', [order.id]), isPage: true },
]}
breadcrumbs={backNav.breadcrumbs as any}
>
<Head title={`調撥單 ${order.doc_no}`} />
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500 space-y-6">
<div>
<Link href={route('inventory.transfer.index')}>
<Link href={backNav.href}>
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-6"
>
<ArrowLeft className="h-4 w-4" />
調
{backNav.label}
</Button>
</Link>
@@ -220,9 +316,7 @@ export default function Show({ order }: any) {
<ArrowLeftRight className="h-6 w-6 text-primary-main" />
調: {order.doc_no}
</h1>
{order.status === 'completed' && <Badge className="bg-green-500 hover:bg-green-600"></Badge>}
{order.status === 'draft' && <Badge className="bg-blue-500 hover:bg-blue-600">稿</Badge>}
{order.status === 'voided' && <Badge variant="destructive"></Badge>}
{renderStatusBadge()}
</div>
<p className="text-sm text-gray-500 mt-1 font-medium">
: {order.from_warehouse_name} <ArrowLeftRight className="inline-block h-3 w-3 mx-1" /> : {order.to_warehouse_name} <span className="mx-2">|</span> : {order.created_by}
@@ -240,6 +334,7 @@ export default function Show({ order }: any) {
</Button>
{/* 草稿狀態:儲存 + 出貨/過帳 + 刪除 */}
{!isReadOnly && (
<div className="flex items-center gap-2">
<Can permission="inventory_transfer.delete">
@@ -284,30 +379,168 @@ export default function Show({ order }: any) {
className="button-filled-primary"
disabled={items.length === 0 || isSaving}
>
<CheckCircle className="w-4 h-4 mr-2" />
{hasTransit ? (
<><Truck className="w-4 h-4 mr-2" /></>
) : (
<><CheckCircle className="w-4 h-4 mr-2" /></>
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogTitle>
{hasTransit ? '確定要出貨嗎?' : '確定要過帳嗎?'}
</AlertDialogTitle>
<AlertDialogDescription>
{order.from_warehouse_name}{order.to_warehouse_name}
{hasTransit ? (
<>{order.from_warehouse_name}{selectedTransitWarehouse?.name || order.transit_warehouse_name}調</>
) : (
<>{order.from_warehouse_name}{order.to_warehouse_name}</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handlePost} className="button-filled-primary"></AlertDialogAction>
<AlertDialogAction onClick={handlePost} className="button-filled-primary">
{hasTransit ? '確認出貨' : '確認過帳'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
</div>
)}
{/* 已出貨狀態:確認收貨按鈕 */}
{order.status === 'dispatched' && (
<Can permission="inventory_transfer.edit">
<AlertDialog open={isReceiveDialogOpen} onOpenChange={setIsReceiveDialogOpen}>
<AlertDialogTrigger asChild>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700 text-white"
>
<PackageCheck className="w-4 h-4 mr-2" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{order.transit_warehouse_name}{order.to_warehouse_name}調
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleReceive} className="bg-green-600 hover:bg-green-700 text-white"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
)}
</div>
</div>
</div>
{/* 在途倉資訊卡片 */}
{(hasTransit || transitWarehouses.length > 0) && (
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
<div className="flex items-center gap-2">
<Truck className="h-5 w-5 text-orange-500" />
<Label className="text-gray-700 font-semibold text-base"></Label>
</div>
{order.status === 'draft' && canEdit ? (
/* 草稿狀態:可選擇在途倉 */
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label className="text-xs text-gray-500 mb-1 block"></Label>
<Select
value={transitWarehouseId || ''}
onValueChange={(v) => setTransitWarehouseId(v === 'none' ? null : v)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="不使用在途倉(直接過帳)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">使</SelectItem>
{transitWarehouses.map((w) => (
<SelectItem key={w.id} value={w.id}>
{w.name} {w.license_plate ? `(${w.license_plate})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedTransitWarehouse && (
<>
<div>
<Label className="text-xs text-gray-500 mb-1 block"></Label>
<div className="text-sm font-medium text-gray-700 p-2 bg-gray-50 rounded border">
{selectedTransitWarehouse.license_plate || '-'}
</div>
</div>
<div>
<Label className="text-xs text-gray-500 mb-1 block"></Label>
<div className="text-sm font-medium text-gray-700 p-2 bg-gray-50 rounded border">
{selectedTransitWarehouse.driver_name || '-'}
</div>
</div>
</>
)}
</div>
) : hasTransit ? (
/* 非草稿狀態:唯讀顯示在途倉資訊 */
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<Label className="text-xs text-gray-500 mb-1 block"></Label>
<div className="text-sm font-semibold text-gray-700">{order.transit_warehouse_name}</div>
</div>
<div>
<Label className="text-xs text-gray-500 mb-1 block"></Label>
<div className="text-sm font-medium text-gray-700">{order.transit_warehouse_plate || '-'}</div>
</div>
<div>
<Label className="text-xs text-gray-500 mb-1 block"></Label>
<div className="text-sm font-medium text-gray-700">{order.transit_warehouse_driver || '-'}</div>
</div>
<div>
<Label className="text-xs text-gray-500 mb-1 block"></Label>
<div className="text-sm font-medium">
{order.status === 'dispatched' && (
<span className="text-orange-600">{order.dispatched_at}</span>
)}
{order.status === 'completed' && (
<span className="text-green-600">{order.received_at}</span>
)}
</div>
</div>
</div>
) : null}
{/* 顯示時間軸(已出貨或已完成時) */}
{(order.dispatched_at || order.received_at) && (
<div className="border-t pt-3 mt-3 flex flex-wrap gap-6 text-sm text-gray-500">
{order.dispatched_at && (
<div className="flex items-center gap-1.5">
<Truck className="h-3.5 w-3.5 text-orange-400" />
<span>{order.dispatched_at}</span>
<span className="text-gray-400">({order.dispatched_by})</span>
</div>
)}
{order.received_at && (
<div className="flex items-center gap-1.5">
<PackageCheck className="h-3.5 w-3.5 text-green-500" />
<span>{order.received_at}</span>
<span className="text-gray-400">({order.received_by})</span>
</div>
)}
</div>
)}
</div>
)}
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
<div className="flex items-center justify-between">
<Label className="text-gray-500 font-semibold"></Label>
@@ -497,7 +730,7 @@ export default function Show({ order }: any) {
<TableHead className="font-medium text-grey-600"> / </TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="text-right w-32 font-medium text-grey-600">
{order.status === 'completed' ? '過帳時庫存' : '可用庫存'}
{stockColumnTitle()}
</TableHead>
<TableHead className="text-right w-40 font-medium text-grey-600">調</TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>

View File

@@ -6,7 +6,7 @@ import { Head, Link } from "@inertiajs/react";
import { ArrowLeft, Package, Tag, Layers, MapPin, DollarSign } from "lucide-react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { Label } from "@/Components/ui/label";
import { getShowBreadcrumbs } from "@/utils/breadcrumb";
import { Can } from "@/Components/Permission/Can";
@@ -105,15 +105,15 @@ export default function ProductShow({ product }: Props) {
<div>
<Label className="text-muted-foreground text-xs text-secondary-text"></Label>
<div className="mt-1">
<Badge variant="outline">{product.category?.name || "未分類"}</Badge>
<StatusBadge variant="neutral">{product.category?.name || "未分類"}</StatusBadge>
</div>
</div>
<div>
<Label className="text-muted-foreground text-xs text-secondary-text"></Label>
<div className="mt-1">
<Badge className={product.is_active ? "bg-green-100 text-green-700 border-green-200" : "bg-gray-100 text-gray-500 border-gray-200"}>
<StatusBadge variant={product.is_active ? "success" : "neutral"}>
{product.is_active ? "啟用中" : "已停用"}
</Badge>
</StatusBadge>
</div>
</div>
</div>

View File

@@ -12,7 +12,7 @@ import {
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { Loader2, Package, Calendar, Clock, BookOpen } from "lucide-react";
interface RecipeDetailModalProps {
@@ -34,9 +34,9 @@ export function RecipeDetailModal({ isOpen, onClose, recipe, isLoading }: Recipe
</DialogTitle>
{recipe && (
<Badge variant={recipe.is_active ? "default" : "secondary"} className="text-xs font-normal">
<StatusBadge variant={recipe.is_active ? "success" : "neutral"} className="text-xs font-normal">
{recipe.is_active ? "啟用中" : "已停用"}
</Badge>
</StatusBadge>
)}
</div>

View File

@@ -13,7 +13,7 @@ import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Input } from "@/Components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { Can } from "@/Components/Permission/Can";
import { RecipeDetailModal } from "./Components/RecipeDetailModal";
import axios from 'axios';
@@ -231,9 +231,11 @@ export default function RecipeIndex({ recipes, filters }: Props) {
{recipe.yield_quantity}
</TableCell>
<TableCell className="text-center">
<Badge variant={recipe.is_active ? "default" : "secondary"}>
{recipe.is_active ? "啟用" : "停用"}
</Badge>
{recipe.is_active ? (
<StatusBadge variant="success"></StatusBadge>
) : (
<StatusBadge variant="neutral"></StatusBadge>
)}
</TableCell>
<TableCell className="text-gray-500 text-sm">
{new Date(recipe.updated_at).toLocaleDateString()}

View File

@@ -10,7 +10,7 @@ import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, useForm, router } from "@inertiajs/react";
import { getBreadcrumbs } from "@/utils/breadcrumb";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import ProductionOrderStatusBadge from '@/Components/ProductionOrder/ProductionOrderStatusBadge';
import { ProductionStatusProgressBar } from '@/Components/ProductionOrder/ProductionStatusProgressBar';
import { PRODUCTION_ORDER_STATUS, ProductionOrderStatus } from '@/constants/production-order';
@@ -348,9 +348,9 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
<Link2 className="h-5 w-5 text-primary-main" />
</h2>
<Badge variant="outline" className="text-grey-3 font-medium">
<StatusBadge variant="neutral" className="text-grey-3 font-medium">
{productionOrder.items.length}
</Badge>
</StatusBadge>
</div>
{productionOrder.items.length === 0 ? (

View File

@@ -20,7 +20,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { Plus, FileUp, Eye, Trash2, Search, X } from 'lucide-react';
import { useState, useEffect } from "react";
import { format } from 'date-fns';
@@ -201,9 +201,11 @@ export default function SalesImportIndex({ batches, filters = {} }: Props) {
NT$ {Number(batch.total_amount || 0).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })}
</TableCell>
<TableCell className="text-center">
<Badge variant={batch.status === 'confirmed' ? 'default' : 'secondary'}>
{batch.status === 'confirmed' ? '已確認' : '待確認'}
</Badge>
{batch.status === 'confirmed' ? (
<StatusBadge variant="success"></StatusBadge>
) : (
<StatusBadge variant="warning"></StatusBadge>
)}
</TableCell>
<TableCell>
<div className="flex justify-center gap-2">

View File

@@ -22,7 +22,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { ArrowLeft, CheckCircle, Trash2, Printer } from 'lucide-react';
import { format } from 'date-fns';
import Pagination from "@/Components/shared/Pagination";
@@ -137,9 +137,9 @@ export default function SalesImportShow({ import: batch, items, filters = {} }:
<p className="text-gray-500 mt-1">#{batch.id} | {format(new Date(batch.created_at), 'yyyy/MM/dd HH:mm')}</p>
</div>
<div className="flex items-center gap-3">
<Badge variant={batch.status === 'confirmed' ? 'default' : 'secondary'}>
<StatusBadge variant={batch.status === 'confirmed' ? 'success' : 'neutral'}>
{batch.status === 'confirmed' ? '已確認' : '待確認'}
</Badge>
</StatusBadge>
{batch.status === 'pending' && (
<div className="flex gap-3">
{can('sales_imports.delete') && (
@@ -304,9 +304,9 @@ export default function SalesImportShow({ import: batch, items, filters = {} }:
{item.slot || '--'}
</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className={item.original_status === '已出貨' ? "text-green-600 border-green-200 bg-green-50" : "text-gray-500"}>
<StatusBadge variant={item.original_status === '已出貨' ? "success" : "neutral"} className={item.original_status === '已出貨' ? "" : "text-gray-500"}>
{item.original_status}
</Badge>
</StatusBadge>
</TableCell>
<TableCell className="text-right font-medium">{Math.floor(item.quantity)}</TableCell>
<TableCell className="text-right font-bold text-primary">

View File

@@ -16,7 +16,7 @@ import {
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
interface Props {
orders: {
@@ -54,13 +54,13 @@ export default function ShippingOrderIndex({ orders, filters, warehouses }: Prop
const getStatusBadge = (status: string) => {
switch (status) {
case 'draft':
return <Badge variant="secondary">稿</Badge>;
return <StatusBadge variant="neutral">稿</StatusBadge>;
case 'completed':
return <Badge className="bg-green-100 text-green-800"></Badge>;
return <StatusBadge variant="success"></StatusBadge>;
case 'cancelled':
return <Badge variant="destructive"></Badge>;
return <StatusBadge variant="destructive"></StatusBadge>;
default:
return <Badge>{status}</Badge>;
return <StatusBadge variant="neutral">{status}</StatusBadge>;
}
};

View File

@@ -2,7 +2,7 @@ import { ArrowLeft, Package, Info, CheckCircle2, AlertCircle, Trash2, Edit } fro
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { toast } from "sonner";
import ActivityLog from "@/Components/ActivityLog/ActivityLog";
@@ -31,16 +31,16 @@ export default function ShippingOrderShow({ order, activities = [] }: Props) {
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'draft':
return <Badge variant="secondary" className="px-3 py-1">稿</Badge>;
case 'completed':
return <Badge className="bg-green-100 text-green-800 px-3 py-1"></Badge>;
case 'cancelled':
return <Badge variant="destructive" className="px-3 py-1"></Badge>;
default:
return <Badge>{status}</Badge>;
}
const statusConfig: Record<string, { variant: "neutral" | "success" | "destructive", label: string }> = {
draft: { variant: 'neutral', label: '草稿' },
completed: { variant: 'success', label: '已完成' },
cancelled: { variant: 'destructive', label: '已取消' },
};
const config = statusConfig[status];
if (!config) return <StatusBadge variant="neutral">{status}</StatusBadge>;
return <StatusBadge variant={config.variant}>{config.label}</StatusBadge>;
};
return (
@@ -130,7 +130,7 @@ export default function ShippingOrderShow({ order, activities = [] }: Props) {
<h2 className="text-lg font-bold flex items-center gap-2">
<Package className="h-5 w-5 text-primary-main" />
</h2>
<Badge variant="outline">{order.items.length} </Badge>
<StatusBadge variant="neutral">{order.items.length} </StatusBadge>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
@@ -151,7 +151,7 @@ export default function ShippingOrderShow({ order, activities = [] }: Props) {
<div className="text-xs text-gray-500 font-mono">{item.product_code}</div>
</td>
<td className="px-6 py-4">
<Badge variant="outline" className="font-mono">{item.batch_number || 'N/A'}</Badge>
<StatusBadge variant="neutral" className="font-mono">{item.batch_number || 'N/A'}</StatusBadge>
</td>
<td className="px-6 py-4 text-right">
<span className="font-medium text-gray-900">{parseFloat(item.quantity).toLocaleString()}</span>

View File

@@ -0,0 +1,374 @@
import { useState } from "react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import { Label } from "@/Components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { toast } from "sonner";
import {
Store,
Plus,
Trash2,
Loader2,
Save,
SendHorizontal,
ArrowLeft,
} from "lucide-react";
interface Product {
id: number;
name: string;
code: string;
unit_name: string;
}
interface Warehouse {
id: number;
name: string;
type: string;
}
interface RequisitionItem {
product_id: string;
requested_qty: string;
remark: string;
}
interface Props {
requisition?: {
id: number;
store_warehouse_id: number;
remark: string | null;
status: string;
items: {
id: number;
product_id: number;
requested_qty: number;
remark: string | null;
}[];
};
warehouses: Warehouse[];
products: Product[];
}
export default function Create({ requisition, warehouses, products }: Props) {
const isEditing = !!requisition;
const [storeWarehouseId, setStoreWarehouseId] = useState(
requisition?.store_warehouse_id?.toString() || ""
);
const [remark, setRemark] = useState(requisition?.remark || "");
const [items, setItems] = useState<RequisitionItem[]>(
requisition?.items?.map((item) => ({
product_id: item.product_id.toString(),
requested_qty: item.requested_qty.toString(),
remark: item.remark || "",
})) || [{ product_id: "", requested_qty: "", remark: "" }]
);
const [saving, setSaving] = useState(false);
const [submitting, setSubmitting] = useState(false);
const addItem = () => {
setItems([...items, { product_id: "", requested_qty: "", remark: "" }]);
};
const removeItem = (index: number) => {
if (items.length <= 1) {
toast.error("至少需要一項商品");
return;
}
setItems(items.filter((_, i) => i !== index));
};
const updateItem = (index: number, field: keyof RequisitionItem, value: string) => {
const newItems = [...items];
newItems[index] = { ...newItems[index], [field]: value };
setItems(newItems);
};
const validate = (): boolean => {
if (!storeWarehouseId) {
toast.error("請選擇申請倉庫");
return false;
}
if (items.length === 0) {
toast.error("至少需要一項商品");
return false;
}
for (let i = 0; i < items.length; i++) {
if (!items[i].product_id) {
toast.error(`${i + 1} 行請選擇商品`);
return false;
}
const qty = parseInt(items[i].requested_qty);
if (!qty || qty < 1) {
toast.error(`${i + 1} 行需求數量必須大於等於 1`);
return false;
}
}
// 檢查是否有重複商品
const productIds = items.map((item) => item.product_id);
if (new Set(productIds).size !== productIds.length) {
toast.error("不可重複選擇商品");
return false;
}
return true;
};
const handleSave = (submitImmediately: boolean = false) => {
if (!validate()) return;
const setter = submitImmediately ? setSubmitting : setSaving;
setter(true);
const payload = {
store_warehouse_id: storeWarehouseId,
remark: remark || null,
items: items.map((item) => ({
product_id: parseInt(item.product_id),
requested_qty: parseFloat(item.requested_qty),
remark: item.remark || null,
})),
submit_immediately: submitImmediately,
};
if (isEditing) {
router.put(route("store-requisitions.update", [requisition!.id]), payload, {
onFinish: () => setter(false),
});
} else {
router.post(route("store-requisitions.store"), payload, {
onFinish: () => setter(false),
});
}
};
// 已選商品列表(用於過濾下拉選項)
const selectedProductIds = items.map((item) => item.product_id).filter(Boolean);
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: "商品與庫存管理", href: "#" },
{ label: "門市叫貨", href: route("store-requisitions.index") },
{
label: isEditing ? "編輯叫貨單" : "新增叫貨單",
href: "#",
isPage: true,
},
]}
>
<Head title={isEditing ? "編輯叫貨單" : "新增叫貨單"} />
<div className="container mx-auto p-6 max-w-7xl">
{/* 返回按鈕 */}
<div className="mb-6">
<Link href={route("store-requisitions.index")}>
<Button variant="outline" className="gap-2 button-outlined-primary">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
</div>
{/* 頁面標題 */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Store className="h-6 w-6 text-primary-main" />
{isEditing ? `編輯叫貨單 ${requisition?.status === "rejected" ? "(重新提交)" : ""}` : "新增叫貨單"}
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
{/* 基本資訊 */}
<div className="bg-white rounded-lg shadow-sm border p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<SearchableSelect
value={storeWarehouseId}
onValueChange={setStoreWarehouseId}
options={warehouses.map((w) => ({
label: w.name,
value: w.id.toString(),
}))}
placeholder="請選擇倉庫"
className="h-9"
/>
<p className="text-xs text-gray-400"></p>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea
value={remark}
onChange={(e) => setRemark(e.target.value)}
placeholder="補充說明(選填)"
rows={3}
/>
</div>
</div>
</div>
{/* 商品明細 */}
<div className="bg-white rounded-lg shadow-sm border p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-800"></h2>
<Button
type="button"
variant="outline"
size="sm"
className="button-outlined-primary"
onClick={addItem}
>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center font-medium text-gray-600">
#
</TableHead>
<TableHead className="font-medium text-gray-600 min-w-[250px]">
<span className="text-red-500">*</span>
</TableHead>
<TableHead className="font-medium text-gray-600 w-[150px]">
<span className="text-red-500">*</span>
</TableHead>
<TableHead className="font-medium text-gray-600 w-[100px]"></TableHead>
<TableHead className="font-medium text-gray-600 min-w-[150px]"></TableHead>
<TableHead className="w-[60px] text-center font-medium text-gray-600">
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => {
const selectedProduct = products.find(
(p) => String(p.id) === String(item.product_id)
);
return (
<TableRow key={index}>
<TableCell className="text-center text-gray-500 font-medium">
{index + 1}
</TableCell>
<TableCell>
<SearchableSelect
value={item.product_id}
onValueChange={(val) =>
updateItem(index, "product_id", val)
}
options={products
.filter(
(p) =>
!selectedProductIds.includes(
p.id.toString()
) ||
p.id.toString() === item.product_id
)
.map((p) => ({
label: `${p.code} - ${p.name}`,
value: p.id.toString(),
}))}
placeholder="選擇商品"
className="h-9"
/>
</TableCell>
<TableCell>
<Input
type="number"
step="1"
min="1"
value={item.requested_qty}
onChange={(e) =>
updateItem(index, "requested_qty", e.target.value)
}
placeholder="0"
className="h-9 text-right"
/>
</TableCell>
<TableCell className="text-gray-500">
{selectedProduct?.unit_name || "-"}
</TableCell>
<TableCell>
<Input
value={item.remark}
onChange={(e) =>
updateItem(index, "remark", e.target.value)
}
placeholder="備註"
className="h-9"
/>
</TableCell>
<TableCell className="text-center">
<Button
variant="outline"
size="sm"
onClick={() => removeItem(index)}
className="button-outlined-error"
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
{/* 操作按鈕列 */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="outline"
className="button-outlined-primary"
onClick={() => router.visit(route("store-requisitions.index"))}
>
</Button>
<Button
type="button"
variant="outline"
className="button-outlined-primary"
disabled={saving || submitting}
onClick={() => handleSave(false)}
>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="w-4 h-4 mr-1" />
稿
</Button>
<Button
type="button"
className="button-filled-primary"
disabled={saving || submitting}
onClick={() => handleSave(true)}
>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<SendHorizontal className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,396 @@
import { useState, useCallback, useEffect } from "react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react";
import { debounce } from "lodash";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import Pagination from "@/Components/shared/Pagination";
import { toast } from "sonner";
import { Can } from "@/Components/Permission/Can";
import { usePermission } from "@/hooks/usePermission";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import {
Plus,
Search,
Store,
Eye,
Pencil,
Trash2,
X,
} from "lucide-react";
import { formatDate } from "@/lib/date";
function getStatusBadge(status: string) {
switch (status) {
case 'draft':
return <StatusBadge variant="neutral">稿</StatusBadge>;
case 'pending':
return <StatusBadge variant="warning"></StatusBadge>;
case 'approved':
return <StatusBadge variant="success"></StatusBadge>;
case 'rejected':
return <StatusBadge variant="destructive"></StatusBadge>;
case 'completed':
return <StatusBadge variant="success"></StatusBadge>;
case 'cancelled':
return <StatusBadge variant="neutral"></StatusBadge>;
default:
return <StatusBadge variant="neutral">{status}</StatusBadge>;
}
}
export default function Index({
requisitions,
filters,
warehouses,
}: {
requisitions: any;
filters: any;
warehouses: { id: number; name: string }[];
}) {
const { can } = usePermission();
const [searchTerm, setSearchTerm] = useState(filters.search || "");
const [statusFilter, setStatusFilter] = useState(filters.status || "all");
const [warehouseFilter, setWarehouseFilter] = useState(filters.warehouse_id || "all");
const [perPage, setPerPage] = useState(filters.per_page || "10");
const [deleteId, setDeleteId] = useState<string | null>(null);
useEffect(() => {
setSearchTerm(filters.search || "");
setStatusFilter(filters.status || "all");
setWarehouseFilter(filters.warehouse_id || "all");
setPerPage(filters.per_page || "10");
}, [filters]);
const applyFilters = useCallback(
(overrides: Record<string, string> = {}) => {
const params: Record<string, string> = {
search: searchTerm,
status: statusFilter === "all" ? "" : statusFilter,
warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter,
per_page: perPage,
...overrides,
};
// 清理空值
Object.keys(params).forEach((key) => {
if (!params[key]) delete params[key];
});
router.get(route("store-requisitions.index"), params, {
preserveState: true,
replace: true,
preserveScroll: true,
});
},
[searchTerm, statusFilter, warehouseFilter, perPage]
);
const debouncedSearch = useCallback(
debounce((term: string) => {
applyFilters({ search: term });
}, 500),
[applyFilters]
);
const handleSearchChange = (term: string) => {
setSearchTerm(term);
debouncedSearch(term);
};
const handleClearSearch = () => {
setSearchTerm("");
applyFilters({ search: "" });
};
const handleStatusChange = (value: string) => {
setStatusFilter(value);
applyFilters({ status: value === "all" ? "" : value });
};
const handleWarehouseChange = (value: string) => {
setWarehouseFilter(value);
applyFilters({ warehouse_id: value === "all" ? "" : value });
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
applyFilters({ per_page: value });
};
const handleDelete = () => {
if (deleteId) {
router.delete(route("store-requisitions.destroy", [deleteId]), {
onSuccess: () => {
setDeleteId(null);
toast.success("已成功刪除");
},
onError: () => setDeleteId(null),
});
}
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: "商品與庫存管理", href: "#" },
{ label: "門市叫貨", href: route("store-requisitions.index"), isPage: true },
]}
>
<Head title="門市叫貨" />
<div className="container mx-auto p-6 max-w-7xl">
{/* 頁面標題 */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Store className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
</div>
{/* 篩選工具列 */}
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div className="flex flex-col md:flex-row gap-4">
{/* 搜尋 */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="搜尋單號..."
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10 pr-10 h-9"
/>
{searchTerm && (
<button
onClick={handleClearSearch}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* 狀態篩選 */}
<SearchableSelect
value={statusFilter}
onValueChange={handleStatusChange}
options={[
{ label: "所有狀態", value: "all" },
{ label: "草稿", value: "draft" },
{ label: "待審核", value: "pending" },
{ label: "已核准", value: "approved" },
{ label: "已駁回", value: "rejected" },
{ label: "已完成", value: "completed" },
{ label: "已取消", value: "cancelled" },
]}
placeholder="選擇狀態"
className="w-full md:w-[160px] h-9"
showSearch={false}
/>
{/* 倉庫篩選 */}
<SearchableSelect
value={warehouseFilter}
onValueChange={handleWarehouseChange}
options={[
{ label: "所有倉庫", value: "all" },
...warehouses.map((w) => ({
label: w.name,
value: w.id.toString(),
})),
]}
placeholder="選擇倉庫"
className="w-full md:w-[200px] h-9"
/>
{/* 操作按鈕 */}
<div className="flex gap-2 w-full md:w-auto">
<Can permission="store_requisitions.create">
<Link href={route("store-requisitions.create")}>
<Button className="flex-1 md:flex-none button-filled-primary">
<Plus className="w-4 h-4 mr-2" />
</Button>
</Link>
</Can>
</div>
</div>
</div>
{/* 資料表格 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center font-medium text-gray-600">#</TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="text-center font-medium text-gray-600"></TableHead>
<TableHead className="text-center font-medium text-gray-600"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requisitions.data.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center h-24 text-gray-500">
</TableCell>
</TableRow>
) : (
requisitions.data.map((req: any, index: number) => (
<TableRow
key={req.id}
className="hover:bg-gray-50/50 transition-colors cursor-pointer group"
onClick={() =>
router.visit(route("store-requisitions.show", [req.id]))
}
>
<TableCell className="text-center text-gray-500 font-medium">
{(requisitions.current_page - 1) * requisitions.per_page + index + 1}
</TableCell>
<TableCell className="font-medium text-primary-main">
{req.doc_no}
</TableCell>
<TableCell className="text-gray-700">
{req.store_warehouse_name}
</TableCell>
<TableCell className="text-gray-700">
{req.supply_warehouse_name}
</TableCell>
<TableCell className="text-sm">{req.creator_name}</TableCell>
<TableCell className="text-gray-500 text-sm">
{formatDate(req.created_at)}
</TableCell>
<TableCell className="text-center">
{getStatusBadge(req.status)}
</TableCell>
<TableCell className="text-center">
<div
className="flex items-center justify-center gap-2"
onClick={(e) => e.stopPropagation()}
>
{(() => {
const isEditable = ["draft", "rejected"].includes(req.status);
const canEdit = can("store_requisitions.edit");
if (isEditable && canEdit) {
return (
<Link href={route("store-requisitions.edit", [req.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="w-4 h-4 ml-0.5" />
</Button>
</Link>
);
}
return (
<Link href={route("store-requisitions.show", [req.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="查閱"
>
<Eye className="w-4 h-4 ml-0.5" />
</Button>
</Link>
);
})()}
{req.status === "draft" && (
<Can permission="store_requisitions.delete">
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
onClick={() => setDeleteId(req.id)}
>
<Trash2 className="w-4 h-4 ml-0.5" />
</Button>
</Can>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 分頁 */}
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span></span>
<SearchableSelect
value={perPage}
onValueChange={handlePerPageChange}
options={[
{ label: "10", value: "10" },
{ label: "20", value: "20" },
{ label: "50", value: "50" },
{ label: "100", value: "100" },
]}
className="w-[90px] h-8"
showSearch={false}
/>
<span></span>
</div>
<span className="text-sm text-gray-500"> {requisitions.total} </span>
</div>
<Pagination links={requisitions.links} />
</div>
{/* 刪除確認對話框 */}
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="button-filled-error">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,588 @@
import { useState } from "react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import { Label } from "@/Components/ui/label";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/Components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import { toast } from "sonner";
import { Can } from "@/Components/Permission/Can";
import { usePermission } from "@/hooks/usePermission";
import {
Store,
SendHorizontal,
CheckCircle2,
XCircle,
Pencil,
Loader2,
ArrowLeft,
} from "lucide-react";
import { formatDate } from "@/lib/date";
function getStatusBadge(status: string) {
const statusMap: Record<string, { label: string; variant: "neutral" | "warning" | "success" | "destructive" | "info" }> = {
draft: { label: "草稿", variant: "neutral" },
pending: { label: "待審核", variant: "warning" },
approved: { label: "已核准", variant: "success" },
rejected: { label: "已駁回", variant: "destructive" },
completed: { label: "已完成", variant: "success" },
cancelled: { label: "已取消", variant: "neutral" },
};
const config = statusMap[status];
if (!config) return <StatusBadge variant="neutral">{status}</StatusBadge>;
return (
<StatusBadge variant={config.variant}>
{config.label}
</StatusBadge>
);
}
interface RequisitionItem {
id: number;
product_id: number;
product_name: string;
product_code: string;
unit_name: string;
requested_qty: number;
approved_qty: number | null;
current_stock: number;
remark: string | null;
}
interface Requisition {
id: number;
doc_no: string;
status: string;
store_warehouse_id: number;
store_warehouse_name: string;
supply_warehouse_id: number | null;
supply_warehouse_name: string;
remark: string | null;
reject_reason: string | null;
creator_name: string;
approver_name: string;
submitted_at: string | null;
approved_at: string | null;
transfer_order_id: number | null;
created_at: string;
items: RequisitionItem[];
}
interface Props {
requisition: Requisition;
warehouses: { id: number; name: string }[];
activities: any[];
}
export default function Show({ requisition, warehouses }: Props) {
usePermission();
const [submitting, setSubmitting] = useState(false);
const [approving, setApproving] = useState(false);
const [rejecting, setRejecting] = useState(false);
// 核准狀態
const [showApproveDialog, setShowApproveDialog] = useState(false);
const [supplyWarehouseId, setSupplyWarehouseId] = useState("");
const [approvedItems, setApprovedItems] = useState<{ id: number; approved_qty: string }[]>(
requisition.items.map((item) => ({
id: item.id,
approved_qty: item.requested_qty.toString(),
}))
);
// 駁回狀態
const [showRejectDialog, setShowRejectDialog] = useState(false);
const [rejectReason, setRejectReason] = useState("");
// 提交確認
const [showSubmitDialog, setShowSubmitDialog] = useState(false);
const handleSubmit = () => {
setSubmitting(true);
router.post(route("store-requisitions.submit", [requisition.id]), {}, {
onFinish: () => {
setSubmitting(false);
setShowSubmitDialog(false);
},
});
};
const handleApprove = () => {
if (!supplyWarehouseId) {
toast.error("請選擇供貨倉庫");
return;
}
// 確認每個核准數量
for (const item of approvedItems) {
const qty = parseFloat(item.approved_qty);
if (isNaN(qty) || qty < 0) {
toast.error("核准數量不能為負數");
return;
}
}
setApproving(true);
router.post(
route("store-requisitions.approve", [requisition.id]),
{
supply_warehouse_id: supplyWarehouseId,
items: approvedItems.map((item) => ({
id: item.id,
approved_qty: parseFloat(item.approved_qty),
})),
},
{
onFinish: () => {
setApproving(false);
setShowApproveDialog(false);
},
}
);
};
const handleReject = () => {
if (!rejectReason.trim()) {
toast.error("請填寫駁回原因");
return;
}
setRejecting(true);
router.post(
route("store-requisitions.reject", [requisition.id]),
{ reject_reason: rejectReason },
{
onFinish: () => {
setRejecting(false);
setShowRejectDialog(false);
},
}
);
};
const updateApprovedQty = (itemId: number, qty: string) => {
setApprovedItems(
approvedItems.map((item) => (item.id === itemId ? { ...item, approved_qty: qty } : item))
);
};
const isEditable = ["draft", "rejected"].includes(requisition.status);
const isPending = requisition.status === "pending";
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: "商品與庫存管理", href: "#" },
{ label: "門市叫貨", href: route("store-requisitions.index") },
{ label: requisition.doc_no, href: "#", isPage: true },
]}
>
<Head title={`叫貨單 ${requisition.doc_no}`} />
<div className="container mx-auto p-6 max-w-7xl">
{/* 返回按鈕 */}
<div className="mb-6">
<Link href={route("store-requisitions.index")}>
<Button variant="outline" className="gap-2 button-outlined-primary">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
</div>
{/* 頁面標題與操作 */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Store className="h-6 w-6 text-primary-main" />
{requisition.doc_no}
</h1>
<div className="flex items-center gap-2 mt-1">
{getStatusBadge(requisition.status)}
<span className="text-gray-500 text-sm">
{formatDate(requisition.created_at)}
</span>
</div>
</div>
{/* 操作按鈕 */}
<div className="flex gap-2">
{isEditable && (
<>
<Can permission="store_requisitions.edit">
<Link href={route("store-requisitions.edit", [requisition.id])}>
<Button variant="outline" className="button-outlined-primary">
<Pencil className="w-4 h-4 mr-1" />
</Button>
</Link>
</Can>
{requisition.status === "draft" && (
<Can permission="store_requisitions.view">
<Button
className="button-filled-primary"
onClick={() => setShowSubmitDialog(true)}
>
<SendHorizontal className="w-4 h-4 mr-1" />
</Button>
</Can>
)}
</>
)}
{isPending && (
<>
<Can permission="store_requisitions.approve">
<Button
variant="outline"
className="button-outlined-error"
onClick={() => setShowRejectDialog(true)}
>
<XCircle className="w-4 h-4 mr-1" />
</Button>
<Button
className="button-filled-success"
onClick={() => setShowApproveDialog(true)}
>
<CheckCircle2 className="w-4 h-4 mr-1" />
</Button>
</Can>
</>
)}
</div>
</div>
{/* 基本資訊 */}
<div className="bg-white rounded-lg shadow-sm border p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{requisition.store_warehouse_name}
</p>
</div>
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{requisition.supply_warehouse_name || "-"}
</p>
</div>
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{requisition.creator_name}
</p>
</div>
{requisition.submitted_at && (
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{formatDate(requisition.submitted_at)}
</p>
</div>
)}
{requisition.approved_at && (
<>
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{requisition.approver_name}
</p>
</div>
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{formatDate(requisition.approved_at)}
</p>
</div>
</>
)}
{requisition.remark && (
<div className="md:col-span-3">
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{requisition.remark}
</p>
</div>
)}
{requisition.reject_reason && (
<div className="md:col-span-3">
<span className="text-sm text-red-500 font-medium"></span>
<p className="text-red-600 bg-red-50 rounded-md p-3 mt-1">
{requisition.reject_reason}
</p>
</div>
)}
{requisition.transfer_order_id && (
<div>
<span className="text-sm text-gray-500">調</span>
<p className="mt-1">
<Link
href={`${route("inventory.transfer.show", [requisition.transfer_order_id])}?from=requisition&from_id=${requisition.id}&from_doc=${encodeURIComponent(requisition.doc_no)}`}
className="text-primary-main hover:underline font-medium"
>
調
</Link>
</p>
</div>
)}
</div>
</div>
{/* 商品明細 */}
<div className="bg-white rounded-lg shadow-sm border p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4"></h2>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center font-medium text-gray-600">
#
</TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="text-right font-medium text-gray-600">
</TableHead>
<TableHead className="text-right font-medium text-gray-600">
</TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
{["approved", "completed"].includes(requisition.status) && (
<TableHead className="text-right font-medium text-gray-600">
</TableHead>
)}
<TableHead className="font-medium text-gray-600"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requisition.items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center text-gray-500 font-medium">
{index + 1}
</TableCell>
<TableCell className="font-mono text-sm text-gray-600">
{item.product_code}
</TableCell>
<TableCell className="font-medium text-gray-800">
{item.product_name}
</TableCell>
<TableCell className="text-right text-gray-600">
{Number(item.current_stock).toLocaleString()}
</TableCell>
<TableCell className="text-right font-medium text-gray-800">
{Number(item.requested_qty).toLocaleString()}
</TableCell>
<TableCell className="text-gray-500">{item.unit_name}</TableCell>
{["approved", "completed"].includes(requisition.status) && (
<TableCell className="text-right font-medium text-green-600">
{item.approved_qty !== null
? Number(item.approved_qty).toLocaleString()
: "-"}
</TableCell>
)}
<TableCell className="text-gray-500 text-sm">
{item.remark || "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
{/* 提交確認 */}
<AlertDialog open={showSubmitDialog} onOpenChange={setShowSubmitDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleSubmit}
className="button-filled-primary"
disabled={submitting}
>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 核准對話框 */}
<Dialog open={showApproveDialog} onOpenChange={setShowApproveDialog}>
<DialogContent className="sm:max-w-[700px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<SearchableSelect
value={supplyWarehouseId}
onValueChange={setSupplyWarehouseId}
options={warehouses
.filter((w) => w.id !== requisition.store_warehouse_id)
.map((w) => ({
label: w.name,
value: w.id.toString(),
}))}
placeholder="請選擇供貨倉庫"
className="h-9"
/>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="text-right font-medium text-gray-600 w-[120px]">
</TableHead>
<TableHead className="font-medium text-gray-600 w-[80px]"></TableHead>
<TableHead className="font-medium text-gray-600 w-[150px]">
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requisition.items.map((item) => (
<TableRow key={item.id}>
<TableCell>
<span className="font-mono text-xs text-gray-500">
{item.product_code}
</span>
<span className="ml-2 text-gray-800">{item.product_name}</span>
</TableCell>
<TableCell className="text-right text-gray-700">
{Number(item.requested_qty).toLocaleString()}
</TableCell>
<TableCell className="text-gray-500 text-sm">
{item.unit_name}
</TableCell>
<TableCell>
<Input
type="number"
step="1"
min="0"
value={
approvedItems.find((ai) => ai.id === item.id)
?.approved_qty || ""
}
onChange={(e) =>
updateApprovedQty(item.id, e.target.value)
}
className="h-8 text-right"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
className="button-outlined-primary"
onClick={() => setShowApproveDialog(false)}
>
</Button>
<Button
className="bg-green-600 hover:bg-green-700 text-white"
onClick={handleApprove}
disabled={approving || !supplyWarehouseId}
>
{approving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 駁回對話框 */}
<Dialog open={showRejectDialog} onOpenChange={setShowRejectDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<Textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="請填寫駁回原因..."
rows={4}
className="mt-2"
/>
</div>
<DialogFooter>
<Button
variant="outline"
className="button-outlined-primary"
onClick={() => setShowRejectDialog(false)}
>
</Button>
<Button
variant="destructive"
onClick={handleReject}
disabled={rejecting || !rejectReason.trim()}
>
{rejecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AuthenticatedLayout>
);
}

View File

@@ -39,13 +39,19 @@ interface PageProps {
book_amount: number;
abnormal_amount: number;
};
transitWarehouses: Array<{
id: string;
name: string;
license_plate?: string;
driver_name?: string;
}>;
filters: {
search?: string;
per_page?: string;
};
}
export default function WarehouseIndex({ warehouses, totals, filters }: PageProps) {
export default function WarehouseIndex({ warehouses, totals, transitWarehouses, filters }: PageProps) {
// 篩選狀態
const [searchTerm, setSearchTerm] = useState(filters.search || "");
const [perPage, setPerPage] = useState(filters.per_page || '10');
@@ -306,6 +312,7 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
warehouse={editingWarehouse}
onSave={handleSaveWarehouse}
onDelete={handleDeleteWarehouse}
transitWarehouses={transitWarehouses}
/>
{/* 調撥單建立對話框 */}

View File

@@ -3,18 +3,19 @@
*/
import type { PurchaseOrderStatus, PaymentMethod, InvoiceType } from "@/types/purchase-order";
import { StatusVariant } from "@/Components/shared/StatusBadge";
// 狀態標籤配置
export const STATUS_CONFIG: Record<
PurchaseOrderStatus,
{ label: string; variant: "default" | "secondary" | "destructive" | "outline" }
{ label: string; variant: StatusVariant }
> = {
draft: { label: "草稿", variant: "outline" },
pending: { label: "簽核中", variant: "outline" },
approved: { label: "已核准", variant: "default" },
partial: { label: "部分收貨", variant: "secondary" },
completed: { label: "全數收貨", variant: "outline" },
closed: { label: "已結案", variant: "outline" },
draft: { label: "草稿", variant: "neutral" },
pending: { label: "簽核中", variant: "warning" },
approved: { label: "已核准", variant: "success" },
partial: { label: "部分收貨", variant: "neutral" },
completed: { label: "全數收貨", variant: "success" },
closed: { label: "已結案", variant: "neutral" },
cancelled: { label: "已作廢", variant: "destructive" },
};

View File

@@ -31,6 +31,8 @@ export interface Warehouse {
available_stock?: number;
book_amount?: number;
abnormal_amount?: number;
default_transit_warehouse_id?: string | null; // 預設在途倉 ID
default_transit_warehouse_name?: string | null; // 預設在途倉名稱
}
// 倉庫中的庫存項目
export interface WarehouseInventory {