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
+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;