feat: 整合門市領料日誌、API 文件存取、修改庫存與併發編號問題、供應商商品內聯編輯及日誌 UI 優化
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m0s

This commit is contained in:
2026-03-02 16:42:12 +08:00
parent 7dac2d1f77
commit 0a955fb993
33 changed files with 1424 additions and 853 deletions

View File

@@ -168,6 +168,28 @@ const fieldLabels: Record<string, string> = {
posted_by: '過帳者',
counted_qty: '盤點數量',
adjust_qty: '調整數量',
// 調撥單專有欄位
transit_warehouse_id: '在途倉庫',
transit_warehouse_name: '在途倉庫名稱',
dispatched_at: '出貨日期',
dispatched_by: '出貨人',
received_at: '收貨日期',
received_by: '收貨人',
reserved_quantity: '預扣數量',
snapshot_quantity: '異動前庫存 (快照)',
// 門市叫貨欄位
store_warehouse_id: '申請倉庫',
store_warehouse_name: '申請倉庫',
supply_warehouse_id: '供貨倉庫',
supply_warehouse_name: '供貨倉庫',
approved_by: '審核人',
approved_user_name: '審核人',
approved_at: '審核時間',
submitted_at: '提交時間',
reject_reason: '駁回原因',
status_label: '處理狀態',
transfer_order_id: '調撥單 ID',
transfer_order_name: '調撥單號',
};
// 狀態翻譯對照表
@@ -192,6 +214,7 @@ const statusMap: Record<string, string> = {
in_progress: '生產中',
// 調撥單狀態
voided: '已作廢',
dispatched: '已出貨',
};
// 主體類型解析 (Model 類名轉中文)
@@ -206,17 +229,7 @@ const subjectTypeMap: Record<string, string> = {
'App\\Modules\\Inventory\\Models\\GoodsReceipt': '進貨單',
'App\\Modules\\Inventory\\Models\\InventoryCountDoc': '庫存盤點單',
'App\\Modules\\Inventory\\Models\\InventoryAdjustDoc': '庫存盤調單',
'App\\Modules\\Inventory\\Models\\InventoryTransferOrder': '庫存調撥單',
'App\\Modules\\Inventory\\Models\\StockMovementDoc': '庫存單據',
'App\\Modules\\Procurement\\Models\\Vendor': '廠商資料',
'App\\Modules\\Procurement\\Models\\PurchaseOrder': '採購單',
'App\\Modules\\Production\\Models\\ProductionOrder': '生產工單',
'App\\Modules\\Production\\Models\\Recipe': '生產配方',
'App\\Modules\\Production\\Models\\RecipeItem': '配方品項',
'App\\Modules\\Production\\Models\\ProductionOrderItem': '工單品項',
'App\\Modules\\Finance\\Models\\UtilityFee': '公共事業費',
'App\\Modules\\Core\\Models\\User': '使用者帳號',
'App\\Modules\\Core\\Models\\Role': '角色權限',
'App\\Modules\\Inventory\\Models\\StoreRequisition': '門市叫貨單',
// 簡寫映射 (應對後端回傳 class_basename 的情況)
'Product': '商品資料',
'Warehouse': '倉庫資料',
@@ -231,7 +244,8 @@ const subjectTypeMap: Record<string, string> = {
'Recipe': '生產配方',
'InventoryCountDoc': '庫存盤點單',
'InventoryAdjustDoc': '庫存盤調單',
'InventoryTransferOrder': '庫調撥單',
'InventoryTransferOrder': '庫調撥單',
'StoreRequisition': '門市叫貨單',
'StockMovementDoc': '庫存單據',
'User': '使用者帳號',
'Role': '角色權限',
@@ -264,17 +278,31 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
// 自訂欄位排序順序
const sortOrder = [
'doc_no', 'po_number', 'gr_number', 'production_number',
'doc_no', 'po_number', 'gr_number', 'production_number', 'transfer_order_name',
'vendor_name', 'warehouse_name', 'order_date', 'expected_delivery_date', 'status', 'remark',
'invoice_number', 'invoice_date', 'invoice_amount',
'total_amount', 'tax_amount', 'grand_total'
];
// 過濾掉通常會記錄但對使用者無用的內部鍵
// 過濾掉通常會記錄但對使用者無用的內部鍵,以及已被解析為名稱的原始 ID 欄位
const filteredKeys = allKeys
.filter(key =>
!['created_at', 'updated_at', 'deleted_at', 'id', 'remember_token'].includes(key)
)
.filter(key => {
if (['created_at', 'updated_at', 'deleted_at', 'id', 'remember_token', 'activityProperties'].includes(key)) return false;
// 隱藏冗餘的狀態標籤 (因為後端已統一替換 status 內容)
if (key === 'status_label') return false;
// 隱藏技術用的 ID 欄位 (如果已有對應的名稱欄位)
if (key.endsWith('_id')) {
const nameKey = key.replace('_id', '_name');
const userNameKey = key.replace('_id', '_user_name');
if (allKeys.includes(nameKey) || allKeys.includes(userNameKey)) return false;
}
// 特別隱藏調撥單 ID
if (key === 'transfer_order_id' && (allKeys.includes('transfer_order_name') || allKeys.includes('transfer_order_doc_no'))) return false;
return true;
})
.sort((a, b) => {
const indexA = sortOrder.indexOf(a);
const indexB = sortOrder.indexOf(b);
@@ -336,9 +364,13 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
if ((key === 'order_date' || key === 'expected_delivery_date' || key === 'invoice_date' || key === 'arrival_date' || key === 'expiry_date' || key === 'received_date' || key === 'production_date') && typeof value === 'string') {
return value.split('T')[0].split(' ')[0];
}
if ((key === 'snapshot_date' || key === 'completed_at' || key === 'posted_at' || key === 'created_at' || key === 'updated_at' || key === 'actual_time') && typeof value === 'string') {
if ((key === 'snapshot_date' || key === 'completed_at' || key === 'posted_at' || key === 'created_at' || key === 'updated_at' || key === 'actual_time' || key === 'submitted_at' || key === 'approved_at') && typeof value === 'string') {
try {
const date = new Date(value);
// 處理部分 ISO 字串包含 T 的情況
const normalizedValue = value.replace('T', ' ');
const date = new Date(normalizedValue);
if (isNaN(date.getTime())) return value;
return date.toLocaleString('zh-TW', {
timeZone: 'Asia/Taipei',
year: 'numeric',
@@ -537,65 +569,89 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
</TableRow>
</TableHeader>
<TableBody>
{/* 更新項目 */}
{activity.properties.items_diff.updated?.map((item: any, idx: number) => (
<TableRow key={`upd-${idx}`} className="bg-blue-50/10 hover:bg-blue-50/20">
<TableCell className="font-medium">{item.product_name}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200"></Badge>
{/* 1. 處理物件格式的 items_diff (現有的採購、盤點、調撥初始紀錄格式) */}
{!Array.isArray(activity.properties?.items_diff) && (
<>
{/* 更新項目 */}
{activity.properties?.items_diff?.updated?.map((item: any, idx: number) => (
<TableRow key={`upd-${idx}`} className="bg-blue-50/10 hover:bg-blue-50/20">
<TableCell className="font-medium">{item.product_name}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200"></Badge>
</TableCell>
<TableCell className="text-sm">
<div className="space-y-1 text-xs">
{item.old?.quantity !== item.new?.quantity && item.old?.quantity !== undefined && (
<div>: <span className="text-gray-500 line-through">{item.old.quantity}</span> <span className="text-blue-700 font-bold">{item.new.quantity}</span></div>
)}
{item.old?.counted_qty !== item.new?.counted_qty && item.old?.counted_qty !== undefined && (
<div>: <span className="text-gray-500 line-through">{item.old.counted_qty ?? '未盤'}</span> <span className="text-blue-700 font-bold">{item.new.counted_qty ?? '未盤'}</span></div>
)}
</div>
</TableCell>
</TableRow>
)) || null}
{/* 新增項目 */}
{activity.properties?.items_diff?.added?.map((item: any, idx: number) => (
<TableRow key={`add-${idx}`} className="bg-green-50/10 hover:bg-green-50/20">
<TableCell className="font-medium">{item.product_name}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200"></Badge>
</TableCell>
<TableCell className="text-sm">
: {item.new?.quantity ?? item.quantity} {item.unit_name || item.new?.unit_name || ''}
</TableCell>
</TableRow>
)) || null}
{/* 移除項目 */}
{activity.properties?.items_diff?.removed?.map((item: any, idx: number) => (
<TableRow key={`rem-${idx}`} className="bg-red-50/10 hover:bg-red-50/20">
<TableCell className="font-medium text-gray-400 line-through">{item.product_name}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200"></Badge>
</TableCell>
<TableCell className="text-sm text-gray-400">
: {item.old?.quantity ?? item.quantity} {item.unit_name || item.old?.unit_name || ''}
</TableCell>
</TableRow>
)) || null}
</>
)}
{/* 2. 處理陣列格式的 items_diff (調撥單過帳/收貨的複合紀錄格式) */}
{Array.isArray(activity.properties?.items_diff) && activity.properties.items_diff.map((item: any, idx: number) => (
<TableRow key={`trf-${idx}`} className="hover:bg-gray-50/50">
<TableCell className="font-medium">
{item.product_name}
<div className="text-[10px] text-gray-400 font-normal">: {item.batch_number}</div>
</TableCell>
<TableCell className="text-sm">
<div className="space-y-1">
{item.old?.quantity !== item.new?.quantity && item.old?.quantity !== undefined && (
<div>: <span className="text-gray-500 line-through">{item.old.quantity}</span> <span className="text-blue-700 font-bold">{item.new.quantity}</span></div>
<TableCell className="text-center">
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-200"></Badge>
</TableCell>
<TableCell className="text-xs p-2">
<div className="flex flex-col gap-2">
{item.source_warehouse && (
<div className="flex items-center gap-2">
<span className="w-16 text-gray-400 truncate"> ({item.source_warehouse}):</span>
<span className="text-gray-400 line-through">{Number(item.source_before || 0).toFixed(0)}</span>
<span className="text-rose-600 font-bold"> {Number(item.source_after || 0).toFixed(0)}</span>
<span className="text-[10px] bg-rose-50 text-rose-600 px-1 rounded">-{item.quantity}</span>
</div>
)}
{item.old?.counted_qty !== item.new?.counted_qty && item.old?.counted_qty !== undefined && (
<div>: <span className="text-gray-500 line-through">{item.old.counted_qty ?? '未盤'}</span> <span className="text-blue-700 font-bold">{item.new.counted_qty ?? '未盤'}</span></div>
)}
{item.old?.adjust_qty !== item.new?.adjust_qty && (
<div>調: <span className="text-gray-500 line-through">{item.old?.adjust_qty ?? '0'}</span> <span className="text-blue-700 font-bold">{item.new?.adjust_qty ?? '0'}</span></div>
)}
{item.old?.unit_name !== item.new?.unit_name && item.old?.unit_name !== undefined && (
<div>: <span className="text-gray-500 line-through">{item.old.unit_name || '-'}</span> <span className="text-blue-700 font-bold">{item.new.unit_name || '-'}</span></div>
)}
{item.old?.subtotal !== item.new?.subtotal && item.old?.subtotal !== undefined && (
<div>: <span className="text-gray-500 line-through">${item.old.subtotal}</span> <span className="text-blue-700 font-bold">${item.new.subtotal}</span></div>
)}
{item.old?.notes !== item.new?.notes && (
<div>: <span className="text-gray-500 line-through">{item.old?.notes || '-'}</span> <span className="text-blue-700 font-bold">{item.new?.notes || '-'}</span></div>
{item.target_warehouse && (
<div className="flex items-center gap-2">
<span className="w-16 text-gray-400 truncate"> ({item.target_warehouse}):</span>
<span className="text-gray-400 line-through">{Number(item.target_before || 0).toFixed(0)}</span>
<span className="text-emerald-600 font-bold"> {Number(item.target_after || 0).toFixed(0)}</span>
<span className="text-[10px] bg-emerald-50 text-emerald-600 px-1 rounded">+{item.quantity}</span>
</div>
)}
</div>
</TableCell>
</TableRow>
)) || null}
{/* 新增項目 */}
{activity.properties.items_diff.added?.map((item: any, idx: number) => (
<TableRow key={`add-${idx}`} className="bg-green-50/10 hover:bg-green-50/20">
<TableCell className="font-medium">{item.product_name}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200"></Badge>
</TableCell>
<TableCell className="text-sm">
{item.quantity !== undefined ? `數量: ${item.quantity} ${item.unit_name || ''} / ` : ''}
{item.adjust_qty !== undefined ? `調整量: ${item.adjust_qty} / ` : ''}
{item.subtotal !== undefined ? `小計: $${item.subtotal}` : ''}
</TableCell>
</TableRow>
)) || null}
{/* 移除項目 */}
{activity.properties.items_diff.removed?.map((item: any, idx: number) => (
<TableRow key={`rem-${idx}`} className="bg-red-50/10 hover:bg-red-50/20">
<TableCell className="font-medium text-gray-400 line-through">{item.product_name}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200"></Badge>
</TableCell>
<TableCell className="text-sm text-gray-400">
: {item.quantity} {item.unit_name}
</TableCell>
</TableRow>
)) || null}
))}
</TableBody>
</Table>
</div>

View File

@@ -42,6 +42,7 @@ const subjectTypeMap: Record<string, string> = {
'App\\Modules\\Inventory\\Models\\InventoryCountDoc': '庫存盤點單',
'App\\Modules\\Inventory\\Models\\InventoryAdjustDoc': '庫存盤調單',
'App\\Modules\\Inventory\\Models\\InventoryTransferOrder': '庫存調撥單',
'App\\Modules\\Inventory\\Models\\StoreRequisition': '門市叫貨單',
'App\\Modules\\Inventory\\Models\\StockMovementDoc': '庫存單據',
'App\\Modules\\Procurement\\Models\\Vendor': '廠商資料',
'App\\Modules\\Procurement\\Models\\PurchaseOrder': '採購單',
@@ -67,6 +68,7 @@ const subjectTypeMap: Record<string, string> = {
'InventoryCountDoc': '庫存盤點單',
'InventoryAdjustDoc': '庫存盤調單',
'InventoryTransferOrder': '庫存調撥單',
'StoreRequisition': '門市叫貨單',
'StockMovementDoc': '庫存單據',
'User': '使用者帳號',
'Role': '角色權限',

View File

@@ -1,193 +0,0 @@
/**
* 新增供貨商品對話框
*/
import { useState, useMemo } from "react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/Components/ui/dialog";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/Components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/Components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Product } from "@/types/product";
import type { SupplyProduct } from "@/types/vendor";
interface AddSupplyProductDialogProps {
open: boolean;
products: Product[];
existingSupplyProducts: SupplyProduct[];
onClose: () => void;
onAdd: (productId: string, lastPrice?: number) => void;
}
export default function AddSupplyProductDialog({
open,
products,
existingSupplyProducts,
onClose,
onAdd,
}: AddSupplyProductDialogProps) {
const [selectedProductId, setSelectedProductId] = useState<string>("");
const [lastPrice, setLastPrice] = useState<string>("");
const [openCombobox, setOpenCombobox] = useState(false);
// 過濾掉已經在供貨列表中的商品
const availableProducts = useMemo(() => {
const existingIds = new Set(existingSupplyProducts.map(sp => String(sp.productId)));
return products.filter(p => !existingIds.has(String(p.id)));
}, [products, existingSupplyProducts]);
const selectedProduct = availableProducts.find(p => p.id === selectedProductId);
const handleAdd = () => {
if (!selectedProductId) return;
const price = lastPrice ? parseFloat(lastPrice) : undefined;
onAdd(selectedProductId, price);
// 重置表單
setSelectedProductId("");
setLastPrice("");
};
const handleCancel = () => {
setSelectedProductId("");
setLastPrice("");
onClose();
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 商品選擇 */}
<div className="flex flex-col gap-2">
<Label className="text-sm font-medium"></Label>
<Popover open={openCombobox} onOpenChange={setOpenCombobox}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={openCombobox}
className="flex h-9 w-full items-center justify-between rounded-md border-2 border-grey-3 !bg-grey-5 px-3 py-1 text-sm font-normal text-grey-0 text-left outline-none transition-colors hover:!bg-grey-5 hover:border-primary/50 focus-visible:border-[var(--primary-main)] focus-visible:ring-[3px] focus-visible:ring-[var(--primary-main)]/20"
onClick={() => setOpenCombobox(!openCombobox)}
>
{selectedProduct ? (
<span className="font-medium text-gray-900">{selectedProduct.name}</span>
) : (
<span className="text-gray-400">...</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[450px] p-0 shadow-lg border-2 z-[9999]" align="start">
<Command>
<CommandInput placeholder="搜尋商品名稱..." />
<CommandList className="max-h-[300px]">
<CommandEmpty className="py-6 text-center text-sm text-gray-500">
</CommandEmpty>
<CommandGroup>
{availableProducts.map((product) => (
<CommandItem
key={product.id}
value={product.name}
onSelect={() => {
setSelectedProductId(product.id);
setOpenCombobox(false);
}}
className="cursor-pointer aria-selected:bg-primary/5 aria-selected:text-primary py-3"
>
<Check
className={cn(
"mr-2 h-4 w-4 text-primary",
selectedProductId === product.id ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex items-center justify-between flex-1">
<span className="font-medium">{product.name}</span>
<span className="text-xs text-gray-400 bg-gray-50 px-2 py-1 rounded">
{product.baseUnit?.name || (product.base_unit as any)?.name || product.base_unit || "個"}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 單位(自動帶入) */}
<div className="flex flex-col gap-2">
<Label className="text-sm font-medium text-gray-500"></Label>
<div className="h-10 px-3 py-2 bg-gray-50 border border-gray-200 rounded-md text-gray-600 font-medium text-sm flex items-center">
{selectedProduct ? (selectedProduct.baseUnit?.name || (selectedProduct.base_unit as any)?.name || selectedProduct.base_unit || "個") : "-"}
</div>
</div>
{/* 上次採購價格 */}
<div>
<Label className="text-muted-foreground text-xs">
/ {selectedProduct ? (selectedProduct.baseUnit?.name || (selectedProduct.base_unit as any)?.name || selectedProduct.base_unit || "個") : "單位"}
</Label>
<Input
type="number"
min="0"
step="any"
placeholder="輸入價格"
value={lastPrice}
onChange={(e) => setLastPrice(e.target.value)}
className="mt-1"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={handleCancel}
className="gap-2 button-outlined-primary"
>
</Button>
<Button
size="sm"
onClick={handleAdd}
disabled={!selectedProductId}
className="gap-2 button-filled-primary"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,119 +0,0 @@
/**
* 編輯供貨商品對話框
*/
import { useEffect, useState } from "react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/Components/ui/dialog";
import type { SupplyProduct } from "@/types/vendor";
interface EditSupplyProductDialogProps {
open: boolean;
product: SupplyProduct | null;
onClose: () => void;
onSave: (productId: string, lastPrice?: number) => void;
}
export default function EditSupplyProductDialog({
open,
product,
onClose,
onSave,
}: EditSupplyProductDialogProps) {
const [lastPrice, setLastPrice] = useState<string>("");
useEffect(() => {
if (product) {
setLastPrice(product.lastPrice?.toString() || "");
}
}, [product, open]);
const handleSave = () => {
if (!product) return;
const price = lastPrice ? parseFloat(lastPrice) : undefined;
onSave(product.productId, price);
setLastPrice("");
};
const handleCancel = () => {
setLastPrice("");
onClose();
};
if (!product) return null;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 商品名稱(不可編輯) */}
<div>
<Label className="text-muted-foreground text-xs"></Label>
<Input
value={product.productName}
disabled
className="mt-1 bg-muted"
/>
</div>
{/* 單位(不可編輯) */}
<div>
<Label className="text-muted-foreground text-xs"></Label>
<Input
value={product.unit}
disabled
className="mt-1 bg-muted"
/>
</div>
{/* 上次採購價格 */}
<div>
<Label className="text-muted-foreground text-xs"> / {product.baseUnit || "單位"}</Label>
<Input
type="number"
min="0"
step="any"
placeholder="輸入價格"
value={lastPrice}
onChange={(e) => setLastPrice(e.target.value)}
className="mt-1"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={handleCancel}
className="gap-2 button-outlined-primary"
>
</Button>
<Button
size="sm"
onClick={handleSave}
className="gap-2 button-filled-primary"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,5 +1,7 @@
import { Pencil, Trash2 } from "lucide-react";
import { Trash2 } from "lucide-react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import {
Table,
TableBody,
@@ -8,90 +10,129 @@ import {
TableHeader,
TableRow,
} from "@/Components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog";
import type { SupplyProduct } from "@/types/vendor";
interface SupplyProductListProps {
products: SupplyProduct[];
onEdit: (product: SupplyProduct) => void;
onRemove: (product: SupplyProduct) => void;
items: SupplyProduct[];
allProducts: any[];
onRemoveItem: (index: number) => void;
onItemChange: (index: number, field: keyof SupplyProduct, value: string | number) => void;
}
export default function SupplyProductList({
products,
onEdit,
onRemove,
items,
allProducts,
onRemoveItem,
onItemChange,
}: SupplyProductListProps) {
return (
<div className="bg-white rounded-lg border shadow-sm">
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableRow className="bg-gray-50 hover:bg-gray-50 text-grey-0">
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right">
<TableHead className="w-[40%] text-left"></TableHead>
<TableHead className="w-[15%] text-left"></TableHead>
<TableHead className="w-[20%] text-left">
<div className="text-xs font-normal text-muted-foreground">()</div>
<span className="text-[10px] font-normal text-muted-foreground block">()</span>
</TableHead>
<TableHead className="text-center w-[150px]"></TableHead>
<TableHead className="text-center w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.length === 0 ? (
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
<TableCell colSpan={5} className="text-center text-muted-foreground py-12 italic text-sm">
</TableCell>
</TableRow>
) : (
products.map((product, index) => (
<TableRow key={product.id}>
items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-gray-500 font-medium text-center">
{index + 1}
</TableCell>
<TableCell>{product.productName}</TableCell>
<TableCell>
{product.baseUnit || product.unit || "-"}
<SearchableSelect
value={item.productId}
onValueChange={(value) => onItemChange(index, "productId", value)}
options={allProducts.map(p => ({
label: p.name,
value: String(p.id)
}))}
placeholder="選擇商品"
searchPlaceholder="搜尋商品..."
className="w-full h-10"
/>
</TableCell>
<TableCell>
{product.largeUnit && product.conversionRate ? (
<span className="text-sm text-gray-500">
1 {product.largeUnit} = {Number(product.conversionRate)} {product.baseUnit || product.unit}
</span>
) : (
"-"
)}
</TableCell>
<TableCell className="text-right">
{product.lastPrice ? (
<span>
${product.lastPrice.toLocaleString()} / {product.baseUnit || product.unit || "單位"}
</span>
) : (
"-"
)}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onEdit(product)}
className="button-outlined-primary"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onRemove(product)}
className="button-outlined-error"
>
<Trash2 className="h-4 w-4" />
</Button>
<div className="h-10 px-3 py-2 bg-gray-50/50 border border-border rounded-md text-gray-600 font-medium text-sm flex items-center">
{item.baseUnit || "-"}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs">$</span>
<Input
type="number"
min="0"
step="any"
value={item.lastPrice === undefined ? "" : item.lastPrice}
onChange={(e) => onItemChange(index, "lastPrice", e.target.value === "" ? 0 : Number(e.target.value))}
placeholder="0.00"
className="pl-6 h-10 w-full text-right"
/>
</div>
</div>
</TableCell>
<TableCell className="text-center">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="button-outlined-error h-10 w-10 p-0"
title="移除項目"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{item.productName || "此商品"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="button-outlined-primary"></AlertDialogCancel>
<AlertDialogAction
onClick={() => onRemoveItem(index)}
className="bg-red-600 hover:bg-red-700 text-white"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))
)}

View File

@@ -1,29 +1,14 @@
/**
* 廠商詳細資訊頁面
*/
import { useState } from "react";
import { useState, useEffect } from "react";
import { Head, Link, router } from "@inertiajs/react";
import { Phone, Mail, Plus, ArrowLeft, Contact2 } from "lucide-react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Label } from "@/Components/ui/label";
import { Button } from "@/Components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import SupplyProductList from "@/Components/Vendor/SupplyProductList";
import AddSupplyProductDialog from "@/Components/Vendor/AddSupplyProductDialog";
import EditSupplyProductDialog from "@/Components/Vendor/EditSupplyProductDialog";
import type { Vendor } from "@/Pages/Vendor/Index";
import type { SupplyProduct } from "@/types/vendor";
import { getShowBreadcrumbs } from "@/utils/breadcrumb";
import { toast } from "sonner";
interface Pivot {
last_price: number | null;
@@ -33,12 +18,11 @@ interface VendorProduct {
id: number;
name: string;
unit?: string;
// Relations might be camelCase or snake_case depending on serialization settings
baseUnit?: { name: string };
base_unit?: { name: string };
largeUnit?: { name: string };
large_unit?: { name: string };
purchaseUnit?: string; // Note: if it's a relation it might be an object, but original code treated it as string
purchaseUnit?: string;
purchase_unit?: string;
conversion_rate?: number;
pivot: Pivot;
@@ -53,78 +37,102 @@ interface ShowProps {
products: any[];
}
export default function VendorShow({ vendor, products }: ShowProps) {
const [showAddDialog, setShowAddDialog] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false);
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [selectedProduct, setSelectedProduct] = useState<SupplyProduct | null>(null);
export default function VendorShow({ vendor, products: allProducts }: ShowProps) {
const [items, setItems] = useState<SupplyProduct[]>([]);
// 轉換後端資料格式為前端組件需要的格式
const supplyProducts: SupplyProduct[] = vendor.products.map(p => {
// Laravel load('relationName') usually results in camelCase key in JSON if method is camelCase
const baseUnitName = p.baseUnit?.name || p.base_unit?.name;
const largeUnitName = p.largeUnit?.name || p.large_unit?.name;
// 初始化資料
useEffect(() => {
const initialItems: SupplyProduct[] = vendor.products.map(p => {
const baseUnitName = p.baseUnit?.name || p.base_unit?.name;
const largeUnitName = p.largeUnit?.name || p.large_unit?.name;
// Check purchase unit - seemingly originally a field string, but if relation, check if object
// Assuming purchase_unit is a string field on product table here based on original code usage?
// Wait, original code usage: p.purchase_unit || ...
// In Product model: purchase_unit_id exists, purchaseUnit is relation.
// If p.purchase_unit was working before, it might be an attribute (accessors).
// Let's stick to safe access.
return {
id: String(p.id),
productId: String(p.id),
productName: p.name,
unit: p.purchase_unit || baseUnitName || "個",
baseUnit: baseUnitName,
largeUnit: largeUnitName,
conversionRate: p.conversion_rate,
lastPrice: p.pivot.last_price || undefined,
};
});
const handleAddProduct = (productId: string, lastPrice?: number) => {
router.post(route('vendors.products.store', vendor.id), {
product_id: productId,
last_price: lastPrice,
}, {
onSuccess: () => setShowAddDialog(false),
return {
id: String(p.id) + "_" + Math.random().toString(36).substr(2, 9), // 加上隨機碼以確保 key 唯一
productId: String(p.id),
productName: p.name,
unit: p.purchase_unit || baseUnitName || "個",
baseUnit: baseUnitName,
largeUnit: largeUnitName,
conversionRate: p.conversion_rate,
lastPrice: p.pivot.last_price || undefined,
};
});
setItems(initialItems);
}, [vendor.products]);
const handleAddItem = () => {
const newItem: SupplyProduct = {
id: "new_" + Math.random().toString(36).substr(2, 9),
productId: "",
productName: "",
unit: "個",
lastPrice: undefined,
};
setItems([...items, newItem]);
};
const handleEditProduct = (product: SupplyProduct) => {
setSelectedProduct(product);
setShowEditDialog(true);
const handleRemoveItem = (index: number) => {
const newItems = [...items];
newItems.splice(index, 1);
setItems(newItems);
};
const handleUpdateProduct = (productId: string, lastPrice?: number) => {
router.put(route('vendors.products.update', [vendor.id, productId]), {
last_price: lastPrice,
const handleItemChange = (index: number, field: keyof SupplyProduct, value: any) => {
const newItems = [...items];
const item = { ...newItems[index] };
if (field === "productId") {
const product = allProducts.find(p => String(p.id) === String(value));
if (product) {
item.productId = String(product.id);
item.productName = product.name;
item.baseUnit = product.baseUnit?.name || product.base_unit?.name || product.base_unit || "個";
item.largeUnit = product.largeUnit?.name || product.large_unit?.name;
item.conversionRate = product.conversion_rate;
item.unit = item.baseUnit || "個";
} else {
item.productId = value;
item.productName = "";
}
} else {
(item as any)[field] = value;
}
newItems[index] = item;
setItems(newItems);
};
const handleSaveAll = () => {
// 過濾掉沒有選擇商品的項目
const validItems = items.filter(item => item.productId !== "");
if (validItems.length === 0 && items.length > 0) {
toast.error("請至少選擇一個有效的商品,或移除空白列");
return;
}
// 檢查重複商品
const productIds = validItems.map(i => i.productId);
if (new Set(productIds).size !== productIds.length) {
toast.error("供貨清單中有重複的商品,請檢查");
return;
}
router.put(route('vendors.products.sync', vendor.id), {
products: validItems.map(item => ({
product_id: item.productId,
last_price: item.lastPrice,
}))
}, {
onSuccess: () => {
setShowEditDialog(false);
setSelectedProduct(null);
toast.success("供貨商品已更新");
},
onError: () => {
toast.error("更新失敗,請檢查欄位格式");
}
});
};
const handleRemoveProduct = (product: SupplyProduct) => {
setSelectedProduct(product);
setShowRemoveDialog(true);
};
const handleConfirmRemove = () => {
if (selectedProduct) {
router.delete(route('vendors.products.destroy', [vendor.id, selectedProduct.productId]), {
onSuccess: () => {
setShowRemoveDialog(false);
setSelectedProduct(null);
}
});
}
};
return (
<AuthenticatedLayout breadcrumbs={getShowBreadcrumbs("vendors", `廠商詳情 (${vendor.name})`)}>
<Head title={`廠商詳情 - ${vendor.name}`} />
@@ -164,11 +172,11 @@ export default function VendorShow({ vendor, products }: ShowProps) {
</div>
<div>
<Label className="text-muted-foreground text-xs"></Label>
<p className="mt-1 font-medium">{vendor.short_name || "-"}</p>
<p className="mt-1 font-medium">{vendor.shortName || "-"}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs"></Label>
<p className="mt-1">{vendor.tax_id || "-"}</p>
<p className="mt-1">{vendor.taxId || "-"}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs"></Label>
@@ -187,7 +195,7 @@ export default function VendorShow({ vendor, products }: ShowProps) {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label className="text-muted-foreground text-xs"></Label>
<p className="mt-1">{vendor.contact_name || "-"}</p>
<p className="mt-1">{vendor.contactName || "-"}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs"></Label>
@@ -212,9 +220,9 @@ export default function VendorShow({ vendor, products }: ShowProps) {
{/* 供貨商品列表 */}
<div className="bg-white rounded-lg border border-border p-6 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3></h3>
<h3 className="font-bold"></h3>
<Button
onClick={() => setShowAddDialog(true)}
onClick={handleAddItem}
className="gap-2 button-filled-primary"
size="sm"
>
@@ -222,57 +230,30 @@ export default function VendorShow({ vendor, products }: ShowProps) {
</Button>
</div>
<SupplyProductList
products={supplyProducts}
onEdit={handleEditProduct}
onRemove={handleRemoveProduct}
items={items}
allProducts={allProducts}
onRemoveItem={handleRemoveItem}
onItemChange={handleItemChange}
/>
</div>
{/* 新增供貨商品對話框 */}
<AddSupplyProductDialog
open={showAddDialog}
products={products}
existingSupplyProducts={supplyProducts}
onClose={() => setShowAddDialog(false)}
onAdd={handleAddProduct}
/>
{/* 編輯供貨商品對話框 */}
<EditSupplyProductDialog
open={showEditDialog}
product={selectedProduct}
onClose={() => {
setShowEditDialog(false);
setSelectedProduct(null);
}}
onSave={handleUpdateProduct}
/>
{/* 取消供貨確認對話框 */}
<AlertDialog open={showRemoveDialog} onOpenChange={setShowRemoveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{selectedProduct?.productName}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
className="gap-2 button-outlined-primary"
>
</AlertDialogCancel>
<AlertDialogAction
className="gap-2 button-filled-error"
onClick={handleConfirmRemove}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 底部按鈕 - 移至容器外 */}
<div className="mt-6 flex items-center justify-end gap-4 py-6 border-t border-gray-100">
<Link href="/vendors">
<Button variant="ghost" className="h-11 px-6 text-gray-500 hover:text-gray-700">
</Button>
</Link>
<Button
onClick={handleSaveAll}
className="bg-primary hover:bg-primary/90 text-white shadow-primary/20 h-11 px-8 text-lg font-bold rounded-xl"
size="lg"
>
</Button>
</div>
</div>
</AuthenticatedLayout>
);

View File

@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $title ?? 'API Documentation' }} - Star ERP</title>
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
scroll-behavior: smooth;
}
code, pre {
font-family: 'JetBrains Mono', monospace;
}
.prose pre {
background-color: #1e293b;
color: #f8fafc;
border-radius: 0.75rem;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
}
.sidebar-link.active {
color: #0ea5e9;
background-color: #f0f9ff;
border-right: 4px solid #0ea5e9;
}
</style>
</head>
<body class="bg-slate-50 text-slate-900">
<div class="flex min-h-screen">
<!-- Sidebar -->
<aside class="w-64 bg-white border-r border-slate-200 fixed h-full overflow-y-auto hidden md:block">
<div class="p-6">
<div class="flex items-center gap-2 mb-8">
<div class="w-8 h-8 bg-sky-500 rounded-lg flex items-center justify-center text-white font-bold">S</div>
<span class="font-bold text-xl tracking-tight">Star ERP</span>
</div>
<nav class="space-y-1">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 px-3">文件導覽</p>
@foreach($toc as $item)
<a href="#{{ $item['id'] }}" class="block px-3 py-2 text-sm font-medium text-slate-600 hover:text-sky-600 hover:bg-slate-50 rounded-md transition-all duration-200">
{{ $item['text'] }}
</a>
@endforeach
</nav>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 md:ml-64 p-6 md:p-12 lg:p-20">
<div class="max-w-4xl mx-auto">
<article class="prose prose-slate prose-sky max-w-none
prose-headings:scroll-mt-20
prose-h1:text-4xl prose-h1:font-extrabold prose-h1:tracking-tight
prose-pre:p-6 prose-pre:text-sm
prose-table:border prose-table:rounded-xl prose-table:overflow-hidden
prose-th:bg-slate-100 prose-th:p-4
prose-td:p-4 prose-td:border-t prose-td:border-slate-100">
{!! $content !!}
</article>
<footer class="mt-20 pt-8 border-t border-slate-200 text-slate-400 text-sm flex justify-between">
<span>© {{ date('Y') }} Star ERP System. All rights reserved.</span>
<span>整合介面版本 v1.2</span>
</footer>
</div>
</main>
</div>
<!-- Mobile Menu Button -->
<div class="md:hidden fixed bottom-6 right-6">
<button id="menu-toggle" class="w-14 h-14 bg-sky-500 text-white rounded-full shadow-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7" />
</svg>
</button>
</div>
</body>
</html>