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.
This commit is contained in:
2026-03-08 18:22:46 -04:00
parent 7fa5b9061c
commit 9b38e28117
3 changed files with 982 additions and 8 deletions

View File

@@ -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<ReportResponse>(`/reports/allocations?${query.toString()}`);
},
};
/**
* Format view type for display
*/
export function formatViewType(viewType: ViewType): string {
const labels: Record<ViewType, string> = {
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;

View File

@@ -0,0 +1,508 @@
<script lang="ts">
import { onMount } from 'svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import LoadingState from '$lib/components/common/LoadingState.svelte';
import FilterBar from '$lib/components/common/FilterBar.svelte';
import { AlertCircle, ChevronLeft, ChevronRight } from 'lucide-svelte';
import {
projectMonthPlanService,
type ProjectMonthPlan,
type BulkUpdateRequest
} from '$lib/services/projectMonthPlanService';
let plans = $state<ProjectMonthPlan[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let isSavingCell = $state(false);
let savingCellKey = $state<{ projectId: string; month: string } | null>(null);
let saveSuccess = $state<{ projectId: string; month: string } | null>(null);
let cellError = $state<{ projectId: string; month: string; message: string } | null>(null);
let currentYear = $state(new Date().getFullYear());
let codeFilter = $state('');
let projectFilter = $state('');
let statusFilter = $state('all');
let sortBy = $state('default');
let editingCell = $state<{ projectId: string; month: string } | null>(null);
let editValue = $state<number | null>(null);
let originalValue = $state<number | null>(null);
let moveToNext = $state(false);
function autofocus(node: HTMLInputElement) {
node.focus();
node.select();
}
onMount(() => {
loadPlans();
});
async function loadPlans() {
try {
loading = true;
error = null;
const response = await projectMonthPlanService.getPlans(currentYear);
plans = response || [];
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load plans';
console.error('Error loading plans:', err);
} finally {
loading = false;
}
}
function getMonthColumns(): string[] {
const months = [];
for (let m = 1; m <= 12; m++) {
months.push(`${currentYear}-${String(m).padStart(2, '0')}-01`);
}
return months;
}
function toNumber(value: unknown): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function getPlanValue(plan: ProjectMonthPlan, month: string): number | null {
const monthData = plan.months[month];
if (!monthData) return null;
if (monthData.is_blank) return null;
return toNumber(monthData.planned_hours);
}
function getReconciliationBadgeClass(status: string | undefined): string {
switch (status) {
case 'OVER':
return 'badge-error';
case 'UNDER':
return 'badge-warning';
case 'MATCH':
return 'badge-neutral';
default:
return 'badge-ghost';
}
}
function getStatusBadgeClass(status: string | null | undefined): string {
switch (status) {
case 'Estimate Approved':
return 'badge-success';
case 'Resource Allocation':
case 'Sprint 0':
return 'badge-info';
case 'In Progress':
return 'badge-primary';
case 'UAT':
return 'badge-secondary';
case 'Handover / Sign-off':
return 'badge-accent';
case 'Closed':
return 'badge-neutral';
case 'On Hold':
return 'badge-warning';
case 'Cancelled':
return 'badge-error';
default:
return 'badge-ghost';
}
}
function startEdit(projectId: string, month: string, currentValue: number | null) {
if (isSavingCell) return;
editingCell = { projectId, month };
editValue = currentValue;
originalValue = currentValue;
cellError = null;
}
function moveToNextCell(currentProjectId: string, currentMonth: string) {
const months = getMonthColumns();
const currentIndex = months.indexOf(currentMonth);
if (currentIndex < months.length - 1) {
const nextMonth = months[currentIndex + 1];
const plan = plans.find((p) => p.project_id === currentProjectId);
if (plan) {
startEdit(currentProjectId, nextMonth, getPlanValue(plan, nextMonth));
}
}
}
async function saveCell() {
if (!editingCell || isSavingCell) return;
const snapshot = {
projectId: editingCell.projectId,
month: editingCell.month,
monthStr: editingCell.month.substring(0, 7),
valueToSave: editValue,
shouldMoveNext: moveToNext
};
if (snapshot.valueToSave === originalValue) {
editingCell = null;
editValue = null;
originalValue = null;
moveToNext = false;
return;
}
try {
isSavingCell = true;
savingCellKey = { projectId: snapshot.projectId, month: snapshot.month };
cellError = null;
const request: BulkUpdateRequest = {
year: currentYear,
items: [
{
project_id: snapshot.projectId,
month: snapshot.monthStr,
planned_hours: snapshot.valueToSave
}
]
};
await projectMonthPlanService.bulkUpdate(request);
editingCell = null;
editValue = null;
originalValue = null;
moveToNext = false;
await loadPlans();
saveSuccess = { projectId: snapshot.projectId, month: snapshot.month };
setTimeout(() => {
saveSuccess = null;
}, 1500);
if (snapshot.shouldMoveNext) {
moveToNextCell(snapshot.projectId, snapshot.month);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save';
cellError = { projectId: snapshot.projectId, month: snapshot.month, message };
console.error('Error saving plan:', err);
editingCell = null;
editValue = null;
originalValue = null;
moveToNext = false;
} finally {
isSavingCell = false;
savingCellKey = null;
}
}
function cancelEdit() {
editingCell = null;
editValue = null;
originalValue = null;
moveToNext = false;
cellError = null;
}
function previousYear() {
currentYear--;
loadPlans();
}
function nextYear() {
currentYear++;
loadPlans();
}
function formatMonth(monthStr: string): string {
const [, month] = monthStr.split('-');
return new Date(2000, parseInt(month) - 1).toLocaleDateString('en-US', { month: 'short' });
}
function isCellEditing(projectId: string, month: string): boolean {
return editingCell?.projectId === projectId && editingCell?.month === month;
}
function hasCellError(projectId: string, month: string): boolean {
return cellError?.projectId === projectId && cellError?.month === month;
}
function getStatusLabel(status: string | null | undefined): string {
if (!status) {
return '-';
}
const statusMap: Record<string, string> = {
'Estimate Approved': 'Est. Approved',
'Resource Allocation': 'Resourcing',
'Handover / Sign-off': 'Handover',
'In Progress': 'In Progress',
'On Hold': 'On Hold',
Cancelled: 'Cancelled',
Closed: 'Closed',
UAT: 'UAT',
'Sprint 0': 'Sprint 0',
Estimation: 'Estimation',
'Pre-sales': 'Pre-sales',
'SOW Approval': 'SOW Approval',
'Estimate Rework': 'Est. Rework',
'Project Kickoff': 'Kickoff'
};
return statusMap[status] ?? status;
}
function getMonthlyTotals(): Record<string, number> {
const totals: Record<string, number> = {};
const displayedPlans = getDisplayedPlans();
for (const month of getMonthColumns()) {
totals[month] = displayedPlans.reduce((sum, plan) => {
const value = getPlanValue(plan, month);
return sum + (value ?? 0);
}, 0);
}
return totals;
}
function getStatusOptions(): string[] {
const uniqueStatuses = new Set(
plans
.map((plan) => plan.project_status)
.filter((status): status is string => Boolean(status))
);
return Array.from(uniqueStatuses).sort((a, b) => a.localeCompare(b));
}
function getDisplayedPlans(): ProjectMonthPlan[] {
const normalizedCode = codeFilter.trim().toLowerCase();
const normalizedProject = projectFilter.trim().toLowerCase();
const filtered = plans.filter((plan) => {
const matchesCode =
normalizedCode === '' || plan.project_code.toLowerCase().includes(normalizedCode);
const matchesProject =
normalizedProject === '' || plan.project_name.toLowerCase().includes(normalizedProject);
const matchesStatus =
statusFilter === 'all' || (plan.project_status ?? '') === statusFilter;
return matchesCode && matchesProject && matchesStatus;
});
return filtered.sort((a, b) => {
switch (sortBy) {
case 'code-asc':
return a.project_code.localeCompare(b.project_code);
case 'code-desc':
return b.project_code.localeCompare(a.project_code);
case 'project-asc':
return a.project_name.localeCompare(b.project_name);
case 'project-desc':
return b.project_name.localeCompare(a.project_name);
case 'sum-asc':
return toNumber(a.plan_sum) - toNumber(b.plan_sum);
case 'sum-desc':
return toNumber(b.plan_sum) - toNumber(a.plan_sum);
default:
return 0;
}
});
}
function clearFilters() {
codeFilter = '';
projectFilter = '';
statusFilter = 'all';
sortBy = 'default';
}
</script>
<svelte:head>
<title>Planning | Headroom</title>
</svelte:head>
<PageHeader title="Project-Month Planning" description="Set explicit monthly planned effort per project">
{#snippet children()}
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-sm btn-circle" onclick={previousYear}>
<ChevronLeft size={18} />
</button>
<span class="min-w-[80px] text-center font-medium">
{currentYear}
</span>
<button class="btn btn-ghost btn-sm btn-circle" onclick={nextYear}>
<ChevronRight size={18} />
</button>
</div>
{/snippet}
</PageHeader>
{#if loading}
<LoadingState />
{:else if error}
<div class="alert alert-error">
<AlertCircle size={20} />
<span>{error}</span>
</div>
{:else}
<FilterBar
searchValue={codeFilter}
searchPlaceholder="Filter by project code..."
onSearchChange={(value) => {
codeFilter = value;
}}
onClear={clearFilters}
>
{#snippet children()}
<input
type="text"
placeholder="Filter by project name..."
class="input input-bordered input-sm w-56"
bind:value={projectFilter}
/>
<select class="select select-bordered select-sm w-48" bind:value={statusFilter}>
<option value="all">All statuses</option>
{#each getStatusOptions() as status}
<option value={status}>{status}</option>
{/each}
</select>
<select class="select select-bordered select-sm w-48" bind:value={sortBy}>
<option value="default">Default order</option>
<option value="code-asc">Code A-Z</option>
<option value="code-desc">Code Z-A</option>
<option value="project-asc">Project A-Z</option>
<option value="project-desc">Project Z-A</option>
<option value="sum-desc">Plan Sum high-low</option>
<option value="sum-asc">Plan Sum low-high</option>
</select>
{/snippet}
</FilterBar>
<div class="overflow-x-auto planning-grid">
<table class="table table-xs w-full min-w-[1400px]">
<thead>
<tr>
<th class="sticky left-0 bg-base-200 z-30 min-w-[80px]">Code</th>
<th class="sticky left-[80px] bg-base-200 z-30 min-w-[220px] shadow-[4px_0_8px_-4px_oklch(0_0_0/0.12)]">Project</th>
<th class="text-center min-w-[110px] bg-base-200">Status</th>
<th class="text-center min-w-[60px] bg-base-200">Approved</th>
{#each getMonthColumns() as column}
<th class="text-center min-w-[80px]">{formatMonth(column)}</th>
{/each}
<th class="text-center min-w-[80px] bg-base-200 font-bold">Plan Sum</th>
<th class="text-center min-w-[80px] bg-base-200 font-bold">Recon</th>
</tr>
</thead>
<tbody>
{#each getDisplayedPlans() as plan}
{@const planSum = toNumber(plan.plan_sum)}
<tr class="hover">
<td class="sticky left-0 bg-base-100 z-20 font-mono text-xs">
{plan.project_code}
</td>
<td class="sticky left-[80px] bg-base-100 z-20 font-medium max-w-[220px] truncate shadow-[4px_0_8px_-4px_oklch(0_0_0/0.08)]" title={plan.project_name}>
{plan.project_name}
</td>
<td class="text-center">
<span class="badge badge-sm whitespace-nowrap {getStatusBadgeClass(plan.project_status)}" title={plan.project_status || ''}>
{getStatusLabel(plan.project_status)}
</span>
</td>
<td class="text-center bg-base-200/50">
{plan.approved_estimate ? `${plan.approved_estimate}h` : '-'}
</td>
{#each getMonthColumns() as month}
<td
class="text-center cursor-pointer hover:bg-base-200 transition-colors relative {isCellEditing(plan.project_id, month) ? 'ring-2 ring-primary bg-primary/5' : ''}"
onclick={() => !isCellEditing(plan.project_id, month) && startEdit(plan.project_id, month, getPlanValue(plan, month))}
>
{#if isCellEditing(plan.project_id, month)}
<input
type="number"
class="input input-xs input-bordered w-16 text-center"
bind:value={editValue}
use:autofocus
onkeydown={async (e) => {
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)}
<span class="text-error cursor-pointer" title={cellError?.message || 'Error'}>
{getPlanValue(plan, month) ?? '-'}h ✗
</span>
{:else if isSavingCell && savingCellKey?.projectId === plan.project_id && savingCellKey?.month === month}
<span class="loading loading-xs loading-spinner text-primary"></span>
{:else if saveSuccess?.projectId === plan.project_id && saveSuccess?.month === month}
<span class="text-success">{getPlanValue(plan, month) ?? 0}h ✓</span>
{:else}
{@const value = getPlanValue(plan, month)}
{#if value !== null}
<span>{value}h</span>
{:else}
<span class="text-base-content/30">-</span>
{/if}
{/if}
</td>
{/each}
<td class="text-center bg-base-200/50 font-medium">
{planSum > 0 ? `${planSum}h` : '0h'}
</td>
<td class="text-center bg-base-200">
{#if plan.approved_estimate && plan.approved_estimate > 0}
<span class="badge badge-sm {getReconciliationBadgeClass(plan.reconciliation_status)}">
{plan.reconciliation_status || '-'}
</span>
{:else}
<span>-</span>
{/if}
</td>
</tr>
{/each}
</tbody>
{#if getDisplayedPlans().length > 0}
{@const monthlyTotals = getMonthlyTotals()}
{@const grandTotal = Object.values(monthlyTotals).reduce((sum, value) => sum + toNumber(value), 0)}
<tfoot>
<tr class="bg-base-200 border-t-2 border-base-300 font-semibold">
<td class="sticky left-0 bg-base-200 z-30"></td>
<td class="sticky left-[80px] bg-base-200 z-30 shadow-[4px_0_8px_-4px_oklch(0_0_0/0.12)]">Monthly Totals</td>
<td></td>
<td></td>
{#each getMonthColumns() as month}
{@const monthlyTotal = monthlyTotals[month]}
<td class="text-center tabular-nums">
{#if monthlyTotal > 0}
<span class="font-bold text-primary">{monthlyTotal}h</span>
{:else}
<span class="text-base-content/30">0h</span>
{/if}
</td>
{/each}
<td class="text-center bg-base-300/50 tabular-nums">
<span class="font-bold">{grandTotal}h</span>
</td>
<td></td>
</tr>
</tfoot>
{:else}
<tfoot>
<tr>
<td colspan="18" class="text-center py-8">
<span class="text-base-content/50">No projects match the current filters.</span>
</td>
</tr>
</tfoot>
{/if}
</table>
</div>
{/if}

View File

@@ -1,17 +1,352 @@
<script lang="ts">
import { onMount } from 'svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import FilterBar from '$lib/components/common/FilterBar.svelte';
import LoadingState from '$lib/components/common/LoadingState.svelte';
import EmptyState from '$lib/components/common/EmptyState.svelte';
import { Grid3X3 } from 'lucide-svelte';
import { Calendar, FileText, AlertCircle, Users, FolderKanban } from 'lucide-svelte';
import {
reportService,
formatViewType,
getStatusBadgeClass,
type ReportResponse,
type ViewType,
type VarianceStatus
} from '$lib/services/reportService';
import { projectService, type Project } from '$lib/services/projectService';
import { teamMemberService, type TeamMember } from '$lib/services/teamMemberService';
// State
let report = $state<ReportResponse | null>(null);
let projects = $state<Project[]>([]);
let teamMembers = $state<TeamMember[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
// Filter state
let startDate = $state('');
let endDate = $state('');
let selectedProjects = $state<string[]>([]);
let selectedMembers = $state<string[]>([]);
let viewMode = $state<'aggregate' | 'detailed'>('aggregate');
// Initialize with current quarter
onMount(async () => {
const now = new Date();
const quarter = Math.floor(now.getMonth() / 3);
const year = now.getFullYear();
startDate = `${year}-${String(quarter * 3 + 1).padStart(2, '0')}-01`;
endDate = `${year}-${String((quarter + 1) * 3).padStart(2, '0')}-${new Date(year, (quarter + 1) * 3, 0).getDate()}`;
await Promise.all([loadProjects(), loadTeamMembers(), loadReport()]);
});
async function loadProjects() {
try {
projects = await projectService.getAll();
} catch (err) {
console.error('Error loading projects:', err);
}
}
async function loadTeamMembers() {
try {
teamMembers = await teamMemberService.getAll(true);
} catch (err) {
console.error('Error loading team members:', err);
}
}
async function loadReport() {
if (!startDate || !endDate) return;
try {
loading = true;
error = null;
const response = await reportService.getAllocations({
start_date: startDate,
end_date: endDate,
project_ids: selectedProjects.length > 0 ? selectedProjects : undefined,
member_ids: selectedMembers.length > 0 ? selectedMembers : undefined,
});
report = response;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load report';
console.error('Error loading report:', err);
} finally {
loading = false;
}
}
function handleApplyFilters() {
loadReport();
}
function formatHours(hours: number): string {
return `${hours.toFixed(1)}h`;
}
function getViewTypeBadgeClass(viewType: ViewType): string {
switch (viewType) {
case 'did':
return 'bg-gray-100 text-gray-800';
case 'is':
return 'bg-blue-100 text-blue-800';
case 'will':
return 'bg-purple-100 text-purple-800';
}
}
</script>
<svelte:head>
<title>Allocation Matrix | Headroom</title>
<title>Allocation Report | Headroom</title>
</svelte:head>
<PageHeader title="Allocation Matrix" description="Resource allocation visualization" />
<EmptyState
title="Coming Soon"
description="Allocation matrix will be available in a future update."
icon={Grid3X3}
<PageHeader
title="Allocation Report"
description="Resource planning and execution analysis across projects and team members"
/>
<!-- Filters -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 items-end">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
<input
type="date"
bind:value={startDate}
class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">End Date</label>
<input
type="date"
bind:value={endDate}
class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Projects</label>
<select
bind:value={selectedProjects}
multiple
class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 h-10"
>
{#each projects as project}
<option value={project.id}>{project.code} - {project.title}</option>
{/each}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">View Mode</label>
<select
bind:value={viewMode}
class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option value="aggregate">📊 Aggregate</option>
<option value="detailed">📋 Detailed</option>
</select>
</div>
<button
onclick={handleApplyFilters}
disabled={loading || !startDate || !endDate}
class="inline-flex justify-center items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-300"
>
{#if loading}
<span class="animate-spin mr-2"></span> Loading...
{:else}
Apply Filters
{/if}
</button>
</div>
</div>
<!-- Error State -->
{#if error}
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div class="flex items-center">
<AlertCircle class="h-5 w-5 text-red-400 mr-2" />
<p class="text-red-800">{error}</p>
</div>
</div>
{/if}
<!-- Loading State -->
{#if loading}
<LoadingState type="text" />
{/if}
<!-- Report Content -->
{#if report && !loading}
<!-- View Type Badge -->
<div class="mb-6 flex items-center gap-4">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium {getViewTypeBadgeClass(report.view_type)}">
<Calendar class="h-4 w-4 mr-1" />
{formatViewType(report.view_type)}
</span>
<span class="text-sm text-gray-500">
Period: {new Date(report.period.start).toLocaleDateString()} - {new Date(report.period.end).toLocaleDateString()}
</span>
</div>
<!-- Aggregates Summary -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<p class="text-sm text-gray-500 mb-1">Total Planned</p>
<p class="text-2xl font-bold text-gray-900">{formatHours(report.aggregates.total_planned)}</p>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<p class="text-sm text-gray-500 mb-1">Total Allocated</p>
<p class="text-2xl font-bold text-gray-900">{formatHours(report.aggregates.total_allocated)}</p>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<p class="text-sm text-gray-500 mb-1">Variance</p>
<p class="text-2xl font-bold {report.aggregates.total_variance > 0 ? 'text-red-600' : report.aggregates.total_variance < 0 ? 'text-amber-600' : 'text-green-600'}">
{report.aggregates.total_variance > 0 ? '+' : ''}{formatHours(report.aggregates.total_variance)}
</p>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<p class="text-sm text-gray-500 mb-1">Status</p>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-sm font-medium {getStatusBadgeClass(report.aggregates.status)}">
{report.aggregates.status}
</span>
</div>
</div>
{#if viewMode === 'aggregate'}
<!-- Aggregate View: Project Summary Table -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden mb-6">
<div class="px-4 py-3 border-b border-gray-200 flex items-center">
<FolderKanban class="h-5 w-5 text-gray-400 mr-2" />
<h3 class="text-lg font-medium text-gray-900">Project Summary</h3>
</div>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Project</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Approved Estimate</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Plan Sum</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Period Planned</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Period Allocated</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Variance</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each report.projects as project}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<div class="font-medium text-gray-900">{project.code}</div>
<div class="text-sm text-gray-500">{project.title}</div>
</td>
<td class="px-4 py-3 text-right text-sm text-gray-900">{formatHours(project.approved_estimate)}</td>
<td class="px-4 py-3 text-right text-sm text-gray-900">{formatHours(project.plan_sum)}</td>
<td class="px-4 py-3 text-right text-sm text-gray-900">{formatHours(project.period_planned)}</td>
<td class="px-4 py-3 text-right text-sm text-gray-900">{formatHours(project.period_allocated)}</td>
<td class="px-4 py-3 text-right text-sm {project.period_variance > 0 ? 'text-red-600' : project.period_variance < 0 ? 'text-amber-600' : 'text-green-600'}">
{project.period_variance > 0 ? '+' : ''}{formatHours(project.period_variance)}
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium {getStatusBadgeClass(project.period_status)}">
{project.period_status}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Member Summary Table -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200 flex items-center">
<Users class="h-5 w-5 text-gray-400 mr-2" />
<h3 class="text-lg font-medium text-gray-900">Member Summary</h3>
</div>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Member</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Period Allocated</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Project Breakdown</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each report.members as member}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-medium text-gray-900">{member.name}</td>
<td class="px-4 py-3 text-right text-sm text-gray-900">{formatHours(member.period_allocated)}</td>
<td class="px-4 py-3 text-sm">
<div class="flex flex-wrap gap-2">
{#each member.projects as proj}
<span class="inline-flex items-center px-2 py-1 rounded text-xs bg-gray-100">
{proj.project_code}: {formatHours(proj.total_hours)}
</span>
{/each}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<!-- Detailed View: Project-Month Breakdown -->
{#each report.projects as project}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden mb-4">
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50 flex items-center justify-between">
<div>
<span class="font-medium text-gray-900">{project.code}</span>
<span class="text-gray-500 mx-2">-</span>
<span class="text-gray-700">{project.title}</span>
</div>
<div class="flex items-center gap-4 text-sm">
<span class="text-gray-500">Lifecycle: <span class="font-medium {project.lifecycle_status === 'OVER' ? 'text-red-600' : project.lifecycle_status === 'UNDER' ? 'text-amber-600' : 'text-green-600'}">{project.lifecycle_status}</span></span>
</div>
</div>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Month</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Planned</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Allocated</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Variance</th>
<th class="px-4 py-2 text-center text-xs font-medium text-gray-500 uppercase">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each project.months as month}
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 text-sm text-gray-900">{new Date(month.month + '-01').toLocaleDateString(undefined, { month: 'short', year: 'numeric' })}</td>
<td class="px-4 py-2 text-right text-sm">
{#if month.is_blank}
<span class="text-gray-400 italic">blank</span>
{:else}
{formatHours(month.planned_hours ?? 0)}
{/if}
</td>
<td class="px-4 py-2 text-right text-sm text-gray-900">{formatHours(month.allocated_hours)}</td>
<td class="px-4 py-2 text-right text-sm {month.variance > 0 ? 'text-red-600' : month.variance < 0 ? 'text-amber-600' : 'text-green-600'}">
{month.variance > 0 ? '+' : ''}{formatHours(month.variance)}
</td>
<td class="px-4 py-2 text-center">
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium {getStatusBadgeClass(month.status)}">
{month.status}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/each}
{/if}
{:else if !loading}
<EmptyState
title="No Data"
description="Select a date range and apply filters to generate a report."
icon={FileText}
/>
{/if}