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:
@@ -250,6 +250,11 @@
|
||||
border-color: var(--other-warning);
|
||||
}
|
||||
|
||||
.button-outlined-warning:hover {
|
||||
@apply bg-amber-50 text-[var(--grey-0)];
|
||||
border-color: var(--other-warning);
|
||||
}
|
||||
|
||||
.button-filled-error {
|
||||
@apply bg-[var(--button-err-normal)] text-[var(--grey-5)] border-transparent transition-colors shadow-sm;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function GoodsReceiptActions({
|
||||
receipt,
|
||||
}: { receipt: GoodsReceipt }) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const { delete: destroy, processing } = useForm({});
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
@@ -46,6 +47,8 @@ export default function GoodsReceiptActions({
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex justify-center gap-2">
|
||||
<Link href={route('goods-receipts.show', receipt.id)}>
|
||||
@@ -59,43 +62,46 @@ export default function GoodsReceiptActions({
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Delete typically restricted for Goods Receipts, checking permission */}
|
||||
<Can permission="goods_receipts.delete">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error"
|
||||
title="刪除"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={processing}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* 只允許刪除草稿或已退回的進貨單 */}
|
||||
{(receipt.status === 'draft' || receipt.status === 'rejected') && (
|
||||
<Can permission="goods_receipts.delete">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error"
|
||||
title="刪除"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={processing}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</Can>
|
||||
)}
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確認刪除進貨單</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
確定要刪除進貨單 「{receipt.code}」 嗎?
|
||||
<br />
|
||||
<span className="text-red-500 font-bold mt-2 block">
|
||||
注意:刪除動作無法復原!
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="button-outlined-primary">取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="button-filled-error"
|
||||
>
|
||||
確認刪除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確認刪除進貨單</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
確定要刪除進貨單 「{receipt.code}」 嗎?
|
||||
<br />
|
||||
<span className="text-red-500 font-bold mt-2 block">
|
||||
注意:刪除進貨單將會扣除已入庫的庫存數量!
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="button-outlined-primary">取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="button-filled-error"
|
||||
>
|
||||
確認刪除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Can>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
|
||||
export type GoodsReceiptStatus = 'processing' | 'completed' | 'cancelled';
|
||||
|
||||
export const GOODS_RECEIPT_STATUS_CONFIG: Record<string, { label: string; variant: StatusVariant }> = {
|
||||
draft: { label: "草稿", variant: "neutral" },
|
||||
pending_audit: { label: "待審核", variant: "warning" },
|
||||
processing: { label: "處理中", variant: "info" },
|
||||
completed: { label: "已完成", variant: "success" },
|
||||
cancelled: { label: "已取消", variant: "destructive" },
|
||||
rejected: { label: "已退回", variant: "destructive" },
|
||||
};
|
||||
|
||||
interface GoodsReceiptStatusBadgeProps {
|
||||
|
||||
@@ -225,15 +225,22 @@ export default function AuthenticatedLayout({
|
||||
id: "finance-management",
|
||||
label: "財務管理",
|
||||
icon: <Wallet className="h-5 w-5" />,
|
||||
permission: "utility_fees.view",
|
||||
permission: ["utility_fees.view", "account_payables.view"],
|
||||
children: [
|
||||
{
|
||||
id: "utility-fee-list",
|
||||
label: "公共事業費",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
route: "/utility-fees",
|
||||
route: '/utility-fees',
|
||||
permission: "utility_fees.view",
|
||||
},
|
||||
{
|
||||
id: "account-payable-list",
|
||||
label: "應付帳款",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
route: '/finance/account-payables',
|
||||
permission: "account_payables.view", // 假設這為該功能的權限
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -246,14 +253,14 @@ export default function AuthenticatedLayout({
|
||||
id: "accounting-report",
|
||||
label: "會計報表",
|
||||
icon: <FileSpreadsheet className="h-4 w-4" />,
|
||||
route: "/accounting-report",
|
||||
route: '/accounting-report',
|
||||
permission: "accounting.view",
|
||||
},
|
||||
{
|
||||
id: "inventory-report",
|
||||
label: "庫存報表",
|
||||
icon: <BarChart3 className="h-4 w-4" />,
|
||||
route: "/inventory/report",
|
||||
route: '/inventory/report',
|
||||
permission: "inventory_report.view",
|
||||
},
|
||||
{
|
||||
@@ -612,7 +619,7 @@ export default function AuthenticatedLayout({
|
||||
{isCollapsed ? <PanelLeftOpen className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</aside >
|
||||
</aside>
|
||||
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
{
|
||||
@@ -650,9 +657,10 @@ export default function AuthenticatedLayout({
|
||||
"flex-1 flex flex-col transition-all duration-300 min-h-screen",
|
||||
"lg:ml-64",
|
||||
isCollapsed && "lg:ml-20",
|
||||
"pt-16" // 始終為頁首保留空間
|
||||
"pt-16",
|
||||
"w-full min-w-0 overflow-x-hidden"
|
||||
)}>
|
||||
<div className="relative flex-1 flex flex-col min-h-0">
|
||||
<div className="relative flex-1 flex flex-col min-w-0 w-full">
|
||||
<div className="container mx-auto px-6 pt-6 max-w-7xl shrink-0">
|
||||
{breadcrumbs && breadcrumbs.length > 1 && (
|
||||
<BreadcrumbNav items={breadcrumbs} className="mb-2" />
|
||||
@@ -665,6 +673,6 @@ export default function AuthenticatedLayout({
|
||||
</footer>
|
||||
<Toaster richColors closeButton position="top-center" />
|
||||
</main>
|
||||
</div >
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
400
resources/js/Pages/AccountPayable/Show.tsx
Normal file
400
resources/js/Pages/AccountPayable/Show.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
import { useState } from 'react';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { ExternalLink, ArrowLeft, Wallet, FileText, CheckCircle } from 'lucide-react';
|
||||
import { StatusBadge } from '@/Components/shared/StatusBadge';
|
||||
import { formatDate } from '@/lib/date';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/Components/ui/dialog';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const getStatusBadgeVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return 'warning';
|
||||
case 'partially_paid': return 'info';
|
||||
case 'paid': return 'success';
|
||||
case 'cancelled': return 'destructive';
|
||||
default: return 'neutral';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return '待付款';
|
||||
case 'partially_paid': return '部分付款';
|
||||
case 'paid': return '已結清';
|
||||
case 'cancelled': return '已作廢';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
export default function AccountPayableShow({ payable }: any) {
|
||||
const [invoiceDialogOpen, setInvoiceDialogOpen] = useState(false);
|
||||
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
|
||||
|
||||
const invoiceForm = useForm({
|
||||
invoice_number: payable.invoice_number || '',
|
||||
invoice_date: payable.invoice_date || '',
|
||||
});
|
||||
|
||||
const paymentForm = useForm({
|
||||
payment_method: payable.payment_method || 'bank_transfer',
|
||||
paid_at: payable.paid_at ? payable.paid_at.split('T')[0] : new Date().toISOString().split('T')[0],
|
||||
payment_note: payable.payment_note || '',
|
||||
});
|
||||
|
||||
const handleInvoiceSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
invoiceForm.post(route('account-payables.invoice', payable.id), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
setInvoiceDialogOpen(false);
|
||||
toast.success('發票資訊已更新');
|
||||
},
|
||||
onError: (errors) => {
|
||||
toast.error(Object.values(errors)[0] as string || '更新失敗');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handlePaymentSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
paymentForm.post(route('account-payables.pay', payable.id), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
setPaymentDialogOpen(false);
|
||||
toast.success('帳款已成功標記為已付款');
|
||||
},
|
||||
onError: (errors) => {
|
||||
toast.error(Object.values(errors)[0] as string || '標記失敗');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: '財務管理', href: '#' },
|
||||
{ label: '應付帳款', href: route('account-payables.index') },
|
||||
{ label: `詳情: ${payable.document_number}` }
|
||||
]}
|
||||
>
|
||||
<Head title={`應付帳款 - ${payable.document_number}`} />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Back Button */}
|
||||
<div className="mb-6">
|
||||
<Link href={route('account-payables.index')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回列表
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 頁面標題與操作 */}
|
||||
<div className="flex items-start 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" />
|
||||
{payable.document_number}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{/* @ts-ignore */}
|
||||
<StatusBadge variant={getStatusBadgeVariant(payable.status)}>
|
||||
{getStatusLabel(payable.status)}
|
||||
</StatusBadge>
|
||||
<span className="text-gray-500 text-sm">
|
||||
{formatDate(payable.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary"
|
||||
onClick={() => setInvoiceDialogOpen(true)}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
登記發票
|
||||
</Button>
|
||||
|
||||
{payable.status !== 'paid' && (
|
||||
<Button
|
||||
className="gap-2 button-filled-primary"
|
||||
onClick={() => setPaymentDialogOpen(true)}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
標記已付款
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 基本資料 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">基本資料</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">供應商</span>
|
||||
<p className="font-medium text-gray-800 mt-1">
|
||||
{payable.vendor?.name || '未知供應商'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">總金額</span>
|
||||
<p className="font-medium text-gray-800 mt-1">
|
||||
${parseFloat(payable.total_amount).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">到期日</span>
|
||||
<p className="font-medium text-gray-800 mt-1">
|
||||
{formatDate(payable.due_date)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">建立人</span>
|
||||
<p className="font-medium text-gray-800 mt-1">
|
||||
{payable.creator?.name || '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">提交時間</span>
|
||||
<p className="font-medium text-gray-800 mt-1">
|
||||
{formatDate(payable.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{payable.remarks && (
|
||||
<div className="md:col-span-3">
|
||||
<span className="text-sm text-gray-500">備註</span>
|
||||
<p className="font-medium text-gray-800 mt-1">
|
||||
{payable.remarks}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 來源關聯 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">來源關聯</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">來源類型</span>
|
||||
<p className="font-medium text-gray-800 mt-1">
|
||||
{payable.source_document_type === 'goods_receipt' ? (
|
||||
<Badge variant="outline" className="font-normal">進貨單</Badge>
|
||||
) : (
|
||||
payable.source_document_type || '-'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">來源單號</span>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="font-medium text-gray-800">
|
||||
{payable.source_document_code || payable.source_document_id || '-'}
|
||||
</p>
|
||||
{payable.source_document_type === 'goods_receipt' && payable.source_document_id && (
|
||||
<Link
|
||||
href={route('goods-receipts.show', [payable.source_document_id])}
|
||||
className="text-primary-main hover:underline flex items-center gap-1 text-sm font-medium"
|
||||
>
|
||||
查閱 <ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 發票資訊 */}
|
||||
<div className="bg-primary-main/5 rounded-xl border border-primary-main/20 p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold text-primary-main flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-primary-main" />
|
||||
發票資訊
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-primary-main hover:text-primary-dark hover:bg-primary-main/10"
|
||||
onClick={() => setInvoiceDialogOpen(true)}
|
||||
>
|
||||
{payable.invoice_number ? '修改' : '填寫'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="text-sm text-primary-main/70">發票號碼</span>
|
||||
<p className="font-medium text-primary-dark mt-1">
|
||||
{payable.invoice_number || <span className="opacity-70 italic">尚未登記</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-primary-main/70">發票日期</span>
|
||||
<p className="font-medium text-primary-dark mt-1">
|
||||
{payable.invoice_date ? formatDate(payable.invoice_date) : '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 付款資訊 */}
|
||||
{payable.status === 'paid' && (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-success-main" />
|
||||
付款資訊
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">付款狀態</span>
|
||||
<p className="font-medium text-success-main mt-1">
|
||||
已結清
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">付款方式</span>
|
||||
<p className="font-medium text-gray-800 mt-1">
|
||||
{payable.payment_method === 'cash' && '現金'}
|
||||
{payable.payment_method === 'bank_transfer' && '銀行轉帳'}
|
||||
{payable.payment_method === 'check' && '支票'}
|
||||
{payable.payment_method === 'credit_card' && '信用卡'}
|
||||
{!['cash', 'bank_transfer', 'check', 'credit_card'].includes(payable.payment_method) && (payable.payment_method || '-')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">付款日期</span>
|
||||
<p className="font-medium text-gray-800 mt-1">
|
||||
{payable.paid_at ? formatDate(payable.paid_at) : '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{payable.payment_note && (
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">付款備註</span>
|
||||
<p className="font-medium text-gray-800 mt-1">
|
||||
{payable.payment_note}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 發票對話框 */}
|
||||
<Dialog open={invoiceDialogOpen} onOpenChange={setInvoiceDialogOpen}>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleInvoiceSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>登記發票資訊</DialogTitle>
|
||||
<DialogDescription>
|
||||
請填寫供應商開立的發票資料以利後續帳務核對
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_number">發票號碼</Label>
|
||||
<Input
|
||||
id="invoice_number"
|
||||
value={invoiceForm.data.invoice_number}
|
||||
onChange={e => invoiceForm.setData('invoice_number', e.target.value)}
|
||||
placeholder="例如: AB12345678"
|
||||
/>
|
||||
{invoiceForm.errors.invoice_number && <p className="text-sm text-destructive">{invoiceForm.errors.invoice_number}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_date">發票日期</Label>
|
||||
<Input
|
||||
id="invoice_date"
|
||||
type="date"
|
||||
value={invoiceForm.data.invoice_date}
|
||||
onChange={e => invoiceForm.setData('invoice_date', e.target.value)}
|
||||
/>
|
||||
{invoiceForm.errors.invoice_date && <p className="text-sm text-destructive">{invoiceForm.errors.invoice_date}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" className="button-outlined-primary" onClick={() => setInvoiceDialogOpen(false)}>取消</Button>
|
||||
<Button type="submit" disabled={invoiceForm.processing} className="button-filled-primary">儲存</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 付款對話框 */}
|
||||
<Dialog open={paymentDialogOpen} onOpenChange={setPaymentDialogOpen}>
|
||||
<DialogContent>
|
||||
<form onSubmit={handlePaymentSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>標記為已付款</DialogTitle>
|
||||
<DialogDescription>
|
||||
標示此應付帳款已支付給供應商。記錄一旦送出後,付款資訊將無法輕易修改。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment_method">付款方式</Label>
|
||||
<select
|
||||
id="payment_method"
|
||||
className="flex h-10 w-full items-center justify-between rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-main focus:border-transparent text-gray-700"
|
||||
value={paymentForm.data.payment_method}
|
||||
onChange={(e) => paymentForm.setData('payment_method', e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="bank_transfer">銀行轉帳/匯款</option>
|
||||
<option value="cash">現金</option>
|
||||
<option value="check">支票</option>
|
||||
<option value="credit_card">信用卡</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
{paymentForm.errors.payment_method && <p className="text-sm text-destructive">{paymentForm.errors.payment_method}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="paid_at">付款日期</Label>
|
||||
<Input
|
||||
id="paid_at"
|
||||
type="date"
|
||||
value={paymentForm.data.paid_at}
|
||||
onChange={e => paymentForm.setData('paid_at', e.target.value)}
|
||||
required
|
||||
/>
|
||||
{paymentForm.errors.paid_at && <p className="text-sm text-destructive">{paymentForm.errors.paid_at}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment_note">付款備註 (選填)</Label>
|
||||
<Input
|
||||
id="payment_note"
|
||||
value={paymentForm.data.payment_note}
|
||||
onChange={e => paymentForm.setData('payment_note', e.target.value)}
|
||||
placeholder="例如: 匯款帳號後五碼、支票號碼..."
|
||||
/>
|
||||
{paymentForm.errors.payment_note && <p className="text-sm text-destructive">{paymentForm.errors.payment_note}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" className="button-outlined-primary" onClick={() => setPaymentDialogOpen(false)}>取消</Button>
|
||||
<Button type="submit" disabled={paymentForm.processing} className="button-filled-primary">確認已付款</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -106,8 +106,8 @@ export default function GoodsReceiptIndex({ receipts, filters, warehouses }: Pro
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部狀態', value: 'all' },
|
||||
{ label: '草稿', value: 'draft' },
|
||||
{ label: '已完成', value: 'completed' },
|
||||
{ label: '處理中', value: 'processing' },
|
||||
];
|
||||
|
||||
const warehouseOptions = [
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
import { ArrowLeft, Package } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link } from "@inertiajs/react";
|
||||
import { Head, Link, usePage, useForm } from "@inertiajs/react";
|
||||
import { useState } from "react";
|
||||
import GoodsReceiptStatusBadge from "@/Components/Inventory/GoodsReceiptStatusBadge";
|
||||
import CopyButton from "@/Components/shared/CopyButton";
|
||||
import {
|
||||
@@ -16,8 +17,20 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import { formatCurrency, formatDate, formatDateTime } from "@/utils/format";
|
||||
import { getShowBreadcrumbs } from "@/utils/breadcrumb";
|
||||
import { PageProps } from "@/types/global";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// ... (省略中間介面定義)
|
||||
|
||||
interface GoodsReceiptItem {
|
||||
id: number;
|
||||
@@ -66,34 +79,43 @@ export default function ViewGoodsReceiptPage({ receipt }: Props) {
|
||||
other: "其他入庫",
|
||||
};
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: "庫存管理", href: "#" },
|
||||
{ label: "進貨單管理", href: route("goods-receipts.index") },
|
||||
{ label: `單據詳情 (#${receipt.code})` },
|
||||
];
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout breadcrumbs={getShowBreadcrumbs("goodsReceipts", `詳情 (#${receipt.code})`)}>
|
||||
<AuthenticatedLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title={`進貨單詳情 - ${receipt.code}`} />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link href="/goods-receipts">
|
||||
<Link href={route("goods-receipts.index")}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
className="gap-2 button-outlined-primary"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回進貨單列表
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Package className="h-6 w-6 text-primary-main" />
|
||||
查看進貨單
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">單號:{receipt.code}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Package className="h-6 w-6 text-primary-main" />
|
||||
查看進貨單
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<GoodsReceiptStatusBadge status={receipt.status} />
|
||||
<span className="text-gray-500 text-sm">單號:{receipt.code}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<GoodsReceiptActions receipt={receipt} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
@@ -219,3 +241,95 @@ export default function ViewGoodsReceiptPage({ receipt }: Props) {
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function GoodsReceiptActions({ receipt }: { receipt: GoodsReceipt }) {
|
||||
const { auth } = usePage<PageProps>().props;
|
||||
const permissions = auth.user?.permissions || [];
|
||||
const roles = auth.user?.roles || [];
|
||||
const isSuperAdmin = roles.includes('super-admin');
|
||||
|
||||
// 權限判斷
|
||||
const canView = isSuperAdmin || permissions.includes('goods_receipts.view');
|
||||
const canEdit = isSuperAdmin || permissions.includes('goods_receipts.update');
|
||||
const canDelete = isSuperAdmin || permissions.includes('goods_receipts.delete');
|
||||
|
||||
const canSubmit = canEdit || canView;
|
||||
|
||||
// 對話框狀態
|
||||
const [dialogType, setDialogType] = useState<"submit" | "delete" | null>(null);
|
||||
|
||||
const { post, delete: destroy, processing } = useForm({});
|
||||
|
||||
const handleAction = () => {
|
||||
if (!dialogType) return;
|
||||
|
||||
const options = {
|
||||
onSuccess: () => {
|
||||
toast.success('操作成功');
|
||||
setDialogType(null);
|
||||
},
|
||||
onError: (errors: any) => toast.error(errors.error || '操作失敗')
|
||||
};
|
||||
|
||||
switch (dialogType) {
|
||||
case "submit":
|
||||
post(route('goods-receipts.submit', receipt.id), options);
|
||||
break;
|
||||
case "delete":
|
||||
destroy(route('goods-receipts.destroy', receipt.id), options);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{receipt.status === 'draft' && canDelete && (
|
||||
<Button
|
||||
onClick={() => setDialogType("delete")}
|
||||
variant="outline"
|
||||
className="button-outlined-error border-red-600 text-red-600 hover:bg-red-50"
|
||||
disabled={processing}
|
||||
>
|
||||
刪除
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{receipt.status === 'draft' && canSubmit && (
|
||||
<Button
|
||||
onClick={() => setDialogType("submit")}
|
||||
className="button-filled-primary shadow-primary/20"
|
||||
disabled={processing}
|
||||
>
|
||||
確認點收
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 統一確認對話框 */}
|
||||
<AlertDialog open={dialogType !== null} onOpenChange={(open) => !open && setDialogType(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{dialogType === "submit" && "確認點收"}
|
||||
{dialogType === "delete" && "刪除進貨單"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{dialogType === "submit" && "確定已點收無誤嗎?送出後將會更新庫存、關聯單據數量,並自動產生應付帳款,且無法再次退回。"}
|
||||
{dialogType === "delete" && `將刪除進貨單「${receipt.code}」。注意:此操作無法復原。`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={processing}>取消</AlertDialogCancel>
|
||||
<Button
|
||||
onClick={handleAction}
|
||||
disabled={processing}
|
||||
className={dialogType === "delete" ? "button-filled-error" : "button-filled-primary"}
|
||||
>
|
||||
{processing ? "處理中..." : "確定"}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link, router } from "@inertiajs/react";
|
||||
import { debounce } from "lodash";
|
||||
@@ -56,12 +55,9 @@ export default function Index({ warehouses, orders, filters }: any) {
|
||||
const [warehouseFilter, setWarehouseFilter] = useState(filters.warehouse_id || "all");
|
||||
const [perPage, setPerPage] = useState(filters.per_page || "10");
|
||||
|
||||
// Sync state with props
|
||||
useEffect(() => {
|
||||
setSearchTerm(filters.search || "");
|
||||
setWarehouseFilter(filters.warehouse_id || "all");
|
||||
setPerPage(filters.per_page || "10");
|
||||
}, [filters]);
|
||||
// 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);
|
||||
@@ -70,47 +66,52 @@ export default function Index({ warehouses, orders, filters }: any) {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
// Debounced Search Handler
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((term: string, warehouse: string) => {
|
||||
router.get(
|
||||
route('inventory.transfer.index'),
|
||||
{ ...filters, search: term, warehouse_id: warehouse === "all" ? "" : warehouse },
|
||||
{ preserveState: true, replace: true, preserveScroll: true }
|
||||
);
|
||||
const debouncedFilter = useCallback(
|
||||
debounce((params: any) => {
|
||||
router.get(route('inventory.transfer.index'), params, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
}, 500),
|
||||
[filters]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchChange = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
debouncedSearch(term, warehouseFilter);
|
||||
debouncedFilter({
|
||||
...filters,
|
||||
search: term,
|
||||
warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter,
|
||||
page: 1
|
||||
});
|
||||
};
|
||||
|
||||
const handleFilterChange = (value: string) => {
|
||||
setWarehouseFilter(value);
|
||||
router.get(
|
||||
route('inventory.transfer.index'),
|
||||
{ ...filters, warehouse_id: value === "all" ? "" : value },
|
||||
{ preserveState: false, replace: true, preserveScroll: true }
|
||||
);
|
||||
debouncedFilter({
|
||||
...filters,
|
||||
search: searchTerm,
|
||||
warehouse_id: value === "all" ? "" : value,
|
||||
page: 1
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchTerm("");
|
||||
router.get(
|
||||
route('inventory.transfer.index'),
|
||||
{ ...filters, search: "", warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter },
|
||||
{ preserveState: true, replace: true, preserveScroll: true }
|
||||
);
|
||||
debouncedFilter({
|
||||
...filters,
|
||||
search: "",
|
||||
warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter,
|
||||
page: 1
|
||||
});
|
||||
};
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
setPerPage(value);
|
||||
router.get(
|
||||
route('inventory.transfer.index'),
|
||||
{ ...filters, per_page: value },
|
||||
{ preserveState: false, replace: true, preserveScroll: true }
|
||||
{ ...filters, search: searchTerm, warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter, per_page: value, page: 1 },
|
||||
{ preserveState: true, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -26,29 +26,34 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
|
||||
<Head title={`採購單詳情 - ${order.poNumber}`} />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
{/* 返回按鈕 */}
|
||||
<div className="mb-6">
|
||||
<Link href="/purchase-orders">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
className="gap-2 button-outlined-primary"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回採購單列表
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<ShoppingCart className="h-6 w-6 text-primary-main" />
|
||||
查看採購單
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">單號:{order.poNumber}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 頁面標題與操作 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<ShoppingCart className="h-6 w-6 text-primary-main" />
|
||||
查看採購單
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<PurchaseOrderStatusBadge status={order.status} />
|
||||
<span className="text-gray-500 text-sm">單號:{order.poNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<PurchaseOrderActions order={order} />
|
||||
</div>
|
||||
</div>
|
||||
{/* 狀態流程條 */}
|
||||
<div className="mb-8">
|
||||
@@ -169,10 +174,6 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按鈕 (底部) */}
|
||||
<div className="flex justify-end pt-4">
|
||||
<PurchaseOrderActions order={order} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -225,16 +226,15 @@ function PurchaseOrderActions({ order }: { order: PurchaseOrder }) {
|
||||
const canSubmit = canEdit || canView;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{['draft', 'pending', 'approved'].includes(order.status) && canCancel && (
|
||||
<Button
|
||||
onClick={() => handleUpdateStatus('cancelled', '作廢')}
|
||||
disabled={processing}
|
||||
variant="outline"
|
||||
size="xl"
|
||||
className="button-outlined-error shadow-red-200/20 border-red-600 text-red-600 hover:bg-red-50"
|
||||
className="button-outlined-error border-red-600 text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<XCircle className="h-5 w-5" /> 作廢
|
||||
<XCircle className="h-4 w-4 mr-1" /> 作廢
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -243,23 +243,19 @@ function PurchaseOrderActions({ order }: { order: PurchaseOrder }) {
|
||||
onClick={() => handleUpdateStatus('draft', '退回')}
|
||||
disabled={processing}
|
||||
variant="outline"
|
||||
size="xl"
|
||||
className="button-outlined-warning shadow-amber-200/20"
|
||||
className="button-outlined-warning"
|
||||
>
|
||||
<RotateCcw className="h-5 w-5" /> 退回
|
||||
<RotateCcw className="h-4 w-4 mr-1" /> 退回
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{order.status === 'draft' && canSubmit && (
|
||||
<Button
|
||||
onClick={() => handleUpdateStatus('pending', '送出審核')}
|
||||
disabled={processing}
|
||||
size="xl"
|
||||
className="button-filled-primary shadow-primary/20"
|
||||
>
|
||||
<Send className="h-5 w-5" /> 送審
|
||||
<Send className="h-4 w-4 mr-1" /> 送審
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -267,10 +263,9 @@ function PurchaseOrderActions({ order }: { order: PurchaseOrder }) {
|
||||
<Button
|
||||
onClick={() => handleUpdateStatus('approved', '核准')}
|
||||
disabled={processing}
|
||||
size="xl"
|
||||
className="button-filled-primary shadow-primary/20"
|
||||
>
|
||||
<CheckCircle className="h-5 w-5" /> 核准
|
||||
<CheckCircle className="h-4 w-4 mr-1" /> 核准
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -81,58 +81,74 @@ export default function Index({
|
||||
setPerPage(filters.per_page || "10");
|
||||
}, [filters]);
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(overrides: Record<string, string> = {}) => {
|
||||
const params: Record<string, string> = {
|
||||
search: searchTerm,
|
||||
status: statusFilter === "all" ? "" : statusFilter,
|
||||
warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter,
|
||||
per_page: perPage,
|
||||
...overrides,
|
||||
};
|
||||
// 清理空值
|
||||
Object.keys(params).forEach((key) => {
|
||||
if (!params[key]) delete params[key];
|
||||
});
|
||||
const debouncedFilter = useCallback(
|
||||
debounce((params: any) => {
|
||||
router.get(route("store-requisitions.index"), params, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
},
|
||||
[searchTerm, statusFilter, warehouseFilter, perPage]
|
||||
);
|
||||
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((term: string) => {
|
||||
applyFilters({ search: term });
|
||||
}, 500),
|
||||
[applyFilters]
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchChange = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
debouncedSearch(term);
|
||||
debouncedFilter({
|
||||
...filters,
|
||||
search: term,
|
||||
status: statusFilter === "all" ? "" : statusFilter,
|
||||
warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter,
|
||||
page: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchTerm("");
|
||||
applyFilters({ search: "" });
|
||||
debouncedFilter({
|
||||
...filters,
|
||||
search: "",
|
||||
status: statusFilter === "all" ? "" : statusFilter,
|
||||
warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter,
|
||||
page: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const handleStatusChange = (value: string) => {
|
||||
setStatusFilter(value);
|
||||
applyFilters({ status: value === "all" ? "" : value });
|
||||
debouncedFilter({
|
||||
...filters,
|
||||
search: searchTerm,
|
||||
status: value === "all" ? "" : value,
|
||||
warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter,
|
||||
page: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const handleWarehouseChange = (value: string) => {
|
||||
setWarehouseFilter(value);
|
||||
applyFilters({ warehouse_id: value === "all" ? "" : value });
|
||||
debouncedFilter({
|
||||
...filters,
|
||||
search: searchTerm,
|
||||
status: statusFilter === "all" ? "" : statusFilter,
|
||||
warehouse_id: value === "all" ? "" : value,
|
||||
page: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
setPerPage(value);
|
||||
applyFilters({ per_page: 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 = () => {
|
||||
|
||||
@@ -23,6 +23,13 @@ import {
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/Components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -138,10 +145,6 @@ export default function Show({ requisition, warehouses }: Props) {
|
||||
};
|
||||
|
||||
const handleApprove = () => {
|
||||
if (!supplyWarehouseId) {
|
||||
toast.error("請選擇供貨倉庫");
|
||||
return;
|
||||
}
|
||||
// 確認每個核准數量
|
||||
for (const item of approvedItems) {
|
||||
const qty = parseFloat(item.approved_qty);
|
||||
@@ -155,7 +158,6 @@ export default function Show({ requisition, warehouses }: Props) {
|
||||
router.post(
|
||||
route("store-requisitions.approve", [requisition.id]),
|
||||
{
|
||||
supply_warehouse_id: supplyWarehouseId,
|
||||
items: approvedItems.map((item) => ({
|
||||
id: item.id,
|
||||
approved_qty: parseFloat(item.approved_qty),
|
||||
@@ -196,6 +198,20 @@ export default function Show({ requisition, warehouses }: Props) {
|
||||
|
||||
const isEditable = ["draft", "rejected"].includes(requisition.status);
|
||||
const isPending = requisition.status === "pending";
|
||||
const canApprove = usePermission().can("store_requisitions.approve");
|
||||
|
||||
const handleUpdateSupplyWarehouse = (warehouseId: string) => {
|
||||
setSubmitting(true);
|
||||
router.patch(
|
||||
route("store-requisitions.update-supply-warehouse", [requisition.id]),
|
||||
{ supply_warehouse_id: warehouseId },
|
||||
{
|
||||
onFinish: () => setSubmitting(false),
|
||||
onSuccess: () => toast.success("供貨倉庫已更新"),
|
||||
preserveScroll: true,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
@@ -294,9 +310,27 @@ export default function Show({ requisition, warehouses }: Props) {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">供貨倉庫</span>
|
||||
<p className="font-medium text-gray-800 mt-1">
|
||||
{requisition.supply_warehouse_name || "-"}
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
{isPending && canApprove ? (
|
||||
<SearchableSelect
|
||||
value={requisition.supply_warehouse_id?.toString() || ""}
|
||||
onValueChange={handleUpdateSupplyWarehouse}
|
||||
options={warehouses
|
||||
.filter((w) => w.id !== requisition.store_warehouse_id)
|
||||
.map((w) => ({
|
||||
label: w.name,
|
||||
value: w.id.toString(),
|
||||
}))}
|
||||
placeholder="選擇供貨倉庫"
|
||||
className="h-9 w-full max-w-[200px]"
|
||||
disabled={submitting}
|
||||
/>
|
||||
) : (
|
||||
<p className="font-medium text-gray-800">
|
||||
{requisition.supply_warehouse_name || "-"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">申請人</span>
|
||||
@@ -455,22 +489,14 @@ export default function Show({ requisition, warehouses }: Props) {
|
||||
<DialogDescription>選擇供貨倉庫,並確認各商品的核准數量。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
供貨倉庫 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<SearchableSelect
|
||||
value={supplyWarehouseId}
|
||||
onValueChange={setSupplyWarehouseId}
|
||||
options={warehouses
|
||||
.filter((w) => w.id !== requisition.store_warehouse_id)
|
||||
.map((w) => ({
|
||||
label: w.name,
|
||||
value: w.id.toString(),
|
||||
}))}
|
||||
placeholder="請選擇供貨倉庫"
|
||||
className="h-9"
|
||||
/>
|
||||
<div className="flex items-center justify-between p-3 bg-blue-50 border border-blue-100 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-blue-700 font-medium">供貨倉庫:</span>
|
||||
<span className="text-blue-900 font-bold">{requisition.supply_warehouse_name || "尚未選擇"}</span>
|
||||
</div>
|
||||
{!requisition.supply_warehouse_id && (
|
||||
<span className="text-xs text-red-500 font-medium">* 請先在基本資訊中選擇供貨倉庫</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
@@ -534,7 +560,7 @@ export default function Show({ requisition, warehouses }: Props) {
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
onClick={handleApprove}
|
||||
disabled={approving || !supplyWarehouseId}
|
||||
disabled={approving || !requisition.supply_warehouse_id}
|
||||
>
|
||||
{approving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
確認核准
|
||||
|
||||
Reference in New Issue
Block a user