[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

@@ -0,0 +1,90 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class NotifyUtilityFeeStatus extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'finance:notify-utility-fees';
/**
* The console command description.
*
* @var string
*/
protected $description = '檢查公共事業費狀態並寄送 Email 通知管理員';
/**
* Execute the console command.
*/
public function handle()
{
$this->info("正在掃描公共事業費狀態...");
// 1. 更新逾期狀態 (pending -> overdue)
\App\Modules\Finance\Models\UtilityFee::where('payment_status', \App\Modules\Finance\Models\UtilityFee::STATUS_PENDING)
->whereNotNull('due_date')
->where('due_date', '<', now()->startOfDay())
->update(['payment_status' => \App\Modules\Finance\Models\UtilityFee::STATUS_OVERDUE]);
// 2. 獲取需要處理的單據 (pending 或 overdue)
$unpaidFees = \App\Modules\Finance\Models\UtilityFee::whereIn('payment_status', [
\App\Modules\Finance\Models\UtilityFee::STATUS_PENDING,
\App\Modules\Finance\Models\UtilityFee::STATUS_OVERDUE
])
->orderBy('due_date', 'asc')
->get();
if ($unpaidFees->isEmpty()) {
$this->info("目前沒有需要繳納的公共事業費。");
return 0;
}
// 3. 讀取系統設定
$senderEmail = \App\Modules\Core\Models\SystemSetting::getVal('notification.utility_fee_sender_email');
$senderPassword = \App\Modules\Core\Models\SystemSetting::getVal('notification.utility_fee_sender_password');
$recipientEmailsStr = \App\Modules\Core\Models\SystemSetting::getVal('notification.utility_fee_recipient_emails');
if (empty($senderEmail) || empty($senderPassword) || empty($recipientEmailsStr)) {
$this->warn("系統設定中缺乏完整的 Email 通知參數,跳過寄送通知。請至「系統設定」->「通知設定」完善資料。");
return 0;
}
// 4. 動態覆寫應用程式名稱與 SMTP Config
$tenantName = tenant('name') ?? config('app.name');
config([
'app.name' => $tenantName,
'mail.mailers.smtp.username' => $senderEmail,
'mail.mailers.smtp.password' => $senderPassword,
'mail.from.address' => $senderEmail,
'mail.from.name' => $tenantName . ' (系統通知)'
]);
// 清理原先可能的 Mailer 實例,確保使用新的 Config
\Illuminate\Support\Facades\Mail::purge();
// 5. 解析收件者並寄送 Email
$recipients = array_map('trim', explode(',', $recipientEmailsStr));
$validRecipients = array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL));
if (empty($validRecipients)) {
$this->warn("無效的收件者 Email 格式,跳過寄送通知。");
return 0;
}
try {
\Illuminate\Support\Facades\Mail::to($validRecipients)->send(new \App\Mail\PaymentReminderMail($unpaidFees));
$this->info("通知郵件已成功寄送至: " . implode(', ', $validRecipients));
} catch (\Exception $e) {
$this->error("Email 寄送失敗: " . $e->getMessage());
}
return 0;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PaymentReminderMail extends Mailable
{
use Queueable, SerializesModels;
public $fees;
/**
* Create a new message instance.
*/
public function __construct($fees)
{
$this->fees = $fees;
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
$tenantName = tenant('name') ?? '系統';
return new Envelope(
subject: "{$tenantName}】公共事業費繳費/逾期通知",
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.payment-reminder',
with: [
'fees' => $this->fees,
],
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class TestNotificationMail extends Mailable
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct()
{
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
$tenantName = tenant('name') ?? '系統';
return new Envelope(
subject: "{$tenantName}】電子郵件通知測試",
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.test-notification',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -43,4 +43,55 @@ class SystemSettingController extends Controller
return redirect()->back()->with('success', '系統設定已更新'); return redirect()->back()->with('success', '系統設定已更新');
} }
/**
* 測試發送通知信
*/
public function testNotification(Request $request)
{
$validated = $request->validate([
'settings' => 'required|array',
'settings.*.key' => 'required|string',
'settings.*.value' => 'nullable|string',
]);
$settings = collect($validated['settings'])->pluck('value', 'key');
$senderEmail = $settings['notification.utility_fee_sender_email'] ?? null;
$senderPassword = $settings['notification.utility_fee_sender_password'] ?? null;
$recipientEmailsStr = $settings['notification.utility_fee_recipient_emails'] ?? null;
if (empty($senderEmail) || empty($senderPassword) || empty($recipientEmailsStr)) {
return back()->with('error', '請先填寫完整發信帳號、密碼及收件者信箱。');
} }
// 動態覆寫應用程式名稱與 SMTP Config
$tenantName = tenant('name') ?? config('app.name');
config([
'app.name' => $tenantName,
'mail.mailers.smtp.username' => $senderEmail,
'mail.mailers.smtp.password' => $senderPassword,
'mail.from.address' => $senderEmail,
'mail.from.name' => $tenantName . ' (系統通知)'
]);
// 清理原先可能的 Mailer 實例,確保使用新的 Config
\Illuminate\Support\Facades\Mail::purge();
// 解析收件者
$recipients = array_map('trim', explode(',', $recipientEmailsStr));
$validRecipients = array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL));
if (empty($validRecipients)) {
return back()->with('error', '無效的收件者 Email 格式。');
}
try {
\Illuminate\Support\Facades\Mail::to($validRecipients)->send(new \App\Mail\TestNotificationMail());
return back()->with('success', '測試信件已成功發送,請檢查收件匣。');
} catch (\Exception $e) {
return back()->with('error', '測試發信失敗: ' . $e->getMessage());
}
}
}

