feat: 完成進貨單自動拋轉應付帳款流程與AP介面優化
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m8s

1. 新增 AccountPayable (應付帳款) 模組,包含 Migration、Model、Service 與 Controller
2. 修改 GoodsReceipt (進貨單) 流程,在確認進貨時自動產生對應的應付帳款單 (AP-YYYYMMDD-XX)
3. 實作應付帳款詳細頁面 (Show.tsx),包含發票登記與標記付款功能
4. 修正應付帳款 Show 頁面的排版,將發票資訊套用標準的綠色背景區塊,並調整按鈕位置
5. 更新相關的 Service Provider 與 Routes
This commit is contained in:
2026-02-24 16:46:55 +08:00
parent aaa93a921e
commit 455f945296
33 changed files with 1708 additions and 186 deletions

View File

@@ -0,0 +1,288 @@
import { useState, useCallback } from 'react';
import { Head, Link, router } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import {
Search,
Wallet,
Eye,
X,
} from "lucide-react";
import { StatusBadge } from '@/Components/shared/StatusBadge';
import { formatDate } from '@/lib/date';
import Pagination from '@/Components/shared/Pagination';
import { Can } from '@/Components/Permission/Can';
import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
import { SearchableSelect } from '@/Components/ui/searchable-select';
import { debounce } from "lodash";
const STATUS_OPTIONS = [
{ value: 'all', label: '所有狀態' },
{ value: 'pending', label: '待處理' },
{ value: 'posted', label: '已入帳' },
{ value: 'paid', label: '已支付' },
{ value: 'voided', label: '已作廢' },
];
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case 'pending': return 'warning';
case 'posted': return 'info';
case 'paid': return 'success';
case 'voided': return 'destructive';
default: return 'neutral';
}
};
const getStatusLabel = (status: string) => {
const found = STATUS_OPTIONS.find(opt => opt.value === status);
return found ? found.label : status;
};
export default function AccountPayableIndex({ payables, filters, vendors }: any) {
const [searchTerm, setSearchTerm] = useState(filters.search || "");
const [statusFilter, setStatusFilter] = useState(filters.status || "all");
const [vendorFilter, setVendorFilter] = useState(filters.vendor_id || "all");
const [perPage, setPerPage] = useState(filters.per_page || "10");
// 穩定的防抖過濾函式
const debouncedFilter = useCallback(
debounce((params: any) => {
router.get(route('account-payables.index'), params, {
preserveState: true,
replace: true,
preserveScroll: true,
});
}, 500),
[]
);
const handleSearchChange = (term: string) => {
setSearchTerm(term);
debouncedFilter({
...filters,
search: term,
status: statusFilter === "all" ? "" : statusFilter,
vendor_id: vendorFilter === "all" ? "" : vendorFilter,
page: 1
});
};
const handleClearSearch = () => {
setSearchTerm("");
debouncedFilter({
...filters,
search: "",
status: statusFilter === "all" ? "" : statusFilter,
vendor_id: vendorFilter === "all" ? "" : vendorFilter,
page: 1
});
};
const handleStatusChange = (value: string) => {
setStatusFilter(value);
debouncedFilter({
...filters,
search: searchTerm,
status: value === "all" ? "" : value,
vendor_id: vendorFilter === "all" ? "" : vendorFilter,
page: 1
});
};
const handleVendorChange = (value: string) => {
setVendorFilter(value);
debouncedFilter({
...filters,
search: searchTerm,
status: statusFilter === "all" ? "" : statusFilter,
vendor_id: value === "all" ? "" : value,
page: 1
});
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
debouncedFilter({
...filters,
per_page: value,
page: 1
});
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '財務管理', href: '#' },
{ label: '應付帳款管理' }
]}
>
<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">
<Wallet 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: "pending" },
{ label: "已入帳", value: "posted" },
{ label: "已支付", value: "paid" },
{ label: "已作廢", value: "voided" }
]}
placeholder="選擇狀態"
className="w-full md:w-[160px] h-9"
showSearch={false}
/>
{/* 供應商篩選 */}
<SearchableSelect
value={vendorFilter}
onValueChange={handleVendorChange}
options={[
{ label: "所有供應商", value: "all" },
...vendors.map((v: any) => ({ label: v.name, value: v.id.toString() }))
]}
placeholder="選擇供應商"
className="w-full md:w-[200px] h-9"
/>
</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 text-right"></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>
{payables.data.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-10 text-gray-500">
</TableCell>
</TableRow>
) : (
payables.data.map((payable: any, index: number) => (
<TableRow
key={payable.id}
className="hover:bg-gray-50/50 transition-colors cursor-pointer group"
onClick={() => router.visit(route('account-payables.show', [payable.id]))}
>
<TableCell className="text-center text-gray-500 font-medium">
{(payables.current_page - 1) * payables.per_page + index + 1}
</TableCell>
<TableCell className="font-medium text-primary-main">
{payable.document_number}
</TableCell>
<TableCell className="text-gray-700">
{payable.vendor?.name}
</TableCell>
<TableCell className="text-right font-mono">
{new Intl.NumberFormat().format(payable.total_amount)}
</TableCell>
<TableCell className="text-gray-500 text-sm">
{formatDate(payable.due_date)}
</TableCell>
<TableCell className="text-center">
{/* @ts-ignore */}
<StatusBadge variant={getStatusBadgeVariant(payable.status)}>
{getStatusLabel(payable.status)}
</StatusBadge>
</TableCell>
<TableCell className="text-center">
<div
className="flex items-center justify-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<Can permission="account_payables.view">
<Link href={route('account-payables.show', [payable.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="查閱"
>
<Eye className="w-4 h-4 ml-0.5" />
</Button>
</Link>
</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"> {payables.total} </span>
</div>
<Pagination links={payables.links} />
</div>
</div>
</AuthenticatedLayout>
);
}