Files
star-erp/resources/js/Pages/Inventory/Adjust/Show.tsx
sky121113 7619dc24f7
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m4s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
feat(inventory): 統一庫存調整與調撥模組 UI,實作多選、搜尋與明細欄位重構
2026-01-29 14:37:21 +08:00

588 lines
33 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 {
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;
}
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 isDraft = doc.status === 'draft';
// 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: '',
});
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,
onSuccess: () => toast.success("草稿儲存成功"),
});
};
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);
toast.success("盤調單過帳成功");
}
});
};
const handleDelete = () => {
destroy(route('inventory.adjust.destroy', [doc.id]), {
onSuccess: () => toast.success("盤調單已刪除"),
});
};
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])}
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">
{isDraft && (
<Can permission="inventory.adjust">
<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="bg-red-500 hover:bg-red-600"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<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="bg-primary-600 hover:bg-primary-700"></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>
{isDraft ? (
<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>
{isDraft ? (
<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>
{isDraft && !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-mono text-sm text-grey-1">{inv.product_code}</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-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>
{isDraft && <TableHead className="w-[50px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={isDraft ? 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">{item.batch_number || '-'}</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">
{isDraft ? (
<div className="flex justify-end pr-2">
<Input
type="number"
className="text-right h-9 w-32 font-medium"
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>
{isDraft ? (
<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>
{isDraft && !doc.count_doc_id && (
<TableCell className="text-center">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 p-0"
onClick={() => removeItem(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}