feat(project): Complete Project Lifecycle capability with full TDD workflow

- Implement ProjectController with CRUD, status transitions, estimate/forecast
- Add ProjectService with state machine validation
- Extract ProjectStatusService for reusable state machine logic
- Add ProjectPolicy for role-based authorization
- Create ProjectSeeder with test data
- Implement frontend project management UI with modal forms
- Add projectService API client
- Complete all 9 incomplete unit tests (ProjectModelTest, ProjectForecastTest, ProjectPolicyTest)
- Fix E2E test timing issues with loading state waits
- Add Scribe API documentation annotations
- Improve forecasted effort validation messages with detailed feedback

Test Results:
- Backend: 49 passed (182 assertions)
- Frontend Unit: 32 passed
- E2E: 134 passed (Chromium + Firefox)

Phase 3 Refactor:
- Extract ProjectStatusService for state machine
- Optimize project list query with status joins
- Improve forecasted effort validation messages

Phase 4 Document:
- Add Scribe annotations to ProjectController
- Generate API documentation
This commit is contained in:
2026-02-19 02:43:05 -05:00
parent 8f70e81d29
commit 8ed56c9f7c
19 changed files with 5126 additions and 173 deletions

View File

@@ -0,0 +1,102 @@
import { api } from './api';
export interface ProjectType {
id: number;
name: string;
}
export interface ProjectStatus {
id: number;
name: string;
order?: number;
}
export interface Project {
id: string;
code: string;
title: string;
type_id: number;
status_id: number;
type?: ProjectType;
status?: ProjectStatus;
approved_estimate?: string | number | null;
forecasted_effort?: Record<string, number> | null;
created_at?: string;
updated_at?: string;
}
export interface CreateProjectRequest {
code: string;
title: string;
type_id: number;
}
export interface UpdateProjectRequest {
code?: string;
title?: string;
type_id?: number;
}
export const projectService = {
/**
* Get all projects
*/
getAll: (statusId?: number, typeId?: number) => {
const params = new URLSearchParams();
if (statusId) params.append('status_id', String(statusId));
if (typeId) params.append('type_id', String(typeId));
const query = params.toString() ? `?${params.toString()}` : '';
return api.get<Project[]>(`/projects${query}`);
},
/**
* Get a single project by ID
*/
getById: (id: string) => api.get<Project>(`/projects/${id}`),
/**
* Create a new project
*/
create: (data: CreateProjectRequest) => api.post<Project>('/projects', data),
/**
* Update project basic info (code, title, type)
*/
update: (id: string, data: UpdateProjectRequest) =>
api.put<Project>(`/projects/${id}`, data),
/**
* Delete a project
*/
delete: (id: string) => api.delete<{ message: string }>(`/projects/${id}`),
/**
* Transition project status
*/
updateStatus: (id: string, statusId: number) =>
api.put<Project>(`/projects/${id}/status`, { status_id: statusId }),
/**
* Set approved estimate
*/
setEstimate: (id: string, estimate: number) =>
api.put<Project>(`/projects/${id}/estimate`, { approved_estimate: estimate }),
/**
* Set forecasted effort
*/
setForecast: (id: string, forecast: Record<string, number>) =>
api.put<Project>(`/projects/${id}/forecast`, { forecasted_effort: forecast }),
/**
* Get all project types
*/
getTypes: () => api.get<ProjectType[]>('/projects/types'),
/**
* Get all project statuses
*/
getStatuses: () => api.get<ProjectStatus[]>('/projects/statuses'),
};
export default projectService;

View File

