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.
518 lines
15 KiB
Svelte
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}
|