690402:2240 fix dashboard
CI / CD Pipeline / build (push) Failing after 4m18s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
2026-04-02 22:40:11 +07:00
parent c188219e28
commit d4f0d02c62
22 changed files with 396 additions and 232 deletions
@@ -776,127 +776,6 @@ export class CorrespondenceService {
await recipientRepo.save(newRecipients); 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); const updated = await this.findOne(id);
// Re-index updated document in Elasticsearch (fire-and-forget) // Re-index updated document in Elasticsearch (fire-and-forget)
@@ -13,7 +13,7 @@ import { User } from '../user/entities/user.entity';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
// DTOs // DTOs
import { GetActivityDto, GetPendingDto } from './dto'; import { GetActivityDto, GetPendingDto, GetStatsDto } from './dto';
@ApiTags('Dashboard') @ApiTags('Dashboard')
@ApiBearerAuth() @ApiBearerAuth()
@@ -27,8 +27,8 @@ export class DashboardController {
*/ */
@Get('stats') @Get('stats')
@ApiOperation({ summary: 'Get dashboard statistics' }) @ApiOperation({ summary: 'Get dashboard statistics' })
async getStats(@CurrentUser() user: User) { async getStats(@CurrentUser() user: User, @Query() query: GetStatsDto) {
return this.dashboardService.getStats(user.user_id); return this.dashboardService.getStats(user.user_id, query);
} }
/** /**
@@ -8,6 +8,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Correspondence } from '../correspondence/entities/correspondence.entity'; import { Correspondence } from '../correspondence/entities/correspondence.entity';
import { AuditLog } from '../../common/entities/audit-log.entity'; import { AuditLog } from '../../common/entities/audit-log.entity';
import { WorkflowInstance } from '../workflow-engine/entities/workflow-instance.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 // Controller & Service
import { DashboardController } from './dashboard.controller'; import { DashboardController } from './dashboard.controller';
@@ -15,7 +17,13 @@ import { DashboardService } from './dashboard.service';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Correspondence, AuditLog, WorkflowInstance]), TypeOrmModule.forFeature([
Correspondence,
AuditLog,
WorkflowInstance,
Project,
UserAssignment,
]),
], ],
controllers: [DashboardController], controllers: [DashboardController],
providers: [DashboardService], providers: [DashboardService],
@@ -20,7 +20,11 @@ import {
ActivityItemDto, ActivityItemDto,
GetPendingDto, GetPendingDto,
PendingTaskItemDto, PendingTaskItemDto,
GetStatsDto,
} from './dto'; } from './dto';
import { Project } from '../project/entities/project.entity';
import { UserAssignment } from '../user/entities/user-assignment.entity';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
@Injectable() @Injectable()
export class DashboardService { export class DashboardService {
@@ -33,18 +37,82 @@ export class DashboardService {
private auditLogRepo: Repository<AuditLog>, private auditLogRepo: Repository<AuditLog>,
@InjectRepository(WorkflowInstance) @InjectRepository(WorkflowInstance)
private workflowInstanceRepo: Repository<WorkflowInstance>, private workflowInstanceRepo: Repository<WorkflowInstance>,
@InjectRepository(Project)
private projectRepo: Repository<Project>,
@InjectRepository(UserAssignment)
private userAssignmentRepo: Repository<UserAssignment>,
private dataSource: DataSource private dataSource: DataSource
) {} ) {}
/**
* ตรวจสอบว่า User มีสิทธิเข้าถึงโครงการหรือไม่
*/
private async checkProjectAccess(
userId: number,
projectId: string
): Promise<number> {
// 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 * ดึงสถิติ Dashboard
* @param userId - ID ของ User ที่ Login * @param userId - ID ของ User ที่ Login
*/ */
async getStats(userId: number): Promise<DashboardStatsDto> { async getStats(userId: number, dto: GetStatsDto): Promise<DashboardStatsDto> {
this.logger.debug(`Getting dashboard stats for user ${userId}`); const { projectId } = dto;
this.logger.debug(
`Getting dashboard stats for user ${userId}, project: ${projectId || 'Global'}`
);
// นับจำนวนเอกสารทั้งหมด let internalProjectId: number | undefined;
const totalDocuments = await this.correspondenceRepo.count(); 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(); const startOfMonth = new Date();
@@ -57,28 +125,43 @@ export class DashboardService {
.getCount(); .getCount();
// นับงานที่รอ Approve (Workflow Active) // นับงานที่รอ Approve (Workflow Active)
const pendingApprovals = await this.workflowInstanceRepo.count({ const pendingApprovalsQuery = this.workflowInstanceRepo
where: { status: WorkflowStatus.ACTIVE }, .createQueryBuilder('w')
}); .where('w.status = :status', { status: WorkflowStatus.ACTIVE });
// นับ RFA ทั้งหมด (correspondence_type_id = RFA type) if (internalProjectId) {
// ใช้ Raw Query เพราะต้อง JOIN กับ correspondence_types // WorkflowInstance JOIN กับ Correspondence เพื่อเช็ค Project
const rfaCountResult = await this.dataSource.query< pendingApprovalsQuery
{ count: string | number }[] .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 SELECT COUNT(*) as count
FROM correspondences c FROM correspondences c
JOIN correspondence_types ct ON c.correspondence_type_id = ct.id JOIN correspondence_types ct ON c.correspondence_type_id = ct.id
WHERE ct.type_code = 'RFA' 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'); const totalRfas = Number(rfaCountResult[0]?.count || '0');
// นับ Circulation ทั้งหมด // นับ 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< const circulationsCountResult = await this.dataSource.query<
{ count: string | number }[] { count: string | number }[]
>(` >(circSql, internalProjectId ? [internalProjectId] : []);
SELECT COUNT(*) as count FROM circulations
`);
const totalCirculations = Number(circulationsCountResult[0]?.count || '0'); const totalCirculations = Number(circulationsCountResult[0]?.count || '0');
// นับเอกสารที่อนุมัติแล้ว (APPROVED) // นับเอกสารที่อนุมัติแล้ว (APPROVED)
@@ -86,21 +169,25 @@ export class DashboardService {
// เบื้องต้นนับจาก CorrespondenceStatus ที่เป็น 'APPROVED' หรือ 'CODE 1' // เบื้องต้นนับจาก CorrespondenceStatus ที่เป็น 'APPROVED' หรือ 'CODE 1'
// หรือนับจาก Workflow ที่ Completed และ Action เป็น APPROVE // หรือนับจาก Workflow ที่ Completed และ Action เป็น APPROVE
// เพื่อความง่ายในเบื้องต้น นับจาก CorrespondenceRevision ที่มี status 'APPROVED' (ถ้ามี) // เพื่อความง่ายในเบื้องต้น นับจาก CorrespondenceRevision ที่มี status 'APPROVED' (ถ้ามี)
// หรือนับจาก RFA ที่มี Approve Code
// สำหรับ LCBP3 นับ RFA ที่ approveCodeId ไม่ใช่ null (หรือ check status code = APR/FAP) // สำหรับ LCBP3 นับ RFA ที่ approveCodeId ไม่ใช่ null (หรือ check status code = APR/FAP)
// และ Correspondence ทั่วไปที่มีสถานะ Completed // และ Correspondence ทั่วไปที่มีสถานะ Completed
// เพื่อความรวดเร็ว ใช้วิธีนับ Revision ที่ isCurrent = 1 และ statusCode = 'APR' (Approved) // เพื่อความรวดเร็ว ใช้วิธีนับ Revision ที่ isCurrent = 1 และ statusCode = 'APR' (Approved)
// Check status code 'APR' exists // นับเอกสารที่อนุมัติแล้ว
const aprStatusCount = await this.dataSource.query< let appSql = `
{ count: string | number }[]
>(`
SELECT COUNT(r.id) as count SELECT COUNT(r.id) as count
FROM correspondence_revisions r FROM correspondence_revisions r
JOIN correspondence_status s ON r.correspondence_status_id = s.id 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') 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'); const approved = Number(aprStatusCount[0]?.count || '0');
return { return {
@@ -122,11 +209,18 @@ export class DashboardService {
userId: number, userId: number,
dto: GetActivityDto dto: GetActivityDto
): Promise<ActivityItemDto[]> { ): Promise<ActivityItemDto[]> {
const { limit = 10 } = dto; const { limit = 10, projectId } = dto;
this.logger.debug(`Getting recent activity for user ${userId}`); 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 // ดึง Recent Audit Logs
const logs = await this.auditLogRepo const query = this.auditLogRepo
.createQueryBuilder('log') .createQueryBuilder('log')
.leftJoin('log.user', 'user') .leftJoin('log.user', 'user')
.select([ .select([
@@ -139,7 +233,22 @@ export class DashboardService {
'user.username', 'user.username',
'user.firstName', 'user.firstName',
'user.lastName', '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') .orderBy('log.createdAt', 'DESC')
.limit(limit) .limit(limit)
.getMany(); .getMany();
@@ -174,46 +283,73 @@ export class DashboardService {
data: PendingTaskItemDto[]; data: PendingTaskItemDto[];
meta: { total: number; page: number; limit: number }; 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; 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 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([ const [tasks, countResult] = await Promise.all([
this.dataSource.query<PendingTaskItemDto[]>( this.dataSource.query<PendingTaskItemDto[]>(
` `
SELECT SELECT
instance_id as instanceId, v_user_tasks.instance_id as instanceId,
workflow_code as workflowCode, v_user_tasks.workflow_code as workflowCode,
current_state as currentState, v_user_tasks.current_state as currentState,
entity_type as entityType, v_user_tasks.entity_type as entityType,
entity_id as entityId, v_user_tasks.entity_id as entityId,
document_number as documentNumber, v_user_tasks.document_number as documentNumber,
subject, v_user_tasks.subject,
assigned_at as assignedAt v_user_tasks.assigned_at as assignedAt
FROM v_user_tasks FROM v_user_tasks
${joinClause}
WHERE WHERE
JSON_SEARCH(assignee_ids_json, 'one', ?) IS NOT NULL (JSON_SEARCH(v_user_tasks.assignee_ids_json, 'one', ?) IS NOT NULL OR v_user_tasks.owner_id = ?)
OR owner_id = ? ${projectFilter}
ORDER BY assigned_at DESC ORDER BY v_user_tasks.assigned_at DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`, `,
[userIdNum, userIdNum, limit, offset] internalProjectId
? [
userIdNum,
userIdNum,
internalProjectId,
internalProjectId,
limit,
offset,
]
: [userIdNum, userIdNum, limit, offset]
), ),
this.dataSource.query<{ total: string | number }[]>( this.dataSource.query<{ total: string | number }[]>(
` `
SELECT COUNT(*) as total SELECT COUNT(v_user_tasks.instance_id) as total
FROM v_user_tasks FROM v_user_tasks
${joinClause}
WHERE WHERE
JSON_SEARCH(assignee_ids_json, 'one', ?) IS NOT NULL (JSON_SEARCH(v_user_tasks.assignee_ids_json, 'one', ?) IS NOT NULL OR v_user_tasks.owner_id = ?)
OR owner_id = ? ${projectFilter}
`, `,
[userIdNum, userIdNum] internalProjectId
? [userIdNum, userIdNum, internalProjectId, internalProjectId]
: [userIdNum, userIdNum]
), ),
]); ]);
@@ -3,7 +3,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; 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 * DTO สำหรับ Query params ของ GET /dashboard/activity
@@ -16,6 +16,11 @@ export class GetActivityDto {
@Min(1) @Min(1)
@Max(50) @Max(50)
limit?: number = 10; limit?: number = 10;
@ApiPropertyOptional({ description: 'ID ของโครงการ (UUID)' })
@IsOptional()
@IsString()
projectId?: string;
} }
/** /**
@@ -3,7 +3,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; 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 * DTO สำหรับ Query params ของ GET /dashboard/pending
@@ -23,6 +23,11 @@ export class GetPendingDto {
@Min(1) @Min(1)
@Max(50) @Max(50)
limit?: number = 10; limit?: number = 10;
@ApiPropertyOptional({ description: 'ID ของโครงการ (UUID)' })
@IsOptional()
@IsString()
projectId?: string;
} }
/** /**
@@ -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;
}
@@ -4,3 +4,4 @@
export * from './dashboard-stats.dto'; export * from './dashboard-stats.dto';
export * from './get-activity.dto'; export * from './get-activity.dto';
export * from './get-pending.dto'; export * from './get-pending.dto';
export * from './get-stats.dto';
+6 -3
View File
@@ -5,11 +5,14 @@ import { RecentActivity } from '@/components/dashboard/recent-activity';
import { PendingTasks } from '@/components/dashboard/pending-tasks'; import { PendingTasks } from '@/components/dashboard/pending-tasks';
import { QuickActions } from '@/components/dashboard/quick-actions'; import { QuickActions } from '@/components/dashboard/quick-actions';
import { useDashboardStats, useRecentActivity, usePendingTasks } from '@/hooks/use-dashboard'; import { useDashboardStats, useRecentActivity, usePendingTasks } from '@/hooks/use-dashboard';
import { useProjectStore } from '@/lib/stores/project-store';
export default function DashboardPage() { export default function DashboardPage() {
const { data: stats, isLoading: statsLoading } = useDashboardStats(); const selectedProjectId = useProjectStore((state) => state.selectedProjectId);
const { data: activities, isLoading: activityLoading } = useRecentActivity();
const { data: tasks, isLoading: tasksLoading } = usePendingTasks(); const { data: stats, isLoading: statsLoading } = useDashboardStats(selectedProjectId);
const { data: activities, isLoading: activityLoading } = useRecentActivity(selectedProjectId);
const { data: tasks, isLoading: tasksLoading } = usePendingTasks(selectedProjectId);
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -10,7 +10,7 @@ import { format } from 'date-fns';
import Link from 'next/link'; import Link from 'next/link';
interface CirculationStatusCardProps { interface CirculationStatusCardProps {
correspondenceUuid: string; correspondencePublicId: string;
} }
const ROUTING_STATUS_META: Record<string, { icon: React.ElementType; color: string; label: string }> = { const ROUTING_STATUS_META: Record<string, { icon: React.ElementType; color: string; label: string }> = {
@@ -86,8 +86,8 @@ function CirculationItem({ circ }: { circ: Circulation }) {
); );
} }
export function CirculationStatusCard({ correspondenceUuid }: CirculationStatusCardProps) { export function CirculationStatusCard({ correspondencePublicId }: CirculationStatusCardProps) {
const { data, isLoading } = useCirculationsByCorrespondence(correspondenceUuid); const { data, isLoading } = useCirculationsByCorrespondence(correspondencePublicId);
const circulations: Circulation[] = Array.isArray(data) const circulations: Circulation[] = Array.isArray(data)
? data ? data
@@ -122,7 +122,7 @@ export function CirculationStatusCard({ correspondenceUuid }: CirculationStatusC
)) ))
)} )}
<Link href={`/circulation/new?correspondenceUuid=${correspondenceUuid}`}> <Link href={`/circulation/new?correspondencePublicId=${correspondencePublicId}`}>
<Button variant="outline" size="sm" className="w-full h-8 text-xs mt-1"> <Button variant="outline" size="sm" className="w-full h-8 text-xs mt-1">
<GitBranch className="h-3 w-3 mr-1.5" /> <GitBranch className="h-3 w-3 mr-1.5" />
New Circulation New Circulation
@@ -416,7 +416,7 @@ export function CorrespondenceDetail({ data, selectedRevisionId }: Correspondenc
</Card> </Card>
{/* Circulations */} {/* Circulations */}
<CirculationStatusCard correspondenceUuid={data.publicId} /> <CirculationStatusCard correspondencePublicId={data.publicId} />
{/* Tags */} {/* Tags */}
<TagManager <TagManager
+11 -18
View File
@@ -356,6 +356,12 @@ export function CorrespondenceForm({
} }
const fetchPreview = async () => { const fetchPreview = async () => {
// Don't preview or change number in edit mode
if (uuid) {
setPreview(null);
return;
}
try { try {
const res = await numberingApi.previewNumber({ const res = await numberingApi.previewNumber({
projectId, projectId,
@@ -387,29 +393,21 @@ export function CorrespondenceForm({
readOnly readOnly
className="bg-muted font-mono font-bold text-lg w-full" className="bg-muted font-mono font-bold text-lg w-full"
/> />
{preview && preview.number !== initialData.correspondenceNumber && (
<span className="text-xs text-amber-600 font-semibold whitespace-nowrap px-2">Start Change Detected</span>
)}
</div> </div>
</div> </div>
)} )}
{/* Preview Section */} {/* Preview Section - Only for New Documents */}
{preview && ( {preview && !uuid && (
<div <div
className={`p-4 rounded-md border ${preview.number !== initialData?.correspondenceNumber ? 'bg-amber-50 border-amber-200' : 'bg-muted border-border'}`} className="p-4 rounded-md border bg-muted border-border"
> >
<p className="text-sm font-semibold mb-1 flex items-center gap-2"> <p className="text-sm font-semibold mb-1 flex items-center gap-2">
{initialData?.correspondenceNumber ? 'New Document Number (Preview)' : 'Document Number Preview'} Document Number Preview
{preview.number !== initialData?.correspondenceNumber && initialData?.correspondenceNumber && (
<span className="text-[10px] bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full border border-amber-200">
Will Update
</span>
)}
</p> </p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <span
className={`text-xl font-bold font-mono tracking-wide ${preview.number !== initialData?.correspondenceNumber ? 'text-amber-700' : 'text-primary'}`} className="text-xl font-bold font-mono tracking-wide text-primary"
> >
{preview.number} {preview.number}
</span> </span>
@@ -419,11 +417,6 @@ export function CorrespondenceForm({
</span> </span>
)} )}
</div> </div>
{preview.number !== initialData?.correspondenceNumber && initialData?.correspondenceNumber && (
<p className="text-xs text-muted-foreground mt-2">
* The document number will be regenerated because critical fields were changed.
</p>
)}
</div> </div>
)} )}
+2
View File
@@ -5,6 +5,7 @@ import { GlobalSearch } from './global-search';
import { NotificationsDropdown } from './notifications-dropdown'; import { NotificationsDropdown } from './notifications-dropdown';
import { MobileSidebar } from './sidebar'; import { MobileSidebar } from './sidebar';
import { ThemeToggle } from './theme-toggle'; import { ThemeToggle } from './theme-toggle';
import { ProjectSwitcher } from './project-switcher';
export function Header() { export function Header() {
return ( return (
@@ -18,6 +19,7 @@ export function Header() {
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<ProjectSwitcher />
<ThemeToggle /> <ThemeToggle />
<NotificationsDropdown /> <NotificationsDropdown />
<UserMenu /> <UserMenu />
@@ -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 <Skeleton className="h-9 w-[200px] lg:w-[250px]" />;
}
// 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 (
<div className="flex h-9 items-center px-3 py-2 text-sm border rounded-md bg-muted/50 w-[200px] lg:w-[250px]">
<Building2 className="h-4 w-4 mr-2 opacity-50 flex-shrink-0" />
<span className="truncate font-medium">{projects[0].projectName}</span>
</div>
);
}
return (
<Select
value={selectedProjectId || 'global'}
onValueChange={(value) => setSelectedProjectId(value === 'global' ? null : value)}
>
<SelectTrigger className="w-[200px] lg:w-[250px] h-9">
<div className="flex items-center gap-2 truncate">
<Building2 className="h-4 w-4 opacity-50 flex-shrink-0" />
<SelectValue placeholder="Select Project..." className="truncate" />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="global">All Projects (Global)</SelectItem>
{projects.map((project) => (
<SelectItem key={project.publicId} value={project.publicId}>
{project.projectName}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -30,7 +30,7 @@ describe('use-circulation hooks', () => {
}); });
describe('useCirculationsByCorrespondence', () => { describe('useCirculationsByCorrespondence', () => {
it('should fetch circulations for a correspondence UUID', async () => { it('should fetch circulations for a correspondence publicId', async () => {
const mockData = { const mockData = {
data: [ data: [
{ {
@@ -60,7 +60,7 @@ describe('use-circulation hooks', () => {
expect(result.current.data).toEqual(mockData); 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 { wrapper } = createTestQueryClient();
const { result } = renderHook( const { result } = renderHook(
() => useCirculationsByCorrespondence(''), () => useCirculationsByCorrespondence(''),
+4 -4
View File
@@ -6,10 +6,10 @@ export const circulationKeys = {
byCorrespondence: (uuid: string) => ['circulations', 'byCorrespondence', uuid] as const, byCorrespondence: (uuid: string) => ['circulations', 'byCorrespondence', uuid] as const,
}; };
export function useCirculationsByCorrespondence(correspondenceUuid: string) { export function useCirculationsByCorrespondence(correspondencePublicId: string) {
return useQuery({ return useQuery({
queryKey: circulationKeys.byCorrespondence(correspondenceUuid), queryKey: circulationKeys.byCorrespondence(correspondencePublicId),
queryFn: () => circulationService.getByCorrespondenceUuid(correspondenceUuid), queryFn: () => circulationService.getByCorrespondenceUuid(correspondencePublicId),
enabled: !!correspondenceUuid, enabled: !!correspondencePublicId,
}); });
} }
+12 -12
View File
@@ -3,31 +3,31 @@ import { dashboardService } from '@/lib/services/dashboard.service';
export const dashboardKeys = { export const dashboardKeys = {
all: ['dashboard'] as const, all: ['dashboard'] as const,
stats: () => [...dashboardKeys.all, 'stats'] as const, stats: (projectId?: string | null) => [...dashboardKeys.all, 'stats', projectId] as const,
activity: () => [...dashboardKeys.all, 'activity'] as const, activity: (projectId?: string | null) => [...dashboardKeys.all, 'activity', projectId] as const,
pending: () => [...dashboardKeys.all, 'pending'] as const, pending: (projectId?: string | null) => [...dashboardKeys.all, 'pending', projectId] as const,
}; };
export function useDashboardStats() { export function useDashboardStats(projectId?: string | null) {
return useQuery({ return useQuery({
queryKey: dashboardKeys.stats(), queryKey: dashboardKeys.stats(projectId),
queryFn: dashboardService.getStats, queryFn: () => dashboardService.getStats(projectId),
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
}); });
} }
export function useRecentActivity() { export function useRecentActivity(projectId?: string | null) {
return useQuery({ return useQuery({
queryKey: dashboardKeys.activity(), queryKey: dashboardKeys.activity(projectId),
queryFn: dashboardService.getRecentActivity, queryFn: () => dashboardService.getRecentActivity(projectId),
staleTime: 1 * 60 * 1000, staleTime: 1 * 60 * 1000,
}); });
} }
export function usePendingTasks() { export function usePendingTasks(projectId?: string | null) {
return useQuery({ return useQuery({
queryKey: dashboardKeys.pending(), queryKey: dashboardKeys.pending(projectId),
queryFn: dashboardService.getPendingTasks, queryFn: () => dashboardService.getPendingTasks(projectId),
staleTime: 2 * 60 * 1000, staleTime: 2 * 60 * 1000,
}); });
} }
+24 -5
View File
@@ -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'; import { DashboardStats, ActivityLog, PendingTask } from '@/types/dashboard';
export const dashboardApi = { export const dashboardApi = {
@@ -17,7 +22,7 @@ export const dashboardApi = {
await new Promise((resolve) => setTimeout(resolve, 600)); await new Promise((resolve) => setTimeout(resolve, 600));
return [ return [
{ {
id: 1, id: 'activity-1',
user: { name: 'John Doe', initials: 'JD' }, user: { name: 'John Doe', initials: 'JD' },
action: 'Created RFA', action: 'Created RFA',
description: 'RFA-001: Concrete Pouring Request', description: 'RFA-001: Concrete Pouring Request',
@@ -25,7 +30,7 @@ export const dashboardApi = {
targetUrl: '/rfas/1', targetUrl: '/rfas/1',
}, },
{ {
id: 2, id: 'activity-2',
user: { name: 'Jane Smith', initials: 'JS' }, user: { name: 'Jane Smith', initials: 'JS' },
action: 'Approved Correspondence', action: 'Approved Correspondence',
description: 'COR-005: Site Safety Report', description: 'COR-005: Site Safety Report',
@@ -33,7 +38,7 @@ export const dashboardApi = {
targetUrl: '/correspondences/5', targetUrl: '/correspondences/5',
}, },
{ {
id: 3, id: 'activity-3',
user: { name: 'Mike Johnson', initials: 'MJ' }, user: { name: 'Mike Johnson', initials: 'MJ' },
action: 'Uploaded Drawing', action: 'Uploaded Drawing',
description: 'A-101: Ground Floor Plan Rev B', description: 'A-101: Ground Floor Plan Rev B',
@@ -47,7 +52,14 @@ export const dashboardApi = {
await new Promise((resolve) => setTimeout(resolve, 400)); await new Promise((resolve) => setTimeout(resolve, 400));
return [ 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', title: 'Review RFA-002',
description: 'Approval required for steel reinforcement', description: 'Approval required for steel reinforcement',
daysOverdue: 2, daysOverdue: 2,
@@ -55,7 +67,14 @@ export const dashboardApi = {
priority: 'HIGH', 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', title: 'Approve Monthly Report',
description: 'January 2025 Progress Report', description: 'January 2025 Progress Report',
daysOverdue: 0, daysOverdue: 0,
+3 -3
View File
@@ -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', { const response = await apiClient.get('/circulations', {
params: { correspondenceUuid, limit: 50 }, params: { correspondencePublicId, limit: 50 },
}); });
return response.data; return response.data;
}, },
+9 -6
View File
@@ -27,14 +27,16 @@ interface RawPendingTask {
} }
export const dashboardService = { export const dashboardService = {
getStats: async (): Promise<DashboardStats> => { getStats: async (projectId?: string | null): Promise<DashboardStats> => {
const response = await apiClient.get('/dashboard/stats'); const params = projectId ? { projectId } : undefined;
const response = await apiClient.get('/dashboard/stats', { params });
return response.data; return response.data;
}, },
getRecentActivity: async (): Promise<ActivityLog[]> => { getRecentActivity: async (projectId?: string | null): Promise<ActivityLog[]> => {
try { 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)) { if (Array.isArray(response.data)) {
return (response.data as RawActivityLog[]).map((log) => { return (response.data as RawActivityLog[]).map((log) => {
const firstName = log.user?.firstName || ''; const firstName = log.user?.firstName || '';
@@ -59,9 +61,10 @@ export const dashboardService = {
} }
}, },
getPendingTasks: async (): Promise<PendingTask[]> => { getPendingTasks: async (projectId?: string | null): Promise<PendingTask[]> => {
try { 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[]; const rawTasks = (response.data?.data || (Array.isArray(response.data) ? response.data : [])) as RawPendingTask[];
return rawTasks.map((task) => { return rawTasks.map((task) => {
+23
View File
@@ -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<ProjectState>()(
persist(
(set) => ({
selectedProjectId: null,
setSelectedProjectId: (projectId) => set({ selectedProjectId: projectId }),
}),
{
name: 'project-storage',
}
)
);
@@ -7,6 +7,9 @@ export interface SearchCirculationDto {
/** OPEN, COMPLETED, CANCELLED */ /** OPEN, COMPLETED, CANCELLED */
status?: string; status?: string;
/** กรองตาม correspondence publicId (ADR-019) */
correspondencePublicId?: string;
page?: number; page?: number;
limit?: number; limit?: number;