Files
star-erp/resources/js/Pages/Inventory/Transfer/Index.tsx
sky121113 455f945296
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m8s
feat: 完成進貨單自動拋轉應付帳款流程與AP介面優化
1. 新增 AccountPayable (應付帳款) 模組,包含 Migration、Model、Service 與 Controller
2. 修改 GoodsReceipt (進貨單) 流程,在確認進貨時自動產生對應的應付帳款單 (AP-YYYYMMDD-XX)
3. 實作應付帳款詳細頁面 (Show.tsx),包含發票登記與標記付款功能
4. 修正應付帳款 Show 頁面的排版,將發票資訊套用標準的綠色背景區塊,並調整按鈕位置
5. 更新相關的 Service Provider 與 Routes
2026-02-24 16:46:55 +08:00

429 lines
21 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 { useState, useCallback } from "react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react";
import { debounce } from "lodash";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
DialogDescription,
} from "@/Components/ui/dialog";
import { Label } from "@/Components/ui/label";
import {
Plus,
Search,
ArrowLeftRight,
Loader2,
Eye,
Pencil,
Trash2,
X,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import Pagination from "@/Components/shared/Pagination";
import { toast } from "sonner";
import { Can } from '@/Components/Permission/Can';
import { usePermission } from '@/hooks/usePermission';
export default function Index({ warehouses, orders, filters }: any) {
const { can } = usePermission();
const [searchTerm, setSearchTerm] = useState(filters.search || "");
const [warehouseFilter, setWarehouseFilter] = useState(filters.warehouse_id || "all");
const [perPage, setPerPage] = useState(filters.per_page || "10");
// Sync state with props only on initial load or when necessary
// Removed overly aggressive useEffect that overwrites local state on every filters change
// This was causing the search input to reset when props returned before the next debounce cycle
// Create Dialog State
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [sourceWarehouseId, setSourceWarehouseId] = useState("");
const [targetWarehouseId, setTargetWarehouseId] = useState("");
const [creating, setCreating] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const debouncedFilter = useCallback(
debounce((params: any) => {
router.get(route('inventory.transfer.index'), params, {
preserveState: true,
replace: true,
});
}, 500),
[]
);
const handleSearchChange = (term: string) => {
setSearchTerm(term);
debouncedFilter({
...filters,
search: term,
warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter,
page: 1
});
};
const handleFilterChange = (value: string) => {
setWarehouseFilter(value);
debouncedFilter({
...filters,
search: searchTerm,
warehouse_id: value === "all" ? "" : value,
page: 1
});
};
const handleClearSearch = () => {
setSearchTerm("");
debouncedFilter({
...filters,
search: "",
warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter,
page: 1
});
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(
route('inventory.transfer.index'),
{ ...filters, search: searchTerm, warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter, per_page: value, page: 1 },
{ preserveState: true, replace: true, preserveScroll: true }
);
};
const handleCreate = () => {
if (!sourceWarehouseId) {
toast.error("請選擇來源倉庫");
return;
}
if (!targetWarehouseId) {
toast.error("請選擇目的倉庫");
return;
}
if (sourceWarehouseId === targetWarehouseId) {
toast.error("來源與目的倉庫不能相同");
return;
}
setCreating(true);
router.post(route('inventory.transfer.store'), {
from_warehouse_id: sourceWarehouseId,
to_warehouse_id: targetWarehouseId
}, {
onFinish: () => setCreating(false),
onSuccess: () => {
setIsCreateOpen(false);
setSourceWarehouseId("");
setTargetWarehouseId("");
}
});
};
const confirmDelete = (id: string) => {
setDeleteId(id);
};
const handleDelete = () => {
if (deleteId) {
router.delete(route('inventory.transfer.destroy', [deleteId]), {
onSuccess: () => {
setDeleteId(null);
toast.success("已成功刪除");
},
onError: () => setDeleteId(null),
});
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'draft':
return <StatusBadge variant="neutral">稿</StatusBadge>;
case 'dispatched':
return <StatusBadge variant="info"></StatusBadge>;
case 'completed':
return <StatusBadge variant="success"></StatusBadge>;
case 'voided':
return <StatusBadge variant="destructive"></StatusBadge>;
default:
return <StatusBadge variant="neutral">{status}</StatusBadge>;
}
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '商品與庫存管理', href: '#' },
{ label: '庫存調撥', href: route('inventory.transfer.index'), isPage: true },
]}
>
<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">
<ArrowLeftRight className="h-6 w-6 text-primary-main" />
調
</h1>
<p className="text-gray-500 mt-1">
調
</p>
</div>
</div>
{/* Toolbar */}
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div className="flex flex-col md:flex-row gap-4">
{/* Search */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="搜尋調撥單號、備註..."
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10 pr-10 h-9"
/>
{searchTerm && (
<button
onClick={handleClearSearch}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Warehouse Filter */}
<SearchableSelect
value={warehouseFilter}
onValueChange={handleFilterChange}
options={[
{ label: "所有倉庫", value: "all" },
...warehouses.map((w: any) => ({ label: w.name, value: w.id.toString() }))
]}
placeholder="選擇倉庫"
className="w-full md:w-[200px] h-9"
/>
{/* Action Buttons */}
<div className="flex gap-2 w-full md:w-auto">
<Can permission="inventory_transfer.create">
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button className="flex-1 md:flex-none button-filled-primary">
<Plus className="w-4 h-4 mr-2" />
調
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>調</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label></Label>
<SearchableSelect
value={sourceWarehouseId}
onValueChange={setSourceWarehouseId}
options={warehouses.map((w: any) => ({ label: w.name, value: w.id.toString() }))}
placeholder="請選擇來源倉庫"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label></Label>
<SearchableSelect
value={targetWarehouseId}
onValueChange={setTargetWarehouseId}
options={warehouses.filter((w: any) => w.id.toString() !== sourceWarehouseId).map((w: any) => ({ label: w.name, value: w.id.toString() }))}
placeholder="請選擇目的倉庫"
className="h-9"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" className="button-outlined-primary" onClick={() => setIsCreateOpen(false)}>
</Button>
<Button onClick={handleCreate} className="button-filled-primary" disabled={creating || !sourceWarehouseId || !targetWarehouseId}>
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Can>
</div>
</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-[50px] text-center font-medium text-gray-600">#</TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="text-center font-medium text-gray-600"></TableHead>
<TableHead className="text-center font-medium text-gray-600"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.data.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center h-24 text-gray-500">
調
</TableCell>
</TableRow>
) : (
orders.data.map((order: any, index: number) => (
<TableRow
key={order.id}
className="hover:bg-gray-50/50 transition-colors cursor-pointer group"
onClick={() => router.visit(route('inventory.transfer.show', [order.id]))}
>
<TableCell className="text-center text-gray-500 font-medium">
{(orders.current_page - 1) * orders.per_page + index + 1}
</TableCell>
<TableCell className="font-medium text-primary-main">{order.doc_no}</TableCell>
<TableCell className="text-gray-700">{order.from_warehouse_name}</TableCell>
<TableCell className="text-gray-700">{order.to_warehouse_name}</TableCell>
<TableCell className="text-gray-500 text-sm">{order.created_at}</TableCell>
<TableCell className="text-gray-500 text-sm">{order.posted_at || '-'}</TableCell>
<TableCell className="text-sm">{order.created_by}</TableCell>
<TableCell className="text-center">{getStatusBadge(order.status)}</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2" onClick={(e) => e.stopPropagation()}>
{(() => {
const isEditable = order.status === 'draft';
const canEdit = can('inventory_transfer.edit');
const canView = can('inventory_transfer.view');
if (isEditable && canEdit) {
return (
<Link href={route('inventory.transfer.show', [order.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="w-4 h-4 ml-0.5" />
</Button>
</Link>
);
}
if (canView) {
return (
<Link href={route('inventory.transfer.show', [order.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="查閱"
>
<Eye className="w-4 h-4 ml-0.5" />
</Button>
</Link>
);
}
return null;
})()}
{order.status === 'draft' && (
<Can permission="inventory_transfer.delete">
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
onClick={() => confirmDelete(order.id)}
>
<Trash2 className="w-4 h-4 ml-0.5" />
</Button>
</Can>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center 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-[90px] h-8"
showSearch={false}
/>
<span></span>
</div>
<span className="text-sm text-gray-500"> {orders.total} </span>
</div>
<Pagination links={orders.links} />
</div>
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>調</AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="button-filled-error"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</AuthenticatedLayout>
);
}