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);
}
// 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)
@@ -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);
}
/**
@@ -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],
@@ -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<AuditLog>,
@InjectRepository(WorkflowInstance)
private workflowInstanceRepo: Repository<WorkflowInstance>,
@InjectRepository(Project)
private projectRepo: Repository<Project>,
@InjectRepository(UserAssignment)
private userAssignmentRepo: Repository<UserAssignment>,
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
* @param userId - ID ของ User ที่ Login
*/
async getStats(userId: number): Promise<DashboardStatsDto> {
this.logger.debug(`Getting dashboard stats for user ${userId}`);
async getStats(userId: number, dto: GetStatsDto): Promise<DashboardStatsDto> {
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<ActivityItemDto[]> {
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<PendingTaskItemDto[]>(
`
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]
),
]);
@@ -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;
}
/**
@@ -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;
}
/**
@@ -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 './get-activity.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 { 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 (
<div className="space-y-8">
@@ -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<string, { icon: React.ElementType; color: string; label: string }> = {
@@ -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
))
)}
<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">
<GitBranch className="h-3 w-3 mr-1.5" />
New Circulation
@@ -416,7 +416,7 @@ export function CorrespondenceDetail({ data, selectedRevisionId }: Correspondenc
</Card>
{/* Circulations */}
<CirculationStatusCard correspondenceUuid={data.publicId} />
<CirculationStatusCard correspondencePublicId={data.publicId} />
{/* Tags */}
<TagManager
+11 -18
View File
@@ -356,6 +356,12 @@ export function CorrespondenceForm({
}
const fetchPreview = async () => {
// Don't preview or change number in edit mode
if (uuid) {
setPreview(null);
return;
}
try {
const res = await numberingApi.previewNumber({
projectId,
@@ -387,29 +393,21 @@ export function CorrespondenceForm({
readOnly
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>
)}
{/* Preview Section */}
{preview && (
{/* Preview Section - Only for New Documents */}
{preview && !uuid && (
<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">
{initialData?.correspondenceNumber ? 'New 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>
)}
Document Number Preview
</p>
<div className="flex items-center gap-3">
<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}
</span>
@@ -419,11 +417,6 @@ export function CorrespondenceForm({
</span>
)}
</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>
)}
+2
View File
@@ -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() {
</div>
<div className="flex items-center gap-4">
<ProjectSwitcher />
<ThemeToggle />
<NotificationsDropdown />
<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', () => {
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(''),
+4 -4
View File
@@ -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,
});
}
+12 -12
View File
@@ -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,
});
}
+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';
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,
+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', {
params: { correspondenceUuid, limit: 50 },
params: { correspondencePublicId, limit: 50 },
});
return response.data;
},
+9 -6
View File
@@ -27,14 +27,16 @@ interface RawPendingTask {
}
export const dashboardService = {
getStats: async (): Promise<DashboardStats> => {
const response = await apiClient.get('/dashboard/stats');
getStats: async (projectId?: string | null): Promise<DashboardStats> => {
const params = projectId ? { projectId } : undefined;
const response = await apiClient.get('/dashboard/stats', { params });
return response.data;
},
getRecentActivity: async (): Promise<ActivityLog[]> => {
getRecentActivity: async (projectId?: string | null): Promise<ActivityLog[]> => {
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<PendingTask[]> => {
getPendingTasks: async (projectId?: string | null): Promise<PendingTask[]> => {
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) => {
+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 */
status?: string;
/** กรองตาม correspondence publicId (ADR-019) */
correspondencePublicId?: string;
page?: number;
limit?: number;