[FEAT] 實作公共事業費逾期提醒、租戶自訂通知設定及發送測試信功能
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 56s

This commit is contained in:
2026-03-05 16:01:00 +08:00
parent 016366407c
commit 07b7d9b327
15 changed files with 519 additions and 44 deletions

View File

@@ -20,9 +20,11 @@ import { validateInvoiceNumber } from "@/utils/validation";
export interface UtilityFee {
id: number;
transaction_date: string;
transaction_date: string | null;
due_date: string;
category: string;
amount: number | string;
payment_status: 'pending' | 'paid' | 'overdue';
invoice_number?: string;
description?: string;
created_at: string;
@@ -53,7 +55,8 @@ export default function UtilityFeeDialog({
availableCategories,
}: UtilityFeeDialogProps) {
const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({
transaction_date: getCurrentDate(),
transaction_date: "",
due_date: getCurrentDate(),
category: "",
amount: "",
invoice_number: "",
@@ -68,7 +71,8 @@ export default function UtilityFeeDialog({
clearErrors();
if (fee) {
setData({
transaction_date: fee.transaction_date,
transaction_date: fee.transaction_date || "",
due_date: fee.due_date,
category: fee.category,
amount: fee.amount.toString(),
invoice_number: fee.invoice_number || "",
@@ -76,7 +80,14 @@ export default function UtilityFeeDialog({
});
} else {
reset();
setData("transaction_date", getCurrentDate());
setData({
transaction_date: "",
due_date: getCurrentDate(),
category: "",
amount: "",
invoice_number: "",
description: "",
});
}
}
}, [open, fee]);
@@ -131,22 +142,41 @@ export default function UtilityFeeDialog({
<form onSubmit={handleSubmit} className="space-y-4 py-2">
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="transaction_date">
<span className="text-red-500">*</span>
</Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
id="transaction_date"
type="date"
value={data.transaction_date}
onChange={(e) => setData("transaction_date", e.target.value)}
className={`pl-9 block w-full ${errors.transaction_date ? "border-red-500" : ""}`}
required
/>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="due_date">
<span className="text-red-500">*</span>
</Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
id="due_date"
type="date"
value={data.due_date}
onChange={(e) => setData("due_date", e.target.value)}
className={`pl-9 block w-full ${errors.due_date ? "border-red-500" : ""}`}
required
/>
</div>
{errors.due_date && <p className="text-sm text-red-500">{errors.due_date}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="transaction_date">
</Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
id="transaction_date"
type="date"
value={data.transaction_date}
onChange={(e) => setData("transaction_date", e.target.value)}
className={`pl-9 block w-full ${errors.transaction_date ? "border-red-500" : ""}`}
/>
</div>
{errors.transaction_date && <p className="text-sm text-red-500">{errors.transaction_date}</p>}
</div>
{errors.transaction_date && <p className="text-sm text-red-500">{errors.transaction_date}</p>}
</div>
<div className="grid grid-cols-2 gap-4">

View File

@@ -1,6 +1,6 @@
import React from "react";
import React, { useState } from "react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, useForm } from "@inertiajs/react";
import { Head, useForm, router } from "@inertiajs/react";
import {
Card,
CardContent,
@@ -17,8 +17,10 @@ import {
Package,
RefreshCcw,
Monitor,
Bell,
Save,
Settings
Settings,
Send
} from "lucide-react";
import { toast } from "sonner";
@@ -33,6 +35,7 @@ interface PageProps {
}
export default function SettingIndex({ settings }: PageProps) {
const [isTesting, setIsTesting] = useState(false);
const { data, setData, post, processing } = useForm({
settings: Object.values(settings).flat().map(s => ({
key: s.key,
@@ -40,6 +43,8 @@ export default function SettingIndex({ settings }: PageProps) {
}))
});
const [activeTab, setActiveTab] = useState("finance");
const handleValueChange = (key: string, value: string) => {
const newSettings = data.settings.map(s =>
s.key === key ? { ...s, value } : s
@@ -54,6 +59,14 @@ export default function SettingIndex({ settings }: PageProps) {
});
};
const handleTestNotification = () => {
setIsTesting(true);
router.post(route('settings.test-notification'), { settings: data.settings }, {
preserveScroll: true,
onFinish: () => setIsTesting(false)
});
};
const renderSettingRow = (setting: Setting) => {
const currentVal = data.settings.find(s => s.key === setting.key)?.value || '';
@@ -65,11 +78,14 @@ export default function SettingIndex({ settings }: PageProps) {
</div>
<div>
<Input
type="text"
type={setting.key.includes('password') ? 'password' : 'text'}
value={currentVal}
onChange={(e) => handleValueChange(setting.key, e.target.value)}
className="max-w-xs"
/>
{setting.key === 'notification.utility_fee_recipient_emails' && (
<p className="text-xs text-gray-400 mt-1 mt-2">, Emaila@test.com,b@test.com</p>
)}
</div>
</div>
);
@@ -96,7 +112,7 @@ export default function SettingIndex({ settings }: PageProps) {
</div>
<form onSubmit={submit}>
<Tabs defaultValue="finance" className="space-y-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList className="bg-white border p-1 h-auto gap-2">
<TabsTrigger value="finance" className="gap-2 py-2 data-[state=active]:button-filled-primary data-[state=active]:text-white">
<Coins className="h-4 w-4" />
@@ -110,6 +126,9 @@ export default function SettingIndex({ settings }: PageProps) {
<TabsTrigger value="display" className="gap-2 py-2 data-[state=active]:button-filled-primary data-[state=active]:text-white">
<Monitor className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="notification" className="gap-2 py-2 data-[state=active]:button-filled-primary data-[state=active]:text-white">
<Bell className="h-4 w-4" />
</TabsTrigger>
</TabsList>
<TabsContent value="finance">
@@ -160,7 +179,31 @@ export default function SettingIndex({ settings }: PageProps) {
</Card>
</TabsContent>
<TabsContent value="notification">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> Email </CardDescription>
</CardHeader>
<CardContent className="space-y-1">
{settings.notification?.map(renderSettingRow)}
</CardContent>
</Card>
</TabsContent>
<div className="flex justify-end gap-4 mt-6">
{activeTab === 'notification' && (
<Button
type="button"
variant="outline"
className="button-outlined-primary"
onClick={handleTestNotification}
disabled={isTesting || processing}
>
<Send className="h-4 w-4 mr-2" />
{isTesting ? "發送中..." : "發送測試信"}
</Button>
)}
<Button
type="submit"
className="button-filled-primary"
@@ -176,3 +219,4 @@ export default function SettingIndex({ settings }: PageProps) {
</AuthenticatedLayout>
);
}

View File

@@ -41,7 +41,8 @@ import {
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import { Can } from "@/Components/Permission/Can";
import { formatDateWithDayOfWeek, formatInvoiceNumber, getDateRange } from "@/utils/format";
import { formatDateWithDayOfWeek, getDateRange } from "@/utils/format";
import { StatusBadge } from "@/Components/shared/StatusBadge";
interface PageProps {
fees: {
@@ -361,12 +362,20 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead className="w-[120px]">
<button
onClick={() => handleSort('due_date')}
className="flex items-center hover:text-gray-900"
>
<SortIcon field="due_date" />
</button>
</TableHead>
<TableHead className="w-[120px]">
<button
onClick={() => handleSort('transaction_date')}
className="flex items-center hover:text-gray-900"
>
<SortIcon field="transaction_date" />
<SortIcon field="transaction_date" />
</button>
</TableHead>
<TableHead>
@@ -377,14 +386,7 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
<SortIcon field="category" />
</button>
</TableHead>
<TableHead>
<button
onClick={() => handleSort('invoice_number')}
className="flex items-center hover:text-gray-900"
>
<SortIcon field="invoice_number" />
</button>
</TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="text-right">
<div className="flex justify-end">
<button
@@ -402,7 +404,7 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
<TableBody>
{fees.data.length === 0 ? (
<TableRow>
<TableCell colSpan={7}>
<TableCell colSpan={8}>
<div className="flex flex-col items-center justify-center space-y-2 py-8">
<FileText className="h-8 w-8 text-gray-300" />
<p className="text-gray-500"></p>
@@ -415,16 +417,27 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
<TableCell className="text-gray-500 font-medium text-center">
{fees.from + index}
</TableCell>
<TableCell className="font-medium text-gray-700">
{formatDateWithDayOfWeek(fee.transaction_date)}
<TableCell className="text-gray-700">
{formatDateWithDayOfWeek(fee.due_date)}
</TableCell>
<TableCell className={`font-medium ${fee.transaction_date ? "text-gray-700" : "text-gray-400 italic"}`}>
{fee.transaction_date ? formatDateWithDayOfWeek(fee.transaction_date) : "未填寫"}
</TableCell>
<TableCell>
<Badge variant="outline">
{fee.category}
</Badge>
</TableCell>
<TableCell className="font-mono text-sm text-gray-600">
{formatInvoiceNumber(fee.invoice_number)}
<TableCell className="text-center">
{fee.payment_status === "paid" && (
<StatusBadge variant="success"></StatusBadge>
)}
{fee.payment_status === "pending" && (
<StatusBadge variant="warning"></StatusBadge>
)}
{fee.payment_status === "overdue" && (
<StatusBadge variant="destructive"></StatusBadge>
)}
</TableCell>
<TableCell className="text-right font-bold text-gray-900">
$ {Number(fee.amount).toLocaleString()}

View File

@@ -0,0 +1,22 @@
<x-mail::message>
# 公共事業費繳費提醒
您好,系統偵測到有公共事業費單據處於 **未完成繳費** **已逾期** 狀態,請儘速處理。
以下為待處理清單:
<x-mail::table>
| 費用類別 | 金額 | 繳費期限 | 目前狀態 |
| :--- | :--- | :--- | :--- |
@foreach($fees as $fee)
| {{ $fee->category }} | {{ number_format($fee->amount, 2) }} | {{ $fee->due_date ? $fee->due_date->format('Y-m-d') : '未設定' }} | {{ $fee->payment_status === 'overdue' ? '🔴 已逾期' : '🟡 待繳納' }} |
@endforeach
</x-mail::table>
<x-mail::button :url="config('app.url') . '/utility-fees'">
前往系統查看詳情
</x-mail::button>
感謝您的配合,<br>
{{ config('app.name') }} 系統管理團隊
</x-mail::message>

View File

@@ -0,0 +1,12 @@
<x-mail::message>
# 電子郵件通知測試成功
您好,
這是一封系統自動發送的測試信件。當您看到這封信時,表示您在系統中設定的 SMTP 寄件帳號與密碼已經可以正常運作。
此信件由系統發出,請勿直接回覆。
感謝您,<br>
{{ config('app.name') }} 系統管理團隊
</x-mail::message>