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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user