Files
star-erp/resources/js/Pages/Inventory/Adjust/Show.tsx
sky121113 3ce96537b3
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m0s
feat: 標準化全系統數值輸入欄位與擴充商品價格功能
1. UI 標準化:
   - 針對全系統數值輸入欄位統一加上 step='any' 以支援小數點。
   - 表格形式 (Table) 的數值輸入欄位統一加上 text-right 靠右對齊。
   - 修正 Components 與 Pages 中所有涉及金額與數量的輸入框。

2. 功能擴充與修正:
   - 擴充 Product 模型與相關 Dialog 以支援多種價格設定。
   - 修正 Inventory/GoodsReceipt/Create.tsx 未使用的變數錯誤。
   - 優化庫存相關頁面的 UI 一致性。

3. 其他:
   - 更新相關的 Type 定義與 Controller 邏輯。
2026-02-05 11:45:08 +08:00

605 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, useForm, router } from '@inertiajs/react';
import { usePermission } from '@/hooks/usePermission';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Badge } from "@/Components/ui/badge";
import { Checkbox } from "@/Components/ui/checkbox";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog";
import { Label } from "@/Components/ui/label";
import { Save, CheckCircle, Trash2, ArrowLeft, Plus, ClipboardCheck, Package, Search } from "lucide-react";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/Components/ui/dialog";
import axios from 'axios';
import { Can } from '@/Components/Permission/Can';
import { toast } from 'sonner';
interface AdjItem {
id?: string;
product_id: string;
product_name: string;
product_code: string;
batch_number: string | null;
unit: string;
qty_before: number | string;
adjust_qty: number | string;
notes: string;
expiry_date?: string | null;
}
interface AdjDoc {
id: string;
doc_no: string;
warehouse_id: string;
warehouse_name: string;
status: string;
reason: string;
remarks: string;
created_at: string;
created_by: string;
count_doc_id?: string;
count_doc_no?: string;
items: AdjItem[];
}
export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
const { can } = usePermission();
const isDraft = doc.status === 'draft';
const canEdit = can('inventory_adjust.edit');
const isReadOnly = !isDraft || !canEdit;
// Main Form using Inertia useForm
const { data, setData, put, delete: destroy, processing } = useForm({
reason: doc.reason,
remarks: doc.remarks || '',
items: doc.items || [],
action: 'save',
});
// Product Selection State
const [isProductDialogOpen, setIsProductDialogOpen] = useState(false);
const [availableInventory, setAvailableInventory] = useState<any[]>([]);
const [loadingInventory, setLoadingInventory] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedInventory, setSelectedInventory] = useState<string[]>([]); // product_id-batch
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
useEffect(() => {
if (isProductDialogOpen) {
loadInventory();
setSelectedInventory([]); // Reset selection when opening
setSearchQuery(''); // Reset search when opening
}
}, [isProductDialogOpen]);
const loadInventory = async () => {
setLoadingInventory(true);
try {
const response = await axios.get(route('api.warehouses.inventories', doc.warehouse_id));
setAvailableInventory(response.data);
} catch (error) {
console.error("Failed to load inventory", error);
toast.error("無法載入庫存資料");
} finally {
setLoadingInventory(false);
}
};
const toggleSelect = (key: string) => {
setSelectedInventory(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
);
};
const toggleSelectAll = () => {
const filtered = availableInventory.filter(inv =>
inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
inv.product_code.toLowerCase().includes(searchQuery.toLowerCase())
);
const filteredKeys = filtered.map(inv => `${inv.product_id}-${inv.batch_number}`);
if (filteredKeys.length > 0 && filteredKeys.every(k => selectedInventory.includes(k))) {
setSelectedInventory(prev => prev.filter(k => !filteredKeys.includes(k)));
} else {
setSelectedInventory(prev => Array.from(new Set([...prev, ...filteredKeys])));
}
};
// Helper to add selected items to the main list
const handleAddSelected = () => {
if (selectedInventory.length === 0) return;
const newItems = [...data.items];
let addedCount = 0;
availableInventory.forEach(inv => {
const key = `${inv.product_id}-${inv.batch_number}`;
if (selectedInventory.includes(key)) {
// Check if already exists
const exists = newItems.find((i: any) =>
i.product_id === String(inv.product_id) &&
i.batch_number === inv.batch_number
);
if (!exists) {
newItems.push({
product_id: String(inv.product_id),
product_name: inv.product_name,
product_code: inv.product_code,
unit: inv.unit_name,
batch_number: inv.batch_number,
qty_before: inv.quantity || 0,
adjust_qty: 0,
notes: '',
expiry_date: inv.expiry_date,
});
addedCount++;
}
}
});
setData('items', newItems);
setIsProductDialogOpen(false);
if (addedCount > 0) {
toast.success(`已成功加入 ${addedCount} 個項目`);
} else {
toast.info("選取的商品已在清單中");
}
};
const removeItem = (index: number) => {
const newItems = [...data.items];
newItems.splice(index, 1);
setData('items', newItems);
};
const updateItem = (index: number, field: keyof AdjItem, value: any) => {
const newItems = [...data.items];
(newItems[index] as any)[field] = value;
setData('items', newItems);
};
const handleSave = () => {
setData('action', 'save');
put(route('inventory.adjust.update', [doc.id]), {
preserveScroll: true,
});
};
const handlePost = () => {
if (data.items.length === 0) {
toast.error('請至少加入一個調整項目');
return;
}
router.put(route('inventory.adjust.update', [doc.id]), {
...data,
action: 'post'
} as any, {
onSuccess: () => {
setIsPostDialogOpen(false);
}
});
};
const handleDelete = () => {
destroy(route('inventory.adjust.destroy', [doc.id]));
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '商品與庫存管理', href: '#' },
{ label: '庫存盤調', href: route('inventory.adjust.index') },
{ label: `盤調單: ${doc.doc_no}`, href: route('inventory.adjust.show', [doc.id]), isPage: true },
]}
>
<Head title={`盤調單 ${doc.doc_no}`} />
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500 space-y-6">
<div>
<Link href={route('inventory.adjust.index')}>
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-6"
>
<ArrowLeft className="h-4 w-4" />
調
</Button>
</Link>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<ClipboardCheck className="h-6 w-6 text-primary-main" />
調: {doc.doc_no}
</h1>
{isDraft ? (
<Badge variant="secondary" className="bg-blue-500 text-white border-none py-1 px-3">稿</Badge>
) : (
<Badge className="bg-green-500 text-white border-none py-1 px-3"></Badge>
)}
</div>
<p className="text-sm text-gray-500 mt-1 font-medium flex items-center gap-2">
: {doc.warehouse_name} <span className="mx-1">|</span>
: {doc.created_by} <span className="mx-1">|</span>
: {doc.created_at}
{doc.count_doc_id && (
<>
<span className="mx-1">|</span>
<Link
href={route('inventory.count.show', [doc.count_doc_id]) + `?from=adjust&adjust_id=${doc.id}`}
className="flex items-center gap-1 text-primary-main hover:underline"
>
: {doc.count_doc_no}
</Link>
</>
)}
</p>
</div>
<div className="flex items-center gap-2">
{!isReadOnly && (
<>
<Can permission="inventory_adjust.delete">
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={processing} className="button-outlined-error">
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>調</AlertDialogTitle>
<AlertDialogDescription>
稿
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="button-filled-error"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
<Can permission="inventory_adjust.edit">
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
onClick={handleSave}
disabled={processing}
>
<Save className="w-4 h-4 mr-2" />
</Button>
<AlertDialog open={isPostDialogOpen} onOpenChange={setIsPostDialogOpen}>
<AlertDialogTrigger asChild>
<Button
size="sm"
className="button-filled-primary"
disabled={processing || data.items.length === 0}
>
<CheckCircle className="w-4 h-4 mr-2" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
調調
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handlePost} className="button-filled-primary"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
</>
)}
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
{/* Header Fields - Inline */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pb-2">
<div className="space-y-1">
<Label className="text-xs font-bold text-grey-500 uppercase tracking-wider font-semibold">調</Label>
{!isReadOnly ? (
<Input
value={data.reason}
onChange={e => setData('reason', e.target.value)}
className="focus:ring-primary-main h-9"
placeholder="請輸入調整原因..."
/>
) : (
<div className="text-grey-900 font-medium py-1">{data.reason}</div>
)}
</div>
<div className="space-y-1">
<Label className="text-xs font-bold text-grey-500 uppercase tracking-wider font-semibold"></Label>
{!isReadOnly ? (
<Input
value={data.remarks}
onChange={e => setData('remarks', e.target.value)}
className="focus:ring-primary-main h-9"
placeholder="選填備註..."
/>
) : (
<div className="text-grey-600 py-1">{data.remarks || '-'}</div>
)}
</div>
</div>
<div className="border-t pt-4"></div>
<div className="flex flex-row items-center justify-between mb-2">
<div>
<h3 className="text-lg font-semibold text-grey-900">調</h3>
<p className="text-sm text-gray-500">
</p>
</div>
{!isReadOnly && !doc.count_doc_id && (
<Dialog open={isProductDialogOpen} onOpenChange={setIsProductDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="button-outlined-primary">
<Plus className="h-4 w-4 mr-2" />
調
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col p-6">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<DialogTitle className="text-xl"> ({doc.warehouse_name})</DialogTitle>
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-grey-3" />
<Input
placeholder="搜尋品名或代號..."
className="pl-9 h-9 border-2 border-grey-3 focus:ring-primary-main"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</DialogHeader>
<div className="flex-1 overflow-auto pr-1">
{loadingInventory ? (
<div className="text-center py-12">
<Package className="h-10 w-10 animate-bounce mx-auto text-gray-300 mb-2" />
<p className="text-grey-2 text-sm">...</p>
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="bg-gray-50/80 sticky top-0 z-10 shadow-sm">
<TableRow>
<TableHead className="w-[50px] text-center">
<Checkbox
checked={availableInventory.length > 0 && selectedInventory.length === availableInventory.length}
onCheckedChange={() => toggleSelectAll()}
/>
</TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="text-right font-medium text-grey-600 pr-6"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(() => {
const filtered = availableInventory.filter(inv =>
inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
inv.product_code.toLowerCase().includes(searchQuery.toLowerCase())
);
if (filtered.length === 0) {
return (
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-grey-3 italic font-medium">
{searchQuery ? `找不到與 "${searchQuery}" 相關的商品` : '尚無庫存資料'}
</TableCell>
</TableRow>
);
}
return filtered.map((inv) => {
const key = `${inv.product_id}-${inv.batch_number}`;
const isSelected = selectedInventory.includes(key);
return (
<TableRow
key={key}
className={`hover:bg-primary-lightest/20 cursor-pointer transition-colors ${isSelected ? 'bg-primary-lightest/40' : ''}`}
onClick={() => toggleSelect(key)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelect(key)}
/>
</TableCell>
<TableCell className="font-semibold text-grey-0">{inv.product_name}</TableCell>
<TableCell className="text-sm font-mono text-grey-2">{inv.batch_number || '-'}</TableCell>
<TableCell className="text-sm font-mono text-grey-2">{inv.expiry_date || '-'}</TableCell>
<TableCell className="text-right font-bold text-primary-main pr-6">{inv.quantity} {inv.unit_name}</TableCell>
</TableRow>
);
});
})()}
</TableBody>
</Table>
</div>
)}
</div>
<div className="mt-6 flex items-center justify-between border-t pt-4">
<div className="flex items-center gap-3">
<div className="px-3 py-1 bg-primary-lightest/50 border border-primary-light/20 rounded-full text-sm font-medium text-primary-main animate-in zoom-in duration-200">
{selectedInventory.length}
</div>
{selectedInventory.length > 0 && (
<Button
variant="ghost"
size="sm"
className="text-grey-3 hover:text-red-500 hover:bg-red-50 text-xs px-2 h-7"
onClick={() => setSelectedInventory([])}
>
</Button>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
className="button-outlined-primary w-24"
onClick={() => setIsProductDialogOpen(false)}
>
</Button>
<Button
className="button-filled-primary min-w-32"
disabled={selectedInventory.length === 0}
onClick={handleAddSelected}
>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center font-medium text-grey-600">#</TableHead>
<TableHead className="pl-4 font-medium text-grey-600"> / </TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="w-24 text-center font-medium text-grey-600"></TableHead>
<TableHead className="w-32 text-right font-medium text-grey-600">調</TableHead>
<TableHead className="w-40 text-right font-medium text-grey-600">調 (+/-)</TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
{!isReadOnly && <TableHead className="w-[50px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={!isReadOnly ? 8 : 7} className="h-32 text-center text-grey-400">
</TableCell>
</TableRow>
) : (
data.items.map((item, index) => (
<TableRow
key={`${item.product_id}-${item.batch_number}-${index}`}
className="group hover:bg-gray-50/50 transition-colors"
>
<TableCell className="text-center text-grey-400 font-medium">{index + 1}</TableCell>
<TableCell className="pl-4 py-3">
<div className="flex flex-col">
<span className="font-semibold text-gray-900">{item.product_name}</span>
<span className="text-xs text-gray-500 font-mono">{item.product_code}</span>
</div>
</TableCell>
<TableCell className="text-grey-600 font-mono text-sm">
<div>{item.batch_number || '-'}</div>
{item.expiry_date && (
<div className="text-xs text-gray-400 mt-1">
: {item.expiry_date}
</div>
)}
</TableCell>
<TableCell className="text-center text-grey-500">{item.unit}</TableCell>
<TableCell className="text-right font-medium text-grey-400">
{item.qty_before}
</TableCell>
<TableCell className="text-right">
{!isReadOnly ? (
<div className="flex justify-end pr-2">
<Input
type="number"
step="any"
className="h-9 w-32 font-medium text-right"
value={item.adjust_qty}
onChange={e => updateItem(index, 'adjust_qty', e.target.value)}
/>
</div>
) : (
<span className={`font-bold mr-2 ${Number(item.adjust_qty) > 0 ? 'text-green-600' : Number(item.adjust_qty) < 0 ? 'text-red-600' : 'text-gray-600'}`}>
{Number(item.adjust_qty) > 0 ? '+' : ''}{item.adjust_qty}
</span>
)}
</TableCell>
<TableCell>
{!isReadOnly ? (
<Input
className="h-9 text-sm"
value={item.notes || ''}
onChange={e => updateItem(index, 'notes', e.target.value)}
placeholder="備註..."
/>
) : (
<span className="text-grey-600 text-sm">{item.notes || '-'}</span>
)}
</TableCell>
{!isReadOnly && !doc.count_doc_id && (
<TableCell className="text-center">
<Button
variant="outline"
size="icon"
className="button-outlined-error h-8 w-8"
onClick={() => removeItem(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}