feat(allocation): implement resource allocation feature

- Add AllocationController with CRUD + bulk endpoints
- Add AllocationValidationService for capacity/estimate validation
- Add AllocationMatrixService for optimized matrix queries
- Add AllocationPolicy for authorization
- Add AllocationResource for API responses
- Add frontend allocationService and matrix UI
- Add E2E tests for allocation matrix (20 tests)
- Add unit tests for validation service and policies
- Fix month format conversion (YYYY-MM to YYYY-MM-01)
This commit is contained in:
2026-02-25 16:28:47 -05:00
parent fedfc21425
commit 3324c4f156
35 changed files with 3337 additions and 67 deletions

View File

@@ -1,17 +1,396 @@
<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 { Calendar } from 'lucide-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;
allocations = await allocationService.getAll(currentPeriod);
} 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): 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);
}
function getTeamMemberColumnTotal(teamMemberId: string): number {
return allocations
.filter(a => a.team_member_id === teamMemberId)
.reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0);
}
function getProjectTotal(): number {
return allocations.reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0);
}
function handleCellClick(projectId: string, teamMemberId: string) {
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;
if (editingAllocation) {
await allocationService.update(editingAllocation.id, {
allocated_hours: formData.allocated_hours
});
} else {
await allocationService.create(formData);
}
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): string {
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="Allocations" description="Manage resource allocations" />
<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>
<EmptyState
title="Coming Soon"
description="Resource allocation management will be available in a future update."
icon={Calendar}
/>
{#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}
<span class="badge badge-primary badge-sm">
{allocation.allocated_hours}h
</span>
{:else}
<span class="text-base-content/30">-</span>
{/if}
</td>
{/each}
<td class="text-center bg-base-200 font-bold">
{getProjectRowTotal(project.id)}h
</td>
</tr>
{/each}
</tbody>
<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">
{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}