feat(notification): 實作通知輪詢與優化顯示名稱
- 新增通知輪詢 API 與前端自動更新機制 - 修正生產工單單號格式為 PRO-YYYYMMDD-XX - 確保通知顯示實際建立者名稱而非系統
This commit is contained in:
@@ -90,6 +90,12 @@ class HandleInertiaRequests extends Middleware
|
|||||||
|
|
||||||
return $brandingData;
|
return $brandingData;
|
||||||
},
|
},
|
||||||
|
'notifications' => function () use ($request) {
|
||||||
|
return $request->user() ? [
|
||||||
|
'latest' => $request->user()->notifications()->latest()->limit(10)->get(),
|
||||||
|
'unread_count' => $request->user()->unreadNotifications()->count(),
|
||||||
|
] : null;
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/Modules/Core/Controllers/NotificationController.php
Normal file
41
app/Modules/Core/Controllers/NotificationController.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Core\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class NotificationController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Mark a specific notification as read.
|
||||||
|
*/
|
||||||
|
public function markAsRead(Request $request, string $id)
|
||||||
|
{
|
||||||
|
$notification = $request->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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,11 @@ Route::post('/login', [LoginController::class, 'store']);
|
|||||||
Route::post('/logout', [LoginController::class, 'destroy'])->name('logout');
|
Route::post('/logout', [LoginController::class, 'destroy'])->name('logout');
|
||||||
|
|
||||||
Route::middleware('auth')->group(function () {
|
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');
|
Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ class PurchaseOrder extends Model
|
|||||||
return $this->belongsTo(Vendor::class);
|
return $this->belongsTo(Vendor::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(\App\Modules\Core\Models\User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
54
app/Modules/Procurement/Notifications/NewPurchaseOrder.php
Normal file
54
app/Modules/Procurement/Notifications/NewPurchaseOrder.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Procurement\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use App\Modules\Procurement\Models\PurchaseOrder;
|
||||||
|
|
||||||
|
class NewPurchaseOrder extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
protected $purchaseOrder;
|
||||||
|
protected $creatorName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new notification instance.
|
||||||
|
*/
|
||||||
|
public function __construct(PurchaseOrder $purchaseOrder, string $creatorName)
|
||||||
|
{
|
||||||
|
$this->purchaseOrder = $purchaseOrder;
|
||||||
|
$this->creatorName = $creatorName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array representation of the notification.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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]), // 暫時導向列表並搜尋,若有詳情頁可改
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Modules/Procurement/Observers/PurchaseOrderObserver.php
Normal file
31
app/Modules/Procurement/Observers/PurchaseOrderObserver.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Procurement\Observers;
|
||||||
|
|
||||||
|
use App\Modules\Procurement\Models\PurchaseOrder;
|
||||||
|
use App\Modules\Procurement\Notifications\NewPurchaseOrder;
|
||||||
|
use App\Modules\Core\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
|
||||||
|
class PurchaseOrderObserver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle the PurchaseOrder "created" event.
|
||||||
|
*/
|
||||||
|
public function created(PurchaseOrder $purchaseOrder): void
|
||||||
|
{
|
||||||
|
// 找出有檢視採購單權限的使用者
|
||||||
|
$users = User::permission('purchase_orders.view')->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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ use Illuminate\Support\ServiceProvider;
|
|||||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||||
use App\Modules\Procurement\Services\ProcurementService;
|
use App\Modules\Procurement\Services\ProcurementService;
|
||||||
|
|
||||||
|
|
||||||
|
use App\Modules\Procurement\Models\PurchaseOrder;
|
||||||
|
use App\Modules\Procurement\Observers\PurchaseOrderObserver;
|
||||||
|
|
||||||
class ProcurementServiceProvider extends ServiceProvider
|
class ProcurementServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
public function register(): void
|
public function register(): void
|
||||||
@@ -15,6 +19,6 @@ class ProcurementServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
PurchaseOrder::observe(PurchaseOrderObserver::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,13 +112,17 @@ class ProductionOrder extends Model
|
|||||||
|
|
||||||
public static function generateCode()
|
public static function generateCode()
|
||||||
{
|
{
|
||||||
$prefix = 'PO' . now()->format('Ymd');
|
$prefix = 'PRO-' . now()->format('Ymd') . '-';
|
||||||
$lastOrder = self::where('code', 'like', $prefix . '%')->latest()->first();
|
$lastOrder = self::where('code', 'like', $prefix . '%')
|
||||||
|
->lockForUpdate()
|
||||||
|
->orderBy('code', 'desc')
|
||||||
|
->first();
|
||||||
|
|
||||||
if ($lastOrder) {
|
if ($lastOrder) {
|
||||||
$lastSequence = intval(substr($lastOrder->code, -3));
|
$lastSequence = intval(substr($lastOrder->code, -2));
|
||||||
$sequence = str_pad($lastSequence + 1, 3, '0', STR_PAD_LEFT);
|
$sequence = str_pad($lastSequence + 1, 2, '0', STR_PAD_LEFT);
|
||||||
} else {
|
} else {
|
||||||
$sequence = '001';
|
$sequence = '01';
|
||||||
}
|
}
|
||||||
return $prefix . $sequence;
|
return $prefix . $sequence;
|
||||||
}
|
}
|
||||||
@@ -127,4 +131,9 @@ class ProductionOrder extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(ProductionOrderItem::class);
|
return $this->hasMany(ProductionOrderItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(\App\Modules\Core\Models\User::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
app/Modules/Production/Notifications/NewProductionOrder.php
Normal file
54
app/Modules/Production/Notifications/NewProductionOrder.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Production\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use App\Modules\Production\Models\ProductionOrder;
|
||||||
|
|
||||||
|
class NewProductionOrder extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
protected $productionOrder;
|
||||||
|
protected $creatorName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new notification instance.
|
||||||
|
*/
|
||||||
|
public function __construct(ProductionOrder $productionOrder, string $creatorName)
|
||||||
|
{
|
||||||
|
$this->productionOrder = $productionOrder;
|
||||||
|
$this->creatorName = $creatorName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array representation of the notification.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Modules/Production/Observers/ProductionOrderObserver.php
Normal file
26
app/Modules/Production/Observers/ProductionOrderObserver.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Production\Observers;
|
||||||
|
|
||||||
|
use App\Modules\Production\Models\ProductionOrder;
|
||||||
|
use App\Modules\Production\Notifications\NewProductionOrder;
|
||||||
|
use App\Modules\Core\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
|
||||||
|
class ProductionOrderObserver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle the ProductionOrder "created" event.
|
||||||
|
*/
|
||||||
|
public function created(ProductionOrder $productionOrder): void
|
||||||
|
{
|
||||||
|
// 找出有檢視生產工單權限的使用者
|
||||||
|
$users = User::permission('production_orders.view')->get();
|
||||||
|
|
||||||
|
$creatorName = $productionOrder->user ? $productionOrder->user->name : '系統';
|
||||||
|
|
||||||
|
if ($users->isNotEmpty()) {
|
||||||
|
Notification::send($users, new NewProductionOrder($productionOrder, $creatorName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Modules/Production/ProductionServiceProvider.php
Normal file
20
app/Modules/Production/ProductionServiceProvider.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Production;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use App\Modules\Production\Models\ProductionOrder;
|
||||||
|
use App\Modules\Production\Observers\ProductionOrderObserver;
|
||||||
|
|
||||||
|
class ProductionServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
ProductionOrder::observe(ProductionOrderObserver::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?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::create('notifications', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
190
resources/js/Components/Header/NotificationDropdown.tsx
Normal file
190
resources/js/Components/Header/NotificationDropdown.tsx
Normal file
@@ -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<NotificationsProp>(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 (
|
||||||
|
<DropdownMenu open={isOpen} onOpenChange={setIsOpen} modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="relative text-slate-500 hover:text-slate-700 hover:bg-slate-100">
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
{unread_count > 0 && (
|
||||||
|
<span className="absolute top-1.5 right-1.5 flex h-2.5 w-2.5">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-red-500"></span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-80 p-0 z-[100]" sideOffset={8}>
|
||||||
|
<div className="flex items-center justify-between p-4 pb-2">
|
||||||
|
<h4 className="font-semibold text-sm">通知中心</h4>
|
||||||
|
{unread_count > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto px-2 py-1 text-xs text-primary-main hover:text-primary-dark"
|
||||||
|
onClick={handleMarkAllAsRead}
|
||||||
|
>
|
||||||
|
<CheckCheck className="mr-1 h-3 w-3" />
|
||||||
|
全部已讀
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<ScrollArea className="h-[300px]">
|
||||||
|
{latest.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-slate-500">
|
||||||
|
<Bell className="h-8 w-8 mb-2 opacity-20" />
|
||||||
|
<p className="text-sm">目前沒有新通知</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{latest.map((notification) => (
|
||||||
|
<button
|
||||||
|
key={notification.id}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left px-4 py-3 hover:bg-slate-50 transition-colors border-b border-slate-100 last:border-0",
|
||||||
|
!notification.read_at && "bg-blue-50/50"
|
||||||
|
)}
|
||||||
|
onClick={() => handleNotificationClick(notification)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
"mt-1 h-2 w-2 rounded-full flex-shrink-0",
|
||||||
|
!notification.read_at ? "bg-primary-main" : "bg-slate-200"
|
||||||
|
)} />
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<p className={cn(
|
||||||
|
"text-sm leading-tight",
|
||||||
|
!notification.read_at ? "font-medium text-slate-900" : "text-slate-600"
|
||||||
|
)}>
|
||||||
|
{notification.data.message}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{formatDate(notification.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="p-2 text-center">
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="text-xs text-slate-500 hover:text-primary-main transition-colors"
|
||||||
|
>
|
||||||
|
查看所有通知
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ import { usePermission } from "@/hooks/usePermission";
|
|||||||
import ApplicationLogo from "@/Components/ApplicationLogo";
|
import ApplicationLogo from "@/Components/ApplicationLogo";
|
||||||
import { generateLightestColor, generateLightColor, generateDarkColor, generateActiveColor } from "@/utils/colorUtils";
|
import { generateLightestColor, generateLightColor, generateDarkColor, generateActiveColor } from "@/utils/colorUtils";
|
||||||
import { PageProps } from "@/types/global";
|
import { PageProps } from "@/types/global";
|
||||||
|
import NotificationDropdown from "@/Components/Header/NotificationDropdown";
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -491,47 +492,51 @@ export default function AuthenticatedLayout({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Menu */}
|
{/* User Menu */}
|
||||||
<DropdownMenu modal={false}>
|
<div className="flex items-center gap-2">
|
||||||
<DropdownMenuTrigger className="flex items-center gap-2 outline-none group">
|
<NotificationDropdown />
|
||||||
<div className="hidden md:flex flex-col items-end mr-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
|
<DropdownMenu modal={false}>
|
||||||
{user.name} ({user.username})
|
<DropdownMenuTrigger className="flex items-center gap-2 outline-none group">
|
||||||
</span>
|
<div className="hidden md:flex flex-col items-end mr-1">
|
||||||
<span className="text-xs text-slate-500">
|
<span className="text-sm font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
|
||||||
{user.role_labels?.[0] || user.roles?.[0] || '一般用戶'}
|
{user.name} ({user.username})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span className="text-xs text-slate-500">
|
||||||
<div className="h-9 w-9 bg-slate-100 rounded-full flex items-center justify-center text-slate-600 group-hover:bg-primary-lightest group-hover:text-primary-main transition-all">
|
{user.role_labels?.[0] || user.roles?.[0] || '一般用戶'}
|
||||||
<User className="h-5 w-5" />
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
<div className="h-9 w-9 bg-slate-100 rounded-full flex items-center justify-center text-slate-600 group-hover:bg-primary-lightest group-hover:text-primary-main transition-all">
|
||||||
<DropdownMenuContent align="end" className="w-56 z-[100]" sideOffset={8}>
|
<User className="h-5 w-5" />
|
||||||
<DropdownMenuLabel>{user.name} ({user.username})</DropdownMenuLabel>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuContent align="end" className="w-56 z-[100]" sideOffset={8}>
|
||||||
<Link
|
<DropdownMenuLabel>{user.name} ({user.username})</DropdownMenuLabel>
|
||||||
href={route('profile.edit')}
|
<DropdownMenuSeparator />
|
||||||
preserveScroll={true}
|
<DropdownMenuItem asChild>
|
||||||
className="w-full flex items-center cursor-pointer text-slate-600 focus:bg-slate-100 focus:text-slate-900 group"
|
<Link
|
||||||
>
|
href={route('profile.edit')}
|
||||||
<Settings className="mr-2 h-4 w-4 text-slate-500 group-focus:text-slate-900" />
|
preserveScroll={true}
|
||||||
<span>使用者設定</span>
|
className="w-full flex items-center cursor-pointer text-slate-600 focus:bg-slate-100 focus:text-slate-900 group"
|
||||||
</Link>
|
>
|
||||||
</DropdownMenuItem>
|
<Settings className="mr-2 h-4 w-4 text-slate-500 group-focus:text-slate-900" />
|
||||||
<DropdownMenuSeparator />
|
<span>使用者設定</span>
|
||||||
<DropdownMenuItem asChild>
|
</Link>
|
||||||
<Link
|
</DropdownMenuItem>
|
||||||
href={route('logout')}
|
<DropdownMenuSeparator />
|
||||||
method="post"
|
<DropdownMenuItem asChild>
|
||||||
as="button"
|
<Link
|
||||||
className="w-full flex items-center cursor-pointer text-red-600 focus:text-red-600 focus:bg-red-50"
|
href={route('logout')}
|
||||||
>
|
method="post"
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
as="button"
|
||||||
<span>登出系統</span>
|
className="w-full flex items-center cursor-pointer text-red-600 focus:text-red-600 focus:bg-red-50"
|
||||||
</Link>
|
>
|
||||||
</DropdownMenuItem>
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
</DropdownMenuContent>
|
<span>登出系統</span>
|
||||||
</DropdownMenu>
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Sidebar Desktop */}
|
{/* Sidebar Desktop */}
|
||||||
|
|||||||
Reference in New Issue
Block a user