docs(openspec): add reporting API contract documentation

Add comprehensive API documentation for the reporting endpoint:
- Request/response structure
- View type inference (did/is/will)
- Blank vs explicit zero semantics
- Status values and error responses

Related to enhanced-allocation change.
This commit is contained in:
2026-03-08 18:22:27 -04:00
parent 3324c4f156
commit b7bbfb45c0
27 changed files with 1632 additions and 35 deletions

View File

@@ -138,6 +138,25 @@
});
}
}
for (const date of daysInMonth) {
const key = getCellKey(member.id, date);
if (cells.has(key)) {
continue;
}
const wknd = isWeekend(date);
const hol = holidayDates.has(date);
const defaultValue: NormalizedToken = hol
? { rawToken: 'H', numericValue: 0, valid: true }
: wknd
? { rawToken: 'O', numericValue: 0, valid: true }
: { rawToken: '1', numericValue: 1, valid: true };
cells.set(key, {
memberId: member.id,
date,
originalValue: defaultValue.numericValue,
currentValue: defaultValue
});
}
}
loading = false;

View File

@@ -9,9 +9,10 @@ import { api } from './api';
export interface Allocation {
id: string;
project_id: string;
team_member_id: string;
team_member_id: string | null;
month: string;
allocated_hours: string;
allocation_indicator?: 'green' | 'yellow' | 'red' | 'gray';
created_at: string;
updated_at: string;
project?: {
@@ -22,7 +23,7 @@ export interface Allocation {
team_member?: {
id: string;
name: string;
};
} | null;
}
export interface CreateAllocationRequest {

View File

@@ -0,0 +1,53 @@
import { api } from '$lib/services/api';
export interface ProjectMonthPlan {
project_id: string;
project_name: string;
approved_estimate: number;
months: Record<string, {
id: string;
planned_hours: number | null;
is_blank: boolean;
} | null>;
plan_sum: number;
reconciliation_status: 'OVER' | 'UNDER' | 'MATCH';
}
export interface ProjectMonthPlansResponse {
data: ProjectMonthPlan[];
meta: {
year: number;
};
}
export interface BulkUpdateRequest {
year: number;
items: Array<{
project_id: string;
month: string;
planned_hours: number | null;
}>;
}
export interface BulkUpdateResponse {
message: string;
summary: {
created: number;
updated: number;
cleared: number;
};
}
class ProjectMonthPlanService {
async getPlans(year: number): Promise<ProjectMonthPlansResponse> {
const response = await api.get<ProjectMonthPlansResponse>(`/project-month-plans?year=${year}`);
return response.data;
}
async bulkUpdate(request: BulkUpdateRequest): Promise<BulkUpdateResponse> {
const response = await api.put<BulkUpdateResponse>('/project-month-plans/bulk', request);
return response.data;
}
}
export const projectMonthPlanService = new ProjectMonthPlanService();

View File

@@ -105,6 +105,23 @@
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);
}
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';
}
function handleCellClick(projectId: string, teamMemberId: string) {
const existing = getAllocation(projectId, teamMemberId);
if (existing) {
@@ -252,7 +269,11 @@
onclick={() => handleCellClick(project.id, member.id)}
>
{#if allocation}
<span class="badge badge-primary badge-sm">
{@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}
@@ -266,6 +287,34 @@
</tr>
{/each}
</tbody>
<!-- Monthly Budget Section -->
<tfoot>
<tr class="bg-base-200/50">
<td class="font-bold">Monthly Budget</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>
</tr>
<tr class="bg-base-200/50">
<td class="font-bold">Status</td>
{#each teamMembers as member}
<td class="text-center">-</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>
</tr>
</tfoot>
<tfoot>
<tr class="font-bold bg-base-200">
<td class="sticky left-0 bg-base-200 z-10">Total</td>

View File

@@ -25,8 +25,8 @@
// Form state
let formData = $state<CreateTeamMemberRequest>({
name: '',
role_id: 0,
hourly_rate: 0,
role_id: 13, // Default to first role
hourly_rate: 0,
active: true
});
@@ -71,16 +71,15 @@
async function loadRoles() {
try {
// For now, we'll use hardcoded roles matching the backend seeder
// In a real app, you'd fetch this from an API endpoint
// Role IDs from database seeder
roles = [
{ id: 1, name: 'Frontend Developer' },
{ id: 2, name: 'Backend Developer' },
{ id: 3, name: 'QA Engineer' },
{ id: 4, name: 'DevOps Engineer' },
{ id: 5, name: 'UX Designer' },
{ id: 6, name: 'Project Manager' },
{ id: 7, name: 'Architect' }
{ 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) {
console.error('Error loading roles:', err);