diff --git a/app/Console/Commands/NotifyUtilityFeeStatus.php b/app/Console/Commands/NotifyUtilityFeeStatus.php new file mode 100644 index 0000000..97ed9e2 --- /dev/null +++ b/app/Console/Commands/NotifyUtilityFeeStatus.php @@ -0,0 +1,90 @@ +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; + } +} diff --git a/app/Mail/PaymentReminderMail.php b/app/Mail/PaymentReminderMail.php new file mode 100644 index 0000000..32abfad --- /dev/null +++ b/app/Mail/PaymentReminderMail.php @@ -0,0 +1,59 @@ +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 + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Mail/TestNotificationMail.php b/app/Mail/TestNotificationMail.php new file mode 100644 index 0000000..bbecfae --- /dev/null +++ b/app/Mail/TestNotificationMail.php @@ -0,0 +1,54 @@ + + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Modules/Core/Controllers/SystemSettingController.php b/app/Modules/Core/Controllers/SystemSettingController.php index ef555f2..b7f6e47 100644 --- a/app/Modules/Core/Controllers/SystemSettingController.php +++ b/app/Modules/Core/Controllers/SystemSettingController.php @@ -43,4 +43,55 @@ class SystemSettingController extends Controller 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()); + } + } } + diff --git a/app/Modules/Core/Routes/web.php b/app/Modules/Core/Routes/web.php index 1996d9c..baa4b69 100644 --- a/app/Modules/Core/Routes/web.php +++ b/app/Modules/Core/Routes/web.php @@ -60,6 +60,7 @@ Route::middleware('auth')->group(function () { Route::middleware('permission:system.settings.view')->group(function () { Route::get('/settings', [SystemSettingController::class, 'index'])->name('settings.index'); Route::post('/settings', [SystemSettingController::class, 'update'])->name('settings.update'); + Route::post('/settings/test-notification', [SystemSettingController::class, 'testNotification'])->name('settings.test-notification'); }); }); diff --git a/app/Modules/Finance/Controllers/UtilityFeeController.php b/app/Modules/Finance/Controllers/UtilityFeeController.php index 332f9df..9cc7738 100644 --- a/app/Modules/Finance/Controllers/UtilityFeeController.php +++ b/app/Modules/Finance/Controllers/UtilityFeeController.php @@ -34,13 +34,16 @@ class UtilityFeeController extends Controller public function store(Request $request) { $validated = $request->validate([ - 'transaction_date' => 'required|date', + 'transaction_date' => 'nullable|date', + 'due_date' => 'required|date', 'category' => 'required|string|max:255', 'amount' => 'required|numeric|min:0', 'invoice_number' => 'nullable|string|max:255', 'description' => 'nullable|string', ]); + $validated['payment_status'] = $this->determineStatus($validated); + $fee = UtilityFee::create($validated); activity() @@ -55,13 +58,16 @@ class UtilityFeeController extends Controller public function update(Request $request, UtilityFee $utility_fee) { $validated = $request->validate([ - 'transaction_date' => 'required|date', + 'transaction_date' => 'nullable|date', + 'due_date' => 'required|date', 'category' => 'required|string|max:255', 'amount' => 'required|numeric|min:0', 'invoice_number' => 'nullable|string|max:255', 'description' => 'nullable|string', ]); + $validated['payment_status'] = $this->determineStatus($validated); + $utility_fee->update($validated); activity() @@ -73,6 +79,22 @@ class UtilityFeeController extends Controller 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) { activity() diff --git a/app/Modules/Finance/Models/UtilityFee.php b/app/Modules/Finance/Models/UtilityFee.php index 2c0c313..aba6528 100644 --- a/app/Modules/Finance/Models/UtilityFee.php +++ b/app/Modules/Finance/Models/UtilityFee.php @@ -10,26 +10,37 @@ class UtilityFee extends Model /** @use HasFactory<\Database\Factories\UtilityFeeFactory> */ use HasFactory; + // 狀態常數 + const STATUS_PENDING = 'pending'; + const STATUS_PAID = 'paid'; + const STATUS_OVERDUE = 'overdue'; + protected $fillable = [ 'transaction_date', + 'due_date', 'category', 'amount', + 'payment_status', 'invoice_number', 'description', ]; protected $casts = [ 'transaction_date' => 'date:Y-m-d', + 'due_date' => 'date:Y-m-d', 'amount' => 'decimal:2', ]; public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) { - $activity->properties = $activity->properties->put('snapshot', [ - 'transaction_date' => $this->transaction_date->format('Y-m-d'), + $snapshot = [ + 'transaction_date' => $this->transaction_date?->format('Y-m-d'), + 'due_date' => $this->due_date?->format('Y-m-d'), 'category' => $this->category, 'amount' => $this->amount, + 'payment_status' => $this->payment_status, 'invoice_number' => $this->invoice_number, - ]); + ]; + $activity->properties = $activity->properties->put('snapshot', $snapshot); } } diff --git a/database/migrations/tenant/2026_03_05_141116_adjust_utility_fees_table.php b/database/migrations/tenant/2026_03_05_141116_adjust_utility_fees_table.php new file mode 100644 index 0000000..e6f1b41 --- /dev/null +++ b/database/migrations/tenant/2026_03_05_141116_adjust_utility_fees_table.php @@ -0,0 +1,39 @@ +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']); + }); + } +}; diff --git a/database/seeders/SystemSettingSeeder.php b/database/seeders/SystemSettingSeeder.php index 8be5324..93671b6 100644 --- a/database/seeders/SystemSettingSeeder.php +++ b/database/seeders/SystemSettingSeeder.php @@ -56,6 +56,28 @@ class SystemSettingSeeder extends Seeder 'type' => 'integer', '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) { diff --git a/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx b/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx index 1bb0710..98e1951 100644 --- a/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx +++ b/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx @@ -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({
-
- -
- - setData("transaction_date", e.target.value)} - className={`pl-9 block w-full ${errors.transaction_date ? "border-red-500" : ""}`} - required - /> +
+
+ +
+ + setData("due_date", e.target.value)} + className={`pl-9 block w-full ${errors.due_date ? "border-red-500" : ""}`} + required + /> +
+ {errors.due_date &&

{errors.due_date}

} +
+ +
+ +
+ + setData("transaction_date", e.target.value)} + className={`pl-9 block w-full ${errors.transaction_date ? "border-red-500" : ""}`} + /> +
+ {errors.transaction_date &&

{errors.transaction_date}

}
- {errors.transaction_date &&

{errors.transaction_date}

}
diff --git a/resources/js/Pages/Admin/Setting/Index.tsx b/resources/js/Pages/Admin/Setting/Index.tsx index 83aa498..daf243b 100644 --- a/resources/js/Pages/Admin/Setting/Index.tsx +++ b/resources/js/Pages/Admin/Setting/Index.tsx @@ -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) {
handleValueChange(setting.key, e.target.value)} className="max-w-xs" /> + {setting.key === 'notification.utility_fee_recipient_emails' && ( +

請以半形逗點「,」分隔多個 Email,例如:a@test.com,b@test.com

+ )}
); @@ -96,7 +112,7 @@ export default function SettingIndex({ settings }: PageProps) {
- + 財務設定 @@ -110,6 +126,9 @@ export default function SettingIndex({ settings }: PageProps) { 顯示設定 + + 通知設定 + @@ -160,7 +179,31 @@ export default function SettingIndex({ settings }: PageProps) { + + + + 通知設定 + 管理系統發送 Email 提醒信函(如:公共事業費逾期通知)的寄件帳號與預設收件群組。 + + + {settings.notification?.map(renderSettingRow)} + + + +
+ {activeTab === 'notification' && ( + + )} + @@ -377,14 +386,7 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }: 費用類別 - - - + 狀態