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:
@@ -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' },
|
||||||
|
|||||||
@@ -1,18 +1,39 @@
|
|||||||
/**
|
/**
|
||||||
* Allocation Service
|
* Allocation Service
|
||||||
*
|
*
|
||||||
* API operations for resource allocation management.
|
* API operations for resource allocation management.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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,32 +81,29 @@ 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
|
||||||
*/
|
*/
|
||||||
update: (id: string, data: UpdateAllocationRequest) =>
|
update: (id: string, data: UpdateAllocationRequest) =>
|
||||||
api.put<Allocation>(`/allocations/${id}`, data),
|
api.put<Allocation>(`/allocations/${id}`, data),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user