feat: 完成進貨單自動拋轉應付帳款流程與AP介面優化
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m8s
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:
288
resources/js/Pages/AccountPayable/Index.tsx
Normal file
288
resources/js/Pages/AccountPayable/Index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user