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.
This commit is contained in:
2026-03-08 18:23:00 -04:00
parent 9b0f42fdf5
commit dd8055f6b7
7 changed files with 180 additions and 89 deletions

View File

@@ -5,6 +5,7 @@ export const navigationSections: NavSection[] = [
title: 'PLANNING', title: 'PLANNING',
items: [ items: [
{ label: 'Dashboard', href: '/dashboard', icon: 'LayoutDashboard' }, { label: 'Dashboard', href: '/dashboard', icon: 'LayoutDashboard' },
{ label: 'Planning', href: '/planning', icon: 'CalendarCheck' },
{ label: 'Team Members', href: '/team-members', icon: 'Users' }, { label: 'Team Members', href: '/team-members', icon: 'Users' },
{ label: 'Projects', href: '/projects', icon: 'Folder' }, { label: 'Projects', href: '/projects', icon: 'Folder' },
{ label: 'Allocations', href: '/allocations', icon: 'Calendar' }, { label: 'Allocations', href: '/allocations', icon: 'Calendar' },

View File

@@ -6,13 +6,34 @@
import { api } from './api'; import { api } from './api';
export type VarianceStatus = 'OVER' | 'UNDER' | 'MATCH' | null;
export interface RowVariance {
allocated_total: number;
planned_month: number;
variance: number;
status: VarianceStatus;
}
export interface ColumnVariance {
allocated: number;
capacity: number;
variance: number;
status: VarianceStatus;
}
export interface Allocation { export interface Allocation {
id: string; id: string;
project_id: string; project_id: string;
team_member_id: string | null; team_member_id: string | null;
month: string; month: string;
allocated_hours: string; allocated_hours: string;
is_untracked?: boolean;
row_variance?: RowVariance;
column_variance?: ColumnVariance | null;
allocation_indicator?: 'green' | 'yellow' | 'red' | 'gray'; allocation_indicator?: 'green' | 'yellow' | 'red' | 'gray';
warnings?: string[];
utilization?: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
project?: { project?: {
@@ -28,7 +49,7 @@ export interface Allocation {
export interface CreateAllocationRequest { export interface CreateAllocationRequest {
project_id: string; project_id: string;
team_member_id: string; team_member_id: string | null;
month: string; month: string;
allocated_hours: number; allocated_hours: number;
} }
@@ -41,6 +62,12 @@ export interface BulkAllocationRequest {
allocations: CreateAllocationRequest[]; allocations: CreateAllocationRequest[];
} }
export interface BulkAllocationResponse {
data: Array<{ index: number; id: string; status: string }>;
failed: Array<{ index: number; errors: Record<string, string[]> }>;
summary: { created: number; failed: number };
}
// Allocation API methods // Allocation API methods
export const allocationService = { export const allocationService = {
/** /**
@@ -54,14 +81,12 @@ export const allocationService = {
/** /**
* Get a single allocation by ID * Get a single allocation by ID
*/ */
getById: (id: string) => getById: (id: string) => api.get<Allocation>(`/allocations/${id}`),
api.get<Allocation>(`/allocations/${id}`),
/** /**
* Create a new allocation * Create a new allocation
*/ */
create: (data: CreateAllocationRequest) => create: (data: CreateAllocationRequest) => api.post<Allocation>('/allocations', data),
api.post<Allocation>('/allocations', data),
/** /**
* Update an existing allocation * Update an existing allocation
@@ -72,14 +97,13 @@ export const allocationService = {
/** /**
* Delete an allocation * Delete an allocation
*/ */
delete: (id: string) => delete: (id: string) => api.delete<{ message: string }>(`/allocations/${id}`),
api.delete<{ message: string }>(`/allocations/${id}`),
/** /**
* Bulk create allocations * Bulk create allocations
*/ */
bulkCreate: (data: BulkAllocationRequest) => bulkCreate: (data: BulkAllocationRequest) =>
api.post<Allocation[]>('/allocations/bulk', data), api.post<BulkAllocationResponse>('/allocations/bulk', data),
}; };
/** /**

View File

@@ -2,7 +2,9 @@ import { api } from '$lib/services/api';
export interface ProjectMonthPlan { export interface ProjectMonthPlan {
project_id: string; project_id: string;
project_code: string;
project_name: string; project_name: string;
project_status: string | null;
approved_estimate: number; approved_estimate: number;
months: Record<string, { months: Record<string, {
id: string; id: string;
@@ -13,13 +15,6 @@ export interface ProjectMonthPlan {
reconciliation_status: 'OVER' | 'UNDER' | 'MATCH'; reconciliation_status: 'OVER' | 'UNDER' | 'MATCH';
} }
export interface ProjectMonthPlansResponse {
data: ProjectMonthPlan[];
meta: {
year: number;
};
}
export interface BulkUpdateRequest { export interface BulkUpdateRequest {
year: number; year: number;
items: Array<{ items: Array<{
@@ -39,14 +34,13 @@ export interface BulkUpdateResponse {
} }
class ProjectMonthPlanService { class ProjectMonthPlanService {
async getPlans(year: number): Promise<ProjectMonthPlansResponse> { // Note: unwrapResponse strips the {data} wrapper, so this returns the array directly
const response = await api.get<ProjectMonthPlansResponse>(`/project-month-plans?year=${year}`); async getPlans(year: number): Promise<ProjectMonthPlan[]> {
return response.data; return api.get<ProjectMonthPlan[]>(`/project-month-plans?year=${year}`);
} }
async bulkUpdate(request: BulkUpdateRequest): Promise<BulkUpdateResponse> { async bulkUpdate(request: BulkUpdateRequest): Promise<BulkUpdateResponse> {
const response = await api.put<BulkUpdateResponse>('/project-month-plans/bulk', request); return api.put<BulkUpdateResponse>('/project-month-plans/bulk', request);
return response.data;
} }
} }

View File

@@ -35,6 +35,8 @@ export interface UpdateProjectRequest {
code?: string; code?: string;
title?: string; title?: string;
type_id?: number; type_id?: number;
status_id?: number;
approved_estimate?: number | null;
} }
export const projectService = { export const projectService = {

View File

@@ -76,7 +76,8 @@
try { try {
loading = true; loading = true;
error = null; error = null;
allocations = await allocationService.getAll(currentPeriod); const response = await allocationService.getAll(currentPeriod) as Allocation[];
allocations = response;
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : 'Failed to load allocations'; error = err instanceof Error ? err.message : 'Failed to load allocations';
console.error('Error loading allocations:', err); console.error('Error loading allocations:', err);
@@ -85,7 +86,7 @@
} }
} }
function getAllocation(projectId: string, teamMemberId: string): Allocation | undefined { function getAllocation(projectId: string, teamMemberId: string | null): Allocation | undefined {
return allocations.find(a => a.project_id === projectId && a.team_member_id === teamMemberId); return allocations.find(a => a.project_id === projectId && a.team_member_id === teamMemberId);
} }
@@ -95,41 +96,80 @@
.reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0); .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 { function getTeamMemberColumnTotal(teamMemberId: string): number {
return allocations return allocations
.filter(a => a.team_member_id === teamMemberId) .filter(a => a.team_member_id === teamMemberId)
.reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0); .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 { function getProjectTotal(): number {
return allocations.reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0); return allocations.reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0);
} }
function getProjectBudget(projectId: string): number { /**
const project = projects.find(p => p.id === projectId); * Get row variance status for a project (OVER/UNDER/MATCH)
if (!project?.approved_estimate) return 0; * Uses red/amber/neutral per design
// Monthly budget = approved_estimate / 12 */
return Math.round(Number(project.approved_estimate) / 12); function getProjectRowStatus(projectId: string): 'OVER' | 'UNDER' | 'MATCH' | null {
const variance = getProjectRowVariance(projectId);
return variance?.status || null;
} }
function getProjectBudgetStatus(projectId: string): 'over' | 'under' | 'ok' { /**
const budget = getProjectBudget(projectId); * Get column variance status for a team member
const allocated = getProjectRowTotal(projectId); */
if (budget === 0) return 'under'; function getTeamMemberColumnStatus(teamMemberId: string): 'OVER' | 'UNDER' | 'MATCH' | null {
const percentage = (allocated / budget) * 100; const variance = getTeamMemberColumnVariance(teamMemberId);
if (percentage > 100) return 'over'; return variance?.status || null;
if (percentage >= 100) return 'ok';
return 'under';
} }
function handleCellClick(projectId: string, teamMemberId: string) { /**
* 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); const existing = getAllocation(projectId, teamMemberId);
if (existing) { if (existing) {
// Edit existing // Edit existing
editingAllocation = existing; editingAllocation = existing;
formData = { formData = {
project_id: existing.project_id, project_id: existing.project_id,
team_member_id: existing.team_member_id, team_member_id: existing.team_member_id || '',
month: existing.month, month: existing.month,
allocated_hours: parseFloat(existing.allocated_hours) allocated_hours: parseFloat(existing.allocated_hours)
}; };
@@ -138,7 +178,7 @@
editingAllocation = null; editingAllocation = null;
formData = { formData = {
project_id: projectId, project_id: projectId,
team_member_id: teamMemberId, team_member_id: teamMemberId || '',
month: currentPeriod, month: currentPeriod,
allocated_hours: 0 allocated_hours: 0
}; };
@@ -152,12 +192,18 @@
formLoading = true; formLoading = true;
formError = null; 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) { if (editingAllocation) {
await allocationService.update(editingAllocation.id, { await allocationService.update(editingAllocation.id, {
allocated_hours: formData.allocated_hours allocated_hours: formData.allocated_hours
}); });
} else { } else {
await allocationService.create(formData); await allocationService.create(submitData);
} }
showModal = false; showModal = false;
@@ -204,7 +250,8 @@
return project ? `${project.code} - ${project.title}` : 'Unknown'; return project ? `${project.code} - ${project.title}` : 'Unknown';
} }
function getTeamMemberName(teamMemberId: string): string { function getTeamMemberName(teamMemberId: string | null): string {
if (!teamMemberId) return 'Untracked';
const member = teamMembers.find(m => m.id === teamMemberId); const member = teamMembers.find(m => m.id === teamMemberId);
return member?.name || 'Unknown'; return member?.name || 'Unknown';
} }
@@ -281,38 +328,60 @@
{/if} {/if}
</td> </td>
{/each} {/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"> <td class="text-center bg-base-200 font-bold">
{getProjectRowTotal(project.id)}h {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> </td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
<!-- Monthly Budget Section --> <!-- Variance Summary Section - replaces Monthly Budget -->
<tfoot> <tfoot>
<tr class="bg-base-200/50"> <tr class="bg-base-200/50">
<td class="font-bold">Monthly Budget</td> <td class="font-bold">Planned</td>
{#each teamMembers as member} {#each teamMembers as member}
<td class="text-center">-</td> <td class="text-center">-</td>
{/each} {/each}
<td class="text-center"> <td class="text-center bg-base-200/50">-</td>
{Math.round(projects.reduce((sum, p) => sum + (getProjectBudget(p.id) || 0), 0))}h <td class="text-center">-</td>
</td>
</tr> </tr>
<tr class="bg-base-200/50"> <tr class="bg-base-200/50">
<td class="font-bold">Status</td> <td class="font-bold">Variance</td>
{#each teamMembers as member} {#each teamMembers as member}
<td class="text-center">-</td> {@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} {/each}
<td class="text-center"> <td class="text-center bg-base-200/50">-</td>
{#if getProjectTotal() > projects.reduce((sum, p) => sum + getProjectBudget(p.id), 0)} <td class="text-center">-</td>
<span class="badge badge-error badge-sm">OVER</span>
{:else if getProjectTotal() < projects.reduce((sum, p) => sum + getProjectBudget(p.id), 0)}
<span class="badge badge-warning badge-sm">UNDER</span>
{:else}
<span class="badge badge-success badge-sm">OK</span>
{/if}
</td>
</tr> </tr>
</tfoot> </tfoot>
<tfoot> <tfoot>
@@ -323,6 +392,9 @@
{getTeamMemberColumnTotal(member.id)}h {getTeamMemberColumnTotal(member.id)}h
</td> </td>
{/each} {/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"> <td class="text-center">
{getProjectTotal()}h {getProjectTotal()}h
</td> </td>

View File

@@ -132,11 +132,14 @@
function handleEdit(project: Project) { function handleEdit(project: Project) {
editingProject = project; editingProject = project;
// Use scalar IDs from API response, fallback to nested objects for backward compatibility
const typeId = project.type_id ?? project.type?.id ?? 0;
const statusId = project.status_id ?? project.status?.id;
formData = { formData = {
code: project.code, code: project.code,
title: project.title, title: project.title,
type_id: project.type_id, type_id: typeId,
status_id: project.status_id, status_id: statusId,
approved_estimate: project.approved_estimate ? parseFloat(String(project.approved_estimate)) : null approved_estimate: project.approved_estimate ? parseFloat(String(project.approved_estimate)) : null
}; };
formError = null; formError = null;
@@ -154,26 +157,14 @@
formError = null; formError = null;
if (editingProject) { if (editingProject) {
// Update basic info // Update basic info including status and estimate
await projectService.update(editingProject.id, { await projectService.update(editingProject.id, {
code: formData.code, code: formData.code,
title: formData.title, title: formData.title,
type_id: formData.type_id type_id: formData.type_id,
status_id: formData.status_id,
approved_estimate: formData.approved_estimate
}); });
// Update status if changed
if (formData.status_id && formData.status_id !== editingProject.status_id) {
await projectService.updateStatus(editingProject.id, formData.status_id);
}
// Update estimate if changed
const newEstimate = formData.approved_estimate ?? null;
const oldEstimate = editingProject.approved_estimate
? parseFloat(String(editingProject.approved_estimate))
: null;
if (newEstimate !== oldEstimate && newEstimate !== null) {
await projectService.setEstimate(editingProject.id, newEstimate);
}
} else { } else {
await projectService.create({ await projectService.create({
code: formData.code, code: formData.code,

View File

@@ -25,7 +25,7 @@
// Form state // Form state
let formData = $state<CreateTeamMemberRequest>({ let formData = $state<CreateTeamMemberRequest>({
name: '', name: '',
role_id: 13, // Default to first role role_id: 0, // Will be set from roles after load
hourly_rate: 0, hourly_rate: 0,
active: true active: true
}); });
@@ -71,24 +71,20 @@
async function loadRoles() { async function loadRoles() {
try { try {
// Role IDs from database seeder // Fetch roles from API endpoint
roles = [ roles = await api.get<{ id: number; name: string }[]>('/roles');
{ id: 13, name: 'Frontend Dev' },
{ id: 14, name: 'Backend Dev' },
{ id: 15, name: 'QA' },
{ id: 16, name: 'DevOps' },
{ id: 17, name: 'UX' },
{ id: 18, name: 'PM' },
{ id: 19, name: 'Architect' }
];
} catch (err) { } catch (err) {
console.error('Error loading roles:', err); console.error('Error loading roles:', err);
// Fallback to empty array - form should not submit without valid roles
roles = [];
} }
} }
function handleCreate() { function handleCreate() {
editingMember = null; editingMember = null;
formData = { name: '', role_id: roles[0]?.id || 0, hourly_rate: 0, active: true }; // Default to first role if available
const defaultRoleId = roles.length > 0 ? roles[0].id : 0;
formData = { name: '', role_id: defaultRoleId, hourly_rate: 0, active: true };
formError = null; formError = null;
showModal = true; showModal = true;
} }
@@ -115,6 +111,17 @@
formLoading = true; formLoading = true;
formError = null; formError = null;
// Validate roles are loaded
if (roles.length === 0) {
formError = 'Roles not loaded. Please refresh the page.';
return;
}
if (!formData.role_id || formData.role_id === 0) {
formError = 'Please select a valid role.';
return;
}
if (editingMember) { if (editingMember) {
await teamMemberService.update(editingMember.id, formData); await teamMemberService.update(editingMember.id, formData);
} else { } else {