@@ -1,110 +1,464 @@
<script lang="ts">
import { onMount } from 'svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import DataTable from '$lib/components/common/DataTable.svelte';
import FilterBar from '$lib/components/common/FilterBar.svelte';
import { Plus } from 'lucide-svelte';
import { onMount } from 'svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import DataTable from '$lib/components/common/DataTable.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 } from 'lucide-svelte';
import { projectService, type Project, type ProjectType, type ProjectStatus } from '$lib/services/projectService';
import type { ColumnDef } from '@tanstack/table-core';
interface Project {
id: string;
code: string;
title: string;
status: string;
type: string;
}
const STATUS_BADGES: Record<string, string> = {
'Pre-sales': 'badge-ghost',
'SOW Approval': 'badge-info',
Estimation: 'badge-info',
'Estimate Approved': 'badge-success',
'Resource Allocation': 'badge-info',
'Sprint 0': 'badge-warning',
'In Progress': 'badge-primary',
UAT: 'badge-primary',
'Handover / Sign-off': 'badge-warning',
'Estimate Rework': 'badge-warning',
'On Hold': 'badge-warning',
Cancelled: 'badge-error',
Closed: 'badge-success'
};
let data = $state<Project[]>([]);
let loading = $state(true);
let search = $state('');
let statusFilter = $state('all');
let typeFilter = $state('all');
interface ProjectFormState {
code: string;
title: string;
type_id: number;
status_id?: number;
approved_estimate: number | null;
}
const statusColors: Record<string, string> = {
'Estimate Requested': 'badge-info',
'Estimate Approved': 'badge-success',
'In Progress': 'badge-primary',
'On Hold': 'badge-warning',
'Completed': 'badge-ghost',
};
let data = $state<Project[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let search = $state('');
let statusFilter = $state<'all' | string>('all');
let typeFilter = $state<'all' | string>('all');
let showModal = $state(false);
let editingProject = $state<Project | null>(null);
let formLoading = $state(false);
let formError = $state<string | null>(null);
let types = $state<ProjectType[]>([]);
let statuses = $state<ProjectStatus[]>([]);
let formData = $state<ProjectFormState>({
code: '',
title: '',
type_id: 0,
status_id: undefined,
approved_estimate: null
});
const columns = [
{ accessorKey: 'code', header: 'Code' },
{ accessorKey: 'title', header: 'Title' },
{
accessorKey: 'status',
header: 'Status'
},
{ accessorKey: 'type', header: 'Type' }
];
const columns: ColumnDef<Project>[] = [
{ accessorKey: 'code', header: 'Code' },
{ accessorKey: 'title', header: 'Title' },
{
accessorKey: 'status',
header: 'Status',
cell: (info) =>
info.row.original.status
? getStatusBadge(info.row.original.status.name)
: '-'
},
{
accessorKey: 'type',
header: 'Type',
cell: (info) => info.row.original.type?.name ?? '-'
},
{
accessorKey: 'approved_estimate',
header: 'Approved Estimate',
cell: (info) => formatEstimate(info.row.original.approved_estimate)
}
];
onMount(async () => {
// TODO: Replace with actual API call
data = [
{ id: '1', code: 'PROJ-001', title: 'Website Redesign', status: 'In Progress', type: 'Project' },
{ id: '2', code: 'PROJ-002', title: 'API Integration', status: 'Estimate Requested', type: 'Project' },
{ id: '3', code: 'SUP-001', title: 'Bug Fixes', status: 'On Hold', type: 'Support' },
];
loading = false;
});
onMount(async () => {
await Promise.all([loadProjects(), loadTypes(), loadStatuses()]);
});
let filteredData = $derived(data.filter(p => {
const matchesSearch = p.title.toLowerCase().includes(search.toLowerCase()) ||
p.code.toLowerCase().includes(search.toLowerCase());
const matchesStatus = statusFilter === 'all' || p.status === statusFilter;
const matchesType = typeFilter === 'all' || p.type === typeFilter;
return matchesSearch && matchesStatus && matchesType;
}));
async function loadProjects() {
try {
loading = true;
error = null;
data = await projectService.getAll();
} catch (err) {
error = extractErrorMessage(err, 'Failed to load projects');
console.error('Error loading projects:', err);
} finally {
loading = false;
}
}
function handleCreate() {
// TODO: Open create modal
console.log('Create project');
}
async function loadTypes() {
try {
types = await projectService.getTypes();
if (types.length && formData.type_id === 0) {
formData = { ...formData, type_id: types[0].id };
}
} catch (err) {
console.error('Error loading project types:', err);
}
}
function handleRowClick(row: Project) {
// TODO: Open edit modal or navigate to detail
console.log('Edit project:', row.id);
}
async function loadStatuses() {
try {
statuses = await projectService.getStatuses();
if (statuses.length && formData.status_id === undefined) {
formData = { ...formData, status_id: statuses[0].id };
}
} catch (err) {
console.error('Error loading project statuses:', err);
}
}
function handleCreate() {
const defaultType = types[0];
const defaultStatus = statuses[0];
editingProject = null;
formData = {
code: '',
title: '',
type_id: defaultType?.id ?? 0,
status_id: defaultStatus?.id,
approved_estimate: null
};
formError = null;
showModal = true;
}
function handleEdit(project: Project) {
editingProject = project;
formData = {
code: project.code,
title: project.title,
type_id: project.type_id,
status_id: project.status_id,
approved_estimate: project.approved_estimate ? parseFloat(String(project.approved_estimate)) : null
};
formError = null;
showModal = true;
}
async function handleSubmit() {
if (!formData.code.trim() || !formData.title.trim() || !formData.type_id) {
formError = 'Code, title, and type are required.';
return;
}
try {
formLoading = true;
formError = null;
if (editingProject) {
// Update basic info
await projectService.update(editingProject.id, {
code: formData.code,
title: formData.title,
type_id: formData.type_id
});
// 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 {
await projectService.create({
code: formData.code,
title: formData.title,
type_id: formData.type_id
});
}
showModal = false;
await loadProjects();
} catch (err) {
const message = extractErrorMessage(err);
if (message.toLowerCase().includes('unique')) {
formError = 'Project code must be unique.';
} else if (message.toLowerCase().includes('cannot transition')) {
formError = message;
} else {
formError = message;
}
} finally {
formLoading = false;
}
}
function closeModal() {
showModal = false;
editingProject = null;
formError = null;
}
function extractErrorMessage(error: unknown, fallback = 'An error occurred') {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'object' && error !== null && 'data' in error) {
const err = error as { data: Record<string, string[]>; message?: string };
if (err.data?.errors) {
return Object.values(err.data.errors)
.flat()
.join('; ');
}
if (err.message) {
return err.message;
}
}
return fallback;
}
function getStatusBadge(name?: string) {
if (!name) return '<span class="badge badge-sm badge-outline">-</span>';
const badgeClass = STATUS_BADGES[name] ?? 'badge-outline';
return `<span class="badge ${badgeClass} badge-sm">${name}</span>`;
}
function formatEstimate(value: number | string | null | undefined) {
if (value == null) return '-';
const num = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(num)) return '-';
return num.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2
});
}
function handleTypeChange(event: Event) {
const target = event.target as HTMLSelectElement;
formData.type_id = Number(target.value);
}
function handleStatusChange(event: Event) {
const target = event.target as HTMLSelectElement;
formData.status_id = Number(target.value);
}
function handleEstimateInput(event: Event) {
const target = event.target as HTMLInputElement;
const value = target.value;
formData.approved_estimate = value ? Number(value) : null;
}
function handleBackdropKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeModal();
}
}
let filteredData = $derived(data.filter((project) => {
const searchTerm = search.toLowerCase();
const matchesSearch =
project.title.toLowerCase().includes(searchTerm) ||
project.code.toLowerCase().includes(searchTerm);
const matchesStatus =
statusFilter === 'all' || project.status?.name === statusFilter;
const matchesType = typeFilter === 'all' || project.type?.name === typeFilter;
return matchesSearch && matchesStatus && matchesType;
}));
</script>
<svelte:head>
<title>Projects | Headroom</title>
<title>Projects | Headroom</title>
</svelte:head>
<PageHeader title="Projects" description="Manage project lifecycle">
{#snippet children()}
<button class="btn btn-primary btn-sm gap-2" onclick={handleCreate}>
<Plus size={16} />
New Project
</button>
{/snippet}
{#snippet children()}
<button class="btn btn-primary btn-sm gap-2" onclick={handleCreate}>
<Plus size={16} />
New Project
</button>
{/snippet}
</PageHeader>
<FilterBar
searchValue={search}
searchPlaceholder="Search projects..."
onSearchChange={(v) => search = v}
<FilterBar
searchValue={search}
searchPlaceholder="Search projects..."
onSearchChange={(value) => (search = value)}
>
{#snippet children()}
<select class="select select-sm" bind:value={statusFilter}>
<option value="all">All Status</option>
<option value="Estimate Requested">Estimate Requested</option>
<option value="In Progress">In Progress</option>
<option value="On Hold">On Hold</option>
<option value="Completed">Completed</option>
</select>
<select class="select select-sm" bind:value={typeFilter}>
<option value="all">All Types</option>
<option value="Project">Project</option>
<option value="Support">Support</option>
</select>
{/snippet}
{#snippet children()}
<select class="select select-sm" bind:value={statusFilter}>
<option value="all">All Status</option>
{#each statuses as status}
<option value={status.name}>{status.name}</option>
{/each}
</select>
<select class="select select-sm" bind:value={typeFilter}>
<option value="all">All Types</option>
{#each types as type}
<option value={type.name}>{type.name}</option>
{/each}
</select>
{/snippet}
</FilterBar>
<DataTable
data={filteredData}
{columns}
{loading}
emptyTitle="No projects"
emptyDescription="Create your first project to get started."
onRowClick={handleRowClick}
/>
{#if loading}
<LoadingState />
{:else if error}
<div class="alert alert-error">
<AlertCircle size={20} />
<span>{error}</span>
</div>
{:else if data.length === 0}
<EmptyState
title="No projects"
description="Create your first project to get started."
>
{#snippet children()}
<button class="btn btn-primary" onclick={handleCreate}>
<Plus size={16} />
New Project
</button>
{/snippet}
</EmptyState>
{:else}
<DataTable
data={filteredData}
{columns}
{loading}
emptyTitle="No matching projects"
emptyDescription="Try adjusting your search or filter."
onRowClick={handleEdit}
/>
{/if}
{#if showModal}
<div class="modal modal-open">
<div class="modal-box max-w-xl">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">
{editingProject ? 'Edit Project' : 'New Project'}
</h3>
<button class="btn btn-ghost btn-sm btn-circle" onclick={closeModal} aria-label="Close">
<X size={18} />
</button>
</div>
{#if formError}
<div class="alert alert-error mb-4 text-sm">
<AlertCircle size={16} />
<span class="ml-2">{formError}</span>
</div>
{/if}
<form onsubmit={(event) => { event.preventDefault(); handleSubmit(); }}>
<div class="form-control mb-4 flex flex-row items-center gap-4">
<label class="label w-28 shrink-0" for="project-code">
<span class="label-text font-medium">Code</span>
</label>
<input
id="project-code"
name="code"
class="input input-bordered flex-1"
type="text"
bind:value={formData.code}
placeholder="Enter project code"
required
/>
</div>
<div class="form-control mb-4 flex flex-row items-center gap-4">
<label class="label w-28 shrink-0" for="project-title">
<span class="label-text font-medium">Title</span>
</label>
<input
id="project-title"
name="title"
class="input input-bordered flex-1"
type="text"
bind:value={formData.title}
placeholder="Enter project title"
required
/>
</div>
<div class="form-control mb-4 flex flex-row items-center gap-4">
<label class="label w-28 shrink-0" for="project-type">
<span class="label-text font-medium">Type</span>
</label>
<select
id="project-type"
name="type_id"
class="select select-bordered flex-1"
onchange={handleTypeChange}
value={formData.type_id}
required
>
{#each types as type}
<option value={type.id}>{type.name}</option>
{/each}
</select>
</div>
{#if editingProject}
<div class="form-control mb-4 flex flex-row items-center gap-4">
<label class="label w-28 shrink-0" for="project-status">
<span class="label-text font-medium">Status</span>
</label>
<select
id="project-status"
name="status_id"
class="select select-bordered flex-1"
onchange={handleStatusChange}
value={formData.status_id}
required
>
{#each statuses as status}
<option value={status.id}>{status.name}</option>
{/each}
</select>
</div>
<div class="form-control mb-6 flex flex-row items-center gap-4">
<label class="label w-40 shrink-0" for="approved-estimate">
<span class="label-text font-medium">Approved Estimate</span>
</label>
<input
id="approved-estimate"
name="approved_estimate"
class="input input-bordered flex-1"
type="number"
min="0"
step="0.01"
value={formData.approved_estimate ?? ''}
oninput={handleEstimateInput}
/>
</div>
{/if}
<div class="modal-action">
<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}
{editingProject ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
<div
class="modal-backdrop"
onclick={closeModal}
onkeydown={handleBackdropKeydown}
role="button"
tabindex="-1"
></div>
</div>
{/if}