feat: 修正庫存與撥補單邏輯並整合文件
1. 修復倉庫統計數據加總與樣式。 2. 修正可用庫存計算邏輯(排除不可銷售倉庫)。 3. 撥補單商品列表加入批號與效期顯示。 4. 修正撥補單儲存邏輯以支援精確批號轉移。 5. 整合 FEATURES.md 至 README.md。
This commit is contained in:
@@ -41,7 +41,7 @@ interface Props {
|
||||
activity: Activity | null;
|
||||
}
|
||||
|
||||
// Field translation map
|
||||
// 欄位翻譯對照表
|
||||
const fieldLabels: Record<string, string> = {
|
||||
name: '名稱',
|
||||
code: '商品代號',
|
||||
@@ -66,19 +66,19 @@ const fieldLabels: Record<string, string> = {
|
||||
role_id: '角色',
|
||||
email_verified_at: '電子郵件驗證時間',
|
||||
remember_token: '登入權杖',
|
||||
// Snapshot fields
|
||||
// 快照欄位
|
||||
category_name: '分類名稱',
|
||||
base_unit_name: '基本單位名稱',
|
||||
large_unit_name: '大單位名稱',
|
||||
purchase_unit_name: '採購單位名稱',
|
||||
// Vendor fields
|
||||
// 廠商欄位
|
||||
short_name: '簡稱',
|
||||
tax_id: '統編',
|
||||
owner: '負責人',
|
||||
contact_name: '聯絡人',
|
||||
tel: '電話',
|
||||
remark: '備註',
|
||||
// Warehouse & Inventory fields
|
||||
// 倉庫與庫存欄位
|
||||
warehouse_name: '倉庫名稱',
|
||||
product_name: '商品名稱',
|
||||
warehouse_id: '倉庫',
|
||||
@@ -86,7 +86,7 @@ const fieldLabels: Record<string, string> = {
|
||||
quantity: '數量',
|
||||
safety_stock: '安全庫存',
|
||||
location: '儲位',
|
||||
// Inventory fields
|
||||
// 庫存欄位
|
||||
batch_number: '批號',
|
||||
box_number: '箱號',
|
||||
origin_country: '來源國家',
|
||||
@@ -95,7 +95,7 @@ const fieldLabels: Record<string, string> = {
|
||||
source_purchase_order_id: '來源採購單',
|
||||
quality_status: '品質狀態',
|
||||
quality_remark: '品質備註',
|
||||
// Purchase Order fields
|
||||
// 採購單欄位
|
||||
po_number: '採購單號',
|
||||
vendor_id: '廠商',
|
||||
vendor_name: '廠商名稱',
|
||||
@@ -110,13 +110,13 @@ const fieldLabels: Record<string, string> = {
|
||||
invoice_date: '發票日期',
|
||||
invoice_amount: '發票金額',
|
||||
last_price: '供貨價格',
|
||||
// Utility Fee fields
|
||||
// 公共事業費欄位
|
||||
transaction_date: '費用日期',
|
||||
category: '費用類別',
|
||||
amount: '金額',
|
||||
};
|
||||
|
||||
// Purchase Order Status Map
|
||||
// 採購單狀態對照表
|
||||
const statusMap: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
pending: '待審核',
|
||||
@@ -127,7 +127,7 @@ const statusMap: Record<string, string> = {
|
||||
completed: '已完成',
|
||||
};
|
||||
|
||||
// Inventory Quality Status Map
|
||||
// 庫存品質狀態對照表
|
||||
const qualityStatusMap: Record<string, string> = {
|
||||
normal: '正常',
|
||||
frozen: '凍結',
|
||||
@@ -141,17 +141,17 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
const old = activity.properties?.old || {};
|
||||
const snapshot = activity.properties?.snapshot || {};
|
||||
|
||||
// Get all keys from both attributes and old to ensure we show all changes
|
||||
// 取得屬性和舊值的所有鍵,以確保顯示所有變更
|
||||
const allKeys = Array.from(new Set([...Object.keys(attributes), ...Object.keys(old)]));
|
||||
|
||||
// Custom sort order for fields
|
||||
// 自訂欄位排序順序
|
||||
const sortOrder = [
|
||||
'po_number', 'vendor_name', 'warehouse_name', 'expected_delivery_date', 'status', 'remark',
|
||||
'invoice_number', 'invoice_date', 'invoice_amount',
|
||||
'total_amount', 'tax_amount', 'grand_total' // Ensure specific order for amounts
|
||||
'total_amount', 'tax_amount', 'grand_total' // 確保金額的特定順序
|
||||
];
|
||||
|
||||
// Filter out internal keys often logged but not useful for users
|
||||
// 過濾掉通常會記錄但對使用者無用的內部鍵
|
||||
const filteredKeys = allKeys
|
||||
.filter(key =>
|
||||
!['created_at', 'updated_at', 'deleted_at', 'id', 'remember_token'].includes(key)
|
||||
@@ -160,16 +160,16 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
const indexA = sortOrder.indexOf(a);
|
||||
const indexB = sortOrder.indexOf(b);
|
||||
|
||||
// If both are in sortOrder, compare indices
|
||||
// 如果兩者都在排序順序中,比較索引
|
||||
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
|
||||
// If only A is in sortOrder, it comes first (or wherever logic dictates, usually put known fields first)
|
||||
// 如果只有 A 在排序順序中,它排在前面(或根據邏輯,通常將已知欄位排在前面)
|
||||
if (indexA !== -1) return -1;
|
||||
if (indexB !== -1) return 1;
|
||||
// Otherwise alphabetical or default
|
||||
// 否則按字母順序或預設
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// Helper to check if a key is a snapshot name field
|
||||
// 檢查鍵是否為快照名稱欄位的輔助函式
|
||||
const isSnapshotField = (key: string) => {
|
||||
return [
|
||||
'category_name', 'base_unit_name', 'large_unit_name', 'purchase_unit_name',
|
||||
@@ -197,26 +197,26 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
};
|
||||
|
||||
const formatValue = (key: string, value: any) => {
|
||||
// Mask password
|
||||
// 遮蔽密碼
|
||||
if (key === 'password') return '******';
|
||||
|
||||
if (value === null || value === undefined) return '-';
|
||||
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||
if (key === 'is_active') return value ? '啟用' : '停用';
|
||||
|
||||
// Handle Purchase Order Status
|
||||
// 處理採購單狀態
|
||||
if (key === 'status' && typeof value === 'string' && statusMap[value]) {
|
||||
return statusMap[value];
|
||||
}
|
||||
|
||||
// Handle Inventory Quality Status
|
||||
// 處理庫存品質狀態
|
||||
if (key === 'quality_status' && typeof value === 'string' && qualityStatusMap[value]) {
|
||||
return qualityStatusMap[value];
|
||||
}
|
||||
|
||||
// Handle Date Fields (YYYY-MM-DD)
|
||||
// 處理日期欄位 (YYYY-MM-DD)
|
||||
if ((key === 'expected_delivery_date' || key === 'invoice_date' || key === 'arrival_date' || key === 'expiry_date') && typeof value === 'string') {
|
||||
// Take only the date part (YYYY-MM-DD)
|
||||
// 僅取日期部分 (YYYY-MM-DD)
|
||||
return value.split('T')[0].split(' ')[0];
|
||||
}
|
||||
|
||||
@@ -224,10 +224,10 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
};
|
||||
|
||||
const getFormattedValue = (key: string, value: any) => {
|
||||
// If it's an ID field, try to find a corresponding name in snapshot or attributes
|
||||
// 如果是 ID 欄位,嘗試在快照或屬性中尋找對應名稱
|
||||
if (key.endsWith('_id')) {
|
||||
const nameKey = key.replace('_id', '_name');
|
||||
// Check snapshot first, then attributes
|
||||
// 先檢查快照,然後檢查屬性
|
||||
const nameValue = snapshot[nameKey] || attributes[nameKey];
|
||||
if (nameValue) {
|
||||
return `${nameValue}`;
|
||||
@@ -236,14 +236,14 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
return formatValue(key, value);
|
||||
};
|
||||
|
||||
// Helper to get translated field label
|
||||
// 取得翻譯欄位標籤的輔助函式
|
||||
const getFieldLabel = (key: string) => {
|
||||
return fieldLabels[key] || key;
|
||||
};
|
||||
|
||||
// Get subject name for header
|
||||
// 取得標題的主題名稱
|
||||
const getSubjectName = () => {
|
||||
// Special handling for Inventory: show "Warehouse - Product"
|
||||
// 庫存的特殊處理:顯示 "倉庫 - 商品"
|
||||
if ((snapshot.warehouse_name || attributes.warehouse_name) && (snapshot.product_name || attributes.product_name)) {
|
||||
const wName = snapshot.warehouse_name || attributes.warehouse_name;
|
||||
const pName = snapshot.product_name || attributes.product_name;
|
||||
@@ -276,7 +276,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Modern Metadata Strip */}
|
||||
{/* 現代化元數據條 */}
|
||||
<div className="flex flex-wrap items-center gap-6 pt-2 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-gray-400" />
|
||||
@@ -293,7 +293,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
{activity.properties?.sub_subject || activity.subject_type}
|
||||
</span>
|
||||
</div>
|
||||
{/* Only show 'description' if it differs from event name (unlikely but safe) */}
|
||||
{/* 僅在描述與事件名稱不同時顯示(不太可能發生但為了安全起見) */}
|
||||
{activity.description !== getEventLabel(activity.event) &&
|
||||
activity.description !== 'created' && activity.description !== 'updated' && (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -367,7 +367,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
const newValue = attributes[key];
|
||||
const isChanged = JSON.stringify(oldValue) !== JSON.stringify(newValue);
|
||||
|
||||
// For deleted events, we want to show the current attributes in the "Before" column
|
||||
// 對於刪除事件,我們希望在 "變更前" 欄位顯示當前屬性
|
||||
const displayBefore = activity.event === 'deleted'
|
||||
? getFormattedValue(key, newValue || oldValue)
|
||||
: getFormattedValue(key, oldValue);
|
||||
@@ -399,7 +399,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
{/* Items Diff Section (Special for Purchase Orders) */}
|
||||
{/* 項目差異區塊(採購單專用) */}
|
||||
{activity.properties?.items_diff && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<h3 className="text-sm font-bold text-gray-900 flex items-center gap-2 px-1">
|
||||
@@ -417,7 +417,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* Updated Items */}
|
||||
{/* 更新項目 */}
|
||||
{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>
|
||||
@@ -440,7 +440,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{/* Added Items */}
|
||||
{/* 新增項目 */}
|
||||
{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>
|
||||
@@ -453,7 +453,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{/* Removed Items */}
|
||||
{/* 移除項目 */}
|
||||
{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>
|
||||
|
||||
@@ -26,7 +26,7 @@ interface LogTableProps {
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
onSort?: (field: string) => void;
|
||||
onViewDetail: (activity: Activity) => void;
|
||||
from?: number; // Starting index number (paginator.from)
|
||||
from?: number; // 起始索引編號 (paginator.from)
|
||||
}
|
||||
|
||||
export default function LogTable({
|
||||
@@ -61,12 +61,12 @@ export default function LogTable({
|
||||
const old = props.old || {};
|
||||
const snapshot = props.snapshot || {};
|
||||
|
||||
// Try to find a name in snapshot, attributes or old values
|
||||
// Priority: snapshot > specific name fields > generic name > code > ID
|
||||
// 嘗試在快照、屬性或舊值中尋找名稱
|
||||
// 優先順序:快照 > 特定名稱欄位 > 通用名稱 > 代碼 > ID
|
||||
const nameParams = ['po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title', 'category'];
|
||||
let subjectName = '';
|
||||
|
||||
// Special handling for Inventory: show "Warehouse - Product"
|
||||
// 庫存的特殊處理:顯示 "倉庫 - 商品"
|
||||
if ((snapshot.warehouse_name || attrs.warehouse_name) && (snapshot.product_name || attrs.product_name)) {
|
||||
const wName = snapshot.warehouse_name || attrs.warehouse_name;
|
||||
const pName = snapshot.product_name || attrs.product_name;
|
||||
@@ -74,7 +74,7 @@ export default function LogTable({
|
||||
} else if (old.warehouse_name && old.product_name) {
|
||||
subjectName = `${old.warehouse_name} - ${old.product_name}`;
|
||||
} else {
|
||||
// Default fallback
|
||||
// 預設備案
|
||||
for (const param of nameParams) {
|
||||
if (snapshot[param]) {
|
||||
subjectName = snapshot[param];
|
||||
@@ -91,12 +91,12 @@ export default function LogTable({
|
||||
}
|
||||
}
|
||||
|
||||
// If no name found, try ID but format it nicely if possible, or just don't show it if it's redundant with subject_type
|
||||
// 如果找不到名稱,嘗試使用 ID,如果可能則格式化顯示,或者如果與主題類型重複則不顯示
|
||||
if (!subjectName && (attrs.id || old.id)) {
|
||||
subjectName = `#${attrs.id || old.id}`;
|
||||
}
|
||||
|
||||
// Combine parts: [Causer] [Action] [Name] [Subject]
|
||||
// 組合部分:[操作者] [動作] [名稱] [主題]
|
||||
// Example: Admin 新增 可樂 商品
|
||||
// Example: Admin 更新 台北倉 - 可樂 庫存
|
||||
return (
|
||||
@@ -114,7 +114,7 @@ export default function LogTable({
|
||||
<span className="text-gray-700">{activity.subject_type}</span>
|
||||
)}
|
||||
|
||||
{/* Display reason/source if available (e.g., from Replenishment) */}
|
||||
{/* 如果有原因/來源則顯示(例如:來自補貨) */}
|
||||
{(attrs._reason || old._reason) && (
|
||||
<span className="text-gray-500 text-xs">
|
||||
(來自 {attrs._reason || old._reason})
|
||||
|
||||
@@ -53,13 +53,13 @@ export default function ProductDialog({
|
||||
setData({
|
||||
code: product.code,
|
||||
name: product.name,
|
||||
category_id: product.category_id.toString(),
|
||||
category_id: product.categoryId.toString(),
|
||||
brand: product.brand || "",
|
||||
specification: product.specification || "",
|
||||
base_unit_id: product.base_unit_id?.toString() || "",
|
||||
large_unit_id: product.large_unit_id?.toString() || "",
|
||||
conversion_rate: product.conversion_rate ? product.conversion_rate.toString() : "",
|
||||
purchase_unit_id: product.purchase_unit_id?.toString() || "",
|
||||
base_unit_id: product.baseUnitId?.toString() || "",
|
||||
large_unit_id: product.largeUnitId?.toString() || "",
|
||||
conversion_rate: product.conversionRate ? product.conversionRate.toString() : "",
|
||||
purchase_unit_id: product.purchaseUnitId?.toString() || "",
|
||||
});
|
||||
} else {
|
||||
reset();
|
||||
|
||||
@@ -26,7 +26,7 @@ import type { Product } from "@/Pages/Product/Index";
|
||||
interface ProductTableProps {
|
||||
products: Product[];
|
||||
onEdit: (product: Product) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onDelete: (id: string) => void;
|
||||
|
||||
startIndex: number;
|
||||
sortField: string | null;
|
||||
@@ -125,11 +125,11 @@ export default function ProductTable({
|
||||
{product.category?.name || '-'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{product.base_unit?.name || '-'}</TableCell>
|
||||
<TableCell>{product.baseUnit?.name || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{product.large_unit ? (
|
||||
{product.largeUnit ? (
|
||||
<span className="text-sm text-gray-500">
|
||||
1 {product.large_unit?.name} = {Number(product.conversion_rate)} {product.base_unit?.name}
|
||||
1 {product.largeUnit?.name} = {Number(product.conversionRate)} {product.baseUnit?.name}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
|
||||
@@ -62,7 +62,7 @@ export function PurchaseOrderItemsTable({
|
||||
) : (
|
||||
items.map((item, index) => {
|
||||
// 計算換算後的單價 (基本單位單價)
|
||||
// unitPrice is derived from subtotal / quantity
|
||||
// 單價由 小計 / 數量 推導得出
|
||||
const currentUnitPrice = item.unitPrice;
|
||||
|
||||
const convertedUnitPrice = item.selectedUnit === 'large' && item.conversion_rate
|
||||
|
||||
@@ -26,7 +26,7 @@ import { toast } from "sonner";
|
||||
import { Trash2, Edit2, Check, X, Plus, Loader2 } from "lucide-react";
|
||||
|
||||
export interface Unit {
|
||||
id: number;
|
||||
id: string;
|
||||
name: string;
|
||||
code: string | null;
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export default function UnitManagerDialog({
|
||||
onOpenChange,
|
||||
units,
|
||||
}: UnitManagerDialogProps) {
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editCode, setEditCode] = useState("");
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function UnitManagerDialog({
|
||||
setEditCode("");
|
||||
};
|
||||
|
||||
const saveEdit = (id: number) => {
|
||||
const saveEdit = (id: string) => {
|
||||
if (!editName.trim()) return;
|
||||
|
||||
router.put(route("units.update", id), { name: editName, code: editCode }, {
|
||||
@@ -98,7 +98,7 @@ export default function UnitManagerDialog({
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
const handleDelete = (id: string) => {
|
||||
router.delete(route("units.destroy", id), {
|
||||
onSuccess: () => {
|
||||
// 由全域 flash 處理
|
||||
|
||||
@@ -45,10 +45,10 @@ export default function VendorDialog({
|
||||
if (vendor) {
|
||||
setData({
|
||||
name: vendor.name,
|
||||
short_name: vendor.short_name || "",
|
||||
tax_id: vendor.tax_id || "",
|
||||
short_name: vendor.shortName || "",
|
||||
tax_id: vendor.taxId || "",
|
||||
owner: vendor.owner || "",
|
||||
contact_name: vendor.contact_name || "",
|
||||
contact_name: vendor.contactName || "",
|
||||
tel: vendor.tel || "",
|
||||
phone: vendor.phone || "",
|
||||
email: vendor.email || "",
|
||||
|
||||
@@ -26,7 +26,7 @@ interface VendorTableProps {
|
||||
vendors: Vendor[];
|
||||
onView: (vendor: Vendor) => void;
|
||||
onEdit: (vendor: Vendor) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onDelete: (id: string) => void;
|
||||
sortField: string | null;
|
||||
sortDirection: "asc" | "desc" | null;
|
||||
onSort: (field: string) => void;
|
||||
@@ -107,11 +107,11 @@ export default function VendorTable({
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{vendor.name}</span>
|
||||
{vendor.short_name && <span className="text-xs text-gray-400">{vendor.short_name}</span>}
|
||||
{vendor.shortName && <span className="text-xs text-gray-400">{vendor.shortName}</span>}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{vendor.owner || '-'}</TableCell>
|
||||
<TableCell>{vendor.contact_name || '-'}</TableCell>
|
||||
<TableCell>{vendor.contactName || '-'}</TableCell>
|
||||
<TableCell>{vendor.phone || vendor.tel || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex justify-center gap-2">
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function AddSafetyStockDialog({
|
||||
// 更新商品安全庫存量
|
||||
const updateQuantity = (productId: string, value: number) => {
|
||||
const newQuantities = new Map(productQuantities);
|
||||
newQuantities.set(productId, value); // Allow 0
|
||||
newQuantities.set(productId, value); // 允許為 0
|
||||
setProductQuantities(newQuantities);
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ interface TransferOrderDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
order: TransferOrder | null;
|
||||
warehouses: Warehouse[];
|
||||
// inventories: WarehouseInventory[]; // Removed as we fetch from API
|
||||
// inventories: WarehouseInventory[]; // 因從 API 獲取而移除
|
||||
onSave: (order: Omit<TransferOrder, "id" | "createdAt" | "orderNumber">) => void;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ interface AvailableProduct {
|
||||
batchNumber: string;
|
||||
availableQty: number;
|
||||
unit: string;
|
||||
expiryDate: string | null;
|
||||
}
|
||||
|
||||
export default function TransferOrderDialog({
|
||||
@@ -99,7 +100,15 @@ export default function TransferOrderDialog({
|
||||
if (formData.sourceWarehouseId) {
|
||||
axios.get(route('api.warehouses.inventories', formData.sourceWarehouseId))
|
||||
.then(response => {
|
||||
setAvailableProducts(response.data);
|
||||
const mappedData = response.data.map((item: any) => ({
|
||||
productId: item.product_id,
|
||||
productName: item.product_name,
|
||||
batchNumber: item.batch_number,
|
||||
availableQty: item.quantity,
|
||||
unit: item.unit_name,
|
||||
expiryDate: item.expiry_date
|
||||
}));
|
||||
setAvailableProducts(mappedData);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Failed to fetch inventories:", error);
|
||||
@@ -240,7 +249,7 @@ export default function TransferOrderDialog({
|
||||
onValueChange={handleProductChange}
|
||||
disabled={!formData.sourceWarehouseId || !!order}
|
||||
options={availableProducts.map((product) => ({
|
||||
label: `${product.productName} (庫存: ${product.availableQty} ${product.unit})`,
|
||||
label: `${product.productName} | 批號: ${product.batchNumber || '-'} | 效期: ${product.expiryDate || '-'} (庫存: ${product.availableQty} ${product.unit})`,
|
||||
value: `${product.productId}|||${product.batchNumber}`,
|
||||
}))}
|
||||
placeholder="選擇商品與批號"
|
||||
|
||||
@@ -78,8 +78,17 @@ export default function WarehouseCard({
|
||||
{warehouse.description || "無描述"}
|
||||
</div>
|
||||
|
||||
{/* 統計區塊 - 庫存警告 */}
|
||||
|
||||
{/* 統計區塊 - 狀態標籤 */}
|
||||
<div className="space-y-3">
|
||||
{/* 銷售狀態 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">銷售狀態</span>
|
||||
<Badge variant={warehouse.is_sellable ? "default" : "secondary"} className={warehouse.is_sellable ? "bg-green-600" : "bg-gray-400"}>
|
||||
{warehouse.is_sellable ? "可銷售" : "暫停銷售"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 低庫存警告狀態 */}
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
|
||||
@@ -51,11 +51,13 @@ export default function WarehouseDialog({
|
||||
name: string;
|
||||
address: string;
|
||||
description: string;
|
||||
is_sellable: boolean;
|
||||
}>({
|
||||
code: "",
|
||||
name: "",
|
||||
address: "",
|
||||
description: "",
|
||||
is_sellable: true,
|
||||
});
|
||||
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
@@ -67,6 +69,7 @@ export default function WarehouseDialog({
|
||||
name: warehouse.name,
|
||||
address: warehouse.address || "",
|
||||
description: warehouse.description || "",
|
||||
is_sellable: warehouse.is_sellable ?? true,
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
@@ -74,6 +77,7 @@ export default function WarehouseDialog({
|
||||
name: "",
|
||||
address: "",
|
||||
description: "",
|
||||
is_sellable: true,
|
||||
});
|
||||
}
|
||||
}, [warehouse, open]);
|
||||
@@ -148,6 +152,23 @@ export default function WarehouseDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 銷售設定 */}
|
||||
<div className="space-y-4">
|
||||
<div className="border-b pb-2">
|
||||
<h4 className="text-sm text-gray-700">銷售設定</h4>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_sellable"
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-main focus:ring-primary-main"
|
||||
checked={formData.is_sellable}
|
||||
onChange={(e) => setFormData({ ...formData, is_sellable: e.target.checked })}
|
||||
/>
|
||||
<Label htmlFor="is_sellable">此倉庫可進行銷售扣庫</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 區塊 B:位置 */}
|
||||
<div className="space-y-4">
|
||||
<div className="border-b pb-2">
|
||||
@@ -210,10 +231,10 @@ export default function WarehouseDialog({
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog >
|
||||
|
||||
{/* 刪除確認對話框 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
< AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog} >
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確認刪除倉庫</AlertDialogTitle>
|
||||
@@ -231,7 +252,7 @@ export default function WarehouseDialog({
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</AlertDialog >
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
FileText,
|
||||
Wallet,
|
||||
BarChart3,
|
||||
FileSpreadsheet
|
||||
FileSpreadsheet,
|
||||
BookOpen
|
||||
} from "lucide-react";
|
||||
import { toast, Toaster } from "sonner";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
@@ -133,8 +134,15 @@ export default function AuthenticatedLayout({
|
||||
id: "production-management",
|
||||
label: "生產管理",
|
||||
icon: <Boxes className="h-5 w-5" />,
|
||||
permission: "production_orders.view",
|
||||
permission: ["production_orders.view", "recipes.view"],
|
||||
children: [
|
||||
{
|
||||
id: "recipe-list",
|
||||
label: "配方管理",
|
||||
icon: <BookOpen className="h-4 w-4" />,
|
||||
route: "/recipes",
|
||||
permission: "recipes.view",
|
||||
},
|
||||
{
|
||||
id: "production-order-list",
|
||||
label: "生產工單",
|
||||
@@ -532,7 +540,7 @@ export default function AuthenticatedLayout({
|
||||
"flex-1 flex flex-col transition-all duration-300 min-h-screen overflow-auto",
|
||||
"lg:ml-64",
|
||||
isCollapsed && "lg:ml-20",
|
||||
"pt-16" // Always allow space for header
|
||||
"pt-16" // 始終為頁首保留空間
|
||||
)}>
|
||||
<div className="relative">
|
||||
<div className="container mx-auto px-6 pt-6 max-w-7xl">
|
||||
|
||||
@@ -20,22 +20,22 @@ export interface Category {
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
category_id: number;
|
||||
categoryId: number;
|
||||
category?: Category;
|
||||
brand?: string;
|
||||
specification?: string;
|
||||
base_unit_id: number;
|
||||
base_unit?: Unit;
|
||||
large_unit_id?: number;
|
||||
large_unit?: Unit;
|
||||
conversion_rate?: number;
|
||||
purchase_unit_id?: number;
|
||||
purchase_unit?: Unit;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
baseUnitId: number;
|
||||
baseUnit?: Unit;
|
||||
largeUnitId?: number;
|
||||
largeUnit?: Unit;
|
||||
conversionRate?: number;
|
||||
purchaseUnitId?: number;
|
||||
purchaseUnit?: Unit;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
@@ -163,7 +163,7 @@ export default function ProductManagement({ products, categories, units, filters
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteProduct = (id: number) => {
|
||||
const handleDeleteProduct = (id: string) => {
|
||||
router.delete(route('products.destroy', id), {
|
||||
onSuccess: () => {
|
||||
// Toast handled by flash message
|
||||
|
||||
@@ -53,18 +53,18 @@ interface InventoryOption {
|
||||
}
|
||||
|
||||
interface BomItem {
|
||||
// Backend required
|
||||
inventory_id: string; // The selected inventory record ID (Specific Batch)
|
||||
quantity_used: string; // The converted final quantity (Base Unit)
|
||||
unit_id: string; // The unit ID (Base Unit ID usually)
|
||||
// 後端必填
|
||||
inventory_id: string; // 所選庫存記錄 ID(特定批號)
|
||||
quantity_used: string; // 轉換後的最終數量(基本單位)
|
||||
unit_id: string; // 單位 ID(通常為基本單位 ID)
|
||||
|
||||
// UI State
|
||||
ui_warehouse_id: string; // Source Warehouse
|
||||
ui_product_id: string; // Filter for batch list
|
||||
ui_input_quantity: string; // User typed quantity
|
||||
ui_selected_unit: 'base' | 'large'; // User selected unit
|
||||
// UI 狀態
|
||||
ui_warehouse_id: string; // 來源倉庫
|
||||
ui_product_id: string; // 批號列表篩選
|
||||
ui_input_quantity: string; // 使用者輸入數量
|
||||
ui_selected_unit: 'base' | 'large'; // 使用者選擇單位
|
||||
|
||||
// UI Helpers / Cache
|
||||
// UI 輔助 / 快取
|
||||
ui_product_name?: string;
|
||||
ui_batch_number?: string;
|
||||
ui_available_qty?: number;
|
||||
@@ -83,8 +83,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // Output Warehouse
|
||||
// Cache map: warehouse_id -> inventories
|
||||
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // 產出倉庫
|
||||
// 快取對照表:warehouse_id -> inventories
|
||||
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
||||
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({});
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
|
||||
});
|
||||
|
||||
// Helper to fetch warehouse data
|
||||
// 獲取倉庫資料的輔助函式
|
||||
const fetchWarehouseInventory = async (warehouseId: string) => {
|
||||
if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return;
|
||||
|
||||
|
||||
@@ -52,18 +52,18 @@ interface InventoryOption {
|
||||
}
|
||||
|
||||
interface BomItem {
|
||||
// Backend required
|
||||
// 後端必填
|
||||
inventory_id: string;
|
||||
quantity_used: string;
|
||||
unit_id: string;
|
||||
|
||||
// UI State
|
||||
ui_warehouse_id: string; // Source Warehouse
|
||||
// UI 狀態
|
||||
ui_warehouse_id: string; // 來源倉庫
|
||||
ui_product_id: string;
|
||||
ui_input_quantity: string;
|
||||
ui_selected_unit: 'base' | 'large';
|
||||
|
||||
// UI Helpers / Cache
|
||||
// UI 輔助 / 快取
|
||||
ui_product_name?: string;
|
||||
ui_batch_number?: string;
|
||||
ui_available_qty?: number;
|
||||
@@ -134,13 +134,13 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
|
||||
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(
|
||||
productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : ""
|
||||
); // Output Warehouse
|
||||
); // 產出倉庫
|
||||
|
||||
// Cache map: warehouse_id -> inventories
|
||||
// 快取對照表:warehouse_id -> inventories
|
||||
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
||||
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Helper to fetch warehouse data
|
||||
// 獲取倉庫資料的輔助函式
|
||||
const fetchWarehouseInventory = async (warehouseId: string) => {
|
||||
if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return;
|
||||
|
||||
@@ -168,7 +168,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
ui_input_quantity: String(item.quantity_used), // 假設已存的資料是基本單位
|
||||
ui_selected_unit: 'base',
|
||||
|
||||
// UI Helpers
|
||||
// UI 輔助
|
||||
ui_product_name: item.inventory?.product?.name,
|
||||
ui_batch_number: item.inventory?.batch_number,
|
||||
ui_available_qty: item.inventory?.quantity,
|
||||
@@ -600,7 +600,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
currentOptions.map(inv => [inv.product_id, { label: inv.product_name, value: String(inv.product_id) }])
|
||||
).values());
|
||||
|
||||
// Fallback for initial state before fetch
|
||||
// 在獲取前初始狀態的備案
|
||||
const displayProductOptions = uniqueProductOptions.length > 0 ? uniqueProductOptions : (item.ui_product_name ? [{ label: item.ui_product_name, value: item.ui_product_id }] : []);
|
||||
|
||||
const batchOptions = currentOptions
|
||||
@@ -610,7 +610,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
value: String(inv.id)
|
||||
}));
|
||||
|
||||
// Fallback
|
||||
// 備案
|
||||
const displayBatchOptions = batchOptions.length > 0 ? batchOptions : (item.inventory_id && item.ui_batch_number ? [{ label: item.ui_batch_number, value: item.inventory_id }] : []);
|
||||
|
||||
|
||||
|
||||
320
resources/js/Pages/Production/Recipe/Create.tsx
Normal file
320
resources/js/Pages/Production/Recipe/Create.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* 新增配方頁面
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { BookOpen, Plus, Trash2, ArrowLeft, Save } from 'lucide-react';
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router, useForm, Link } from "@inertiajs/react";
|
||||
import { toast } from "sonner";
|
||||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||
|
||||
interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
base_unit_id?: number;
|
||||
large_unit_id?: number;
|
||||
}
|
||||
|
||||
interface Unit {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface RecipeItem {
|
||||
product_id: string;
|
||||
quantity: string;
|
||||
unit_id: string;
|
||||
remark: string;
|
||||
// UI Helpers
|
||||
ui_product_name?: string;
|
||||
ui_product_code?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
products: Product[];
|
||||
units: Unit[];
|
||||
}
|
||||
|
||||
export default function RecipeCreate({ products, units }: Props) {
|
||||
const { data, setData, post, processing, errors } = useForm({
|
||||
product_id: "",
|
||||
code: "",
|
||||
name: "",
|
||||
description: "",
|
||||
yield_quantity: "1",
|
||||
items: [] as RecipeItem[],
|
||||
});
|
||||
|
||||
// 自動產生配方名稱 (當選擇商品時)
|
||||
useEffect(() => {
|
||||
if (data.product_id && !data.name) {
|
||||
const product = products.find(p => String(p.id) === data.product_id);
|
||||
if (product) {
|
||||
setData(d => ({ ...d, name: `${product.name} 標準配方` }));
|
||||
}
|
||||
}
|
||||
// 自動產生代號 (簡易版)
|
||||
if (data.product_id && !data.code) {
|
||||
const product = products.find(p => String(p.id) === data.product_id);
|
||||
if (product) {
|
||||
setData(d => ({ ...d, code: `REC-${product.code}` }));
|
||||
}
|
||||
}
|
||||
}, [data.product_id]);
|
||||
|
||||
const addItem = () => {
|
||||
setData('items', [
|
||||
...data.items,
|
||||
{ product_id: "", quantity: "1", unit_id: "", remark: "" }
|
||||
]);
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
setData('items', data.items.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateItem = (index: number, field: keyof RecipeItem, value: string) => {
|
||||
const newItems = [...data.items];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
|
||||
// Auto-fill unit when product selected
|
||||
if (field === 'product_id') {
|
||||
const product = products.find(p => String(p.id) === value);
|
||||
if (product) {
|
||||
newItems[index].ui_product_name = product.name;
|
||||
newItems[index].ui_product_code = product.code;
|
||||
// Default to base unit
|
||||
if (product.base_unit_id) {
|
||||
newItems[index].unit_id = String(product.base_unit_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setData('items', newItems);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(route('recipes.store'), {
|
||||
onSuccess: () => {
|
||||
toast.success("配方已建立");
|
||||
},
|
||||
onError: (errors) => {
|
||||
toast.error("儲存失敗,請檢查欄位");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes", [{ label: "新增", isPage: true }])}>
|
||||
<Head title="新增配方" />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
<div className="mb-6">
|
||||
<Link href={route('recipes.index')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回列表
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<BookOpen className="h-6 w-6 text-primary-main" />
|
||||
新增配方
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
定義新的生產配方
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={processing}
|
||||
className="button-filled-primary gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
儲存配方
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 左側:基本資料 */}
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<h2 className="text-lg font-semibold mb-4">基本資料</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">對應成品 *</Label>
|
||||
<SearchableSelect
|
||||
value={data.product_id}
|
||||
onValueChange={(v) => setData('product_id', v)}
|
||||
options={products.map(p => ({
|
||||
label: `${p.name} (${p.code})`,
|
||||
value: String(p.id),
|
||||
}))}
|
||||
placeholder="選擇商品"
|
||||
className="w-full"
|
||||
/>
|
||||
{errors.product_id && <p className="text-red-500 text-xs">{errors.product_id}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">配方代號 *</Label>
|
||||
<Input
|
||||
value={data.code}
|
||||
onChange={(e) => setData('code', e.target.value)}
|
||||
placeholder="例如: REC-P001"
|
||||
/>
|
||||
{errors.code && <p className="text-red-500 text-xs">{errors.code}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">配方名稱 *</Label>
|
||||
<Input
|
||||
value={data.name}
|
||||
onChange={(e) => setData('name', e.target.value)}
|
||||
placeholder="例如: 草莓冰標準配方"
|
||||
/>
|
||||
{errors.name && <p className="text-red-500 text-xs">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">標準產出量 *</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={data.yield_quantity}
|
||||
onChange={(e) => setData('yield_quantity', e.target.value)}
|
||||
placeholder="1"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">份</span>
|
||||
</div>
|
||||
{errors.yield_quantity && <p className="text-red-500 text-xs">{errors.yield_quantity}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">描述</Label>
|
||||
<Textarea
|
||||
value={data.description}
|
||||
onChange={(e) => setData('description', e.target.value)}
|
||||
placeholder="備註說明..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右側:配方明細 */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 h-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">配方明細 (BOM)</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addItem}
|
||||
className="gap-2 button-filled-primary text-white"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新增原料
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[35%]">原物料商品</TableHead>
|
||||
<TableHead className="w-[20%]">標準用量</TableHead>
|
||||
<TableHead className="w-[20%]">單位</TableHead>
|
||||
<TableHead className="w-[20%]">備註</TableHead>
|
||||
<TableHead className="w-[5%]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center text-gray-500">
|
||||
請新增原物料項目
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.items.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="align-top">
|
||||
<SearchableSelect
|
||||
value={item.product_id}
|
||||
onValueChange={(v) => updateItem(index, 'product_id', v)}
|
||||
options={products.map(p => ({
|
||||
label: `${p.name} (${p.code})`,
|
||||
value: String(p.id)
|
||||
}))}
|
||||
placeholder="選擇原料"
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={item.quantity}
|
||||
onChange={(e) => updateItem(index, 'quantity', e.target.value)}
|
||||
placeholder="數量"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<SearchableSelect
|
||||
value={item.unit_id}
|
||||
onValueChange={(v) => updateItem(index, 'unit_id', v)}
|
||||
options={units.map(u => ({
|
||||
label: u.name,
|
||||
value: String(u.id)
|
||||
}))}
|
||||
placeholder="單位"
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Input
|
||||
value={item.remark}
|
||||
onChange={(e) => updateItem(index, 'remark', e.target.value)}
|
||||
placeholder="備註"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeItem(index)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
344
resources/js/Pages/Production/Recipe/Edit.tsx
Normal file
344
resources/js/Pages/Production/Recipe/Edit.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* 編輯配方頁面
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { BookOpen, Plus, Trash2, ArrowLeft, Save } from 'lucide-react';
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router, useForm, Link } from "@inertiajs/react";
|
||||
import { toast } from "sonner";
|
||||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||
|
||||
interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
base_unit_id?: number;
|
||||
large_unit_id?: number;
|
||||
}
|
||||
|
||||
interface Unit {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Backend Model Structure
|
||||
interface RecipeItemModel {
|
||||
id: number;
|
||||
product_id: number;
|
||||
quantity: number;
|
||||
unit_id: number;
|
||||
remark: string | null;
|
||||
product?: Product;
|
||||
unit?: Unit;
|
||||
}
|
||||
|
||||
interface RecipeModel {
|
||||
id: number;
|
||||
product_id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
yield_quantity: number;
|
||||
items: RecipeItemModel[];
|
||||
product?: Product;
|
||||
}
|
||||
|
||||
// Form State Structure
|
||||
interface RecipeItemForm {
|
||||
product_id: string;
|
||||
quantity: string;
|
||||
unit_id: string;
|
||||
remark: string;
|
||||
// UI Helpers
|
||||
ui_product_name?: string;
|
||||
ui_product_code?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
recipe: RecipeModel;
|
||||
products: Product[];
|
||||
units: Unit[];
|
||||
}
|
||||
|
||||
export default function RecipeEdit({ recipe, products, units }: Props) {
|
||||
const { data, setData, put, processing, errors } = useForm({
|
||||
product_id: String(recipe.product_id),
|
||||
code: recipe.code,
|
||||
name: recipe.name,
|
||||
description: recipe.description || "",
|
||||
yield_quantity: String(recipe.yield_quantity),
|
||||
items: recipe.items.map(item => ({
|
||||
product_id: String(item.product_id),
|
||||
quantity: String(item.quantity),
|
||||
unit_id: String(item.unit_id),
|
||||
remark: item.remark || "",
|
||||
ui_product_name: item.product?.name,
|
||||
ui_product_code: item.product?.code
|
||||
})) as RecipeItemForm[],
|
||||
});
|
||||
|
||||
// 自動產生配方名稱 (當選擇商品時) - 僅在名稱為空時觸發,避免覆蓋舊資料
|
||||
useEffect(() => {
|
||||
if (data.product_id && !data.name) {
|
||||
const product = products.find(p => String(p.id) === data.product_id);
|
||||
if (product) {
|
||||
setData(d => ({ ...d, name: `${product.name} 標準配方` }));
|
||||
}
|
||||
}
|
||||
}, [data.product_id]);
|
||||
|
||||
const addItem = () => {
|
||||
setData('items', [
|
||||
...data.items,
|
||||
{ product_id: "", quantity: "1", unit_id: "", remark: "" }
|
||||
]);
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
setData('items', data.items.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateItem = (index: number, field: keyof RecipeItemForm, value: string) => {
|
||||
const newItems = [...data.items];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
|
||||
// Auto-fill unit when product selected
|
||||
if (field === 'product_id') {
|
||||
const product = products.find(p => String(p.id) === value);
|
||||
if (product) {
|
||||
newItems[index].ui_product_name = product.name;
|
||||
newItems[index].ui_product_code = product.code;
|
||||
// Default to base unit if not set
|
||||
if (product.base_unit_id) {
|
||||
newItems[index].unit_id = String(product.base_unit_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setData('items', newItems);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
put(route('recipes.update', recipe.id), {
|
||||
onSuccess: () => {
|
||||
toast.success("配方已更新");
|
||||
},
|
||||
onError: (errors) => {
|
||||
toast.error("儲存失敗,請檢查欄位");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes", [{ label: "編輯", isPage: true }])}>
|
||||
<Head title="編輯配方" />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
<div className="mb-6">
|
||||
<Link href={route('recipes.index')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回列表
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<BookOpen className="h-6 w-6 text-primary-main" />
|
||||
編輯配方
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
修改 {recipe.name} ({recipe.code})
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={processing}
|
||||
className="button-filled-primary gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
儲存變更
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 左側:基本資料 */}
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<h2 className="text-lg font-semibold mb-4">基本資料</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">對應成品 *</Label>
|
||||
<SearchableSelect
|
||||
value={data.product_id}
|
||||
onValueChange={(v) => setData('product_id', v)}
|
||||
options={products.map(p => ({
|
||||
label: `${p.name} (${p.code})`,
|
||||
value: String(p.id),
|
||||
}))}
|
||||
placeholder="選擇商品"
|
||||
className="w-full"
|
||||
/>
|
||||
{errors.product_id && <p className="text-red-500 text-xs">{errors.product_id}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">配方代號 *</Label>
|
||||
<Input
|
||||
value={data.code}
|
||||
onChange={(e) => setData('code', e.target.value)}
|
||||
placeholder="例如: REC-P001"
|
||||
/>
|
||||
{errors.code && <p className="text-red-500 text-xs">{errors.code}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">配方名稱 *</Label>
|
||||
<Input
|
||||
value={data.name}
|
||||
onChange={(e) => setData('name', e.target.value)}
|
||||
placeholder="例如: 草莓冰標準配方"
|
||||
/>
|
||||
{errors.name && <p className="text-red-500 text-xs">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">標準產出量 *</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={data.yield_quantity}
|
||||
onChange={(e) => setData('yield_quantity', e.target.value)}
|
||||
placeholder="1"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">份</span>
|
||||
</div>
|
||||
{errors.yield_quantity && <p className="text-red-500 text-xs">{errors.yield_quantity}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">描述</Label>
|
||||
<Textarea
|
||||
value={data.description}
|
||||
onChange={(e) => setData('description', e.target.value)}
|
||||
placeholder="備註說明..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右側:配方明細 */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 h-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">配方明細 (BOM)</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addItem}
|
||||
className="gap-2 button-filled-primary text-white"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新增原料
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[35%]">原物料商品</TableHead>
|
||||
<TableHead className="w-[20%]">標準用量</TableHead>
|
||||
<TableHead className="w-[20%]">單位</TableHead>
|
||||
<TableHead className="w-[20%]">備註</TableHead>
|
||||
<TableHead className="w-[5%]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center text-gray-500">
|
||||
請新增原物料項目
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.items.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="align-top">
|
||||
<SearchableSelect
|
||||
value={item.product_id}
|
||||
onValueChange={(v) => updateItem(index, 'product_id', v)}
|
||||
options={products.map(p => ({
|
||||
label: `${p.name} (${p.code})`,
|
||||
value: String(p.id)
|
||||
}))}
|
||||
placeholder="選擇原料"
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={item.quantity}
|
||||
onChange={(e) => updateItem(index, 'quantity', e.target.value)}
|
||||
placeholder="數量"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<SearchableSelect
|
||||
value={item.unit_id}
|
||||
onValueChange={(v) => updateItem(index, 'unit_id', v)}
|
||||
options={units.map(u => ({
|
||||
label: u.name,
|
||||
value: String(u.id)
|
||||
}))}
|
||||
placeholder="單位"
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Input
|
||||
value={item.remark}
|
||||
onChange={(e) => updateItem(index, 'remark', e.target.value)}
|
||||
placeholder="備註"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeItem(index)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
263
resources/js/Pages/Production/Recipe/Index.tsx
Normal file
263
resources/js/Pages/Production/Recipe/Index.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 配方管理主頁面
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Search, RotateCcw, Pencil, Trash2, BookOpen } from 'lucide-react';
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router, Link } from "@inertiajs/react";
|
||||
import Pagination from "@/Components/shared/Pagination";
|
||||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
interface Recipe {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
product_id: number;
|
||||
product?: { id: number; name: string; code: string };
|
||||
yield_quantity: number;
|
||||
is_active: boolean;
|
||||
description: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
recipes: {
|
||||
data: Recipe[];
|
||||
links: any[];
|
||||
total: number;
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
filters: {
|
||||
search?: string;
|
||||
per_page?: string;
|
||||
sort_field?: string;
|
||||
sort_direction?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function RecipeIndex({ recipes, filters }: Props) {
|
||||
const [search, setSearch] = useState(filters.search || "");
|
||||
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
|
||||
|
||||
useEffect(() => {
|
||||
setSearch(filters.search || "");
|
||||
setPerPage(filters.per_page || "10");
|
||||
}, [filters]);
|
||||
|
||||
const handleFilter = () => {
|
||||
router.get(
|
||||
route('recipes.index'),
|
||||
{
|
||||
search,
|
||||
per_page: perPage,
|
||||
},
|
||||
{ preserveState: true, replace: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSearch("");
|
||||
router.get(route('recipes.index'));
|
||||
};
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
setPerPage(value);
|
||||
router.get(
|
||||
route("recipes.index"),
|
||||
{ ...filters, per_page: value },
|
||||
{ preserveState: false, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm("確定要刪除此配方嗎?")) {
|
||||
router.delete(route('recipes.destroy', id));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes")}>
|
||||
<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">
|
||||
<BookOpen className="h-6 w-6 text-primary-main" />
|
||||
配方管理
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
管理產品的標準生產配方與用量
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href={route('recipes.create')}>
|
||||
<Button className="gap-2 button-filled-primary">
|
||||
<Plus className="h-4 w-4" />
|
||||
新增配方
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 篩選區塊 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 mb-6 overflow-hidden">
|
||||
<div className="p-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
|
||||
<div className="md:col-span-12 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">關鍵字搜尋</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="搜尋配方代號、名稱、產品名稱..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 h-9 block"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end px-5 py-4 bg-gray-50/50 border-t border-gray-100 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="button-outlined-primary h-9 gap-2"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFilter}
|
||||
className="button-filled-primary h-9 px-6 gap-2"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
搜尋
|
||||
</Button>
|
||||
</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-[120px]">配方代號</TableHead>
|
||||
<TableHead>配方名稱</TableHead>
|
||||
<TableHead>對應成品</TableHead>
|
||||
<TableHead className="text-right">標準產量</TableHead>
|
||||
<TableHead className="text-center w-[100px]">狀態</TableHead>
|
||||
<TableHead className="w-[150px]">更新時間</TableHead>
|
||||
<TableHead className="text-center w-[120px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recipes.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-32 text-center text-gray-500">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<BookOpen className="h-10 w-10 text-gray-300" />
|
||||
<p>尚無配方資料</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
recipes.data.map((recipe) => (
|
||||
<TableRow key={recipe.id}>
|
||||
<TableCell className="font-medium text-gray-900">
|
||||
{recipe.code}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-gray-900">{recipe.name}</span>
|
||||
{recipe.description && (
|
||||
<span className="text-gray-400 text-xs truncate max-w-[200px]">
|
||||
{recipe.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{recipe.product ? (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{recipe.product.name}</span>
|
||||
<span className="text-xs text-gray-400">{recipe.product.code}</span>
|
||||
</div>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{recipe.yield_quantity}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={recipe.is_active ? "default" : "secondary"}>
|
||||
{recipe.is_active ? "啟用" : "停用"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">
|
||||
{new Date(recipe.updated_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Link href={route('recipes.edit', recipe.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary"
|
||||
title="編輯"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(recipe.id)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
title="刪除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分頁 */}
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-start sm:items-center justify-between 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-[100px] h-8"
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
</div>
|
||||
<div className="w-full sm:w-auto flex justify-center sm:justify-end">
|
||||
<Pagination links={recipes.links} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -255,4 +255,4 @@ export default function ProductionShow({ productionOrder }: Props) {
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
14
resources/js/Pages/Vendor/Index.tsx
vendored
14
resources/js/Pages/Vendor/Index.tsx
vendored
@@ -13,20 +13,20 @@ import Pagination from "@/Components/shared/Pagination";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
|
||||
export interface Vendor {
|
||||
id: number;
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
short_name?: string;
|
||||
tax_id?: string;
|
||||
shortName?: string;
|
||||
taxId?: string;
|
||||
owner?: string;
|
||||
contact_name?: string;
|
||||
contactName?: string;
|
||||
tel?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
address?: string;
|
||||
remark?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
@@ -126,7 +126,7 @@ export default function VendorManagement({ vendors, filters }: PageProps) {
|
||||
router.get(route("vendors.show", vendor.id));
|
||||
};
|
||||
|
||||
const handleDeleteVendor = (id: number) => {
|
||||
const handleDeleteVendor = (id: string) => {
|
||||
router.delete(route('vendors.destroy', id));
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import Pagination from "@/Components/shared/Pagination";
|
||||
import { toast } from "sonner";
|
||||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
import { Card, CardContent } from "@/Components/ui/card";
|
||||
|
||||
interface PageProps {
|
||||
warehouses: {
|
||||
@@ -22,12 +23,16 @@ interface PageProps {
|
||||
last_page: number;
|
||||
total: number;
|
||||
};
|
||||
totals: {
|
||||
available_stock: number;
|
||||
book_stock: number;
|
||||
};
|
||||
filters: {
|
||||
search?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function WarehouseIndex({ warehouses, filters }: PageProps) {
|
||||
export default function WarehouseIndex({ warehouses, totals, filters }: PageProps) {
|
||||
// 篩選狀態
|
||||
const [searchTerm, setSearchTerm] = useState(filters.search || "");
|
||||
|
||||
@@ -119,6 +124,31 @@ export default function WarehouseIndex({ warehouses, filters }: PageProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 統計區塊 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-500 mb-1">可用庫存總計</span>
|
||||
<span className="text-3xl font-bold text-blue-600">
|
||||
{totals.available_stock.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-500 mb-1">帳面庫存總計</span>
|
||||
<span className="text-3xl font-bold text-gray-700">
|
||||
{totals.book_stock.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 工具列 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between">
|
||||
|
||||
@@ -27,6 +27,9 @@ export interface Warehouse {
|
||||
total_quantity?: number;
|
||||
low_stock_count?: number;
|
||||
type?: WarehouseType;
|
||||
is_sellable?: boolean; // 新增欄位
|
||||
book_stock?: number; // 帳面庫存
|
||||
available_stock?: number; // 可用庫存
|
||||
}
|
||||
// 倉庫中的庫存項目
|
||||
export interface WarehouseInventory {
|
||||
|
||||
@@ -36,6 +36,10 @@ export const BREADCRUMB_MAP: Record<string, BreadcrumbItemType[]> = {
|
||||
{ label: "生產工單", href: "/production-orders" },
|
||||
{ label: "詳情", isPage: true }
|
||||
],
|
||||
recipes: [
|
||||
{ label: "生產管理" },
|
||||
{ label: "配方管理", href: "/recipes", isPage: true }
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user