From dd8055f6b7350fed9ff874f38042cb11af838b2e Mon Sep 17 00:00:00 2001 From: Santhosh Janardhanan Date: Sun, 8 Mar 2026 18:23:00 -0400 Subject: [PATCH] feat(frontend): update allocation UI and services Enhance allocation management interface: - Allocations page: modal-based editing, variance display - Updated services: allocationService with bulk ops, projectMonthPlanService - Project and team member pages: reconciliation status indicators - Navigation config: add planning and reports links Part of enhanced-allocation change. --- frontend/src/lib/config/navigation.ts | 1 + .../src/lib/services/allocationService.ts | 46 ++++-- .../lib/services/projectMonthPlanService.ts | 18 +-- frontend/src/lib/services/projectService.ts | 2 + frontend/src/routes/allocations/+page.svelte | 144 +++++++++++++----- frontend/src/routes/projects/+page.svelte | 27 ++-- frontend/src/routes/team-members/+page.svelte | 31 ++-- 7 files changed, 180 insertions(+), 89 deletions(-) diff --git a/frontend/src/lib/config/navigation.ts b/frontend/src/lib/config/navigation.ts index efee9bbc..8b63e686 100644 --- a/frontend/src/lib/config/navigation.ts +++ b/frontend/src/lib/config/navigation.ts @@ -5,6 +5,7 @@ export const navigationSections: NavSection[] = [ title: 'PLANNING', items: [ { label: 'Dashboard', href: '/dashboard', icon: 'LayoutDashboard' }, + { label: 'Planning', href: '/planning', icon: 'CalendarCheck' }, { label: 'Team Members', href: '/team-members', icon: 'Users' }, { label: 'Projects', href: '/projects', icon: 'Folder' }, { label: 'Allocations', href: '/allocations', icon: 'Calendar' }, diff --git a/frontend/src/lib/services/allocationService.ts b/frontend/src/lib/services/allocationService.ts index 09514a30..72ac83ca 100644 --- a/frontend/src/lib/services/allocationService.ts +++ b/frontend/src/lib/services/allocationService.ts @@ -1,18 +1,39 @@ /** * Allocation Service - * + * * API operations for resource allocation management. */ import { api } from './api'; +export type VarianceStatus = 'OVER' | 'UNDER' | 'MATCH' | null; + +export interface RowVariance { + allocated_total: number; + planned_month: number; + variance: number; + status: VarianceStatus; +} + +export interface ColumnVariance { + allocated: number; + capacity: number; + variance: number; + status: VarianceStatus; +} + export interface Allocation { id: string; project_id: string; team_member_id: string | null; month: string; allocated_hours: string; + is_untracked?: boolean; + row_variance?: RowVariance; + column_variance?: ColumnVariance | null; allocation_indicator?: 'green' | 'yellow' | 'red' | 'gray'; + warnings?: string[]; + utilization?: number; created_at: string; updated_at: string; project?: { @@ -28,7 +49,7 @@ export interface Allocation { export interface CreateAllocationRequest { project_id: string; - team_member_id: string; + team_member_id: string | null; month: string; allocated_hours: number; } @@ -41,6 +62,12 @@ export interface BulkAllocationRequest { allocations: CreateAllocationRequest[]; } +export interface BulkAllocationResponse { + data: Array<{ index: number; id: string; status: string }>; + failed: Array<{ index: number; errors: Record }>; + summary: { created: number; failed: number }; +} + // Allocation API methods export const allocationService = { /** @@ -54,32 +81,29 @@ export const allocationService = { /** * Get a single allocation by ID */ - getById: (id: string) => - api.get(`/allocations/${id}`), + getById: (id: string) => api.get(`/allocations/${id}`), /** * Create a new allocation */ - create: (data: CreateAllocationRequest) => - api.post('/allocations', data), + create: (data: CreateAllocationRequest) => api.post('/allocations', data), /** * Update an existing allocation */ - update: (id: string, data: UpdateAllocationRequest) => + update: (id: string, data: UpdateAllocationRequest) => api.put(`/allocations/${id}`, data), /** * Delete an allocation */ - delete: (id: string) => - api.delete<{ message: string }>(`/allocations/${id}`), + delete: (id: string) => api.delete<{ message: string }>(`/allocations/${id}`), /** * Bulk create allocations */ - bulkCreate: (data: BulkAllocationRequest) => - api.post('/allocations/bulk', data), + bulkCreate: (data: BulkAllocationRequest) => + api.post('/allocations/bulk', data), }; /** diff --git a/frontend/src/lib/services/projectMonthPlanService.ts b/frontend/src/lib/services/projectMonthPlanService.ts index a95d87ac..473ce6b6 100644 --- a/frontend/src/lib/services/projectMonthPlanService.ts +++ b/frontend/src/lib/services/projectMonthPlanService.ts @@ -2,7 +2,9 @@ import { api } from '$lib/services/api'; export interface ProjectMonthPlan { project_id: string; + project_code: string; project_name: string; + project_status: string | null; approved_estimate: number; months: Record { - const response = await api.get(`/project-month-plans?year=${year}`); - return response.data; + // Note: unwrapResponse strips the {data} wrapper, so this returns the array directly + async getPlans(year: number): Promise { + return api.get(`/project-month-plans?year=${year}`); } async bulkUpdate(request: BulkUpdateRequest): Promise { - const response = await api.put('/project-month-plans/bulk', request); - return response.data; + return api.put('/project-month-plans/bulk', request); } } diff --git a/frontend/src/lib/services/projectService.ts b/frontend/src/lib/services/projectService.ts index 4d20dd9b..f9074278 100644 --- a/frontend/src/lib/services/projectService.ts +++ b/frontend/src/lib/services/projectService.ts @@ -35,6 +35,8 @@ export interface UpdateProjectRequest { code?: string; title?: string; type_id?: number; + status_id?: number; + approved_estimate?: number | null; } export const projectService = { diff --git a/frontend/src/routes/allocations/+page.svelte b/frontend/src/routes/allocations/+page.svelte index 73fd4a38..600d6be2 100644 --- a/frontend/src/routes/allocations/+page.svelte +++ b/frontend/src/routes/allocations/+page.svelte @@ -76,7 +76,8 @@ try { loading = true; error = null; - allocations = await allocationService.getAll(currentPeriod); + const response = await allocationService.getAll(currentPeriod) as Allocation[]; + allocations = response; } catch (err) { error = err instanceof Error ? err.message : 'Failed to load allocations'; console.error('Error loading allocations:', err); @@ -85,7 +86,7 @@ } } - function getAllocation(projectId: string, teamMemberId: string): Allocation | undefined { + function getAllocation(projectId: string, teamMemberId: string | null): Allocation | undefined { return allocations.find(a => a.project_id === projectId && a.team_member_id === teamMemberId); } @@ -95,41 +96,80 @@ .reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0); } + /** + * Get row variance for a project. + * Uses the first allocation's row_variance data (all allocations for same project have same row variance) + */ + function getProjectRowVariance(projectId: string) { + const projectAllocations = allocations.filter(a => a.project_id === projectId); + if (projectAllocations.length === 0) return null; + return projectAllocations[0].row_variance ?? null; + } + function getTeamMemberColumnTotal(teamMemberId: string): number { return allocations .filter(a => a.team_member_id === teamMemberId) .reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0); } + /** + * Get column variance for a team member. + */ + function getTeamMemberColumnVariance(teamMemberId: string) { + const memberAllocations = allocations.filter(a => a.team_member_id === teamMemberId); + if (memberAllocations.length === 0) return null; + return memberAllocations[0].column_variance || null; + } + function getProjectTotal(): number { return allocations.reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0); } - function getProjectBudget(projectId: string): number { - const project = projects.find(p => p.id === projectId); - if (!project?.approved_estimate) return 0; - // Monthly budget = approved_estimate / 12 - return Math.round(Number(project.approved_estimate) / 12); + /** + * Get row variance status for a project (OVER/UNDER/MATCH) + * Uses red/amber/neutral per design + */ + function getProjectRowStatus(projectId: string): 'OVER' | 'UNDER' | 'MATCH' | null { + const variance = getProjectRowVariance(projectId); + return variance?.status || null; } - function getProjectBudgetStatus(projectId: string): 'over' | 'under' | 'ok' { - const budget = getProjectBudget(projectId); - const allocated = getProjectRowTotal(projectId); - if (budget === 0) return 'under'; - const percentage = (allocated / budget) * 100; - if (percentage > 100) return 'over'; - if (percentage >= 100) return 'ok'; - return 'under'; + /** + * Get column variance status for a team member + */ + function getTeamMemberColumnStatus(teamMemberId: string): 'OVER' | 'UNDER' | 'MATCH' | null { + const variance = getTeamMemberColumnVariance(teamMemberId); + return variance?.status || null; } - function handleCellClick(projectId: string, teamMemberId: string) { + /** + * Format status badge class - red/amber/neutral per design + */ + function getStatusBadgeClass(status: string | null): string { + switch (status) { + case 'OVER': return 'badge-error'; + case 'UNDER': return 'badge-warning'; + case 'MATCH': return 'badge-neutral'; + default: return 'badge-ghost'; + } + } + + /** + * Format status text + */ + function getStatusText(status: string | null): string { + if (!status) return '-'; + return status; + } + + function handleCellClick(projectId: string, teamMemberId: string | null) { const existing = getAllocation(projectId, teamMemberId); if (existing) { // Edit existing editingAllocation = existing; formData = { project_id: existing.project_id, - team_member_id: existing.team_member_id, + team_member_id: existing.team_member_id || '', month: existing.month, allocated_hours: parseFloat(existing.allocated_hours) }; @@ -138,7 +178,7 @@ editingAllocation = null; formData = { project_id: projectId, - team_member_id: teamMemberId, + team_member_id: teamMemberId || '', month: currentPeriod, allocated_hours: 0 }; @@ -152,12 +192,18 @@ formLoading = true; formError = null; + // Handle untracked: team_member_id can be empty string or null + const submitData = { + ...formData, + team_member_id: formData.team_member_id || null + }; + if (editingAllocation) { await allocationService.update(editingAllocation.id, { allocated_hours: formData.allocated_hours }); } else { - await allocationService.create(formData); + await allocationService.create(submitData); } showModal = false; @@ -204,7 +250,8 @@ return project ? `${project.code} - ${project.title}` : 'Unknown'; } - function getTeamMemberName(teamMemberId: string): string { + function getTeamMemberName(teamMemberId: string | null): string { + if (!teamMemberId) return 'Untracked'; const member = teamMembers.find(m => m.id === teamMemberId); return member?.name || 'Unknown'; } @@ -281,38 +328,60 @@ {/if} {/each} + + handleCellClick(project.id, null)} + > + {#if getAllocation(project.id, null)} + {@const untracked = getAllocation(project.id, null)} + + {untracked?.allocated_hours}h + + {:else} + - + {/if} + + {getProjectRowTotal(project.id)}h + {#if getProjectRowStatus(project.id)} + {@const rowStatus = getProjectRowStatus(project.id)} + + {getStatusText(rowStatus)} + + {/if} {/each} - + - Monthly Budget + Planned {#each teamMembers as member} - {/each} - - {Math.round(projects.reduce((sum, p) => sum + (getProjectBudget(p.id) || 0), 0))}h - + - + - - Status + Variance {#each teamMembers as member} - - + {@const colStatus = getTeamMemberColumnStatus(member.id)} + + {#if colStatus} + + {getStatusText(colStatus)} + + {:else} + - + {/if} + {/each} - - {#if getProjectTotal() > projects.reduce((sum, p) => sum + getProjectBudget(p.id), 0)} - OVER - {:else if getProjectTotal() < projects.reduce((sum, p) => sum + getProjectBudget(p.id), 0)} - UNDER - {:else} - OK - {/if} - + - + - @@ -323,6 +392,9 @@ {getTeamMemberColumnTotal(member.id)}h {/each} + + {allocations.filter(a => a.team_member_id === null).reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0)}h + {getProjectTotal()}h diff --git a/frontend/src/routes/projects/+page.svelte b/frontend/src/routes/projects/+page.svelte index 96b08c9c..f77fce11 100644 --- a/frontend/src/routes/projects/+page.svelte +++ b/frontend/src/routes/projects/+page.svelte @@ -132,11 +132,14 @@ function handleEdit(project: Project) { editingProject = project; + // Use scalar IDs from API response, fallback to nested objects for backward compatibility + const typeId = project.type_id ?? project.type?.id ?? 0; + const statusId = project.status_id ?? project.status?.id; formData = { code: project.code, title: project.title, - type_id: project.type_id, - status_id: project.status_id, + type_id: typeId, + status_id: statusId, approved_estimate: project.approved_estimate ? parseFloat(String(project.approved_estimate)) : null }; formError = null; @@ -154,26 +157,14 @@ formError = null; if (editingProject) { - // Update basic info + // Update basic info including status and estimate await projectService.update(editingProject.id, { code: formData.code, title: formData.title, - type_id: formData.type_id + type_id: formData.type_id, + status_id: formData.status_id, + approved_estimate: formData.approved_estimate }); - - // Update status if changed - if (formData.status_id && formData.status_id !== editingProject.status_id) { - await projectService.updateStatus(editingProject.id, formData.status_id); - } - - // Update estimate if changed - const newEstimate = formData.approved_estimate ?? null; - const oldEstimate = editingProject.approved_estimate - ? parseFloat(String(editingProject.approved_estimate)) - : null; - if (newEstimate !== oldEstimate && newEstimate !== null) { - await projectService.setEstimate(editingProject.id, newEstimate); - } } else { await projectService.create({ code: formData.code, diff --git a/frontend/src/routes/team-members/+page.svelte b/frontend/src/routes/team-members/+page.svelte index 6ed91506..9d93f247 100644 --- a/frontend/src/routes/team-members/+page.svelte +++ b/frontend/src/routes/team-members/+page.svelte @@ -25,7 +25,7 @@ // Form state let formData = $state({ name: '', - role_id: 13, // Default to first role + role_id: 0, // Will be set from roles after load hourly_rate: 0, active: true }); @@ -71,24 +71,20 @@ async function loadRoles() { try { - // Role IDs from database seeder - roles = [ - { id: 13, name: 'Frontend Dev' }, - { id: 14, name: 'Backend Dev' }, - { id: 15, name: 'QA' }, - { id: 16, name: 'DevOps' }, - { id: 17, name: 'UX' }, - { id: 18, name: 'PM' }, - { id: 19, name: 'Architect' } - ]; + // Fetch roles from API endpoint + roles = await api.get<{ id: number; name: string }[]>('/roles'); } catch (err) { console.error('Error loading roles:', err); + // Fallback to empty array - form should not submit without valid roles + roles = []; } } function handleCreate() { editingMember = null; - formData = { name: '', role_id: roles[0]?.id || 0, hourly_rate: 0, active: true }; + // Default to first role if available + const defaultRoleId = roles.length > 0 ? roles[0].id : 0; + formData = { name: '', role_id: defaultRoleId, hourly_rate: 0, active: true }; formError = null; showModal = true; } @@ -115,6 +111,17 @@ formLoading = true; formError = null; + // Validate roles are loaded + if (roles.length === 0) { + formError = 'Roles not loaded. Please refresh the page.'; + return; + } + + if (!formData.role_id || formData.role_id === 0) { + formError = 'Please select a valid role.'; + return; + } + if (editingMember) { await teamMemberService.update(editingMember.id, formData); } else {