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

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