From d4f0d02c62a8f58cd56ca14704741935cb6f0183 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 2 Apr 2026 22:40:11 +0700 Subject: [PATCH] 690402:2240 fix dashboard --- .../correspondence/correspondence.service.ts | 121 --------- .../modules/dashboard/dashboard.controller.ts | 6 +- .../src/modules/dashboard/dashboard.module.ts | 10 +- .../modules/dashboard/dashboard.service.ts | 230 ++++++++++++++---- .../modules/dashboard/dto/get-activity.dto.ts | 7 +- .../modules/dashboard/dto/get-pending.dto.ts | 7 +- .../modules/dashboard/dto/get-stats.dto.ts | 16 ++ backend/src/modules/dashboard/dto/index.ts | 1 + frontend/app/(dashboard)/dashboard/page.tsx | 9 +- .../circulation-status-card.tsx | 8 +- .../components/correspondences/detail.tsx | 2 +- frontend/components/correspondences/form.tsx | 29 +-- frontend/components/layout/header.tsx | 2 + .../components/layout/project-switcher.tsx | 68 ++++++ .../hooks/__tests__/use-circulation.test.ts | 4 +- frontend/hooks/use-circulation.ts | 8 +- frontend/hooks/use-dashboard.ts | 24 +- frontend/lib/api/dashboard.ts | 29 ++- frontend/lib/services/circulation.service.ts | 6 +- frontend/lib/services/dashboard.service.ts | 15 +- frontend/lib/stores/project-store.ts | 23 ++ .../dto/circulation/search-circulation.dto.ts | 3 + 22 files changed, 396 insertions(+), 232 deletions(-) create mode 100644 backend/src/modules/dashboard/dto/get-stats.dto.ts create mode 100644 frontend/components/layout/project-switcher.tsx create mode 100644 frontend/lib/stores/project-store.ts diff --git a/backend/src/modules/correspondence/correspondence.service.ts b/backend/src/modules/correspondence/correspondence.service.ts index 755f2a8..288b018 100644 --- a/backend/src/modules/correspondence/correspondence.service.ts +++ b/backend/src/modules/correspondence/correspondence.service.ts @@ -776,127 +776,6 @@ export class CorrespondenceService { await recipientRepo.save(newRecipients); } - // 6. Regenerate Document Number if structural fields changed (Recipient, Discipline, Type, Project) - // AND it is a DRAFT. - - // Fetch fresh data for context and comparison - const currentCorr = await this.correspondenceRepo.findOne({ - where: { id }, - relations: ['type', 'recipients', 'recipients.recipientOrganization'], - }); - - if (currentCorr) { - const currentToRecipient = currentCorr.recipients?.find( - (r) => r.recipientType === 'TO' - ); - const currentRecipientId = currentToRecipient?.recipientOrganizationId; - - // Check for ACTUAL value changes - const isProjectChanged = - updResolvedProjectId !== undefined && - updResolvedProjectId !== currentCorr.projectId; - const isOriginatorChanged = - updResolvedOriginatorId !== undefined && - updResolvedOriginatorId !== currentCorr.originatorId; - const isDisciplineChanged = - updateDto.disciplineId !== undefined && - updateDto.disciplineId !== currentCorr.disciplineId; - const isTypeChanged = - updateDto.typeId !== undefined && - updateDto.typeId !== currentCorr.correspondenceTypeId; - - let isRecipientChanged = false; - let newRecipientId: number | undefined; - - if (updResolvedRecipients) { - const newToRecipient = updResolvedRecipients.find( - (r) => r.type === 'TO' - ); - newRecipientId = newToRecipient?.organizationId; - - if (newRecipientId !== currentRecipientId) { - isRecipientChanged = true; - } - } - - if ( - isProjectChanged || - isDisciplineChanged || - isTypeChanged || - isRecipientChanged || - isOriginatorChanged - ) { - const targetRecipientId = isRecipientChanged - ? newRecipientId - : currentRecipientId; - - // Resolve Recipient Code for the NEW context - let recipientCode = ''; - if (targetRecipientId) { - const recOrg = await this.dataSource.manager.findOne(Organization, { - where: { id: targetRecipientId }, - }); - if (recOrg) recipientCode = recOrg.organizationCode; - } - - // [Fix #6] Fetch real ORG Code from originator organization - const originatorOrgForUpdate = await this.dataSource.manager.findOne( - Organization, - { - where: { - id: updResolvedOriginatorId ?? currentCorr.originatorId ?? 0, - }, - } - ); - const orgCode = originatorOrgForUpdate?.organizationCode ?? 'UNK'; - - // Prepare Contexts - const oldCtx = { - projectId: currentCorr.projectId, - originatorOrganizationId: currentCorr.originatorId ?? 0, - typeId: currentCorr.correspondenceTypeId, - disciplineId: currentCorr.disciplineId, - recipientOrganizationId: currentRecipientId, - year: new Date().getFullYear(), - }; - - const newCtx = { - projectId: updResolvedProjectId ?? currentCorr.projectId, - originatorOrganizationId: - updResolvedOriginatorId ?? currentCorr.originatorId ?? 0, - typeId: updateDto.typeId ?? currentCorr.correspondenceTypeId, - disciplineId: updateDto.disciplineId ?? currentCorr.disciplineId, - recipientOrganizationId: targetRecipientId, - year: new Date().getFullYear(), - userId: user.user_id, // Pass User ID for Audit - customTokens: { - TYPE_CODE: currentCorr.type?.typeCode || '', - ORG_CODE: orgCode, - RECIPIENT_CODE: recipientCode, - REC_CODE: recipientCode, - }, - }; - - // If Type Changed, need NEW Type Code - if (isTypeChanged) { - const newType = await this.typeRepo.findOne({ - where: { id: newCtx.typeId }, - }); - if (newType) newCtx.customTokens.TYPE_CODE = newType.typeCode; - } - - const newDocNumber = await this.numberingService.updateNumberForDraft( - currentCorr.correspondenceNumber, - oldCtx, - newCtx - ); - - await this.correspondenceRepo.update(id, { - correspondenceNumber: newDocNumber, - }); - } - } - const updated = await this.findOne(id); // Re-index updated document in Elasticsearch (fire-and-forget) diff --git a/backend/src/modules/dashboard/dashboard.controller.ts b/backend/src/modules/dashboard/dashboard.controller.ts index 0be904e..068b2a2 100644 --- a/backend/src/modules/dashboard/dashboard.controller.ts +++ b/backend/src/modules/dashboard/dashboard.controller.ts @@ -13,7 +13,7 @@ import { User } from '../user/entities/user.entity'; import { DashboardService } from './dashboard.service'; // DTOs -import { GetActivityDto, GetPendingDto } from './dto'; +import { GetActivityDto, GetPendingDto, GetStatsDto } from './dto'; @ApiTags('Dashboard') @ApiBearerAuth() @@ -27,8 +27,8 @@ export class DashboardController { */ @Get('stats') @ApiOperation({ summary: 'Get dashboard statistics' }) - async getStats(@CurrentUser() user: User) { - return this.dashboardService.getStats(user.user_id); + async getStats(@CurrentUser() user: User, @Query() query: GetStatsDto) { + return this.dashboardService.getStats(user.user_id, query); } /** diff --git a/backend/src/modules/dashboard/dashboard.module.ts b/backend/src/modules/dashboard/dashboard.module.ts index 6eab36d..a39174d 100644 --- a/backend/src/modules/dashboard/dashboard.module.ts +++ b/backend/src/modules/dashboard/dashboard.module.ts @@ -8,6 +8,8 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Correspondence } from '../correspondence/entities/correspondence.entity'; import { AuditLog } from '../../common/entities/audit-log.entity'; import { WorkflowInstance } from '../workflow-engine/entities/workflow-instance.entity'; +import { Project } from '../project/entities/project.entity'; +import { UserAssignment } from '../user/entities/user-assignment.entity'; // Controller & Service import { DashboardController } from './dashboard.controller'; @@ -15,7 +17,13 @@ import { DashboardService } from './dashboard.service'; @Module({ imports: [ - TypeOrmModule.forFeature([Correspondence, AuditLog, WorkflowInstance]), + TypeOrmModule.forFeature([ + Correspondence, + AuditLog, + WorkflowInstance, + Project, + UserAssignment, + ]), ], controllers: [DashboardController], providers: [DashboardService], diff --git a/backend/src/modules/dashboard/dashboard.service.ts b/backend/src/modules/dashboard/dashboard.service.ts index 86a697e..6478f15 100644 --- a/backend/src/modules/dashboard/dashboard.service.ts +++ b/backend/src/modules/dashboard/dashboard.service.ts @@ -20,7 +20,11 @@ import { ActivityItemDto, GetPendingDto, PendingTaskItemDto, + GetStatsDto, } from './dto'; +import { Project } from '../project/entities/project.entity'; +import { UserAssignment } from '../user/entities/user-assignment.entity'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; @Injectable() export class DashboardService { @@ -33,18 +37,82 @@ export class DashboardService { private auditLogRepo: Repository, @InjectRepository(WorkflowInstance) private workflowInstanceRepo: Repository, + @InjectRepository(Project) + private projectRepo: Repository, + @InjectRepository(UserAssignment) + private userAssignmentRepo: Repository, private dataSource: DataSource ) {} + /** + * ตรวจสอบว่า User มีสิทธิเข้าถึงโครงการหรือไม่ + */ + private async checkProjectAccess( + userId: number, + projectId: string + ): Promise { + // 1. หา Internal ID ของ Project + const project = await this.projectRepo.findOne({ + where: { publicId: projectId }, + select: ['id'], + }); + + if (!project) { + throw new NotFoundException(`Project with ID ${projectId} not found`); + } + + // 2. ตรวจสอบสิทธิ (UserAssignment) + // สำหรับ Global Admin อาจจะไม่ต้องเช็ค (ในที่นี้เช็คว่ามีการมอบหมายโครงการนี้ให้หรือไม่) + // NOTE: ในอนาคตอาจจะใช้ CASL แทน + const assignment = await this.userAssignmentRepo.findOne({ + where: { userId, projectId: project.id }, + }); + + // Check if user is a global admin (assigned to NULL project/org/contract) + const isGlobalAdmin = await this.userAssignmentRepo.findOne({ + where: { + userId, + projectId: undefined, + organizationId: undefined, + contractId: undefined, + }, + }); + + if (!assignment && !isGlobalAdmin) { + this.logger.warn( + `User ${userId} attempted to access project ${projectId} without assignment` + ); + throw new ForbiddenException( + `You do not have access to project ${projectId}` + ); + } + + return project.id; + } + /** * ดึงสถิติ Dashboard * @param userId - ID ของ User ที่ Login */ - async getStats(userId: number): Promise { - this.logger.debug(`Getting dashboard stats for user ${userId}`); + async getStats(userId: number, dto: GetStatsDto): Promise { + const { projectId } = dto; + this.logger.debug( + `Getting dashboard stats for user ${userId}, project: ${projectId || 'Global'}` + ); - // นับจำนวนเอกสารทั้งหมด - const totalDocuments = await this.correspondenceRepo.count(); + let internalProjectId: number | undefined; + if (projectId) { + internalProjectId = await this.checkProjectAccess(userId, projectId); + } + + // นับจำนวนเอกสาร + const totalDocumentsQuery = this.correspondenceRepo.createQueryBuilder('c'); + if (internalProjectId) { + totalDocumentsQuery.where('c.projectId = :internalProjectId', { + internalProjectId, + }); + } + const totalDocuments = await totalDocumentsQuery.getCount(); // นับจำนวนเอกสารเดือนนี้ const startOfMonth = new Date(); @@ -57,28 +125,43 @@ export class DashboardService { .getCount(); // นับงานที่รอ Approve (Workflow Active) - const pendingApprovals = await this.workflowInstanceRepo.count({ - where: { status: WorkflowStatus.ACTIVE }, - }); + const pendingApprovalsQuery = this.workflowInstanceRepo + .createQueryBuilder('w') + .where('w.status = :status', { status: WorkflowStatus.ACTIVE }); - // นับ RFA ทั้งหมด (correspondence_type_id = RFA type) - // ใช้ Raw Query เพราะต้อง JOIN กับ correspondence_types - const rfaCountResult = await this.dataSource.query< - { count: string | number }[] - >(` + if (internalProjectId) { + // WorkflowInstance JOIN กับ Correspondence เพื่อเช็ค Project + pendingApprovalsQuery + .innerJoin('correspondences', 'c', 'w.entity_id = c.uuid') + .andWhere('c.project_id = :internalProjectId', { internalProjectId }); + } + const pendingApprovals = await pendingApprovalsQuery.getCount(); + + // นับ RFA ทั้งหมด + let rfaSql = ` SELECT COUNT(*) as count FROM correspondences c JOIN correspondence_types ct ON c.correspondence_type_id = ct.id WHERE ct.type_code = 'RFA' - `); + `; + const params: (string | number)[] = []; + if (internalProjectId) { + rfaSql += ` AND c.project_id = ?`; + params.push(internalProjectId); + } + const rfaCountResult = await this.dataSource.query< + { count: string | number }[] + >(rfaSql, params); const totalRfas = Number(rfaCountResult[0]?.count || '0'); // นับ Circulation ทั้งหมด + let circSql = `SELECT COUNT(*) as count FROM circulations ci`; + if (internalProjectId) { + circSql += ` JOIN correspondences c ON ci.correspondence_id = c.id WHERE c.project_id = ?`; + } const circulationsCountResult = await this.dataSource.query< { count: string | number }[] - >(` - SELECT COUNT(*) as count FROM circulations - `); + >(circSql, internalProjectId ? [internalProjectId] : []); const totalCirculations = Number(circulationsCountResult[0]?.count || '0'); // นับเอกสารที่อนุมัติแล้ว (APPROVED) @@ -86,21 +169,25 @@ export class DashboardService { // เบื้องต้นนับจาก CorrespondenceStatus ที่เป็น 'APPROVED' หรือ 'CODE 1' // หรือนับจาก Workflow ที่ Completed และ Action เป็น APPROVE // เพื่อความง่ายในเบื้องต้น นับจาก CorrespondenceRevision ที่มี status 'APPROVED' (ถ้ามี) - // หรือนับจาก RFA ที่มี Approve Code // สำหรับ LCBP3 นับ RFA ที่ approveCodeId ไม่ใช่ null (หรือ check status code = APR/FAP) // และ Correspondence ทั่วไปที่มีสถานะ Completed // เพื่อความรวดเร็ว ใช้วิธีนับ Revision ที่ isCurrent = 1 และ statusCode = 'APR' (Approved) - // Check status code 'APR' exists - const aprStatusCount = await this.dataSource.query< - { count: string | number }[] - >(` + // นับเอกสารที่อนุมัติแล้ว + let appSql = ` SELECT COUNT(r.id) as count FROM correspondence_revisions r JOIN correspondence_status s ON r.correspondence_status_id = s.id + JOIN correspondences c ON r.correspondence_id = c.id WHERE r.is_current = 1 AND s.status_code IN ('APR', 'CMP') - `); + `; + if (internalProjectId) { + appSql += ` AND c.project_id = ?`; + } + const aprStatusCount = await this.dataSource.query< + { count: string | number }[] + >(appSql, internalProjectId ? [internalProjectId] : []); const approved = Number(aprStatusCount[0]?.count || '0'); return { @@ -122,11 +209,18 @@ export class DashboardService { userId: number, dto: GetActivityDto ): Promise { - const { limit = 10 } = dto; - this.logger.debug(`Getting recent activity for user ${userId}`); + const { limit = 10, projectId } = dto; + this.logger.debug( + `Getting recent activity for user ${userId}, project: ${projectId || 'Global'}` + ); + + let internalProjectId: number | undefined; + if (projectId) { + internalProjectId = await this.checkProjectAccess(userId, projectId); + } // ดึง Recent Audit Logs - const logs = await this.auditLogRepo + const query = this.auditLogRepo .createQueryBuilder('log') .leftJoin('log.user', 'user') .select([ @@ -139,7 +233,22 @@ export class DashboardService { 'user.username', 'user.firstName', 'user.lastName', - ]) + ]); + + // NOTE: AuditLog อาจจะไม่มี projectId โดยตรง + // ในที่นี้ถ้ามี projectId เราจะพยายามกรองจาก detailsJson หรือ Entity ล่าสุด + // หรือถ้า Entity เป็น Correspondence เราสามารถ JOIN ได้ + // เบื้องต้นถ้าไม่ซับซ้อน จะดึง Global มาก่อน หรือถ้าโครงการสำคัญมากให้ปรับ Schema + if (internalProjectId) { + // ตัวอย่างการกรองเบื้องต้นสำหรับ Correspondence + query.andWhere( + `(log.entityType = 'Correspondence' AND CAST(JSON_EXTRACT(log.detailsJson, '$.projectId') AS UNSIGNED) = :internalProjectId) + OR (log.entityType != 'Correspondence')`, // แสดงอย่างอื่นด้วย + { internalProjectId } + ); + } + + const logs = await query .orderBy('log.createdAt', 'DESC') .limit(limit) .getMany(); @@ -174,46 +283,73 @@ export class DashboardService { data: PendingTaskItemDto[]; meta: { total: number; page: number; limit: number }; }> { - const { page = 1, limit = 10 } = dto; + const { page = 1, limit = 10, projectId } = dto; const offset = (page - 1) * limit; - this.logger.debug(`Getting pending tasks for user ${userId}`); + this.logger.debug( + `Getting pending tasks for user ${userId}, project: ${projectId || 'Global'}` + ); + + let internalProjectId: number | undefined; + if (projectId) { + internalProjectId = await this.checkProjectAccess(userId, projectId); + } - // ใช้ Raw Query เพราะต้อง Query จาก View และ Filter ด้วย JSON - // v_user_tasks มี assignee_ids_json สำหรับ Filter - // MariaDB 11.8: ใช้ JSON_SEARCH แทน CAST AS JSON const userIdNum = Number(userId); + const joinClause = internalProjectId + ? `JOIN correspondence_revisions cr ON v_user_tasks.entity_id = CAST(cr.id AS CHAR) AND v_user_tasks.entity_type IN ('rfa_revision', 'correspondence_revision') + JOIN correspondences c ON cr.correspondence_id = c.id + LEFT JOIN circulations circ ON v_user_tasks.entity_id = CAST(circ.id AS CHAR) AND v_user_tasks.entity_type = 'circulation' + LEFT JOIN correspondences c2 ON circ.correspondence_id = c2.id` + : ''; + const projectFilter = internalProjectId + ? `AND (c.project_id = ? OR c2.project_id = ?)` + : ''; + const [tasks, countResult] = await Promise.all([ this.dataSource.query( ` SELECT - instance_id as instanceId, - workflow_code as workflowCode, - current_state as currentState, - entity_type as entityType, - entity_id as entityId, - document_number as documentNumber, - subject, - assigned_at as assignedAt + v_user_tasks.instance_id as instanceId, + v_user_tasks.workflow_code as workflowCode, + v_user_tasks.current_state as currentState, + v_user_tasks.entity_type as entityType, + v_user_tasks.entity_id as entityId, + v_user_tasks.document_number as documentNumber, + v_user_tasks.subject, + v_user_tasks.assigned_at as assignedAt FROM v_user_tasks + ${joinClause} WHERE - JSON_SEARCH(assignee_ids_json, 'one', ?) IS NOT NULL - OR owner_id = ? - ORDER BY assigned_at DESC + (JSON_SEARCH(v_user_tasks.assignee_ids_json, 'one', ?) IS NOT NULL OR v_user_tasks.owner_id = ?) + ${projectFilter} + ORDER BY v_user_tasks.assigned_at DESC LIMIT ? OFFSET ? `, - [userIdNum, userIdNum, limit, offset] + internalProjectId + ? [ + userIdNum, + userIdNum, + internalProjectId, + internalProjectId, + limit, + offset, + ] + : [userIdNum, userIdNum, limit, offset] ), this.dataSource.query<{ total: string | number }[]>( ` - SELECT COUNT(*) as total + SELECT COUNT(v_user_tasks.instance_id) as total FROM v_user_tasks + ${joinClause} WHERE - JSON_SEARCH(assignee_ids_json, 'one', ?) IS NOT NULL - OR owner_id = ? + (JSON_SEARCH(v_user_tasks.assignee_ids_json, 'one', ?) IS NOT NULL OR v_user_tasks.owner_id = ?) + ${projectFilter} `, - [userIdNum, userIdNum] + internalProjectId + ? [userIdNum, userIdNum, internalProjectId, internalProjectId] + : [userIdNum, userIdNum] ), ]); diff --git a/backend/src/modules/dashboard/dto/get-activity.dto.ts b/backend/src/modules/dashboard/dto/get-activity.dto.ts index 98b05cd..d97e2ff 100644 --- a/backend/src/modules/dashboard/dto/get-activity.dto.ts +++ b/backend/src/modules/dashboard/dto/get-activity.dto.ts @@ -3,7 +3,7 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsInt, IsOptional, Max, Min } from 'class-validator'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; /** * DTO สำหรับ Query params ของ GET /dashboard/activity @@ -16,6 +16,11 @@ export class GetActivityDto { @Min(1) @Max(50) limit?: number = 10; + + @ApiPropertyOptional({ description: 'ID ของโครงการ (UUID)' }) + @IsOptional() + @IsString() + projectId?: string; } /** diff --git a/backend/src/modules/dashboard/dto/get-pending.dto.ts b/backend/src/modules/dashboard/dto/get-pending.dto.ts index f634c61..dac7c8a 100644 --- a/backend/src/modules/dashboard/dto/get-pending.dto.ts +++ b/backend/src/modules/dashboard/dto/get-pending.dto.ts @@ -3,7 +3,7 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsInt, IsOptional, Max, Min } from 'class-validator'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; /** * DTO สำหรับ Query params ของ GET /dashboard/pending @@ -23,6 +23,11 @@ export class GetPendingDto { @Min(1) @Max(50) limit?: number = 10; + + @ApiPropertyOptional({ description: 'ID ของโครงการ (UUID)' }) + @IsOptional() + @IsString() + projectId?: string; } /** diff --git a/backend/src/modules/dashboard/dto/get-stats.dto.ts b/backend/src/modules/dashboard/dto/get-stats.dto.ts new file mode 100644 index 0000000..0260274 --- /dev/null +++ b/backend/src/modules/dashboard/dto/get-stats.dto.ts @@ -0,0 +1,16 @@ +// File: src/modules/dashboard/dto/get-stats.dto.ts +// Change Log: +// - Created DTO for Dashboard stats query parameters + +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +/** + * DTO สำหรับ Query params ของ GET /dashboard/stats + */ +export class GetStatsDto { + @ApiPropertyOptional({ description: 'ID ของโครงการ (UUID)' }) + @IsOptional() + @IsString() + projectId?: string; +} diff --git a/backend/src/modules/dashboard/dto/index.ts b/backend/src/modules/dashboard/dto/index.ts index b0fc190..5f2119b 100644 --- a/backend/src/modules/dashboard/dto/index.ts +++ b/backend/src/modules/dashboard/dto/index.ts @@ -4,3 +4,4 @@ export * from './dashboard-stats.dto'; export * from './get-activity.dto'; export * from './get-pending.dto'; +export * from './get-stats.dto'; diff --git a/frontend/app/(dashboard)/dashboard/page.tsx b/frontend/app/(dashboard)/dashboard/page.tsx index 92d60bb..81aa200 100644 --- a/frontend/app/(dashboard)/dashboard/page.tsx +++ b/frontend/app/(dashboard)/dashboard/page.tsx @@ -5,11 +5,14 @@ import { RecentActivity } from '@/components/dashboard/recent-activity'; import { PendingTasks } from '@/components/dashboard/pending-tasks'; import { QuickActions } from '@/components/dashboard/quick-actions'; import { useDashboardStats, useRecentActivity, usePendingTasks } from '@/hooks/use-dashboard'; +import { useProjectStore } from '@/lib/stores/project-store'; export default function DashboardPage() { - const { data: stats, isLoading: statsLoading } = useDashboardStats(); - const { data: activities, isLoading: activityLoading } = useRecentActivity(); - const { data: tasks, isLoading: tasksLoading } = usePendingTasks(); + const selectedProjectId = useProjectStore((state) => state.selectedProjectId); + + const { data: stats, isLoading: statsLoading } = useDashboardStats(selectedProjectId); + const { data: activities, isLoading: activityLoading } = useRecentActivity(selectedProjectId); + const { data: tasks, isLoading: tasksLoading } = usePendingTasks(selectedProjectId); return (
diff --git a/frontend/components/correspondences/circulation-status-card.tsx b/frontend/components/correspondences/circulation-status-card.tsx index cc27c22..4621383 100644 --- a/frontend/components/correspondences/circulation-status-card.tsx +++ b/frontend/components/correspondences/circulation-status-card.tsx @@ -10,7 +10,7 @@ import { format } from 'date-fns'; import Link from 'next/link'; interface CirculationStatusCardProps { - correspondenceUuid: string; + correspondencePublicId: string; } const ROUTING_STATUS_META: Record = { @@ -86,8 +86,8 @@ function CirculationItem({ circ }: { circ: Circulation }) { ); } -export function CirculationStatusCard({ correspondenceUuid }: CirculationStatusCardProps) { - const { data, isLoading } = useCirculationsByCorrespondence(correspondenceUuid); +export function CirculationStatusCard({ correspondencePublicId }: CirculationStatusCardProps) { + const { data, isLoading } = useCirculationsByCorrespondence(correspondencePublicId); const circulations: Circulation[] = Array.isArray(data) ? data @@ -122,7 +122,7 @@ export function CirculationStatusCard({ correspondenceUuid }: CirculationStatusC )) )} - +
)} - {/* Preview Section */} - {preview && ( + {/* Preview Section - Only for New Documents */} + {preview && !uuid && (

- {initialData?.correspondenceNumber ? 'New Document Number (Preview)' : 'Document Number Preview'} - {preview.number !== initialData?.correspondenceNumber && initialData?.correspondenceNumber && ( - - Will Update - - )} + Document Number Preview

{preview.number} @@ -419,11 +417,6 @@ export function CorrespondenceForm({ )}
- {preview.number !== initialData?.correspondenceNumber && initialData?.correspondenceNumber && ( -

- * The document number will be regenerated because critical fields were changed. -

- )}
)} diff --git a/frontend/components/layout/header.tsx b/frontend/components/layout/header.tsx index d5802a1..71d1143 100644 --- a/frontend/components/layout/header.tsx +++ b/frontend/components/layout/header.tsx @@ -5,6 +5,7 @@ import { GlobalSearch } from './global-search'; import { NotificationsDropdown } from './notifications-dropdown'; import { MobileSidebar } from './sidebar'; import { ThemeToggle } from './theme-toggle'; +import { ProjectSwitcher } from './project-switcher'; export function Header() { return ( @@ -18,6 +19,7 @@ export function Header() {
+ diff --git a/frontend/components/layout/project-switcher.tsx b/frontend/components/layout/project-switcher.tsx new file mode 100644 index 0000000..870edc0 --- /dev/null +++ b/frontend/components/layout/project-switcher.tsx @@ -0,0 +1,68 @@ +// File: components/layout/project-switcher.tsx +'use client'; + +import * as React from 'react'; +import { useProjects } from '@/hooks/use-projects'; +import { useProjectStore } from '@/lib/stores/project-store'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Building2 } from 'lucide-react'; +import { Skeleton } from '@/components/ui/skeleton'; + +export function ProjectSwitcher() { + const { data: projects, isLoading } = useProjects({ isActive: true }); + const { selectedProjectId, setSelectedProjectId } = useProjectStore(); + + React.useEffect(() => { + // Auto-select if there's only one project + if (projects && projects.length === 1 && selectedProjectId !== projects[0].publicId) { + setSelectedProjectId(projects[0].publicId); + } + }, [projects, selectedProjectId, setSelectedProjectId]); + + if (isLoading) { + return ; + } + + // If user has no projects, don't show the switcher + if (!projects || projects.length === 0) { + return null; + } + + // If user has exactly one project, show it as text (no dropdown needed) + if (projects.length === 1) { + return ( +
+ + {projects[0].projectName} +
+ ); + } + + return ( + + ); +} diff --git a/frontend/hooks/__tests__/use-circulation.test.ts b/frontend/hooks/__tests__/use-circulation.test.ts index 281c4f6..43a5d60 100644 --- a/frontend/hooks/__tests__/use-circulation.test.ts +++ b/frontend/hooks/__tests__/use-circulation.test.ts @@ -30,7 +30,7 @@ describe('use-circulation hooks', () => { }); describe('useCirculationsByCorrespondence', () => { - it('should fetch circulations for a correspondence UUID', async () => { + it('should fetch circulations for a correspondence publicId', async () => { const mockData = { data: [ { @@ -60,7 +60,7 @@ describe('use-circulation hooks', () => { expect(result.current.data).toEqual(mockData); }); - it('should not fetch when correspondenceUuid is empty', () => { + it('should not fetch when correspondencePublicId is empty', () => { const { wrapper } = createTestQueryClient(); const { result } = renderHook( () => useCirculationsByCorrespondence(''), diff --git a/frontend/hooks/use-circulation.ts b/frontend/hooks/use-circulation.ts index 7445e8d..e95f5d2 100644 --- a/frontend/hooks/use-circulation.ts +++ b/frontend/hooks/use-circulation.ts @@ -6,10 +6,10 @@ export const circulationKeys = { byCorrespondence: (uuid: string) => ['circulations', 'byCorrespondence', uuid] as const, }; -export function useCirculationsByCorrespondence(correspondenceUuid: string) { +export function useCirculationsByCorrespondence(correspondencePublicId: string) { return useQuery({ - queryKey: circulationKeys.byCorrespondence(correspondenceUuid), - queryFn: () => circulationService.getByCorrespondenceUuid(correspondenceUuid), - enabled: !!correspondenceUuid, + queryKey: circulationKeys.byCorrespondence(correspondencePublicId), + queryFn: () => circulationService.getByCorrespondenceUuid(correspondencePublicId), + enabled: !!correspondencePublicId, }); } diff --git a/frontend/hooks/use-dashboard.ts b/frontend/hooks/use-dashboard.ts index db28edb..9599d74 100644 --- a/frontend/hooks/use-dashboard.ts +++ b/frontend/hooks/use-dashboard.ts @@ -3,31 +3,31 @@ import { dashboardService } from '@/lib/services/dashboard.service'; export const dashboardKeys = { all: ['dashboard'] as const, - stats: () => [...dashboardKeys.all, 'stats'] as const, - activity: () => [...dashboardKeys.all, 'activity'] as const, - pending: () => [...dashboardKeys.all, 'pending'] as const, + stats: (projectId?: string | null) => [...dashboardKeys.all, 'stats', projectId] as const, + activity: (projectId?: string | null) => [...dashboardKeys.all, 'activity', projectId] as const, + pending: (projectId?: string | null) => [...dashboardKeys.all, 'pending', projectId] as const, }; -export function useDashboardStats() { +export function useDashboardStats(projectId?: string | null) { return useQuery({ - queryKey: dashboardKeys.stats(), - queryFn: dashboardService.getStats, + queryKey: dashboardKeys.stats(projectId), + queryFn: () => dashboardService.getStats(projectId), staleTime: 5 * 60 * 1000, // 5 minutes }); } -export function useRecentActivity() { +export function useRecentActivity(projectId?: string | null) { return useQuery({ - queryKey: dashboardKeys.activity(), - queryFn: dashboardService.getRecentActivity, + queryKey: dashboardKeys.activity(projectId), + queryFn: () => dashboardService.getRecentActivity(projectId), staleTime: 1 * 60 * 1000, }); } -export function usePendingTasks() { +export function usePendingTasks(projectId?: string | null) { return useQuery({ - queryKey: dashboardKeys.pending(), - queryFn: dashboardService.getPendingTasks, + queryKey: dashboardKeys.pending(projectId), + queryFn: () => dashboardService.getPendingTasks(projectId), staleTime: 2 * 60 * 1000, }); } diff --git a/frontend/lib/api/dashboard.ts b/frontend/lib/api/dashboard.ts index c1ee06a..7969544 100644 --- a/frontend/lib/api/dashboard.ts +++ b/frontend/lib/api/dashboard.ts @@ -1,3 +1,8 @@ +// File: lib/api/dashboard.ts +// Change Log: +// - Fixed TypeScript type error in mock data (id: number -> id: string) +// - Updated PendingTask mock data to match interface + import { DashboardStats, ActivityLog, PendingTask } from '@/types/dashboard'; export const dashboardApi = { @@ -17,7 +22,7 @@ export const dashboardApi = { await new Promise((resolve) => setTimeout(resolve, 600)); return [ { - id: 1, + id: 'activity-1', user: { name: 'John Doe', initials: 'JD' }, action: 'Created RFA', description: 'RFA-001: Concrete Pouring Request', @@ -25,7 +30,7 @@ export const dashboardApi = { targetUrl: '/rfas/1', }, { - id: 2, + id: 'activity-2', user: { name: 'Jane Smith', initials: 'JS' }, action: 'Approved Correspondence', description: 'COR-005: Site Safety Report', @@ -33,7 +38,7 @@ export const dashboardApi = { targetUrl: '/correspondences/5', }, { - id: 3, + id: 'activity-3', user: { name: 'Mike Johnson', initials: 'MJ' }, action: 'Uploaded Drawing', description: 'A-101: Ground Floor Plan Rev B', @@ -47,7 +52,14 @@ export const dashboardApi = { await new Promise((resolve) => setTimeout(resolve, 400)); return [ { - id: 1, + publicId: 'task-1', + workflowCode: 'RFA_WORKFLOW', + currentState: 'REVIEWING', + entityType: 'RFA', + entityId: 'rfa-001-uuid', + documentNumber: 'RFA-002', + subject: 'Review RFA-002', + assignedAt: new Date().toISOString(), title: 'Review RFA-002', description: 'Approval required for steel reinforcement', daysOverdue: 2, @@ -55,7 +67,14 @@ export const dashboardApi = { priority: 'HIGH', }, { - id: 2, + publicId: 'task-2', + workflowCode: 'CORR_WORKFLOW', + currentState: 'PENDING_APPROVAL', + entityType: 'Correspondence', + entityId: 'corr-010-uuid', + documentNumber: 'COR-101', + subject: 'Approve Monthly Report', + assignedAt: new Date().toISOString(), title: 'Approve Monthly Report', description: 'January 2025 Progress Report', daysOverdue: 0, diff --git a/frontend/lib/services/circulation.service.ts b/frontend/lib/services/circulation.service.ts index cca8019..0f5bb36 100644 --- a/frontend/lib/services/circulation.service.ts +++ b/frontend/lib/services/circulation.service.ts @@ -45,11 +45,11 @@ export const circulationService = { }, /** - * ดึงรายการใบเวียนของ correspondence (by correspondence UUID) + * ดึงรายการใบเวียนของ correspondence (by correspondence publicId) */ - getByCorrespondenceUuid: async (correspondenceUuid: string) => { + getByCorrespondenceUuid: async (correspondencePublicId: string) => { const response = await apiClient.get('/circulations', { - params: { correspondenceUuid, limit: 50 }, + params: { correspondencePublicId, limit: 50 }, }); return response.data; }, diff --git a/frontend/lib/services/dashboard.service.ts b/frontend/lib/services/dashboard.service.ts index 33dac4f..81f9d5c 100644 --- a/frontend/lib/services/dashboard.service.ts +++ b/frontend/lib/services/dashboard.service.ts @@ -27,14 +27,16 @@ interface RawPendingTask { } export const dashboardService = { - getStats: async (): Promise => { - const response = await apiClient.get('/dashboard/stats'); + getStats: async (projectId?: string | null): Promise => { + const params = projectId ? { projectId } : undefined; + const response = await apiClient.get('/dashboard/stats', { params }); return response.data; }, - getRecentActivity: async (): Promise => { + getRecentActivity: async (projectId?: string | null): Promise => { try { - const response = await apiClient.get('/dashboard/activity'); + const params = projectId ? { projectId } : undefined; + const response = await apiClient.get('/dashboard/activity', { params }); if (Array.isArray(response.data)) { return (response.data as RawActivityLog[]).map((log) => { const firstName = log.user?.firstName || ''; @@ -59,9 +61,10 @@ export const dashboardService = { } }, - getPendingTasks: async (): Promise => { + getPendingTasks: async (projectId?: string | null): Promise => { try { - const response = await apiClient.get('/dashboard/pending'); + const params = projectId ? { projectId } : undefined; + const response = await apiClient.get('/dashboard/pending', { params }); const rawTasks = (response.data?.data || (Array.isArray(response.data) ? response.data : [])) as RawPendingTask[]; return rawTasks.map((task) => { diff --git a/frontend/lib/stores/project-store.ts b/frontend/lib/stores/project-store.ts new file mode 100644 index 0000000..83422c7 --- /dev/null +++ b/frontend/lib/stores/project-store.ts @@ -0,0 +1,23 @@ +// File: lib/stores/project-store.ts +// Change Log: +// - Created store for managing currently selected project context + +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface ProjectState { + selectedProjectId: string | null; + setSelectedProjectId: (projectId: string | null) => void; +} + +export const useProjectStore = create()( + persist( + (set) => ({ + selectedProjectId: null, + setSelectedProjectId: (projectId) => set({ selectedProjectId: projectId }), + }), + { + name: 'project-storage', + } + ) +); diff --git a/frontend/types/dto/circulation/search-circulation.dto.ts b/frontend/types/dto/circulation/search-circulation.dto.ts index 4a9fbf7..3491193 100644 --- a/frontend/types/dto/circulation/search-circulation.dto.ts +++ b/frontend/types/dto/circulation/search-circulation.dto.ts @@ -7,6 +7,9 @@ export interface SearchCirculationDto { /** OPEN, COMPLETED, CANCELLED */ status?: string; + /** กรองตาม correspondence publicId (ADR-019) */ + correspondencePublicId?: string; + page?: number; limit?: number;