diff --git a/backend/app/api/v1/notifications.py b/backend/app/api/v1/notifications.py new file mode 100644 index 0000000..70fea69 --- /dev/null +++ b/backend/app/api/v1/notifications.py @@ -0,0 +1,104 @@ +import uuid + +from fastapi import APIRouter, Depends, Response, status +from sqlalchemy.orm import Session + +from app.core.deps import get_current_user, require_roles +from app.db.session import get_db +from app.models.enums import UserRole +from app.models.user import User +from app.schemas.notification import ( + NotificationCreate, + NotificationListItem, + NotificationRead, + NotificationUpdate, +) +from app.services.notification import NotificationService + +router = APIRouter(prefix="/notifications", tags=["notifications"]) + +ADMIN_ONLY = [UserRole.ADMIN] +ALL_ROLES = [UserRole.ADMIN, UserRole.MENTOR, UserRole.INTERN] +USER_ROLES = [UserRole.MENTOR, UserRole.INTERN] + + +@router.post("", response_model=NotificationRead, status_code=status.HTTP_201_CREATED) +def create_notification( + payload: NotificationCreate, + current_user: User = Depends(require_roles(ADMIN_ONLY)), + db: Session = Depends(get_db), +): + """Admin tạo và gửi thông báo.""" + return NotificationService.create_notification(db, current_user, payload) + + +@router.get("", response_model=list[NotificationListItem]) +def list_notifications( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Admin: xem tất cả thông báo đã gửi. Intern/Mentor: xem thông báo dành cho mình.""" + if str(current_user.role) == UserRole.ADMIN: + return NotificationService.list_notifications_admin(db, current_user) + return NotificationService.list_notifications_user(db, current_user) + + +@router.post("/read-all", response_model=dict) +def mark_all_as_read( + current_user: User = Depends(require_roles(USER_ROLES)), + db: Session = Depends(get_db), +): + """Intern/Mentor đánh dấu tất cả thông báo đã đọc.""" + return NotificationService.mark_all_as_read(db, current_user) + + +@router.get("/unread-count", response_model=dict) +def get_unread_count( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Lấy số thông báo chưa đọc của người dùng hiện tại.""" + count = NotificationService.get_unread_count(db, current_user) + return {"unread_count": count} + + +@router.get("/{notification_id}", response_model=NotificationRead) +def get_notification( + notification_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Xem chi tiết một thông báo.""" + return NotificationService.get_notification_detail(db, current_user, notification_id) + + +@router.post("/{notification_id}/read", response_model=NotificationRead) +def mark_as_read( + notification_id: uuid.UUID, + current_user: User = Depends(require_roles(USER_ROLES)), + db: Session = Depends(get_db), +): + """Intern/Mentor đánh dấu thông báo đã đọc.""" + return NotificationService.mark_as_read(db, current_user, notification_id) + + +@router.put("/{notification_id}", response_model=NotificationRead) +def update_notification( + notification_id: uuid.UUID, + payload: NotificationUpdate, + current_user: User = Depends(require_roles(ADMIN_ONLY)), + db: Session = Depends(get_db), +): + """Admin chỉnh sửa thông báo (tiêu đề, nội dung, đối tượng nhận).""" + return NotificationService.update_notification(db, current_user, notification_id, payload) + + +@router.delete("/{notification_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_notification( + notification_id: uuid.UUID, + current_user: User = Depends(require_roles(ADMIN_ONLY)), + db: Session = Depends(get_db), +) -> Response: + """Admin xóa thông báo.""" + NotificationService.delete_notification(db, current_user, notification_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 45a4a24..5b2d0f1 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -8,6 +8,7 @@ from app.api.v1.learning import router as learning_router from app.api.v1.me import router as me_router from app.api.v1.mentor import router as mentor_router +from app.api.v1.notifications import router as notifications_router from app.api.v1.profile import router as profile_router from app.api.v1.tasks import router as tasks_router from app.api.v1.training import router as training_router @@ -26,3 +27,4 @@ api_router.include_router(tasks_router) api_router.include_router(learning_router) api_router.include_router(training_router) +api_router.include_router(notifications_router) diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 0000000..c3fc535 --- /dev/null +++ b/backend/app/schemas/notification.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from pydantic import Field, field_validator + +from app.schemas.base import BaseSchema + +TARGET_TYPES = ("ALL", "ROLE", "USER") +VALID_ROLES = ("INTERN", "MENTOR", "ADMIN") + + +class NotificationCreate(BaseSchema): + title: str = Field(..., min_length=1, max_length=255) + content: str = Field(..., min_length=1) + target_type: str = Field(default="ALL") + target_data: list[Any] | None = Field( + None, description="Danh sách role hoặc user_id tuỳ target_type" + ) + + @field_validator("target_type") + @classmethod + def _check_target_type(cls, v: str) -> str: + if v not in TARGET_TYPES: + raise ValueError(f"target_type must be one of {TARGET_TYPES}") + return v + + +class NotificationUpdate(BaseSchema): + title: str | None = Field(None, min_length=1, max_length=255) + content: str | None = Field(None, min_length=1) + target_type: str | None = None + target_data: list[Any] | None = Field( + None, description="Danh sách role hoặc user_id tuỳ target_type" + ) + + @field_validator("target_type") + @classmethod + def _check_target_type(cls, v: str | None) -> str | None: + if v is not None and v not in TARGET_TYPES: + raise ValueError(f"target_type must be one of {TARGET_TYPES}") + return v + + +class NotificationRead(BaseSchema): + id: uuid.UUID + title: str + content: str + target_type: str + target_data: list[Any] | None = None + recipient_names: list[str] | None = None + created_by: uuid.UUID + created_at: datetime + creator_name: str | None = None + is_read: bool = False + read_at: datetime | None = None + + +class NotificationListItem(BaseSchema): + id: uuid.UUID + title: str + content: str + target_type: str + target_data: list[Any] | None = None + recipient_names: list[str] | None = None + created_by: uuid.UUID + created_at: datetime + creator_name: str | None = None + is_read: bool = False + read_count: int = 0 diff --git a/backend/app/services/notification.py b/backend/app/services/notification.py new file mode 100644 index 0000000..6786d38 --- /dev/null +++ b/backend/app/services/notification.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.enums import UserRole +from app.models.interaction import Notification, NotificationRead +from app.models.user import User +from app.schemas.notification import NotificationCreate, NotificationUpdate + + +class NotificationService: + @staticmethod + def _can_receive(notification: Notification, user: User) -> bool: + """Kiểm tra user có được nhận notification này không.""" + t = notification.target_type + if t == "ALL": + return True + if t == "ROLE": + roles: list[str] = notification.target_data or [] + return str(user.role) in roles + if t == "USER": + raw_items = notification.target_data or [] + target_ids: set[str] = set() + for item in raw_items: + if isinstance(item, dict): + for k in ("intern_id", "new_mentor_id", "old_mentor_id"): + if item.get(k): + target_ids.add(str(item[k])) + else: + target_ids.add(str(item)) + return str(user.id) in target_ids + return False + + @classmethod + def create_notification(cls, db: Session, actor: User, data: NotificationCreate) -> dict: + """Admin tạo + gửi thông báo.""" + if str(actor.role) != UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Only Admin can create notifications" + ) + notif = Notification( + title=data.title.strip(), + content=data.content.strip(), + target_type=data.target_type, + target_data=data.target_data, + created_by=actor.id, + ) + db.add(notif) + db.commit() + recipient_names = cls._resolve_recipient_names(db, notif) + return cls._to_dict( + notif, + actor.full_name, + is_read=False, + read_count=0, + recipient_names=recipient_names, + ) + + @classmethod + def update_notification( + cls, db: Session, actor: User, notif_id: uuid.UUID, data: NotificationUpdate + ) -> dict: + """Admin chỉnh sửa thông báo (tiêu đề, nội dung, đối tượng nhận).""" + if str(actor.role) != UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Only Admin can update notifications" + ) + notif = db.query(Notification).filter(Notification.id == notif_id).first() + if not notif: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found" + ) + if data.title is not None: + notif.title = data.title.strip() + if data.content is not None: + notif.content = data.content.strip() + if data.target_type is not None: + notif.target_type = data.target_type + notif.target_data = data.target_data + elif data.target_data is not None: + notif.target_data = data.target_data + + db.commit() + db.refresh(notif) + + creator = db.query(User).filter(User.id == notif.created_by).first() + read_count = ( + db.query(NotificationRead).filter(NotificationRead.notification_id == notif.id).count() + ) + recipient_names = cls._resolve_recipient_names(db, notif) + return cls._to_dict( + notif, + creator.full_name if creator else None, + is_read=False, + read_count=read_count, + recipient_names=recipient_names, + ) + + @classmethod + def list_notifications_admin(cls, db: Session, actor: User) -> list[dict]: + """Admin xem danh sách thông báo đã gửi (tất cả).""" + if str(actor.role) != UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only Admin can view all notifications", + ) + notifications = db.query(Notification).order_by(Notification.created_at.desc()).all() + result = [] + for n in notifications: + creator = db.query(User).filter(User.id == n.created_by).first() + read_count = ( + db.query(NotificationRead).filter(NotificationRead.notification_id == n.id).count() + ) + recipient_names = cls._resolve_recipient_names(db, n) + result.append( + cls._to_dict( + n, + creator.full_name if creator else None, + is_read=False, + read_count=read_count, + recipient_names=recipient_names, + ) + ) + return result + + @classmethod + def list_notifications_user(cls, db: Session, user: User) -> list[dict]: + """Intern/Mentor xem danh sách thông báo của mình.""" + all_notifs = db.query(Notification).order_by(Notification.created_at.desc()).all() + result = [] + for n in all_notifs: + if not cls._can_receive(n, user): + continue + creator = db.query(User).filter(User.id == n.created_by).first() + read_rec = ( + db.query(NotificationRead) + .filter( + NotificationRead.notification_id == n.id, + NotificationRead.user_id == user.id, + ) + .first() + ) + recipient_names = cls._resolve_recipient_names(db, n) + result.append( + cls._to_dict( + n, + creator.full_name if creator else None, + is_read=read_rec is not None, + read_count=0, + read_at=read_rec.read_at if read_rec else None, + recipient_names=recipient_names, + ) + ) + return result + + @classmethod + def get_notification_detail(cls, db: Session, user: User, notif_id: uuid.UUID) -> dict: + """Xem chi tiết một thông báo.""" + notif = db.query(Notification).filter(Notification.id == notif_id).first() + if not notif: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found" + ) + # Admin có thể xem tất cả; Intern/Mentor chỉ xem thông báo dành cho mình + if str(user.role) != UserRole.ADMIN and not cls._can_receive(notif, user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to view this notification", + ) + creator = db.query(User).filter(User.id == notif.created_by).first() + read_rec = None + if str(user.role) != UserRole.ADMIN: + read_rec = ( + db.query(NotificationRead) + .filter( + NotificationRead.notification_id == notif.id, + NotificationRead.user_id == user.id, + ) + .first() + ) + read_count = ( + db.query(NotificationRead).filter(NotificationRead.notification_id == notif.id).count() + ) + recipient_names = cls._resolve_recipient_names(db, notif) + return cls._to_dict( + notif, + creator.full_name if creator else None, + is_read=read_rec is not None, + read_count=read_count, + read_at=read_rec.read_at if read_rec else None, + recipient_names=recipient_names, + ) + + @classmethod + def mark_as_read(cls, db: Session, user: User, notif_id: uuid.UUID) -> dict: + """Intern/Mentor đánh dấu đã đọc.""" + if str(user.role) == UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin does not need to mark notifications as read", + ) + notif = db.query(Notification).filter(Notification.id == notif_id).first() + if not notif: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found" + ) + if not cls._can_receive(notif, user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized for this notification" + ) + existing = ( + db.query(NotificationRead) + .filter( + NotificationRead.notification_id == notif_id, + NotificationRead.user_id == user.id, + ) + .first() + ) + recipient_names = cls._resolve_recipient_names(db, notif) + if existing: + # Already read — idempotent + creator = db.query(User).filter(User.id == notif.created_by).first() + return cls._to_dict( + notif, + creator.full_name if creator else None, + is_read=True, + read_count=0, + read_at=existing.read_at, + recipient_names=recipient_names, + ) + read_rec = NotificationRead(notification_id=notif_id, user_id=user.id) + db.add(read_rec) + db.commit() + db.refresh(read_rec) + creator = db.query(User).filter(User.id == notif.created_by).first() + return cls._to_dict( + notif, + creator.full_name if creator else None, + is_read=True, + read_count=0, + read_at=read_rec.read_at, + recipient_names=recipient_names, + ) + + @classmethod + def mark_all_as_read(cls, db: Session, user: User) -> dict: + """Đánh dấu tất cả thông báo của user là đã đọc.""" + if str(user.role) == UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin does not need to mark notifications as read", + ) + all_notifs = db.query(Notification).all() + count = 0 + for n in all_notifs: + if not cls._can_receive(n, user): + continue + existing = ( + db.query(NotificationRead) + .filter( + NotificationRead.notification_id == n.id, + NotificationRead.user_id == user.id, + ) + .first() + ) + if not existing: + db.add(NotificationRead(notification_id=n.id, user_id=user.id)) + count += 1 + db.commit() + return {"marked_count": count} + + @classmethod + def get_unread_count(cls, db: Session, user: User) -> int: + """ + - Đối với Admin: Trả về tổng số thông báo trong hệ thống (đã gửi). + - Đối với Intern/Mentor: Trả về số thông báo chưa đọc dành cho người dùng. + """ + if str(user.role) == UserRole.ADMIN: + return db.query(Notification).count() + + all_notifs = db.query(Notification).all() + count = 0 + for n in all_notifs: + if not cls._can_receive(n, user): + continue + existing = ( + db.query(NotificationRead) + .filter( + NotificationRead.notification_id == n.id, + NotificationRead.user_id == user.id, + ) + .first() + ) + if not existing: + count += 1 + return count + + @classmethod + def delete_notification(cls, db: Session, actor: User, notif_id: uuid.UUID) -> None: + """Admin xóa thông báo.""" + if str(actor.role) != UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Only Admin can delete notifications" + ) + notif = db.query(Notification).filter(Notification.id == notif_id).first() + if not notif: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found" + ) + db.delete(notif) + db.commit() + + @classmethod + def _resolve_recipient_names(cls, db: Session, n: Notification) -> list[str]: + """Lấy danh sách tên hiển thị của đối tượng nhận.""" + if n.target_type == "ALL": + return ["Tất cả người dùng"] + if n.target_type == "ROLE": + role_map = { + "INTERN": "Thực tập sinh", + "MENTOR": "Mentor", + "ADMIN": "Quản trị viên", + } + roles = n.target_data or [] + return [role_map.get(str(r), str(r)) for r in roles] + if n.target_type == "USER": + raw_items = n.target_data or [] + parsed_ids: list[uuid.UUID] = [] + dict_names: list[str] = [] + + for item in raw_items: + if isinstance(item, dict): + # Lấy tên từ metadata nếu có sẵn + names: list[str] = [] + if item.get("intern_name"): + names.append(f"{item['intern_name']} (INTERN)") + if item.get("new_mentor_name"): + names.append(f"{item['new_mentor_name']} (MENTOR)") + if names: + dict_names.extend(names) + else: + for k in ("intern_id", "new_mentor_id", "old_mentor_id"): + if item.get(k): + try: + parsed_ids.append(uuid.UUID(str(item[k]))) + except ValueError: + pass + else: + try: + parsed_ids.append(uuid.UUID(str(item))) + except ValueError: + continue + + if dict_names: + return dict_names + if not parsed_ids: + return [] + users = db.query(User).filter(User.id.in_(parsed_ids)).all() + return [f"{u.full_name} ({u.role})" for u in users] + return [] + + @staticmethod + def _to_dict( + n: Notification, + creator_name: str | None, + is_read: bool, + read_count: int, + read_at: datetime | None = None, + recipient_names: list[str] | None = None, + ) -> dict: + return { + "id": n.id, + "title": n.title, + "content": n.content, + "target_type": n.target_type, + "target_data": n.target_data, + "recipient_names": recipient_names, + "created_by": n.created_by, + "created_at": n.created_at, + "creator_name": creator_name, + "is_read": is_read, + "read_count": read_count, + "read_at": read_at, + } diff --git a/frontend/src/app/core/api/notification.service.ts b/frontend/src/app/core/api/notification.service.ts new file mode 100644 index 0000000..3db79af --- /dev/null +++ b/frontend/src/app/core/api/notification.service.ts @@ -0,0 +1,206 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject, OnDestroy } from '@angular/core'; +import { BehaviorSubject, Observable, Subject, Subscription, interval, tap } from 'rxjs'; +import { AuthService } from './auth.service'; + +export interface Notification { + id: string; + title: string; + content: string; + target_type: 'ALL' | 'ROLE' | 'USER'; + target_data?: string[] | null; + recipient_names?: string[] | null; + created_by: string; + created_at: string; + creator_name?: string | null; + is_read?: boolean; + read_at?: string | null; + read_count?: number; +} + +export interface NotificationCreate { + title: string; + content: string; + target_type: 'ALL' | 'ROLE' | 'USER'; + target_data?: string[] | null; +} + +export interface NotificationUpdate { + title?: string; + content?: string; + target_type?: 'ALL' | 'ROLE' | 'USER'; + target_data?: string[] | null; +} + +@Injectable({ providedIn: 'root' }) +export class NotificationService implements OnDestroy { + private readonly http = inject(HttpClient); + private readonly authService = inject(AuthService); + + private readonly unreadCountSubject = new BehaviorSubject(0); + readonly unreadCount$ = this.unreadCountSubject.asObservable(); + + readonly notificationsChanged$ = new Subject(); + + private isRefreshing = false; + private pollSubscription?: Subscription; + private visibilityListener?: () => void; + private focusListener?: () => void; + + constructor() { + this.setupWindowListeners(); + } + + notifyChanged(): void { + this.notificationsChanged$.next(); + } + + private setupWindowListeners(): void { + if (typeof window !== 'undefined') { + this.visibilityListener = () => { + if (document.visibilityState === 'visible' && this.authService.currentUser()) { + this.refreshUnreadCount(); + this.notifyChanged(); + } + }; + this.focusListener = () => { + if (this.authService.currentUser()) { + this.refreshUnreadCount(); + this.notifyChanged(); + } + }; + document.addEventListener('visibilitychange', this.visibilityListener); + window.addEventListener('focus', this.focusListener); + } + } + + startPolling(intervalMs = 3000): void { + this.stopPolling(); + this.refreshUnreadCount(); + this.pollSubscription = interval(intervalMs).subscribe(() => { + this.refreshUnreadCount(); + }); + } + + stopPolling(): void { + if (this.pollSubscription) { + this.pollSubscription.unsubscribe(); + this.pollSubscription = undefined; + } + } + + clear(): void { + this.stopPolling(); + this.unreadCountSubject.next(0); + } + + getNotifications(): Observable { + return this.http.get('/api/v1/notifications'); + } + + getUnreadCount(): Observable<{ unread_count: number }> { + return this.http.get<{ unread_count: number }>('/api/v1/notifications/unread-count').pipe( + tap({ + next: (res) => { + const previous = this.unreadCountSubject.value; + this.unreadCountSubject.next(res.unread_count); + if (previous !== res.unread_count) { + this.notifyChanged(); + } + }, + error: (err: { status?: number }) => { + if (err?.status === 401) { + this.stopPolling(); + this.unreadCountSubject.next(0); + } + }, + }), + ); + } + + refreshUnreadCount(): void { + if (!this.authService.currentUser()) { + return; + } + if (this.isRefreshing) { + return; + } + this.isRefreshing = true; + this.getUnreadCount().subscribe({ + next: () => { + this.isRefreshing = false; + }, + error: () => { + this.isRefreshing = false; + }, + }); + } + + setUnreadCount(count: number): void { + this.unreadCountSubject.next(Math.max(0, count)); + } + + getNotification(id: string): Observable { + return this.http.get(`/api/v1/notifications/${id}`); + } + + createNotification(payload: NotificationCreate): Observable { + return this.http.post('/api/v1/notifications', payload).pipe( + tap(() => { + this.refreshUnreadCount(); + this.notifyChanged(); + }), + ); + } + + updateNotification(id: string, payload: NotificationUpdate): Observable { + return this.http.put(`/api/v1/notifications/${id}`, payload).pipe( + tap(() => { + this.refreshUnreadCount(); + this.notifyChanged(); + }), + ); + } + + markAsRead(id: string): Observable { + return this.http.post(`/api/v1/notifications/${id}/read`, {}).pipe( + tap(() => { + const current = this.unreadCountSubject.value; + if (current > 0) { + this.unreadCountSubject.next(current - 1); + } + this.notifyChanged(); + }), + ); + } + + markAllAsRead(): Observable<{ marked_count: number }> { + return this.http.post<{ marked_count: number }>('/api/v1/notifications/read-all', {}).pipe( + tap(() => { + this.unreadCountSubject.next(0); + this.notifyChanged(); + }), + ); + } + + deleteNotification(id: string): Observable { + return this.http.delete(`/api/v1/notifications/${id}`).pipe( + tap(() => { + this.refreshUnreadCount(); + this.notifyChanged(); + }), + ); + } + + ngOnDestroy(): void { + this.stopPolling(); + if (typeof window !== 'undefined') { + if (this.visibilityListener) { + document.removeEventListener('visibilitychange', this.visibilityListener); + } + if (this.focusListener) { + window.removeEventListener('focus', this.focusListener); + } + } + } +} diff --git a/frontend/src/app/features/dashboard/dashboard-shell.component.ts b/frontend/src/app/features/dashboard/dashboard-shell.component.ts index e8534de..ae26baf 100644 --- a/frontend/src/app/features/dashboard/dashboard-shell.component.ts +++ b/frontend/src/app/features/dashboard/dashboard-shell.component.ts @@ -14,6 +14,7 @@ import { Component, computed, inject, + OnDestroy, OnInit, signal, } from '@angular/core'; @@ -26,6 +27,7 @@ import { NzDrawerModule } from 'ng-zorro-antd/drawer'; import { NzIconModule } from 'ng-zorro-antd/icon'; import { NzLayoutModule } from 'ng-zorro-antd/layout'; import { NzMenuModule } from 'ng-zorro-antd/menu'; +import { NzPopoverModule } from 'ng-zorro-antd/popover'; import { NzProgressModule } from 'ng-zorro-antd/progress'; import { NzTagModule } from 'ng-zorro-antd/tag'; import { AuthService } from '../../core/api/auth.service'; @@ -38,6 +40,10 @@ import { TaskManagementComponent } from '../tasks/task-management.component'; import { TrainingManagementComponent } from '../training/training-management.component'; import { UserManagementComponent } from '../users/user-management.component'; import { RequestManagementComponent } from '../requests/request-management.component'; +import { NotificationManagementComponent } from '../notifications/notification-management.component'; +import { Notification, NotificationService } from '../../core/api/notification.service'; +import { AsyncPipe, SlicePipe } from '@angular/common'; +import { Subscription, interval } from 'rxjs'; enum Role { Intern = 'INTERN', @@ -208,6 +214,8 @@ const dashboards: Record = { changeDetection: ChangeDetectionStrategy.OnPush, imports: [ DatePipe, + AsyncPipe, + SlicePipe, NzSpinModule, NzResultModule, NzEmptyModule, @@ -222,6 +230,7 @@ const dashboards: Record = { NzIconModule, NzLayoutModule, NzMenuModule, + NzPopoverModule, NzProgressModule, NzTagModule, InternshipManagementComponent, @@ -233,6 +242,7 @@ const dashboards: Record = { TrainingManagementComponent, UserManagementComponent, RequestManagementComponent, + NotificationManagementComponent, ], styles: [ @@ -417,10 +427,37 @@ const dashboards: Record = {
- + @if (role() === 'ADMIN') { + + + + } @else { + + + + } = { } @else if (section() === 'tasks' && (role() === 'MENTOR' || role() === 'INTERN')) { + } @else if (section() === 'notifications') { + + } @else if ( role() === 'INTERN' && (section() === 'roadmap' || section() === 'documents' || section() === 'quizzes') @@ -690,15 +730,85 @@ const dashboards: Record = {
+ + + +
+
+

Thông báo

+ @if (popoverUnreadCount() > 0) { + + } +
+ +
+ @if (popoverNotifs().length === 0 && !isLoadingPopoverNotifs()) { + + } @else { +
+ @for (n of popoverNotifs(); track n.id) { +
+
+ @if (!n.is_read) { + + } + {{ n.title }} +
+
+ {{ n.content | slice: 0 : 80 }}{{ n.content.length > 80 ? '...' : '' }} +
+
+ {{ n.created_at | date: 'dd/MM/yyyy HH:mm' }} +
+
+ } +
+ } +
+
+ +
+
`, }) -export class DashboardShellComponent implements OnInit { +export class DashboardShellComponent implements OnInit, OnDestroy { private readonly activatedRoute = inject(ActivatedRoute); private readonly authService = inject(AuthService); private readonly router = inject(Router); private readonly dashboardService = inject(DashboardService); private readonly cdr = inject(ChangeDetectorRef); + private readonly notifService = inject(NotificationService); + protected readonly unreadCount$ = this.notifService.unreadCount$; + private notifChangedSub?: Subscription; + private popoverIntervalSub?: Subscription; + + protected isNotifPopoverVisible = false; + protected readonly popoverNotifs = signal([]); + protected readonly isLoadingPopoverNotifs = signal(false); + protected readonly isMarkingAllRead = signal(false); + protected readonly popoverUnreadCount = computed( + () => this.popoverNotifs().filter((n) => !n.is_read).length, + ); protected readonly dashboardData = signal(null); protected readonly isLoading = signal(true); @@ -763,6 +873,24 @@ export class DashboardShellComponent implements OnInit { ngOnInit(): void { this.authService.getProfile().subscribe({ error: () => undefined }); this.loadDashboard(); + this.notifService.startPolling(3000); + this.loadPopoverNotifs(false); + + this.notifChangedSub = this.notifService.notificationsChanged$.subscribe(() => { + this.loadPopoverNotifs(false); + }); + + this.popoverIntervalSub = interval(3000).subscribe(() => { + if (this.isNotifPopoverVisible) { + this.loadPopoverNotifs(false); + } + }); + } + + ngOnDestroy(): void { + this.notifService.stopPolling(); + this.notifChangedSub?.unsubscribe(); + this.popoverIntervalSub?.unsubscribe(); } protected loadDashboard(): void { @@ -810,12 +938,14 @@ export class DashboardShellComponent implements OnInit { const requestedRole = params.get('role')?.toUpperCase(); if (Object.values(Role).includes(requestedRole as Role)) { this.role.set(requestedRole as Role); + this.notifService.refreshUnreadCount(); } }); this.activatedRoute.queryParamMap.subscribe((queryParams) => { const sec = queryParams.get('section') || queryParams.get('tab'); if (sec) { this.section.set(sec); + this.notifService.refreshUnreadCount(); } }); } @@ -823,6 +953,7 @@ export class DashboardShellComponent implements OnInit { protected choose(section: string): void { this.section.set(section); this.isMenuOpen.set(false); + this.notifService.refreshUnreadCount(); this.cdr.markForCheck(); } @@ -857,6 +988,7 @@ export class DashboardShellComponent implements OnInit { } private finishLogout(): void { + this.notifService.clear(); this.authService.clearSession(); void this.router.navigate(['/login']); } @@ -888,6 +1020,71 @@ export class DashboardShellComponent implements OnInit { if (val == null) return 0; return Math.round(val * 100) / 100; } + + protected onNotifPopoverVisibleChange(visible: boolean): void { + this.isNotifPopoverVisible = visible; + if (visible) { + this.loadPopoverNotifs(true); + this.notifService.refreshUnreadCount(); + } + } + + protected loadPopoverNotifs(showSpinner = true): void { + if (showSpinner && this.popoverNotifs().length === 0) { + this.isLoadingPopoverNotifs.set(true); + } + this.notifService.getNotifications().subscribe({ + next: (list) => { + this.popoverNotifs.set(list.slice(0, 8)); + const unread = list.filter((n) => !n.is_read).length; + this.notifService.setUnreadCount(unread); + this.isLoadingPopoverNotifs.set(false); + this.cdr.markForCheck(); + }, + error: () => { + this.isLoadingPopoverNotifs.set(false); + this.cdr.markForCheck(); + }, + }); + } + + protected popoverMarkAllAsRead(): void { + this.isMarkingAllRead.set(true); + this.notifService.markAllAsRead().subscribe({ + next: () => { + this.popoverNotifs.update((list) => list.map((n) => ({ ...n, is_read: true }))); + this.notifService.setUnreadCount(0); + this.isMarkingAllRead.set(false); + this.cdr.markForCheck(); + }, + error: () => { + this.isMarkingAllRead.set(false); + this.cdr.markForCheck(); + }, + }); + } + + protected onPopoverItemClick(n: Notification): void { + if (!n.is_read) { + this.notifService.markAsRead(n.id).subscribe({ + next: () => { + this.popoverNotifs.update((list) => + list.map((item) => (item.id === n.id ? { ...item, is_read: true } : item)), + ); + this.notifService.refreshUnreadCount(); + this.cdr.markForCheck(); + }, + error: () => undefined, + }); + } + this.isNotifPopoverVisible = false; + this.choose('notifications'); + } + + protected viewAllNotifications(): void { + this.isNotifPopoverVisible = false; + this.choose('notifications'); + } } function mapDashboardError(status?: number): string { diff --git a/frontend/src/app/features/notifications/notification-management.component.ts b/frontend/src/app/features/notifications/notification-management.component.ts new file mode 100644 index 0000000..9a2bfff --- /dev/null +++ b/frontend/src/app/features/notifications/notification-management.component.ts @@ -0,0 +1,971 @@ +import { DatePipe, SlicePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + OnDestroy, + OnInit, + inject, + signal, +} from '@angular/core'; +import { Subscription } from 'rxjs'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FormsModule } from '@angular/forms'; +import { NzAlertModule } from 'ng-zorro-antd/alert'; +import { NzBadgeModule } from 'ng-zorro-antd/badge'; +import { NzButtonModule } from 'ng-zorro-antd/button'; +import { NzEmptyModule } from 'ng-zorro-antd/empty'; +import { NzFormModule } from 'ng-zorro-antd/form'; +import { NzIconModule } from 'ng-zorro-antd/icon'; +import { NzInputModule } from 'ng-zorro-antd/input'; +import { NzMessageService } from 'ng-zorro-antd/message'; +import { NzModalModule } from 'ng-zorro-antd/modal'; +import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm'; +import { NzSelectModule } from 'ng-zorro-antd/select'; +import { NzSpinModule } from 'ng-zorro-antd/spin'; +import { NzTableModule } from 'ng-zorro-antd/table'; +import { NzTagModule } from 'ng-zorro-antd/tag'; +import { NzTooltipModule } from 'ng-zorro-antd/tooltip'; +import { NzDividerModule } from 'ng-zorro-antd/divider'; +import { NzCardModule } from 'ng-zorro-antd/card'; +import { AuthService } from '../../core/api/auth.service'; +import { User, UserManagementService } from '../../core/api/user-management.service'; +import { Notification, NotificationService } from '../../core/api/notification.service'; + +@Component({ + selector: 'app-notification-management', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + DatePipe, + SlicePipe, + FormsModule, + ReactiveFormsModule, + NzAlertModule, + NzBadgeModule, + NzButtonModule, + NzCardModule, + NzDividerModule, + NzEmptyModule, + NzFormModule, + NzIconModule, + NzInputModule, + NzModalModule, + NzPopconfirmModule, + NzSelectModule, + NzSpinModule, + NzTableModule, + NzTagModule, + NzTooltipModule, + ], + template: ` +
+ + @if (role === 'ADMIN') { + + + @if (errorMsg()) { + + } + + + @if (notifications().length === 0 && !isLoading()) { + + } @else { + +
+ + + + Tiêu đề + Đối tượng + Lượt đọc + Ngày gửi + Người tạo + Thao tác + + + + @for (n of adminTable.data; track n.id) { + + + {{ n.title }} +
+ {{ n.content | slice: 0 : 80 }}{{ n.content.length > 80 ? '...' : '' }} +
+ + + + {{ targetLabel(n) }} + + + + {{ n.read_count ?? 0 }} + + {{ n.created_at | date: 'dd/MM/yyyy HH:mm' }} + {{ n.creator_name ?? '-' }} + + + + + + } + +
+
+ + +
+ @for (n of notifications(); track n.id) { + +
+
+ {{ n.title }} + + {{ targetLabel(n) }} + +
+
+ + {{ n.created_at | date: 'dd/MM/yyyy HH:mm' }} + @if (n.creator_name) { +  • {{ n.creator_name }} + } +  •  + {{ n.read_count ?? 0 }} lượt đọc +
+
+
+ {{ n.content | slice: 0 : 100 }}{{ n.content.length > 100 ? '...' : '' }} +
+ +
+ } +
+ } +
+ + + + +
+ + Tiêu đề + + + + + + Nội dung + + + + + + Đối tượng nhận + + + + + + + + + @if (createForm.get('target_type')?.value === 'ROLE') { + + Chọn vai trò + + + + + + + + + } + @if (createForm.get('target_type')?.value === 'USER') { + + Chọn người nhận + + + @for (u of usersList(); track u.id) { + + } + + + + } + +
+
+
+ } + + + @if (role === 'INTERN' || role === 'MENTOR') { + + + @if (errorMsg()) { + + } + + + @if (notifications().length === 0 && !isLoading()) { + + } @else { +
+ @for (n of notifications(); track n.id) { + +
+
+ @if (!n.is_read) { + + } + {{ n.title }} + + {{ targetLabel(n) }} + +
+
+ + {{ n.created_at | date: 'dd/MM/yyyy HH:mm' }} + @if (n.creator_name) { +  • {{ n.creator_name }} + } + @if (n.is_read && n.read_at) { +  •  Đã đọc + {{ n.read_at | date: 'dd/MM' }} + } +
+
+
+ {{ n.content | slice: 0 : 120 }}{{ n.content.length > 120 ? '...' : '' }} +
+
+ } +
+ } +
+ } + + + + + @if (selectedNotif(); as n) { +
+
+ + {{ targetLabel(n) }} + + + + {{ n.created_at | date: 'dd/MM/yyyy HH:mm' }} + + @if (n.creator_name) { + {{ n.creator_name }} + } +
+ +
{{ n.content }}
+ @if (n.is_read && n.read_at) { +
+ + Đã đọc lúc {{ n.read_at | date: 'dd/MM/yyyy HH:mm' }} +
+ } +
+ } +
+
+
+ `, + styles: [ + ` + .notif-page { + padding: 24px; + width: 100%; + box-sizing: border-box; + } + .page-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 24px; + h2 { + font-size: 22px; + font-weight: 700; + margin: 0; + display: flex; + align-items: center; + gap: 8px; + } + } + .notif-title-cell { + display: block; + word-break: break-word; + } + .notif-content-preview { + font-size: 12px; + color: #888; + margin-top: 2px; + line-height: 1.4; + word-break: break-word; + } + .target-tag { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; + display: inline-block; + box-sizing: border-box; + } + .notif-row td { + overflow: hidden; + text-overflow: ellipsis; + } + .action-cell { + white-space: nowrap; + } + + /* View switcher: Desktop Table vs Mobile Cards */ + .admin-desktop-view { + display: block; + width: 100%; + } + .admin-mobile-view { + display: none; + } + .notif-card__footer-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 10px; + padding-top: 8px; + border-top: 1px dashed #f0f0f0; + } + + .notif-card--unread { + border-left: 4px solid #1890ff !important; + background: #f0f7ff !important; + } + .notif-card__header { + margin-bottom: 6px; + } + .notif-card__title-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 4px; + } + .notif-card__title { + font-size: 15px; + word-break: break-word; + flex: 1 1 auto; + } + .notif-card__tag { + margin-left: auto; + } + .notif-card__meta { + font-size: 12px; + color: #888; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + } + .notif-card__preview { + color: #555; + font-size: 13px; + line-height: 1.5; + word-break: break-word; + } + .unread-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: #1890ff; + flex-shrink: 0; + } + .notif-detail { + padding: 8px 0; + } + .notif-detail__meta { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + font-size: 13px; + color: #666; + } + .notif-detail__date { + display: flex; + align-items: center; + gap: 4px; + } + .notif-detail__content { + white-space: pre-wrap; + font-size: 14px; + line-height: 1.7; + color: #333; + margin-bottom: 16px; + word-break: break-word; + } + .notif-detail__read-status { + color: #52c41a; + font-size: 13px; + display: flex; + align-items: center; + gap: 6px; + } + .notif-row:hover td { + background: #f5f5f5; + } + .modal-form-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 16px; + } + + /* Responsive breakpoints */ + @media (max-width: 768px) { + .admin-desktop-view { + display: none !important; + } + .admin-mobile-view { + display: flex !important; + flex-direction: column; + gap: 12px; + } + .notif-page { + padding: 16px; + } + .page-header h2 { + font-size: 20px; + } + } + + @media (max-width: 576px) { + .notif-page { + padding: 12px 8px; + } + .page-header { + flex-direction: column; + align-items: flex-start; + gap: 10px; + margin-bottom: 16px; + + button { + width: 100%; + } + } + .notif-card__title-row { + justify-content: space-between; + } + .notif-card__tag { + margin-left: 0; + } + .modal-form-actions { + flex-direction: column-reverse; + button { + width: 100%; + } + } + } + `, + ], +}) +export class NotificationManagementComponent implements OnInit, OnDestroy { + private readonly notifService = inject(NotificationService); + private readonly authService = inject(AuthService); + private readonly message = inject(NzMessageService); + private readonly cdr = inject(ChangeDetectorRef); + private readonly userMgmtService = inject(UserManagementService); + private readonly fb = inject(FormBuilder); + private notifChangedSub?: Subscription; + + protected readonly notifications = signal([]); + protected readonly isLoading = signal(false); + protected readonly errorMsg = signal(null); + protected readonly isSubmitting = signal(false); + protected readonly isMarkingAll = signal(false); + protected readonly selectedNotif = signal(null); + + protected readonly usersList = signal([]); + protected readonly isLoadingUsers = signal(false); + + protected isCreateModalOpen = false; + protected isDetailModalOpen = false; + protected editingNotifId: string | null = null; + + protected role = 'INTERN'; + + protected readonly createForm = this.fb.group({ + title: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(255)]], + content: ['', [Validators.required, Validators.minLength(1)]], + target_type: ['ALL', Validators.required], + target_roles: [[] as string[]], + target_users: [[] as string[]], + }); + + protected readonly unreadCount = () => this.notifications().filter((n) => !n.is_read).length; + + ngOnInit(): void { + const user = this.authService.currentUser(); + this.role = user?.role ?? 'INTERN'; + this.loadNotifications(); + + this.notifChangedSub = this.notifService.notificationsChanged$.subscribe(() => { + if (!this.isSubmitting()) { + this.loadNotifications(false); + } + }); + } + + ngOnDestroy(): void { + this.notifChangedSub?.unsubscribe(); + } + + private loadNotifications(showSpinner = true): void { + if (showSpinner && this.notifications().length === 0) { + this.isLoading.set(true); + } + this.errorMsg.set(null); + this.notifService.getNotifications().subscribe({ + next: (data) => { + this.notifications.set(data); + const count = this.role === 'ADMIN' ? data.length : data.filter((n) => !n.is_read).length; + this.notifService.setUnreadCount(count); + this.isLoading.set(false); + this.cdr.markForCheck(); + }, + error: () => { + this.errorMsg.set('Không thể tải danh sách thông báo. Vui lòng thử lại.'); + this.isLoading.set(false); + this.cdr.markForCheck(); + }, + }); + } + + protected loadUsers(search?: string): void { + this.isLoadingUsers.set(true); + this.userMgmtService.getUsers({ search, limit: 50 }).subscribe({ + next: (users) => { + this.usersList.set(users); + this.isLoadingUsers.set(false); + this.cdr.markForCheck(); + }, + error: () => { + this.isLoadingUsers.set(false); + this.cdr.markForCheck(); + }, + }); + } + + protected onSearchUsers(value: string): void { + this.loadUsers(value.trim() || undefined); + } + + protected openCreateModal(): void { + this.editingNotifId = null; + this.createForm.reset({ + title: '', + content: '', + target_type: 'ALL', + target_roles: [], + target_users: [], + }); + this.isCreateModalOpen = true; + this.loadUsers(); + this.cdr.markForCheck(); + } + + protected openEditModal(n: Notification, event: Event): void { + event.stopPropagation(); + this.editingNotifId = n.id; + let target_roles: string[] = []; + let target_users: string[] = []; + if (n.target_type === 'ROLE' && n.target_data) { + target_roles = n.target_data; + } else if (n.target_type === 'USER' && n.target_data) { + target_users = (n.target_data as unknown[]) + .map((item) => { + if (typeof item === 'string') return item; + if (item && typeof item === 'object') { + const obj = item as Record; + return obj['intern_id'] || obj['new_mentor_id'] || obj['old_mentor_id'] || ''; + } + return ''; + }) + .filter(Boolean); + } + + this.createForm.setValue({ + title: n.title, + content: n.content, + target_type: n.target_type, + target_roles, + target_users, + }); + this.isCreateModalOpen = true; + this.loadUsers(); + this.cdr.markForCheck(); + } + + protected closeCreateModal(): void { + this.isCreateModalOpen = false; + this.editingNotifId = null; + this.cdr.markForCheck(); + } + + protected onTargetTypeChange(): void { + this.createForm.patchValue({ target_roles: [], target_users: [] }); + if (this.createForm.get('target_type')?.value === 'USER' && this.usersList().length === 0) { + this.loadUsers(); + } + this.cdr.markForCheck(); + } + + protected submitCreate(): void { + if (this.createForm.invalid) return; + const val = this.createForm.value; + let target_data: string[] | null = null; + if (val.target_type === 'ROLE') { + target_data = (val.target_roles as string[]) || []; + } else if (val.target_type === 'USER') { + target_data = (val.target_users as string[]) || []; + } + this.isSubmitting.set(true); + + if (this.editingNotifId) { + this.notifService + .updateNotification(this.editingNotifId, { + title: val.title!.trim(), + content: val.content!.trim(), + target_type: val.target_type as 'ALL' | 'ROLE' | 'USER', + target_data, + }) + .subscribe({ + next: (updated) => { + this.notifications.update((list) => + list.map((item) => + item.id === this.editingNotifId ? { ...item, ...updated } : item, + ), + ); + this.message.success('Cập nhật thông báo thành công!'); + this.isSubmitting.set(false); + this.closeCreateModal(); + this.cdr.markForCheck(); + }, + error: (err: { error?: { detail?: string } }) => { + this.message.error( + err?.error?.detail ?? 'Không thể cập nhật thông báo. Vui lòng thử lại.', + ); + this.isSubmitting.set(false); + this.cdr.markForCheck(); + }, + }); + } else { + this.notifService + .createNotification({ + title: val.title!.trim(), + content: val.content!.trim(), + target_type: val.target_type as 'ALL' | 'ROLE' | 'USER', + target_data, + }) + .subscribe({ + next: (created) => { + this.notifications.update((list) => [created, ...list]); + this.message.success('Thông báo đã được gửi thành công!'); + this.isSubmitting.set(false); + this.closeCreateModal(); + this.cdr.markForCheck(); + }, + error: (err: { error?: { detail?: string } }) => { + this.message.error(err?.error?.detail ?? 'Không thể gửi thông báo. Vui lòng thử lại.'); + this.isSubmitting.set(false); + this.cdr.markForCheck(); + }, + }); + } + } + + protected deleteNotification(id: string): void { + this.notifService.deleteNotification(id).subscribe({ + next: () => { + this.notifications.update((list) => list.filter((n) => n.id !== id)); + this.message.success('Xóa thông báo thành công!'); + this.cdr.markForCheck(); + }, + error: () => { + this.message.error('Không thể xóa thông báo.'); + this.cdr.markForCheck(); + }, + }); + } + + protected viewDetail(n: Notification): void { + this.selectedNotif.set(n); + this.isDetailModalOpen = true; + this.cdr.markForCheck(); + } + + protected openUserDetail(n: Notification): void { + this.selectedNotif.set(n); + this.isDetailModalOpen = true; + // Auto-mark as read + if (!n.is_read) { + this.notifService.markAsRead(n.id).subscribe({ + next: (updated) => { + this.notifications.update((list) => + list.map((item) => (item.id === n.id ? { ...item, ...updated } : item)), + ); + this.selectedNotif.set({ ...n, ...updated }); + this.cdr.markForCheck(); + }, + error: () => undefined, + }); + } + this.cdr.markForCheck(); + } + + protected closeDetailModal(): void { + this.isDetailModalOpen = false; + this.selectedNotif.set(null); + this.cdr.markForCheck(); + } + + protected markAllAsRead(): void { + this.isMarkingAll.set(true); + this.notifService.markAllAsRead().subscribe({ + next: () => { + this.notifications.update((list) => list.map((n) => ({ ...n, is_read: true }))); + this.message.success('Đã đánh dấu tất cả thông báo là đã đọc!'); + this.isMarkingAll.set(false); + this.cdr.markForCheck(); + }, + error: () => { + this.message.error('Không thể đánh dấu đã đọc. Vui lòng thử lại.'); + this.isMarkingAll.set(false); + this.cdr.markForCheck(); + }, + }); + } + + protected targetColor(n: Notification): string { + if (n.target_type === 'ALL') return 'blue'; + if (n.target_type === 'ROLE') return 'green'; + if (n.target_type === 'USER') return 'orange'; + return 'default'; + } + + protected targetLabel(n: Notification): string { + if (n.target_type === 'ALL') { + return 'Tất cả người dùng'; + } + if (n.target_type === 'ROLE') { + if (n.recipient_names && n.recipient_names.length > 0) { + return n.recipient_names.join(', '); + } + if (n.target_data && n.target_data.length > 0) { + const roleMap: Record = { + INTERN: 'Thực tập sinh', + MENTOR: 'Mentor', + ADMIN: 'Quản trị viên', + }; + return n.target_data.map((r) => roleMap[r] || r).join(', '); + } + return 'Theo vai trò'; + } + if (n.target_type === 'USER') { + if (n.recipient_names && n.recipient_names.length > 0) { + if (n.recipient_names.length === 1) { + return n.recipient_names[0]; + } + return `${n.recipient_names[0]} (+${n.recipient_names.length - 1})`; + } + const count = n.target_data?.length || 1; + return `${count} người dùng`; + } + return n.target_type; + } + + protected targetTooltip(n: Notification): string | null { + if (n.target_type === 'USER' && n.recipient_names && n.recipient_names.length > 0) { + return n.recipient_names.join(', '); + } + return null; + } +} diff --git a/frontend/src/app/features/training/training-management.component.html b/frontend/src/app/features/training/training-management.component.html index 7cf03e9..b8e8e28 100644 --- a/frontend/src/app/features/training/training-management.component.html +++ b/frontend/src/app/features/training/training-management.component.html @@ -340,7 +340,7 @@

Quản lý Đào tạo (LMS)

Tiêu đề & Nội dung Phân loại Tài nguyên - Thao tác + Thao tác @@ -511,7 +511,7 @@

Quản lý Đào tạo (LMS)

Điểm đạt Số lần thử Trạng thái - Thao tác + Thao tác diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 6308142..20fdcbb 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -1098,3 +1098,125 @@ nz-layout.dashboard-main { .ant-modal-responsive-helper { display: none; } + +/* Notification Popover & Responsive Modals */ +.notif-popover-overlay { + z-index: 1050; + + .ant-popover-inner { + border-radius: 12px; + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.16); + } +} + +.notif-popover { + width: 340px; + max-width: calc(100vw - 32px); + margin: -12px -16px; + + @media (max-width: 768px) { + width: min(280px, calc(100vw - 64px)); + max-width: calc(100vw - 64px); + } +} + +.notif-responsive-modal .ant-modal { + max-width: calc(100vw - 24px); + margin: 16px auto; +} + +.notif-popover__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid #f0f0f0; + + h4 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: #1e293b; + white-space: nowrap; + } + + button { + padding: 0; + font-size: 12px; + white-space: nowrap; + } +} + +.notif-popover__body { + max-height: min(320px, calc(100vh - 150px)); + overflow-y: auto; +} + +.notif-popover__list { + display: flex; + flex-direction: column; +} + +.notif-popover__item { + padding: 10px 14px; + border-bottom: 1px solid #f5f5f5; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: #fafbfc; + } + + &--unread { + background-color: #f0f7ff; + + &:hover { + background-color: #e6f1fc; + } + } +} + +.notif-popover__item-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: #1e293b; + margin-bottom: 3px; + + b { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; + } + + .unread-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #1890ff; + flex-shrink: 0; + } +} + +.notif-popover__item-desc { + font-size: 12px; + color: #64748b; + line-height: 1.4; + margin-bottom: 4px; + word-break: break-word; +} + +.notif-popover__item-meta { + font-size: 11px; + color: #94a3b8; +} + +.notif-popover__footer { + text-align: center; + border-top: 1px solid #f0f0f0; + padding: 6px 14px; +}