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:
131
frontend/src/lib/services/reportService.ts
Normal file
131
frontend/src/lib/services/reportService.ts
Normal 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;
|
||||
508
frontend/src/routes/planning/+page.svelte
Normal file
508
frontend/src/routes/planning/+page.svelte
Normal 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}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user