Files
star-erp/resources/js/Pages/StoreRequisition/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

413 lines
19 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, useEffect } 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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
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";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import {
Plus,
Search,
Store,
Eye,
Pencil,
Trash2,
X,
} from "lucide-react";
import { formatDate } from "@/lib/date";
function getStatusBadge(status: string) {
switch (status) {
case 'draft':
return <StatusBadge variant="neutral">稿</StatusBadge>;
case 'pending':
return <StatusBadge variant="warning"></StatusBadge>;
case 'approved':
return <StatusBadge variant="success"></StatusBadge>;
case 'rejected':
return <StatusBadge variant="destructive"></StatusBadge>;
case 'completed':
return <StatusBadge variant="success"></StatusBadge>;
case 'cancelled':
return <StatusBadge variant="neutral"></StatusBadge>;
default:
return <StatusBadge variant="neutral">{status}</StatusBadge>;
}
}
export default function Index({
requisitions,
filters,
warehouses,
}: {
requisitions: any;
filters: any;
warehouses: { id: number; name: string }[];
}) {
const { can } = usePermission();
const [searchTerm, setSearchTerm] = useState(filters.search || "");
const [statusFilter, setStatusFilter] = useState(filters.status || "all");
const [warehouseFilter, setWarehouseFilter] = useState(filters.warehouse_id || "all");
const [perPage, setPerPage] = useState(filters.per_page || "10");
const [deleteId, setDeleteId] = useState<string | null>(null);
useEffect(() => {
setSearchTerm(filters.search || "");
setStatusFilter(filters.status || "all");
setWarehouseFilter(filters.warehouse_id || "all");
setPerPage(filters.per_page || "10");
}, [filters]);
const debouncedFilter = useCallback(
debounce((params: any) => {
router.get(route("store-requisitions.index"), params, {
preserveState: true,
replace: true,
});
}, 300),
[]
);
const handleSearchChange = (term: string) => {
setSearchTerm(term);
debouncedFilter({
...filters,
search: term,
status: statusFilter === "all" ? "" : statusFilter,
warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter,
page: 1,
});
};
const handleClearSearch = () => {
setSearchTerm("");
debouncedFilter({
...filters,
search: "",
status: statusFilter === "all" ? "" : statusFilter,
warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter,
page: 1,
});
};
const handleStatusChange = (value: string) => {
setStatusFilter(value);
debouncedFilter({
...filters,
search: searchTerm,
status: value === "all" ? "" : value,
warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter,
page: 1,
});
};
const handleWarehouseChange = (value: string) => {
setWarehouseFilter(value);
debouncedFilter({
...filters,
search: searchTerm,
status: statusFilter === "all" ? "" : statusFilter,
warehouse_id: value === "all" ? "" : value,
page: 1,
});
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(
route("store-requisitions.index"),
{
...filters,
search: searchTerm,
status: statusFilter === "all" ? "" : statusFilter,
warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter,
per_page: value,
page: 1,
},
{ preserveState: true, replace: true, preserveScroll: true }
);
};
const handleDelete = () => {
if (deleteId) {
router.delete(route("store-requisitions.destroy", [deleteId]), {
onSuccess: () => {
setDeleteId(null);
toast.success("已成功刪除");
},
onError: () => setDeleteId(null),
});
}
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: "商品與庫存管理", href: "#" },
{ label: "門市叫貨", href: route("store-requisitions.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">
<Store className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
</div>
{/* 篩選工具列 */}
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div className="flex flex-col md:flex-row gap-4">
{/* 搜尋 */}
<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>
{/* 狀態篩選 */}
<SearchableSelect
value={statusFilter}
onValueChange={handleStatusChange}
options={[
{ label: "所有狀態", value: "all" },
{ label: "草稿", value: "draft" },
{ label: "待審核", value: "pending" },
{ label: "已核准", value: "approved" },
{ label: "已駁回", value: "rejected" },
{ label: "已完成", value: "completed" },
{ label: "已取消", value: "cancelled" },
]}
placeholder="選擇狀態"
className="w-full md:w-[160px] h-9"
showSearch={false}
/>
{/* 倉庫篩選 */}
<SearchableSelect
value={warehouseFilter}
onValueChange={handleWarehouseChange}
options={[
{ label: "所有倉庫", value: "all" },
...warehouses.map((w) => ({
label: w.name,
value: w.id.toString(),
})),
]}
placeholder="選擇倉庫"
className="w-full md:w-[200px] h-9"
/>
{/* 操作按鈕 */}
<div className="flex gap-2 w-full md:w-auto">
<Can permission="store_requisitions.create">
<Link href={route("store-requisitions.create")}>
<Button className="flex-1 md:flex-none button-filled-primary">
<Plus className="w-4 h-4 mr-2" />
</Button>
</Link>
</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="text-center font-medium text-gray-600"></TableHead>
<TableHead className="text-center font-medium text-gray-600"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requisitions.data.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center h-24 text-gray-500">
</TableCell>
</TableRow>
) : (
requisitions.data.map((req: any, index: number) => (
<TableRow
key={req.id}
className="hover:bg-gray-50/50 transition-colors cursor-pointer group"
onClick={() =>
router.visit(route("store-requisitions.show", [req.id]))
}
>
<TableCell className="text-center text-gray-500 font-medium">
{(requisitions.current_page - 1) * requisitions.per_page + index + 1}
</TableCell>
<TableCell className="font-medium text-primary-main">
{req.doc_no}
</TableCell>
<TableCell className="text-gray-700">
{req.store_warehouse_name}
</TableCell>
<TableCell className="text-gray-700">
{req.supply_warehouse_name}
</TableCell>
<TableCell className="text-sm">{req.creator_name}</TableCell>
<TableCell className="text-gray-500 text-sm">
{formatDate(req.created_at)}
</TableCell>
<TableCell className="text-center">
{getStatusBadge(req.status)}
</TableCell>
<TableCell className="text-center">
<div
className="flex items-center justify-center gap-2"
onClick={(e) => e.stopPropagation()}
>
{(() => {
const isEditable = ["draft", "rejected"].includes(req.status);
const canEdit = can("store_requisitions.edit");
if (isEditable && canEdit) {
return (
<Link href={route("store-requisitions.edit", [req.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="w-4 h-4 ml-0.5" />
</Button>
</Link>
);
}
return (
<Link href={route("store-requisitions.show", [req.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="查閱"
>
<Eye className="w-4 h-4 ml-0.5" />
</Button>
</Link>
);
})()}
{req.status === "draft" && (
<Can permission="store_requisitions.delete">
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
onClick={() => setDeleteId(req.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"> {requisitions.total} </span>
</div>
<Pagination links={requisitions.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>
);
}