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">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import PageHeader from '$lib/components/layout/PageHeader.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 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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Allocation Matrix | Headroom</title>
|
<title>Allocation Report | Headroom</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<PageHeader title="Allocation Matrix" description="Resource allocation visualization" />
|
<PageHeader
|
||||||
|
title="Allocation Report"
|
||||||
<EmptyState
|
description="Resource planning and execution analysis across projects and team members"
|
||||||
title="Coming Soon"
|
|
||||||
description="Allocation matrix will be available in a future update."
|
|
||||||
icon={Grid3X3}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 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