From 882091ce5f9ed86227b9289075fce335be80cb48 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Thu, 12 Feb 2026 17:13:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(notification):=20=E5=AF=A6=E4=BD=9C?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E8=BC=AA=E8=A9=A2=E8=88=87=E5=84=AA=E5=8C=96?= =?UTF-8?q?=E9=A1=AF=E7=A4=BA=E5=90=8D=E7=A8=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增通知輪詢 API 與前端自動更新機制 - 修正生產工單單號格式為 PRO-YYYYMMDD-XX - 確保通知顯示實際建立者名稱而非系統 --- app/Http/Middleware/HandleInertiaRequests.php | 6 + .../Controllers/NotificationController.php | 41 ++++ app/Modules/Core/Routes/web.php | 5 + .../Procurement/Models/PurchaseOrder.php | 5 + .../Notifications/NewPurchaseOrder.php | 54 +++++ .../Observers/PurchaseOrderObserver.php | 31 +++ .../ProcurementServiceProvider.php | 6 +- .../Production/Models/ProductionOrder.php | 19 +- .../Notifications/NewProductionOrder.php | 54 +++++ .../Observers/ProductionOrderObserver.php | 26 +++ .../Production/ProductionServiceProvider.php | 20 ++ ...2_12_170000_create_notifications_table.php | 31 +++ .../Header/NotificationDropdown.tsx | 190 ++++++++++++++++++ resources/js/Layouts/AuthenticatedLayout.tsx | 87 ++++---- 14 files changed, 528 insertions(+), 47 deletions(-) create mode 100644 app/Modules/Core/Controllers/NotificationController.php create mode 100644 app/Modules/Procurement/Notifications/NewPurchaseOrder.php create mode 100644 app/Modules/Procurement/Observers/PurchaseOrderObserver.php create mode 100644 app/Modules/Production/Notifications/NewProductionOrder.php create mode 100644 app/Modules/Production/Observers/ProductionOrderObserver.php create mode 100644 app/Modules/Production/ProductionServiceProvider.php create mode 100644 database/migrations/tenant/2026_02_12_170000_create_notifications_table.php create mode 100644 resources/js/Components/Header/NotificationDropdown.tsx diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 679266f..7783949 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -90,6 +90,12 @@ class HandleInertiaRequests extends Middleware return $brandingData; }, + 'notifications' => function () use ($request) { + return $request->user() ? [ + 'latest' => $request->user()->notifications()->latest()->limit(10)->get(), + 'unread_count' => $request->user()->unreadNotifications()->count(), + ] : null; + }, ]; } } diff --git a/app/Modules/Core/Controllers/NotificationController.php b/app/Modules/Core/Controllers/NotificationController.php new file mode 100644 index 0000000..ebc3eff --- /dev/null +++ b/app/Modules/Core/Controllers/NotificationController.php @@ -0,0 +1,41 @@ +user()->notifications()->findOrFail($id); + $notification->markAsRead(); + + return back(); + } + + /** + * Mark all notifications as read. + */ + public function markAllAsRead(Request $request) + { + $request->user()->unreadNotifications->markAsRead(); + + return back(); + } + + /** + * Check for new notifications. + */ + public function check(Request $request) + { + return response()->json([ + 'unread_count' => $request->user()->unreadNotifications()->count(), + 'latest' => $request->user()->notifications()->latest()->limit(10)->get(), + ]); + } +} diff --git a/app/Modules/Core/Routes/web.php b/app/Modules/Core/Routes/web.php index 66f55af..e612703 100644 --- a/app/Modules/Core/Routes/web.php +++ b/app/Modules/Core/Routes/web.php @@ -14,6 +14,11 @@ Route::post('/login', [LoginController::class, 'store']); Route::post('/logout', [LoginController::class, 'destroy'])->name('logout'); Route::middleware('auth')->group(function () { + // 通知 + Route::post('/notifications/read-all', [\App\Modules\Core\Controllers\NotificationController::class, 'markAllAsRead'])->name('notifications.read-all'); + Route::post('/notifications/{id}/read', [\App\Modules\Core\Controllers\NotificationController::class, 'markAsRead'])->name('notifications.read'); + Route::get('/notifications/check', [\App\Modules\Core\Controllers\NotificationController::class, 'check'])->name('notifications.check'); + // 儀表板 - 所有登入使用者皆可存取 Route::get('/', [DashboardController::class, 'index'])->name('dashboard'); diff --git a/app/Modules/Procurement/Models/PurchaseOrder.php b/app/Modules/Procurement/Models/PurchaseOrder.php index 1bc7712..d89e54b 100644 --- a/app/Modules/Procurement/Models/PurchaseOrder.php +++ b/app/Modules/Procurement/Models/PurchaseOrder.php @@ -62,6 +62,11 @@ class PurchaseOrder extends Model return $this->belongsTo(Vendor::class); } + public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(\App\Modules\Core\Models\User::class); + } + diff --git a/app/Modules/Procurement/Notifications/NewPurchaseOrder.php b/app/Modules/Procurement/Notifications/NewPurchaseOrder.php new file mode 100644 index 0000000..b6b8152 --- /dev/null +++ b/app/Modules/Procurement/Notifications/NewPurchaseOrder.php @@ -0,0 +1,54 @@ +purchaseOrder = $purchaseOrder; + $this->creatorName = $creatorName; + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + public function via(object $notifiable): array + { + return ['database']; + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'purchase_order', + 'action' => 'created', + 'purchase_order_id' => $this->purchaseOrder->id, + 'code' => $this->purchaseOrder->code, + 'creator_name' => $this->creatorName, + 'message' => "{$this->creatorName} 建立了新的採購單:{$this->purchaseOrder->code}", + 'link' => route('purchase-orders.index', ['search' => $this->purchaseOrder->code]), // 暫時導向列表並搜尋,若有詳情頁可改 + ]; + } +} diff --git a/app/Modules/Procurement/Observers/PurchaseOrderObserver.php b/app/Modules/Procurement/Observers/PurchaseOrderObserver.php new file mode 100644 index 0000000..42a32c5 --- /dev/null +++ b/app/Modules/Procurement/Observers/PurchaseOrderObserver.php @@ -0,0 +1,31 @@ +get(); + + // 排除建立者自己(避免自己收到自己的通知) + // $users = $users->reject(function ($user) use ($purchaseOrder) { + // return $user->id === $purchaseOrder->user_id; + // }); + + $creatorName = $purchaseOrder->user ? $purchaseOrder->user->name : '系統'; + + if ($users->isNotEmpty()) { + Notification::send($users, new NewPurchaseOrder($purchaseOrder, $creatorName)); + } + } +} diff --git a/app/Modules/Procurement/ProcurementServiceProvider.php b/app/Modules/Procurement/ProcurementServiceProvider.php index bd87b78..27dafc3 100644 --- a/app/Modules/Procurement/ProcurementServiceProvider.php +++ b/app/Modules/Procurement/ProcurementServiceProvider.php @@ -6,6 +6,10 @@ use Illuminate\Support\ServiceProvider; use App\Modules\Procurement\Contracts\ProcurementServiceInterface; use App\Modules\Procurement\Services\ProcurementService; + +use App\Modules\Procurement\Models\PurchaseOrder; +use App\Modules\Procurement\Observers\PurchaseOrderObserver; + class ProcurementServiceProvider extends ServiceProvider { public function register(): void @@ -15,6 +19,6 @@ class ProcurementServiceProvider extends ServiceProvider public function boot(): void { - // + PurchaseOrder::observe(PurchaseOrderObserver::class); } } diff --git a/app/Modules/Production/Models/ProductionOrder.php b/app/Modules/Production/Models/ProductionOrder.php index 1197842..bc95e0d 100644 --- a/app/Modules/Production/Models/ProductionOrder.php +++ b/app/Modules/Production/Models/ProductionOrder.php @@ -112,13 +112,17 @@ class ProductionOrder extends Model public static function generateCode() { - $prefix = 'PO' . now()->format('Ymd'); - $lastOrder = self::where('code', 'like', $prefix . '%')->latest()->first(); + $prefix = 'PRO-' . now()->format('Ymd') . '-'; + $lastOrder = self::where('code', 'like', $prefix . '%') + ->lockForUpdate() + ->orderBy('code', 'desc') + ->first(); + if ($lastOrder) { - $lastSequence = intval(substr($lastOrder->code, -3)); - $sequence = str_pad($lastSequence + 1, 3, '0', STR_PAD_LEFT); + $lastSequence = intval(substr($lastOrder->code, -2)); + $sequence = str_pad($lastSequence + 1, 2, '0', STR_PAD_LEFT); } else { - $sequence = '001'; + $sequence = '01'; } return $prefix . $sequence; } @@ -127,4 +131,9 @@ class ProductionOrder extends Model { return $this->hasMany(ProductionOrderItem::class); } + + public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(\App\Modules\Core\Models\User::class); + } } diff --git a/app/Modules/Production/Notifications/NewProductionOrder.php b/app/Modules/Production/Notifications/NewProductionOrder.php new file mode 100644 index 0000000..c5bf2c3 --- /dev/null +++ b/app/Modules/Production/Notifications/NewProductionOrder.php @@ -0,0 +1,54 @@ +productionOrder = $productionOrder; + $this->creatorName = $creatorName; + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + public function via(object $notifiable): array + { + return ['database']; + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'production_order', + 'action' => 'created', + 'production_order_id' => $this->productionOrder->id, + 'code' => $this->productionOrder->code, + 'creator_name' => $this->creatorName, + 'message' => "{$this->creatorName} 建立了新的生產工單:{$this->productionOrder->code}", + 'link' => route('production-orders.index', ['search' => $this->productionOrder->code]), + ]; + } +} diff --git a/app/Modules/Production/Observers/ProductionOrderObserver.php b/app/Modules/Production/Observers/ProductionOrderObserver.php new file mode 100644 index 0000000..7662957 --- /dev/null +++ b/app/Modules/Production/Observers/ProductionOrderObserver.php @@ -0,0 +1,26 @@ +get(); + + $creatorName = $productionOrder->user ? $productionOrder->user->name : '系統'; + + if ($users->isNotEmpty()) { + Notification::send($users, new NewProductionOrder($productionOrder, $creatorName)); + } + } +} diff --git a/app/Modules/Production/ProductionServiceProvider.php b/app/Modules/Production/ProductionServiceProvider.php new file mode 100644 index 0000000..e8a854d --- /dev/null +++ b/app/Modules/Production/ProductionServiceProvider.php @@ -0,0 +1,20 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/resources/js/Components/Header/NotificationDropdown.tsx b/resources/js/Components/Header/NotificationDropdown.tsx new file mode 100644 index 0000000..f051d13 --- /dev/null +++ b/resources/js/Components/Header/NotificationDropdown.tsx @@ -0,0 +1,190 @@ +import { useState, useEffect } from "react"; +import axios from "axios"; +import { Link, router, usePage } from "@inertiajs/react"; +import { Bell, CheckCheck } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/Components/ui/dropdown-menu"; +import { Button } from "@/Components/ui/button"; +import { ScrollArea } from "@/Components/ui/scroll-area"; +import { formatDate } from "@/lib/date"; +import { cn } from "@/lib/utils"; + +interface NotificationData { + message: string; + link?: string; + action?: string; + [key: string]: any; +} + +interface Notification { + id: string; + type: string; + data: NotificationData; + read_at: string | null; + created_at: string; +} + +interface NotificationsProp { + latest: Notification[]; + unread_count: number; +} + +export default function NotificationDropdown() { + const { notifications } = usePage<{ notifications?: NotificationsProp }>().props; + + if (!notifications) return null; + + // 使用整體的 notifications 物件作為初始狀態,方便後續更新 + const [data, setData] = useState(notifications); + const { latest, unread_count } = data; + const [isOpen, setIsOpen] = useState(false); + + // 輪詢機制 + useEffect(() => { + const intervalId = setInterval(() => { + axios.get(route('notifications.check')) + .then(response => { + setData(response.data); + }) + .catch(error => { + console.error("Failed to fetch notifications:", error); + }); + }, 30000); // 30 秒 + + return () => clearInterval(intervalId); + }, []); + + // 當 Inertia props 更新時(例如頁面跳轉),同步更新本地狀態 + useEffect(() => { + if (notifications) { + setData(notifications); + } + }, [notifications]); + + const handleMarkAllAsRead = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // 樂觀更新 (Optimistic Update) + setData(prev => ({ + ...prev, + unread_count: 0, + latest: prev.latest.map(n => ({ ...n, read_at: new Date().toISOString() })) + })); + + router.post(route('notifications.read-all'), {}, { + preserveScroll: true, + preserveState: true, + onSuccess: () => { + // 成功後重新整理一次確保數據正確 (可選) + } + }); + }; + + const handleNotificationClick = (notification: Notification) => { + if (!notification.read_at) { + // 樂觀更新 + setData(prev => ({ + ...prev, + unread_count: Math.max(0, prev.unread_count - 1), + latest: prev.latest.map(n => + n.id === notification.id + ? { ...n, read_at: new Date().toISOString() } + : n + ) + })); + router.post(route('notifications.read', { id: notification.id })); + } + + if (notification.data.link) { + router.visit(notification.data.link); + } + + setIsOpen(false); + }; + + return ( + + + + + +
+

通知中心

+ {unread_count > 0 && ( + + )} +
+ + + {latest.length === 0 ? ( +
+ +

目前沒有新通知

+
+ ) : ( +
+ {latest.map((notification) => ( + + ))} +
+ )} +
+ +
+ + 查看所有通知 + +
+
+
+ ); +} diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index 693bd70..b2c5eac 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -44,6 +44,7 @@ import { usePermission } from "@/hooks/usePermission"; import ApplicationLogo from "@/Components/ApplicationLogo"; import { generateLightestColor, generateLightColor, generateDarkColor, generateActiveColor } from "@/utils/colorUtils"; import { PageProps } from "@/types/global"; +import NotificationDropdown from "@/Components/Header/NotificationDropdown"; interface MenuItem { id: string; @@ -491,47 +492,51 @@ export default function AuthenticatedLayout({ {/* User Menu */} - - -
- - {user.name} ({user.username}) - - - {user.role_labels?.[0] || user.roles?.[0] || '一般用戶'} - -
-
- -
-
- - {user.name} ({user.username}) - - - - - 使用者設定 - - - - - - - 登出系統 - - - -
+
+ + + + +
+ + {user.name} ({user.username}) + + + {user.role_labels?.[0] || user.roles?.[0] || '一般用戶'} + +
+
+ +
+
+ + {user.name} ({user.username}) + + + + + 使用者設定 + + + + + + + 登出系統 + + + +
+
{/* Sidebar Desktop */}