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:
@@ -76,7 +76,8 @@
|
||||
try {
|
||||
loading = true;
|
||||
error = null;
|
||||
allocations = await allocationService.getAll(currentPeriod);
|
||||
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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -95,41 +96,80 @@
|
||||
.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);
|
||||
}
|
||||
|
||||
function getProjectBudget(projectId: string): number {
|
||||
const project = projects.find(p => p.id === projectId);
|
||||
if (!project?.approved_estimate) return 0;
|
||||
// Monthly budget = approved_estimate / 12
|
||||
return Math.round(Number(project.approved_estimate) / 12);
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
function getProjectBudgetStatus(projectId: string): 'over' | 'under' | 'ok' {
|
||||
const budget = getProjectBudget(projectId);
|
||||
const allocated = getProjectRowTotal(projectId);
|
||||
if (budget === 0) return 'under';
|
||||
const percentage = (allocated / budget) * 100;
|
||||
if (percentage > 100) return 'over';
|
||||
if (percentage >= 100) return 'ok';
|
||||
return 'under';
|
||||
/**
|
||||
* Get column variance status for a team member
|
||||
*/
|
||||
function getTeamMemberColumnStatus(teamMemberId: string): 'OVER' | 'UNDER' | 'MATCH' | null {
|
||||
const variance = getTeamMemberColumnVariance(teamMemberId);
|
||||
return variance?.status || null;
|
||||
}
|
||||
|
||||
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);
|
||||
if (existing) {
|
||||
// Edit existing
|
||||
editingAllocation = existing;
|
||||
formData = {
|
||||
project_id: existing.project_id,
|
||||
team_member_id: existing.team_member_id,
|
||||
team_member_id: existing.team_member_id || '',
|
||||
month: existing.month,
|
||||
allocated_hours: parseFloat(existing.allocated_hours)
|
||||
};
|
||||
@@ -138,7 +178,7 @@
|
||||
editingAllocation = null;
|
||||
formData = {
|
||||
project_id: projectId,
|
||||
team_member_id: teamMemberId,
|
||||
team_member_id: teamMemberId || '',
|
||||
month: currentPeriod,
|
||||
allocated_hours: 0
|
||||
};
|
||||
@@ -152,12 +192,18 @@
|
||||
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(formData);
|
||||
await allocationService.create(submitData);
|
||||
}
|
||||
|
||||
showModal = false;
|
||||
@@ -204,7 +250,8 @@
|
||||
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);
|
||||
return member?.name || 'Unknown';
|
||||
}
|
||||
@@ -281,38 +328,60 @@
|
||||
{/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>
|
||||
|
||||
<!-- Monthly Budget Section -->
|
||||
<!-- Variance Summary Section - replaces Monthly Budget -->
|
||||
<tfoot>
|
||||
<tr class="bg-base-200/50">
|
||||
<td class="font-bold">Monthly Budget</td>
|
||||
<td class="font-bold">Planned</td>
|
||||
{#each teamMembers as member}
|
||||
<td class="text-center">-</td>
|
||||
{/each}
|
||||
<td class="text-center">
|
||||
{Math.round(projects.reduce((sum, p) => sum + (getProjectBudget(p.id) || 0), 0))}h
|
||||
</td>
|
||||
<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">Status</td>
|
||||
<td class="font-bold">Variance</td>
|
||||
{#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}
|
||||
<td class="text-center">
|
||||
{#if getProjectTotal() > projects.reduce((sum, p) => sum + getProjectBudget(p.id), 0)}
|
||||
<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>
|
||||
<td class="text-center bg-base-200/50">-</td>
|
||||
<td class="text-center">-</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
<tfoot>
|
||||
@@ -323,6 +392,9 @@
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user