View File

@@ -60,6 +60,7 @@ Route::middleware('auth')->group(function () {
Route::middleware('permission:system.settings.view')->group(function () { Route::middleware('permission:system.settings.view')->group(function () {
Route::get('/settings', [SystemSettingController::class, 'index'])->name('settings.index'); Route::get('/settings', [SystemSettingController::class, 'index'])->name('settings.index');
Route::post('/settings', [SystemSettingController::class, 'update'])->name('settings.update'); Route::post('/settings', [SystemSettingController::class, 'update'])->name('settings.update');
Route::post('/settings/test-notification', [SystemSettingController::class, 'testNotification'])->name('settings.test-notification');
}); });
}); });

View File

@@ -34,13 +34,16 @@ class UtilityFeeController extends Controller
public function store(Request $request) public function store(Request $request)
{ {
$validated = $request->validate([ $validated = $request->validate([
'transaction_date' => 'required|date', 'transaction_date' => 'nullable|date',
'due_date' => 'required|date',
'category' => 'required|string|max:255', 'category' => 'required|string|max:255',
'amount' => 'required|numeric|min:0', 'amount' => 'required|numeric|min:0',
'invoice_number' => 'nullable|string|max:255', 'invoice_number' => 'nullable|string|max:255',
'description' => 'nullable|string', 'description' => 'nullable|string',
]); ]);
$validated['payment_status'] = $this->determineStatus($validated);
$fee = UtilityFee::create($validated); $fee = UtilityFee::create($validated);
activity() activity()
@@ -55,13 +58,16 @@ class UtilityFeeController extends Controller
public function update(Request $request, UtilityFee $utility_fee) public function update(Request $request, UtilityFee $utility_fee)
{ {
$validated = $request->validate([ $validated = $request->validate([
'transaction_date' => 'required|date', 'transaction_date' => 'nullable|date',
'due_date' => 'required|date',
'category' => 'required|string|max:255', 'category' => 'required|string|max:255',
'amount' => 'required|numeric|min:0', 'amount' => 'required|numeric|min:0',
'invoice_number' => 'nullable|string|max:255', 'invoice_number' => 'nullable|string|max:255',
'description' => 'nullable|string', 'description' => 'nullable|string',
]); ]);
$validated['payment_status'] = $this->determineStatus($validated);
$utility_fee->update($validated); $utility_fee->update($validated);
activity() activity()
@@ -73,6 +79,22 @@ class UtilityFeeController extends Controller
return redirect()->back(); return redirect()->back();
} }
/**
* 判定繳費狀態
*/
private function determineStatus(array $data): string
{
if (!empty($data['transaction_date'])) {
return UtilityFee::STATUS_PAID;
}
if (!empty($data['due_date']) && now()->startOfDay()->gt(\Illuminate\Support\Carbon::parse($data['due_date']))) {
return UtilityFee::STATUS_OVERDUE;
}
return UtilityFee::STATUS_PENDING;
}
public function destroy(UtilityFee $utility_fee) public function destroy(UtilityFee $utility_fee)
{ {
activity() activity()

View File

@@ -10,26 +10,37 @@ class UtilityFee extends Model
/** @use HasFactory<\Database\Factories\UtilityFeeFactory> */ /** @use HasFactory<\Database\Factories\UtilityFeeFactory> */
use HasFactory; use HasFactory;
// 狀態常數
const STATUS_PENDING = 'pending';
const STATUS_PAID = 'paid';
const STATUS_OVERDUE = 'overdue';
protected $fillable = [ protected $fillable = [
'transaction_date', 'transaction_date',
'due_date',
'category', 'category',
'amount', 'amount',
'payment_status',
'invoice_number', 'invoice_number',
'description', 'description',
]; ];
protected $casts = [ protected $casts = [
'transaction_date' => 'date:Y-m-d', 'transaction_date' => 'date:Y-m-d',
'due_date' => 'date:Y-m-d',
'amount' => 'decimal:2', 'amount' => 'decimal:2',
]; ];
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{ {
$activity->properties = $activity->properties->put('snapshot', [ $snapshot = [
'transaction_date' => $this->transaction_date->format('Y-m-d'), 'transaction_date' => $this->transaction_date?->format('Y-m-d'),
'due_date' => $this->due_date?->format('Y-m-d'),
'category' => $this->category, 'category' => $this->category,
'amount' => $this->amount, 'amount' => $this->amount,
'payment_status' => $this->payment_status,
'invoice_number' => $this->invoice_number, 'invoice_number' => $this->invoice_number,
]); ];
$activity->properties = $activity->properties->put('snapshot', $snapshot);
} }
} }

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('utility_fees', function (Blueprint $table) {
// 1. 將費用日期改為可為空 (繳費日期)
$table->date('transaction_date')->nullable()->change();
// 2. 新增繳費期限
$table->date('due_date')->nullable()->after('transaction_date')->comment('繳費期限');
// 3. 新增繳費狀態
$table->enum('payment_status', ['pending', 'paid', 'overdue'])
->default('pending')
->after('amount')
->comment('繳費狀態');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('utility_fees', function (Blueprint $table) {
$table->date('transaction_date')->nullable(false)->change();
$table->dropColumn(['due_date', 'payment_status']);
});
}
};

View File

@@ -56,6 +56,28 @@ class SystemSettingSeeder extends Seeder
'type' => 'integer', 'type' => 'integer',
'description' => '每頁預設筆數', 'description' => '每頁預設筆數',
], ],
// 📧 通知設定
[
'group' => 'notification',
'key' => 'notification.utility_fee_sender_email',
'value' => 'sky121113@gmail.com',
'type' => 'string',
'description' => '發送公共事業通知的 Gmail 帳號',
],
[
'group' => 'notification',
'key' => 'notification.utility_fee_sender_password',
'value' => 'qjxcedzcrjoyioxu',
'type' => 'string',
'description' => '發送公共事業通知的 Gmail 應用程式密碼',
],
[
'group' => 'notification',
'key' => 'notification.utility_fee_recipient_emails',
'value' => 'sky121113@gmail.com',
'type' => 'string',
'description' => '接收通知的 Email 清單 (多筆請用逗號分隔)',
],
]; ];
foreach ($settings as $setting) { foreach ($settings as $setting) {

View File

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

View File

@@ -1,6 +1,6 @@
import React from "react"; import React, { useState } from "react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, useForm } from "@inertiajs/react"; import { Head, useForm, router } from "@inertiajs/react";
import { import {
Card, Card,
CardContent, CardContent,
@@ -17,8 +17,10 @@ import {
Package, Package,
RefreshCcw, RefreshCcw,
Monitor, Monitor,
Bell,
Save, Save,
Settings Settings,
Send
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -33,6 +35,7 @@ interface PageProps {
} }
export default function SettingIndex({ settings }: PageProps) { export default function SettingIndex({ settings }: PageProps) {
const [isTesting, setIsTesting] = useState(false);
const { data, setData, post, processing } = useForm({ const { data, setData, post, processing } = useForm({
settings: Object.values(settings).flat().map(s => ({ settings: Object.values(settings).flat().map(s => ({
key: s.key, 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 handleValueChange = (key: string, value: string) => {
const newSettings = data.settings.map(s => const newSettings = data.settings.map(s =>
s.key === key ? { ...s, value } : 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 renderSettingRow = (setting: Setting) => {
const currentVal = data.settings.find(s => s.key === setting.key)?.value || ''; const currentVal = data.settings.find(s => s.key === setting.key)?.value || '';
@@ -65,11 +78,14 @@ export default function SettingIndex({ settings }: PageProps) {
</div> </div>
<div> <div>
<Input <Input
type="text" type={setting.key.includes('password') ? 'password' : 'text'}
value={currentVal} value={currentVal}
onChange={(e) => handleValueChange(setting.key, e.target.value)} onChange={(e) => handleValueChange(setting.key, e.target.value)}
className="max-w-xs" 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>
</div> </div>
); );
@@ -96,7 +112,7 @@ export default function SettingIndex({ settings }: PageProps) {
</div> </div>
<form onSubmit={submit}> <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"> <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"> <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" /> <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"> <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" /> <Monitor className="h-4 w-4" />
</TabsTrigger> </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> </TabsList>
<TabsContent value="finance"> <TabsContent value="finance">
@@ -160,7 +179,31 @@ export default function SettingIndex({ settings }: PageProps) {
</Card> </Card>
</TabsContent> </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"> <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 <Button
type="submit" type="submit"
className="button-filled-primary" className="button-filled-primary"
@@ -176,3 +219,4 @@ export default function SettingIndex({ settings }: PageProps) {
</AuthenticatedLayout> </AuthenticatedLayout>
); );
} }

View File

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

View File

@@ -2,7 +2,12 @@
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
Schedule::command('tenants:run finance:notify-utility-fees')->dailyAt('08:00');