690402:2240 fix dashboard
This commit is contained in:
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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(''),
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user