From 9b38e28117404ebcf9e3e06c934622c9aca473f4 Mon Sep 17 00:00:00 2001 From: Santhosh Janardhanan Date: Sun, 8 Mar 2026 18:22:46 -0400 Subject: [PATCH] feat(frontend): add planning and reporting UI Implement management reporting interface: - New /planning route for monthly resource planning grid - ReportService with type definitions for did/is/will views - Allocation report page with aggregate and detailed views - Date range filters, project/member filtering - Variance badges and status indicators Part of enhanced-allocation change. --- frontend/src/lib/services/reportService.ts | 131 +++++ frontend/src/routes/planning/+page.svelte | 508 ++++++++++++++++++ .../routes/reports/allocation/+page.svelte | 351 +++++++++++- 3 files changed, 982 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/services/reportService.ts create mode 100644 frontend/src/routes/planning/+page.svelte diff --git a/frontend/src/lib/services/reportService.ts b/frontend/src/lib/services/reportService.ts new file mode 100644 index 00000000..cb0833bc --- /dev/null +++ b/frontend/src/lib/services/reportService.ts @@ -0,0 +1,131 @@ +/** + * Report Service + * + * API operations for management reporting (did/is/will views). + */ + +import { api } from './api'; + +export type ViewType = 'did' | 'is' | 'will'; +export type VarianceStatus = 'OVER' | 'UNDER' | 'MATCH'; + +export interface ReportPeriod { + start: string; + end: string; +} + +export interface ProjectMonthData { + month: string; + planned_hours: number | null; + is_blank: boolean; + allocated_hours: number; + variance: number; + status: VarianceStatus; +} + +export interface ProjectReportData { + id: string; + code: string; + title: string; + approved_estimate: number; + lifecycle_status: VarianceStatus; + plan_sum: number; + period_planned: number; + period_allocated: number; + period_variance: number; + period_status: VarianceStatus; + months: ProjectMonthData[]; +} + +export interface MemberProjectAllocation { + project_id: string; + project_code: string; + project_title: string; + total_hours: number; +} + +export interface MemberReportData { + id: string; + name: string; + period_allocated: number; + projects: MemberProjectAllocation[]; +} + +export interface ReportAggregates { + total_planned: number; + total_allocated: number; + total_variance: number; + status: VarianceStatus; +} + +export interface ReportResponse { + period: ReportPeriod; + view_type: ViewType; + projects: ProjectReportData[]; + members: MemberReportData[]; + aggregates: ReportAggregates; +} + +export interface ReportFilterParams { + start_date: string; + end_date: string; + project_ids?: string[]; + member_ids?: string[]; +} + +// Report API methods +export const reportService = { + /** + * Get allocation report for the specified date range + * View type (did/is/will) is inferred from dates by the backend + */ + getAllocations: (params: ReportFilterParams) => { + const query = new URLSearchParams(); + query.append('start_date', params.start_date); + query.append('end_date', params.end_date); + + if (params.project_ids) { + for (const id of params.project_ids) { + query.append('project_ids[]', id); + } + } + + if (params.member_ids) { + for (const id of params.member_ids) { + query.append('member_ids[]', id); + } + } + + return api.get(`/reports/allocations?${query.toString()}`); + }, +}; + +/** + * Format view type for display + */ +export function formatViewType(viewType: ViewType): string { + const labels: Record = { + did: 'Did (Past)', + is: 'Is (Current)', + will: 'Will (Future)', + }; + return labels[viewType] || viewType; +} + +/** + * Get status badge color + */ +export function getStatusBadgeClass(status: VarianceStatus): string { + switch (status) { + case 'OVER': + return 'bg-red-100 text-red-800'; + case 'UNDER': + return 'bg-amber-100 text-amber-800'; + case 'MATCH': + return 'bg-green-100 text-green-800'; + default: + return 'bg-gray-100 text-gray-800'; + } +} + +export default reportService; diff --git a/frontend/src/routes/planning/+page.svelte b/frontend/src/routes/planning/+page.svelte new file mode 100644 index 00000000..15657cb5 --- /dev/null +++ b/frontend/src/routes/planning/+page.svelte @@ -0,0 +1,508 @@ + + + + Planning | Headroom + + + + {#snippet children()} +
+ + + {currentYear} + + +
+ {/snippet} +
+ +{#if loading} + +{:else if error} +
+ + {error} +
+{:else} + { + codeFilter = value; + }} + onClear={clearFilters} + > + {#snippet children()} + + + + {/snippet} + + +
+ + + + + + + + {#each getMonthColumns() as column} + + {/each} + + + + + + {#each getDisplayedPlans() as plan} + {@const planSum = toNumber(plan.plan_sum)} + + + + + + {#each getMonthColumns() as month} + + {/each} + + + + {/each} + + {#if getDisplayedPlans().length > 0} + {@const monthlyTotals = getMonthlyTotals()} + {@const grandTotal = Object.values(monthlyTotals).reduce((sum, value) => sum + toNumber(value), 0)} + + + + + + + {#each getMonthColumns() as month} + {@const monthlyTotal = monthlyTotals[month]} + + {/each} + + + + + {:else} + + + + + + {/if} +
CodeProjectStatusApproved{formatMonth(column)}Plan SumRecon
+ {plan.project_code} + + {plan.project_name} + + + {getStatusLabel(plan.project_status)} + + + {plan.approved_estimate ? `${plan.approved_estimate}h` : '-'} + !isCellEditing(plan.project_id, month) && startEdit(plan.project_id, month, getPlanValue(plan, month))} + > + {#if isCellEditing(plan.project_id, month)} + { + if (e.key === 'Enter') { + e.preventDefault(); + moveToNext = true; + await saveCell(); + } + if (e.key === 'Escape') { + e.preventDefault(); + cancelEdit(); + } + }} + disabled={isSavingCell} + /> + {:else if hasCellError(plan.project_id, month)} + + {getPlanValue(plan, month) ?? '-'}h ✗ + + {:else if isSavingCell && savingCellKey?.projectId === plan.project_id && savingCellKey?.month === month} + + {:else if saveSuccess?.projectId === plan.project_id && saveSuccess?.month === month} + {getPlanValue(plan, month) ?? 0}h ✓ + {:else} + {@const value = getPlanValue(plan, month)} + {#if value !== null} + {value}h + {:else} + - + {/if} + {/if} + + {planSum > 0 ? `${planSum}h` : '0h'} + + {#if plan.approved_estimate && plan.approved_estimate > 0} + + {plan.reconciliation_status || '-'} + + {:else} + - + {/if} +
Monthly Totals + {#if monthlyTotal > 0} + {monthlyTotal}h + {:else} + 0h + {/if} + + {grandTotal}h +
+ No projects match the current filters. +
+
+{/if} diff --git a/frontend/src/routes/reports/allocation/+page.svelte b/frontend/src/routes/reports/allocation/+page.svelte index f95140b6..2994a84c 100644 --- a/frontend/src/routes/reports/allocation/+page.svelte +++ b/frontend/src/routes/reports/allocation/+page.svelte @@ -1,17 +1,352 @@ - Allocation Matrix | Headroom + Allocation Report | Headroom - - - + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +{#if error} +
+
+ +

{error}

+
+
+{/if} + + +{#if loading} + +{/if} + + +{#if report && !loading} + +
+ + + {formatViewType(report.view_type)} + + + Period: {new Date(report.period.start).toLocaleDateString()} - {new Date(report.period.end).toLocaleDateString()} + +
+ + +
+
+

Total Planned

+

{formatHours(report.aggregates.total_planned)}

+
+
+

Total Allocated

+

{formatHours(report.aggregates.total_allocated)}

+
+
+

Variance

+

+ {report.aggregates.total_variance > 0 ? '+' : ''}{formatHours(report.aggregates.total_variance)} +

+
+
+

Status

+ + {report.aggregates.status} + +
+
+ + {#if viewMode === 'aggregate'} + +
+
+ +

Project Summary

+
+ + + + + + + + + + + + + + {#each report.projects as project} + + + + + + + + + + {/each} + +
ProjectApproved EstimatePlan SumPeriod PlannedPeriod AllocatedVarianceStatus
+
{project.code}
+
{project.title}
+
{formatHours(project.approved_estimate)}{formatHours(project.plan_sum)}{formatHours(project.period_planned)}{formatHours(project.period_allocated)} + {project.period_variance > 0 ? '+' : ''}{formatHours(project.period_variance)} + + + {project.period_status} + +
+
+ + +
+
+ +

Member Summary

+
+ + + + + + + + + + {#each report.members as member} + + + + + + {/each} + +
MemberPeriod AllocatedProject Breakdown
{member.name}{formatHours(member.period_allocated)} +
+ {#each member.projects as proj} + + {proj.project_code}: {formatHours(proj.total_hours)} + + {/each} +
+
+
+ {:else} + + {#each report.projects as project} +
+
+
+ {project.code} + - + {project.title} +
+
+ Lifecycle: {project.lifecycle_status} +
+
+ + + + + + + + + + + + {#each project.months as month} + + + + + + + + {/each} + +
MonthPlannedAllocatedVarianceStatus
{new Date(month.month + '-01').toLocaleDateString(undefined, { month: 'short', year: 'numeric' })} + {#if month.is_blank} + blank + {:else} + {formatHours(month.planned_hours ?? 0)} + {/if} + {formatHours(month.allocated_hours)} + {month.variance > 0 ? '+' : ''}{formatHours(month.variance)} + + + {month.status} + +
+
+ {/each} + {/if} +{:else if !loading} + +{/if}