Files
headroom/frontend/src/routes/allocations/+page.svelte
Santhosh Janardhanan dd8055f6b7 feat(frontend): update allocation UI and services
Enhance allocation management interface:
- Allocations page: modal-based editing, variance display
- Updated services: allocationService with bulk ops, projectMonthPlanService
- Project and team member pages: reconciliation status indicators
- Navigation config: add planning and reports links

Part of enhanced-allocation change.
2026-03-08 18:23:00 -04:00

518 lines
15 KiB
Svelte

<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 { Plus, X, AlertCircle, ChevronLeft, ChevronRight, Trash2 } from 'lucide-svelte';
import {
allocationService,
type Allocation,
type CreateAllocationRequest
} from '$lib/services/allocationService';
import { projectService, type Project } from '$lib/services/projectService';
import { teamMemberService, type TeamMember } from '$lib/services/teamMemberService';
import { selectedPeriod, previousMonth, nextMonth, setPeriod } from '$lib/stores/period';
// State
let allocations = $state<Allocation[]>([]);
let projects = $state<Project[]>([]);
let teamMembers = $state<TeamMember[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
// Month navigation
let currentPeriod = $state('2026-02');
// Modal state
let showModal = $state(false);
let editingAllocation = $state<Allocation | null>(null);
let formLoading = $state(false);
let formError = $state<string | null>(null);
// Form state
let formData = $state<CreateAllocationRequest>({
project_id: '',
team_member_id: '',
month: currentPeriod,
allocated_hours: 0
});
// Subscribe to period store - only on client
let unsubscribe: (() => void) | null = null;
onMount(() => {
unsubscribe = selectedPeriod.subscribe(value => {
currentPeriod = value;
loadAllocations();
});
return () => {
if (unsubscribe) unsubscribe();
};
});
onMount(async () => {
await Promise.all([loadProjects(), loadTeamMembers(), loadAllocations()]);
});
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 loadAllocations() {
try {
loading = true;
error = null;
const response = await allocationService.getAll(currentPeriod) as Allocation[];
allocations = response;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load allocations';
console.error('Error loading allocations:', err);
} finally {
loading = false;
}
}
function getAllocation(projectId: string, teamMemberId: string | null): Allocation | undefined {
return allocations.find(a => a.project_id === projectId && a.team_member_id === teamMemberId);
}
function getProjectRowTotal(projectId: string): number {
return allocations
.filter(a => a.project_id === projectId)
.reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0);
}
/**
* Get row variance for a project.
* Uses the first allocation's row_variance data (all allocations for same project have same row variance)
*/
function getProjectRowVariance(projectId: string) {
const projectAllocations = allocations.filter(a => a.project_id === projectId);
if (projectAllocations.length === 0) return null;
return projectAllocations[0].row_variance ?? null;
}
function getTeamMemberColumnTotal(teamMemberId: string): number {
return allocations
.filter(a => a.team_member_id === teamMemberId)
.reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0);
}
/**
* Get column variance for a team member.
*/
function getTeamMemberColumnVariance(teamMemberId: string) {
const memberAllocations = allocations.filter(a => a.team_member_id === teamMemberId);
if (memberAllocations.length === 0) return null;
return memberAllocations[0].column_variance || null;
}
function getProjectTotal(): number {
return allocations.reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0);
}
/**
* Get row variance status for a project (OVER/UNDER/MATCH)
* Uses red/amber/neutral per design
*/
function getProjectRowStatus(projectId: string): 'OVER' | 'UNDER' | 'MATCH' | null {
const variance = getProjectRowVariance(projectId);
return variance?.status || null;
}
/**
* Get column variance status for a team member
*/
function getTeamMemberColumnStatus(teamMemberId: string): 'OVER' | 'UNDER' | 'MATCH' | null {
const variance = getTeamMemberColumnVariance(teamMemberId);
return variance?.status || null;
}
/**
* Format status badge class - red/amber/neutral per design
*/
function getStatusBadgeClass(status: string | null): string {
switch (status) {
case 'OVER': return 'badge-error';
case 'UNDER': return 'badge-warning';
case 'MATCH': return 'badge-neutral';
default: return 'badge-ghost';
}
}
/**
* Format status text
*/
function getStatusText(status: string | null): string {
if (!status) return '-';
return status;
}
function handleCellClick(projectId: string, teamMemberId: string | null) {
const existing = getAllocation(projectId, teamMemberId);
if (existing) {
// Edit existing
editingAllocation = existing;
formData = {
project_id: existing.project_id,
team_member_id: existing.team_member_id || '',
month: existing.month,
allocated_hours: parseFloat(existing.allocated_hours)
};
} else {
// Create new
editingAllocation = null;
formData = {
project_id: projectId,
team_member_id: teamMemberId || '',
month: currentPeriod,
allocated_hours: 0
};
}
formError = null;
showModal = true;
}
async function handleSubmit() {
try {
formLoading = true;
formError = null;
// Handle untracked: team_member_id can be empty string or null
const submitData = {
...formData,
team_member_id: formData.team_member_id || null
};
if (editingAllocation) {
await allocationService.update(editingAllocation.id, {
allocated_hours: formData.allocated_hours
});
} else {
await allocationService.create(submitData);
}
showModal = false;
await loadAllocations();
} catch (err) {
const apiError = err as { message?: string; data?: { errors?: Record<string, string[]> } };
if (apiError.data?.errors) {
const errors = Object.entries(apiError.data.errors)
.map(([field, msgs]) => `${field}: ${msgs.join(', ')}`)
.join('; ');
formError = errors;
} else {
formError = apiError.message || 'An error occurred';
}
} finally {
formLoading = false;
}
}
async function handleDelete() {
if (!editingAllocation) return;
try {
formLoading = true;
await allocationService.delete(editingAllocation.id);
showModal = false;
await loadAllocations();
} catch (err) {
const apiError = err as { message?: string };
formError = apiError.message || 'Failed to delete allocation';
} finally {
formLoading = false;
}
}
function closeModal() {
showModal = false;
editingAllocation = null;
formError = null;
}
function getProjectName(projectId: string): string {
const project = projects.find(p => p.id === projectId);
return project ? `${project.code} - ${project.title}` : 'Unknown';
}
function getTeamMemberName(teamMemberId: string | null): string {
if (!teamMemberId) return 'Untracked';
const member = teamMembers.find(m => m.id === teamMemberId);
return member?.name || 'Unknown';
}
function formatMonth(period: string): string {
const [year, month] = period.split('-');
const date = new Date(parseInt(year), parseInt(month) - 1);
return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
}
</script>
<svelte:head>
<title>Allocations | Headroom</title>
</svelte:head>
<PageHeader title="Resource Allocations" description="Manage team member allocations to projects">
{#snippet children()}
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => previousMonth()}>
<ChevronLeft size={18} />
</button>
<span class="min-w-[140px] text-center font-medium">
{formatMonth(currentPeriod)}
</span>
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => nextMonth()}>
<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}
<!-- Allocation Matrix -->
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<thead>
<tr>
<th class="sticky left-0 bg-base-200 z-10 min-w-[200px]">Project</th>
{#each teamMembers as member}
<th class="text-center min-w-[100px]">{member.name}</th>
{/each}
<th class="text-center bg-base-200 font-bold">Total</th>
</tr>
</thead>
<tbody>
{#each projects as project}
<tr class="hover">
<td class="sticky left-0 bg-base-100 z-10 font-medium">
{project.code} - {project.title}
</td>
{#each teamMembers as member}
{@const allocation = getAllocation(project.id, member.id)}
<td
class="text-center cursor-pointer hover:bg-base-200 transition-colors"
onclick={() => handleCellClick(project.id, member.id)}
>
{#if allocation}
{@const indicator = allocation.allocation_indicator || 'gray'}
<span
class="badge badge-sm {indicator === 'green' ? 'badge-success' : indicator === 'yellow' ? 'badge-warning' : indicator === 'red' ? 'badge-error' : 'badge-ghost'}"
title="{indicator === 'green' ? 'Fully allocated' : indicator === 'yellow' ? 'Under allocated' : indicator === 'red' ? 'Over allocated' : 'No budget'}"
>
{allocation.allocated_hours}h
</span>
{:else}
<span class="text-base-content/30">-</span>
{/if}
</td>
{/each}
<!-- Untracked column -->
<td
class="text-center cursor-pointer hover:bg-base-200 transition-colors bg-base-200/30"
onclick={() => handleCellClick(project.id, null)}
>
{#if getAllocation(project.id, null)}
{@const untracked = getAllocation(project.id, null)}
<span class="badge badge-sm badge-ghost" title="Untracked allocation">
{untracked?.allocated_hours}h
</span>
{:else}
<span class="text-base-content/30">-</span>
{/if}
</td>
<!-- Row Total with Variance Status -->
<td class="text-center bg-base-200 font-bold">
{getProjectRowTotal(project.id)}h
{#if getProjectRowStatus(project.id)}
{@const rowStatus = getProjectRowStatus(project.id)}
<span class="badge badge-sm {getStatusBadgeClass(rowStatus)} ml-1">
{getStatusText(rowStatus)}
</span>
{/if}
</td>
</tr>
{/each}
</tbody>
<!-- Variance Summary Section - replaces Monthly Budget -->
<tfoot>
<tr class="bg-base-200/50">
<td class="font-bold">Planned</td>
{#each teamMembers as member}
<td class="text-center">-</td>
{/each}
<td class="text-center bg-base-200/50">-</td>
<td class="text-center">-</td>
</tr>
<tr class="bg-base-200/50">
<td class="font-bold">Variance</td>
{#each teamMembers as member}
{@const colStatus = getTeamMemberColumnStatus(member.id)}
<td class="text-center">
{#if colStatus}
<span class="badge badge-sm {getStatusBadgeClass(colStatus)}">
{getStatusText(colStatus)}
</span>
{:else}
-
{/if}
</td>
{/each}
<td class="text-center bg-base-200/50">-</td>
<td class="text-center">-</td>
</tr>
</tfoot>
<tfoot>
<tr class="font-bold bg-base-200">
<td class="sticky left-0 bg-base-200 z-10">Total</td>
{#each teamMembers as member}
<td class="text-center">
{getTeamMemberColumnTotal(member.id)}h
</td>
{/each}
<td class="text-center bg-base-200">
{allocations.filter(a => a.team_member_id === null).reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0)}h
</td>
<td class="text-center">
{getProjectTotal()}h
</td>
</tr>
</tfoot>
</table>
</div>
{#if projects.length === 0}
<EmptyState
title="No projects"
description="Create a project first to manage allocations."
/>
{/if}
{/if}
<!-- Allocation Modal -->
{#if showModal}
<div class="modal modal-open">
<div class="modal-box max-w-md">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">
{editingAllocation ? 'Edit Allocation' : 'New Allocation'}
</h3>
<button class="btn btn-ghost btn-sm btn-circle" onclick={closeModal}>
<X size={18} />
</button>
</div>
{#if formError}
<div class="alert alert-error mb-4">
<AlertCircle size={16} />
<span>{formError}</span>
</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<!-- Project (read-only for existing) -->
<div class="form-control mb-4">
<label class="label" for="project">
<span class="label-text font-medium">Project</span>
</label>
<input
type="text"
id="project"
class="input input-bordered w-full"
value={getProjectName(formData.project_id)}
disabled
/>
</div>
<!-- Team Member (read-only for existing) -->
<div class="form-control mb-4">
<label class="label" for="team_member">
<span class="label-text font-medium">Team Member</span>
</label>
<input
type="text"
id="team_member"
class="input input-bordered w-full"
value={getTeamMemberName(formData.team_member_id)}
disabled
/>
</div>
<!-- Month (read-only) -->
<div class="form-control mb-4">
<label class="label" for="month">
<span class="label-text font-medium">Month</span>
</label>
<input
type="text"
id="month"
class="input input-bordered w-full"
value={formatMonth(formData.month)}
disabled
/>
</div>
<!-- Allocated Hours -->
<div class="form-control mb-6">
<label class="label" for="allocated_hours">
<span class="label-text font-medium">Allocated Hours</span>
</label>
<input
type="number"
id="allocated_hours"
class="input input-bordered w-full"
bind:value={formData.allocated_hours}
min="0"
step="0.5"
required
/>
</div>
<div class="modal-action">
{#if editingAllocation}
<button
type="button"
class="btn btn-error"
onclick={handleDelete}
disabled={formLoading}
>
<Trash2 size={16} />
Delete
</button>
{/if}
<button type="button" class="btn btn-ghost" onclick={closeModal}>Cancel</button>
<button type="submit" class="btn btn-primary" disabled={formLoading}>
{#if formLoading}
<span class="loading loading-spinner loading-sm"></span>
{/if}
{editingAllocation ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
<div class="modal-backdrop" onclick={closeModal} onkeydown={(e) => e.key === 'Escape' && closeModal()} role="button" tabindex="-1"></div>
</div>
{/if}