From 5d84e9e0d0ed260f77b127197e4f05cfc1c545c8 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Wed, 12 Aug 2026 00:32:22 -0400 Subject: [PATCH 1/6] feat(projects): project page, add/edit flow and navbar deep links from Figma Builds the project detail page and the add/edit project modal to the Figma design, and lets the sidebar jump straight into a project rather than making users pass through the list page first. The detail page previously fanned out to several endpoints to assemble one view. It now loads from a single GET /projects/{id}/overview, which returns the project, its members and its expenditures together, so the page renders in one round trip instead of N+1. GET /projects is enriched with total_spent, member_count and is_active so the list page no longer recomputes them per card, and GET /projects/assignable-staff backs the staff picker. Membership edits are synced transactionally against the submitted roster, so a partially applied update cannot leave a project with the wrong staff. Shared additions worth reusing: useAnchoredPopover positions and dismisses portalled popovers, which is what keeps the date and staff pickers from being clipped by the modal's scroll container. Co-authored-by: Cursor --- apps/backend/lambdas/projects/auth.ts | 32 ++ apps/backend/lambdas/projects/handler.ts | 327 ++++++++++++++- apps/backend/lambdas/projects/openapi.yaml | 226 ++++++++++- .../projects/test/project-page.unit.test.ts | 276 +++++++++++++ .../lambdas/projects/validation-utils.ts | 71 ++++ apps/frontend/AGENTS.md | 6 +- apps/frontend/mock-api/server.mjs | 362 +++++++++++++++++ apps/frontend/next.config.ts | 9 +- apps/frontend/src/app/components/Button.tsx | 74 ++++ .../src/app/components/DatePickerField.tsx | 245 +++++++++++ .../src/app/components/ExpensesTable.tsx | 69 ++-- .../src/app/components/FundingSummary.tsx | 69 ++++ apps/frontend/src/app/components/Header.tsx | 18 +- apps/frontend/src/app/components/Navbar.tsx | 267 +++++++++++- .../src/app/components/ProjectCard.tsx | 153 ++++--- .../src/app/components/ProjectFormModal.tsx | 383 ++++++++++++++++++ .../src/app/components/SectionHeading.tsx | 58 +++ .../src/app/components/SpendingDonut.tsx | 78 ++++ .../frontend/src/app/components/StaffCard.tsx | 127 ++++-- .../src/app/components/StaffPicker.tsx | 205 ++++++++++ .../src/app/components/TextInputField.tsx | 84 +++- .../app/projects/[id]/ProjectDetailClient.tsx | 282 +++++++------ apps/frontend/src/app/projects/page.tsx | 154 ++++--- apps/frontend/src/hooks/useAnchoredPopover.ts | 121 ++++++ apps/frontend/src/lib/format.ts | 93 +++++ apps/frontend/src/types/project.ts | 63 ++- .../test/components/ProjectPage.test.tsx | 116 +++++- 27 files changed, 3575 insertions(+), 393 deletions(-) create mode 100644 apps/backend/lambdas/projects/test/project-page.unit.test.ts create mode 100644 apps/frontend/mock-api/server.mjs create mode 100644 apps/frontend/src/app/components/Button.tsx create mode 100644 apps/frontend/src/app/components/DatePickerField.tsx create mode 100644 apps/frontend/src/app/components/FundingSummary.tsx create mode 100644 apps/frontend/src/app/components/ProjectFormModal.tsx create mode 100644 apps/frontend/src/app/components/SectionHeading.tsx create mode 100644 apps/frontend/src/app/components/SpendingDonut.tsx create mode 100644 apps/frontend/src/app/components/StaffPicker.tsx create mode 100644 apps/frontend/src/hooks/useAnchoredPopover.ts create mode 100644 apps/frontend/src/lib/format.ts diff --git a/apps/backend/lambdas/projects/auth.ts b/apps/backend/lambdas/projects/auth.ts index d7167a61..66e9fff6 100644 --- a/apps/backend/lambdas/projects/auth.ts +++ b/apps/backend/lambdas/projects/auth.ts @@ -66,6 +66,38 @@ export async function canEditProject( } } +/** + * Gates the staff picker's user list. + * + * Creating a project is admin-only, but editing one is open to its Directors, + * so a Director must be able to read the roster to assign staff. This is + * deliberately narrower than `GET /users` (ADMIN-only) and returns only the + * fields the picker renders — not the full user row. + */ +export async function canListAssignableStaff(userId: number): Promise { + try { + const user = await db + .selectFrom('branch.users') + .where('user_id', '=', userId) + .select('is_admin') + .executeTakeFirst(); + + if (user?.is_admin) return true; + + const membership = await db + .selectFrom('branch.project_memberships') + .where('user_id', '=', userId) + .where('role', 'in', ['Director', 'Admin']) + .select('membership_id') + .executeTakeFirst(); + + return !!membership; + } catch (error) { + console.error('Error checking staff-list access:', error); + return false; + } +} + export async function canCreateProject(userId: number): Promise { try { const user = await db diff --git a/apps/backend/lambdas/projects/handler.ts b/apps/backend/lambdas/projects/handler.ts index 2bca9a14..ecbe9bae 100644 --- a/apps/backend/lambdas/projects/handler.ts +++ b/apps/backend/lambdas/projects/handler.ts @@ -1,13 +1,15 @@ import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; -import { sql } from 'kysely'; +import { sql, Transaction } from 'kysely'; +import type { DB } from '@branch/types'; import db from './db'; -import { ProjectValidationUtils } from './validation-utils'; +import { MemberAssignment, ProjectValidationUtils } from './validation-utils'; import { authenticateRequest, canAccessProject, canCreateProject, canDeleteProject, canEditProject, + canListAssignableStaff, } from './auth'; export const handler = async (event: any): Promise => { @@ -234,17 +236,116 @@ export const handler = async (event: any): Promise => { } }); } + // GET /projects/assignable-staff + // Declared before the /{id} routes: those now require a numeric segment, but + // keeping the literal path first also documents that it is not a project id. + if ((normalizedPath === '/assignable-staff' || normalizedPath.endsWith('/assignable-staff')) && method === 'GET') { + if (!(await canListAssignableStaff(user.userId!))) { + return json(403, { message: 'You do not have access to assign staff' }); + } + const staff = await db + .selectFrom('branch.users') + .select(['user_id', 'name', 'email', 'profile_image']) + .orderBy('name', 'asc') + .execute(); + return json(200, { staff }); + } + // GET /projects if (rawPath === '/' && method === 'GET') { const projects = user.isAdmin - ? await db.selectFrom("branch.projects").selectAll().execute() + ? await db.selectFrom("branch.projects").selectAll().orderBy('project_id', 'asc').execute() : await db .selectFrom("branch.projects as p") .innerJoin("branch.project_memberships as pm", "pm.project_id", "p.project_id") .where("pm.user_id", "=", user.userId!) .selectAll("p") + .orderBy('p.project_id', 'asc') .execute(); - return json(200, projects); + + // The list cards render "spent / budget", a member count and an + // active-vs-archived split. Serving those aggregates here keeps the page + // to one request instead of three per project. + const { spent, members } = await loadProjectAggregates(projects.map((p) => p.project_id)); + + return json( + 200, + projects.map((p) => ({ + ...p, + total_spent: spent.get(p.project_id) ?? 0, + member_count: members.get(p.project_id) ?? 0, + is_active: isProjectActive(p.end_date), + })), + ); + } + + // GET /projects/{id}/overview + // One call for the whole detail page: the header, the funding donut, the + // staff column and the expenses table previously needed three round trips + // and still could not show a spend total without summing on the client. + if (normalizedPath.endsWith('/overview') && method === 'GET') { + const segments = normalizedPath.split('/').filter(Boolean); + const id = projectIdFrom(segments[segments.length - 2]); + if (id === null) return json(400, { message: 'Project id must be a valid number' }); + + if (!(await canAccessProject(user.userId!, id))) { + return json(403, { message: 'You do not have access to this project' }); + } + + const project = await db + .selectFrom('branch.projects') + .where('project_id', '=', id) + .selectAll() + .executeTakeFirst(); + if (!project) return json(404, { message: `Project not found for id: ${id}` }); + + const [members, expenditures, donationRow, canEdit] = await Promise.all([ + db + .selectFrom('branch.project_memberships as pm') + .innerJoin('branch.users as u', 'u.user_id', 'pm.user_id') + .select(['u.user_id', 'u.name', 'u.email', 'u.profile_image', 'pm.role']) + .where('pm.project_id', '=', id) + .orderBy('u.name', 'asc') + .execute(), + db + .selectFrom('branch.expenditures') + .where('project_id', '=', id) + .selectAll() + .orderBy('spent_on', 'desc') + .execute(), + db + .selectFrom('branch.project_donations') + .select(db.fn.sum('amount').as('total')) + .where('project_id', '=', id) + .executeTakeFirst(), + // Returned so the UI does not have to re-derive the rule: editing is + // open to a project's Directors as well as admins, so gating the + // button on `isAdmin` alone would hide it from people who may edit. + canEditProject(user.userId!, id), + ]); + + const totalBudget = project.total_budget !== null ? Number(project.total_budget) : 0; + const totalSpent = expenditures.reduce((sum, e) => sum + Number(e.amount), 0); + // Guarded because a project may legitimately have no budget set yet, and + // 0/0 would render as NaN% in the donut. + const spentPercentage = totalBudget > 0 ? (totalSpent / totalBudget) * 100 : 0; + + return json(200, { + project, + stats: { + totalBudget, + totalSpent, + totalRemaining: totalBudget - totalSpent, + spentPercentage: Number(spentPercentage.toFixed(2)), + totalDonated: Number(donationRow?.total ?? 0), + memberCount: members.length, + expenditureCount: expenditures.length, + }, + members, + expenditures, + isActive: isProjectActive(project.end_date), + canEdit, + }); } // GET /projects/{id}/donors @@ -321,18 +422,70 @@ export const handler = async (event: any): Promise => { if (!result.isValid) return json(400, { message: result.error }); const updateValues = result.values!; - if (Object.keys(updateValues).length === 0) { + const membersResult = ProjectValidationUtils.validateMembers(body.members); + if (!membersResult.isValid) return json(400, { message: membersResult.error }); + const members = membersResult.value; + + if (Object.keys(updateValues).length === 0 && members === undefined) { return json(400, { message: 'No valid fields provided' }); } - const updatedProject = await db - .updateTable("branch.projects") - .set(updateValues) - .where("project_id", "=", Number(id)) - .returning(["project_id", "name", "description", "total_budget"]) + const existing = await db + .selectFrom('branch.projects') + .where('project_id', '=', Number(id)) + .select(['start_date', 'end_date']) .executeTakeFirst(); - if (!updatedProject) return json(404, { message: `Project not found for id: ${id}` }); - return json(200, updatedProject); + if (!existing) return json(404, { message: `Project not found for id: ${id}` }); + + // The edit form can set a start date and clear the end date in the same + // submit, so the range is checked against the merged row rather than the + // patch — validating the patch alone would miss a start date moved past + // an end date that the request never mentions. + const nextStart = 'start_date' in updateValues + ? (updateValues.start_date as string | null) + : toIsoDate(existing.start_date); + const nextEnd = 'end_date' in updateValues + ? (updateValues.end_date as string | null) + : toIsoDate(existing.end_date); + + const rangeResult = ProjectValidationUtils.validateDateRange(nextStart, nextEnd); + if (!rangeResult.isValid) return json(400, { message: rangeResult.error }); + + if (members !== undefined) { + const unknownIds = await findUnknownUserIds(members); + if (unknownIds.length > 0) { + return json(400, { message: `Unknown user ids: ${unknownIds.join(', ')}` }); + } + } + + try { + // Field update and roster replacement share a transaction: a failed + // membership insert must not leave the project with nobody assigned. + const updatedProject = await db.transaction().execute(async (trx) => { + const row = Object.keys(updateValues).length > 0 + ? await trx + .updateTable('branch.projects') + .set(updateValues) + .where('project_id', '=', Number(id)) + .returningAll() + .executeTakeFirst() + : await trx + .selectFrom('branch.projects') + .where('project_id', '=', Number(id)) + .selectAll() + .executeTakeFirst(); + + if (!row) return undefined; + if (members !== undefined) await syncMemberships(trx, Number(id), members); + return row; + }); + + if (!updatedProject) return json(404, { message: `Project not found for id: ${id}` }); + return json(200, updatedProject); + } catch (e) { + console.error('Project update failed', e); + return json(500, { message: 'Failed to update project' }); + } } // DELETE /projects/{id} @@ -395,12 +548,35 @@ export const handler = async (event: any): Promise => { if (!descriptionResult.isValid) return json(400, { message: descriptionResult.error }); values.description = descriptionResult.value; + const rangeResult = ProjectValidationUtils.validateDateRange( + startDateResult.value, + endDateResult.value, + ); + if (!rangeResult.isValid) return json(400, { message: rangeResult.error }); + + const membersResult = ProjectValidationUtils.validateMembers(body.members); + if (!membersResult.isValid) return json(400, { message: membersResult.error }); + const members = membersResult.value ?? []; + + const unknownIds = await findUnknownUserIds(members); + if (unknownIds.length > 0) { + return json(400, { message: `Unknown user ids: ${unknownIds.join(', ')}` }); + } + try { - const inserted = await db - .insertInto('branch.projects') - .values(values) - .returning(['project_id', 'name', 'description', 'total_budget', 'currency', 'start_date', 'end_date', 'created_at']) - .executeTakeFirst(); + // Creating the project and its roster together: a project that saved + // without its staff would look complete but fail the form's own + // "at least one staff member" rule on the next read. + const inserted = await db.transaction().execute(async (trx) => { + const row = await trx + .insertInto('branch.projects') + .values(values) + .returningAll() + .executeTakeFirstOrThrow(); + + if (members.length > 0) await syncMemberships(trx, row.project_id, members); + return row; + }); return json(201, inserted); } catch (e) { @@ -462,6 +638,123 @@ export const handler = async (event: any): Promise => { } }; +/** + * Path segments carrying a project id are matched with this rather than a bare + * "is there a segment here" check. Without it `/projects/assignable-staff` + * matches `GET /projects/{id}` with `id = "assignable-staff"`, which reaches + * the DB as `NaN` and surfaces as a confusing 403 instead of routing correctly. + */ +function projectIdFrom(segment: string | undefined): number | null { + if (!segment || !/^\d+$/.test(segment)) return null; + const id = Number(segment); + return Number.isSafeInteger(id) && id > 0 ? id : null; +} + +/** + * `pg` hands back DATE columns as `Date`, but every date the API accepts and + * returns is a `YYYY-MM-DD` string, so comparisons must go through this. + */ +function toIsoDate(value: unknown): string | null { + if (!value) return null; + if (value instanceof Date) return value.toISOString().slice(0, 10); + return String(value).slice(0, 10); +} + +/** Rows keyed by project id, for stitching aggregates onto a project list. */ +function indexByProject( + rows: T[], + pick: (row: T) => number, +): Map { + return new Map(rows.map((row) => [row.project_id, pick(row)])); +} + +/** + * Per-project spend and headcount, aggregated in two grouped queries rather + * than one query per project — the list page renders every project the caller + * can see, so a per-row lookup is an N+1 that grows with the org. + */ +async function loadProjectAggregates(projectIds: number[]): Promise<{ + spent: Map; + members: Map; +}> { + if (projectIds.length === 0) return { spent: new Map(), members: new Map() }; + + const [spentRows, memberRows] = await Promise.all([ + db + .selectFrom('branch.expenditures') + .select(['project_id', db.fn.sum('amount').as('total')]) + .where('project_id', 'in', projectIds) + .groupBy('project_id') + .execute(), + db + .selectFrom('branch.project_memberships') + .select(['project_id', db.fn.count('user_id').as('count')]) + .where('project_id', 'in', projectIds) + .groupBy('project_id') + .execute(), + ]); + + return { + spent: indexByProject(spentRows, (r) => Number(r.total ?? 0)), + members: indexByProject(memberRows, (r) => Number(r.count ?? 0)), + }; +} + +/** + * A project is archived once it has an end date that has passed. The "this + * project is still in progress" checkbox in the UI simply clears `end_date`, + * so a null end date is always active. + */ +function isProjectActive(endDate: unknown, today = new Date()): boolean { + const iso = toIsoDate(endDate); + if (!iso) return true; + return iso >= today.toISOString().slice(0, 10); +} + +/** + * Replaces a project's roster with `members` inside the caller's transaction. + * + * Delete-then-insert rather than a diff: the set is small and bounded by the + * staff list, and doing it in one transaction means a failed insert cannot + * leave the project with nobody assigned. + */ +async function syncMemberships( + trx: Transaction, + projectId: number, + members: MemberAssignment[], +): Promise { + await trx + .deleteFrom('branch.project_memberships') + .where('project_id', '=', projectId) + .execute(); + + if (members.length === 0) return; + + await trx + .insertInto('branch.project_memberships') + .values( + members.map((m) => ({ + project_id: projectId, + user_id: m.user_id, + role: m.role, + })), + ) + .execute(); +} + +/** Rejects member ids that are not real users, so FK errors never reach the client as a 500. */ +async function findUnknownUserIds(members: MemberAssignment[]): Promise { + if (members.length === 0) return []; + const ids = members.map((m) => m.user_id); + const found = await db + .selectFrom('branch.users') + .select('user_id') + .where('user_id', 'in', ids) + .execute(); + const known = new Set(found.map((r) => r.user_id)); + return ids.filter((id) => !known.has(id)); +} + function json(statusCode: number, body: unknown): APIGatewayProxyResult { return { statusCode, diff --git a/apps/backend/lambdas/projects/openapi.yaml b/apps/backend/lambdas/projects/openapi.yaml index 8b3253c3..417b8230 100644 --- a/apps/backend/lambdas/projects/openapi.yaml +++ b/apps/backend/lambdas/projects/openapi.yaml @@ -28,12 +28,64 @@ paths: required: true schema: type: string + /projects/assignable-staff: + get: + summary: GET /projects/assignable-staff + description: >- + Users who can be assigned to a project, for the project form's staff + picker. Open to admins and to Directors of any project, who may edit + their own projects and so need the roster. Returns only the fields the + picker renders. + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + staff: + type: array + items: + type: object + properties: + user_id: + type: integer + name: + type: string + email: + type: string + profile_image: + type: string + nullable: true + '403': + description: Not permitted to assign staff + /projects: get: summary: GET /projects + description: >- + Every project the caller can see, each enriched with the aggregates the + list cards render. responses: '200': description: OK + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: '#/components/schemas/Project' + - type: object + properties: + total_spent: + type: number + member_count: + type: integer + is_active: + type: boolean + description: False once the end date has passed. post: summary: POST /projects requestBody: @@ -51,9 +103,95 @@ paths: type: number description: type: string + start_date: + type: string + format: date + end_date: + type: string + format: date + nullable: true + members: + $ref: '#/components/schemas/MemberAssignments' + responses: + '201': + description: Created + '400': + description: Validation failed, an unknown user id, or end_date before start_date + + /projects/{id}/overview: + get: + summary: GET /projects/{id}/overview + description: >- + Everything the project detail page renders, in one call: the project, + its financial roll-up, its members and its expenditures. + parameters: + - in: path + name: id + required: true + schema: + type: integer responses: '200': description: OK + content: + application/json: + schema: + type: object + properties: + project: + $ref: '#/components/schemas/Project' + stats: + type: object + properties: + totalBudget: + type: number + totalSpent: + type: number + totalRemaining: + type: number + spentPercentage: + type: number + description: 0-100, reported as 0 when no budget is set. + totalDonated: + type: number + memberCount: + type: integer + expenditureCount: + type: integer + members: + type: array + items: + type: object + properties: + user_id: + type: integer + name: + type: string + email: + type: string + role: + type: string + enum: [Admin, Director, Student] + profile_image: + type: string + nullable: true + expenditures: + type: array + items: + type: object + isActive: + type: boolean + canEdit: + type: boolean + description: >- + Whether the caller may edit — admin, or a Director on this + project. Returned so the client need not re-derive it. + '400': + description: Project id must be a valid number + '403': + description: No access to this project + '404': + description: Project not found /projects/{id}: get: @@ -69,15 +207,47 @@ paths: description: OK put: summary: PUT /projects/{id} + description: >- + Partial update. Omitting `members` leaves the roster untouched; sending + an empty array clears it. Field updates and roster changes are applied + in one transaction. parameters: - in: path name: id required: true schema: - type: string + type: integer + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + total_budget: + type: number + start_date: + type: string + format: date + nullable: true + end_date: + type: string + format: date + nullable: true + members: + $ref: '#/components/schemas/MemberAssignments' responses: '200': description: OK + '400': + description: Validation failed, an unknown user id, or end_date before start_date + '403': + description: Not permitted to edit this project + '404': + description: Project not found delete: summary: DELETE /projects/{id} parameters: @@ -134,4 +304,56 @@ paths: type: string responses: '200': - description: OK \ No newline at end of file + description: OK + +components: + schemas: + Project: + type: object + properties: + project_id: + type: integer + name: + type: string + description: + type: string + nullable: true + total_budget: + type: string + nullable: true + description: NUMERIC, serialised as a string to avoid float rounding. + currency: + type: string + nullable: true + start_date: + type: string + format: date + nullable: true + end_date: + type: string + format: date + nullable: true + created_at: + type: string + format: date-time + nullable: true + + MemberAssignments: + type: array + description: >- + Project roster. Accepts bare user ids (the project form, which has no + role picker and defaults to Student) or objects carrying an explicit + role. A repeated id keeps its last role rather than failing the write. + items: + oneOf: + - type: integer + description: User id, assigned the default Student role. + - type: object + required: + - user_id + properties: + user_id: + type: integer + role: + type: string + enum: [Admin, Director, Student] \ No newline at end of file diff --git a/apps/backend/lambdas/projects/test/project-page.unit.test.ts b/apps/backend/lambdas/projects/test/project-page.unit.test.ts new file mode 100644 index 00000000..3affcf2f --- /dev/null +++ b/apps/backend/lambdas/projects/test/project-page.unit.test.ts @@ -0,0 +1,276 @@ +/** + * Covers the endpoints the project page depends on: the enriched list, the + * single-call overview, the staff roster, and membership sync on write. + */ +import { test, expect, beforeAll, beforeEach, afterAll, jest } from '@jest/globals'; +import { Pool } from 'pg'; +import { ensureSchema, resetData } from '../../../db/testkit'; + +jest.mock('../auth', () => ({ + ...jest.requireActual('../auth'), + authenticateRequest: jest.fn(), +})); + +import { handler } from '../handler'; +import db from '../db'; +import { authenticateRequest } from '../auth'; + +const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction< + typeof authenticateRequest +>; + +const pool = new Pool({ + host: 'localhost', + port: 5432, + user: 'branch_dev', + password: 'password', + database: 'branch_db', + ssl: false, +}); + +const adminUser = { + isAuthenticated: true as const, + user: { cognitoSub: 'admin-sub', userId: 1, email: 'ashley@branch.org', isAdmin: true }, +}; + +function event( + rawPath: string, + method: string, + body?: unknown, +): Parameters[0] { + return { + rawPath, + requestContext: { http: { method } }, + headers: { Authorization: 'Bearer fake-token' }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + } as never; +} + +function parse(res: { body: string }) { + return JSON.parse(res.body); +} + +beforeAll(async () => { + const client = await pool.connect(); + try { + await ensureSchema(client); + } finally { + client.release(); + } +}); + +beforeEach(async () => { + jest.clearAllMocks(); + mockAuthenticateRequest.mockResolvedValue(adminUser); + + const client = await pool.connect(); + try { + await resetData(client); + } finally { + client.release(); + } +}); + +afterAll(async () => { + await pool.end(); + await db.destroy(); +}); + +// ── Routing ────────────────────────────────────────────────────────────────── + +test('/projects/assignable-staff is not parsed as a project id', async () => { + const res = await handler(event('/assignable-staff', 'GET')); + expect(res.statusCode).toBe(200); + const { staff } = parse(res); + expect(Array.isArray(staff)).toBe(true); + expect(staff.length).toBeGreaterThan(0); + // Only the fields the picker renders — not the whole user row. + expect(Object.keys(staff[0]).sort()).toEqual(['email', 'name', 'profile_image', 'user_id']); +}); + +test('400 rather than 403 for a non-numeric project id', async () => { + const res = await handler(event('/not-a-number', 'GET')); + expect(res.statusCode).toBe(400); +}); + +// ── GET /projects ──────────────────────────────────────────────────────────── + +test('list includes spend, member count and the active flag', async () => { + await handler( + event('/', 'POST', { + name: 'Aggregated', + total_budget: 1000, + start_date: '2020-01-01', + end_date: '2020-06-01', + members: [1, 2], + }), + ); + + const res = await handler(event('/', 'GET')); + expect(res.statusCode).toBe(200); + const created = parse(res).find((p: { name: string }) => p.name === 'Aggregated'); + + expect(created.member_count).toBe(2); + expect(created.total_spent).toBe(0); + // End date is in the past, so the list page files it under Archived. + expect(created.is_active).toBe(false); +}); + +test('a project with no end date is always active', async () => { + await handler(event('/', 'POST', { name: 'Ongoing', total_budget: 10, members: [1] })); + const res = await handler(event('/', 'GET')); + const created = parse(res).find((p: { name: string }) => p.name === 'Ongoing'); + expect(created.is_active).toBe(true); +}); + +// ── GET /projects/{id}/overview ────────────────────────────────────────────── + +test('overview returns the project, stats, members and expenditures together', async () => { + const created = parse( + await handler( + event('/', 'POST', { name: 'Overview', total_budget: 1000, members: [1, 2] }), + ), + ); + + const res = await handler(event(`/${created.project_id}/overview`, 'GET')); + expect(res.statusCode).toBe(200); + + const body = parse(res); + expect(body.project.name).toBe('Overview'); + expect(body.members).toHaveLength(2); + expect(body.stats.totalBudget).toBe(1000); + expect(body.stats.totalSpent).toBe(0); + expect(body.stats.totalRemaining).toBe(1000); + expect(body.canEdit).toBe(true); + expect(body.isActive).toBe(true); +}); + +test('overview reports 0% rather than NaN when no budget is set', async () => { + const created = parse(await handler(event('/', 'POST', { name: 'No budget', members: [1] }))); + const body = parse(await handler(event(`/${created.project_id}/overview`, 'GET'))); + expect(body.stats.spentPercentage).toBe(0); +}); + +test('overview 404s for a project that does not exist', async () => { + const res = await handler(event('/99999/overview', 'GET')); + expect(res.statusCode).toBe(404); +}); + +// ── Membership sync ────────────────────────────────────────────────────────── + +test('POST assigns the given staff', async () => { + const created = parse( + await handler(event('/', 'POST', { name: 'Staffed', total_budget: 5, members: [1, 3] })), + ); + const body = parse(await handler(event(`/${created.project_id}/overview`, 'GET'))); + expect(body.members.map((m: { user_id: number }) => m.user_id).sort()).toEqual([1, 3]); +}); + +test('PUT replaces the roster rather than appending to it', async () => { + const created = parse( + await handler(event('/', 'POST', { name: 'Rotating', total_budget: 5, members: [1, 2] })), + ); + + const res = await handler(event(`/${created.project_id}`, 'PUT', { members: [3] })); + expect(res.statusCode).toBe(200); + + const body = parse(await handler(event(`/${created.project_id}/overview`, 'GET'))); + expect(body.members.map((m: { user_id: number }) => m.user_id)).toEqual([3]); +}); + +test('PUT without a members key leaves the roster alone', async () => { + const created = parse( + await handler(event('/', 'POST', { name: 'Untouched', total_budget: 5, members: [1, 2] })), + ); + + await handler(event(`/${created.project_id}`, 'PUT', { name: 'Renamed' })); + + const body = parse(await handler(event(`/${created.project_id}/overview`, 'GET'))); + expect(body.project.name).toBe('Renamed'); + expect(body.members).toHaveLength(2); +}); + +test('PUT with an empty members array clears the roster', async () => { + const created = parse( + await handler(event('/', 'POST', { name: 'Emptied', total_budget: 5, members: [1] })), + ); + await handler(event(`/${created.project_id}`, 'PUT', { members: [] })); + const body = parse(await handler(event(`/${created.project_id}/overview`, 'GET'))); + expect(body.members).toHaveLength(0); +}); + +test('400 for a member id that is not a real user', async () => { + const res = await handler( + event('/', 'POST', { name: 'Ghost staff', total_budget: 5, members: [99999] }), + ); + expect(res.statusCode).toBe(400); + expect(parse(res).message).toMatch(/unknown user ids/i); +}); + +test('400 for a member entry that is not a positive integer id', async () => { + const res = await handler( + event('/', 'POST', { name: 'Bad staff', total_budget: 5, members: ['abc'] }), + ); + expect(res.statusCode).toBe(400); +}); + +test('a duplicated member id is de-duplicated instead of failing the write', async () => { + const created = parse( + await handler(event('/', 'POST', { name: 'Deduped', total_budget: 5, members: [1, 1] })), + ); + const body = parse(await handler(event(`/${created.project_id}/overview`, 'GET'))); + expect(body.members).toHaveLength(1); +}); + +// ── Date range ─────────────────────────────────────────────────────────────── + +test('400 when the end date precedes the start date on create', async () => { + const res = await handler( + event('/', 'POST', { + name: 'Backwards', + total_budget: 5, + start_date: '2026-05-01', + end_date: '2026-01-01', + members: [1], + }), + ); + expect(res.statusCode).toBe(400); + expect(parse(res).message).toMatch(/end_date/); +}); + +test('400 when an update moves the start date past the stored end date', async () => { + const created = parse( + await handler( + event('/', 'POST', { + name: 'Range', + total_budget: 5, + start_date: '2026-01-01', + end_date: '2026-02-01', + members: [1], + }), + ), + ); + + // The request never mentions end_date, so the check has to use the stored row. + const res = await handler(event(`/${created.project_id}`, 'PUT', { start_date: '2026-03-01' })); + expect(res.statusCode).toBe(400); +}); + +test('clearing the end date in the same request that moves the start date is allowed', async () => { + const created = parse( + await handler( + event('/', 'POST', { + name: 'Reopened', + total_budget: 5, + start_date: '2026-01-01', + end_date: '2026-02-01', + members: [1], + }), + ), + ); + + const res = await handler( + event(`/${created.project_id}`, 'PUT', { start_date: '2026-03-01', end_date: null }), + ); + expect(res.statusCode).toBe(200); +}); diff --git a/apps/backend/lambdas/projects/validation-utils.ts b/apps/backend/lambdas/projects/validation-utils.ts index f9f3fc44..ba29e288 100644 --- a/apps/backend/lambdas/projects/validation-utils.ts +++ b/apps/backend/lambdas/projects/validation-utils.ts @@ -5,6 +5,19 @@ export type ValidationResult = { error?: string; }; +/** + * Roles a project membership may carry. The DB check constraint still accepts + * the legacy PI/Accountant/Staff values during the expand phase, but nothing + * writes them any more, so new assignments are restricted to these three. + */ +export const PROJECT_ROLES = ['Admin', 'Director', 'Student'] as const; +export type ProjectRole = (typeof PROJECT_ROLES)[number]; + +/** The project form assigns staff without asking for a role. */ +export const DEFAULT_PROJECT_ROLE: ProjectRole = 'Student'; + +export type MemberAssignment = { user_id: number; role: ProjectRole }; + // Utility class for validating project-related input fields export class ProjectValidationUtils { // Parses numeric input (number or string), converts to fixed 2-decimal string for database storage @@ -87,6 +100,64 @@ export class ProjectValidationUtils { } return { isValid: true, value: d.length === 0 ? '' : d }; } + /** + * Accepts `[1, 2]` or `[{ user_id: 1, role: 'Director' }]`, so the project + * form can post bare ids while a future admin tool can still set roles. + * + * `undefined` (key absent) means "leave memberships alone" and `[]` means + * "remove everyone" — callers must keep those apart, which is why this + * resolves to `undefined` rather than defaulting to an empty array. + */ + static validateMembers(input: unknown): ValidationResult { + if (input === undefined || input === null) return { isValid: true, value: undefined }; + if (!Array.isArray(input)) { + return { isValid: false, error: "'members' must be an array" }; + } + + const members: MemberAssignment[] = []; + + for (const entry of input) { + const isObject = typeof entry === 'object' && entry !== null; + const rawId = isObject ? (entry as Record).user_id : entry; + const userId = + typeof rawId === 'string' && /^\d+$/.test(rawId.trim()) ? Number(rawId.trim()) : rawId; + + if (typeof userId !== 'number' || !Number.isInteger(userId) || userId <= 0) { + return { isValid: false, error: "'members' must contain positive integer user ids" }; + } + + const rawRole = isObject ? (entry as Record).role : undefined; + if (rawRole !== undefined && !PROJECT_ROLES.includes(rawRole as ProjectRole)) { + return { isValid: false, error: `'role' must be one of: ${PROJECT_ROLES.join(', ')}` }; + } + const role = (rawRole as ProjectRole) ?? DEFAULT_PROJECT_ROLE; + + // A repeated id is a UI race, not a client error, and the intent is + // unambiguous — take the last role rather than rejecting the whole write. + const existing = members.find((m) => m.user_id === userId); + if (existing) existing.role = role; + else members.push({ user_id: userId, role }); + } + + return { isValid: true, value: members }; + } + + /** + * An end date before the start date inverts every duration derived from the + * pair (and would render a negative project length), so it is rejected. + */ + static validateDateRange( + startDate: string | null | undefined, + endDate: string | null | undefined, + ): ValidationResult { + if (!startDate || !endDate) return { isValid: true, value: null }; + // Both are already validated as YYYY-MM-DD, which sorts lexicographically. + if (endDate < startDate) { + return { isValid: false, error: "'end_date' must be on or after 'start_date'" }; + } + return { isValid: true, value: null }; + } + static buildUpdateValues(body: Record): { isValid: boolean; error?: string; values?: Record } { const updateValues: Record = {}; diff --git a/apps/frontend/AGENTS.md b/apps/frontend/AGENTS.md index 56d05262..e9e59312 100644 --- a/apps/frontend/AGENTS.md +++ b/apps/frontend/AGENTS.md @@ -75,7 +75,7 @@ Import direction is one-way and must stay that way: `api.ts` ← `authClient.ts` ## Shared UI -Two families of component are **the** way to do their job — don't hand-roll a second one. +Three families of component are **the** way to do their job — don't hand-roll a second one. **Tables — `components/DataTable.tsx`.** Every list view (expenses, reports, donors, donations) renders through it, so the green header row, column widths, empty state, row-click behaviour and loading skeleton stay identical. Columns are data: `{ key, header, width, align, cell, skeleton }`. Pass `selection` (see `reports/page.tsx`) for the leading checkbox column — the page keeps owning the selected ids, since that is what its bulk actions need. `ExpensesTable` is a thin wrapper that fixes the expense column set; add domain wrappers like that rather than re-deriving columns per page. @@ -83,11 +83,13 @@ Two families of component are **the** way to do their job — don't hand-roll a - `LoadingState` for a region whose content has not arrived (`variant="section"` reserves height; `"inline"` for menus and dialog bodies). The label is the accessible name and is hidden unless `showLabel`. - `DataTable isLoading` for tables — skeleton rows keep the header and column widths on screen. Set `skeletonRows` to the page size so nothing resizes when data lands. -- Chakra's `Button loading` prop for in-flight actions; it renders its own spinner. +- `Button isLoading` for in-flight actions (our `Button`; Chakra's own buttons use its `loading` prop). - `Spinner` is the primitive; it takes its colour from `currentColor` and only gets a `label` when nothing around it is already `role="status"`. The animations live in `globals.css` (`.branch-spinner`, `.branch-skeleton`, and their keyframes), not in the components — one timing curve for the whole app, and `FullPageSpinner` can render before any component library is mounted. Both honour `prefers-reduced-motion`. +**Popovers — `hooks/useAnchoredPopover.ts`.** Anything that floats next to a trigger (`DatePickerField`, `StaffPicker`) goes through this hook. The caller owns the open state and passes it in with `onDismiss` and an `estimatedHeight`; the hook returns `{ anchorRef, popoverRef, boundaryRef, position }`, where `position` is viewport coordinates to spread onto a `position: fixed` panel. It flips above the anchor when the viewport would clip it, repositions on scroll and resize, and dismisses on outside-click and `Escape`. Render the panel through `createPortal` into `document.body` — a popover left in normal flow is clipped by the modal body's scroll container, which is the bug this hook exists to prevent. + ## Conventions - Page/interactive components start with `'use client'`. diff --git a/apps/frontend/mock-api/server.mjs b/apps/frontend/mock-api/server.mjs new file mode 100644 index 00000000..032a2b02 --- /dev/null +++ b/apps/frontend/mock-api/server.mjs @@ -0,0 +1,362 @@ +/** + * Dependency-free mock of the BRANCH API, for exercising the frontend without + * Docker, Postgres or Cognito. + * + * It stands in for every microservice at once, which is why the frontend must + * be started with a single base URL override: + * + * node apps/frontend/mock-api/server.mjs + * NEXT_PUBLIC_API_BASE_URL=http://localhost:4010 npm run dev + * + * State is in-memory: restarting the server resets the data. Sign in with any + * email and password. Use `?admin=0` on the login request (or set + * MOCK_ADMIN=false) to see the non-admin view, which hides the create/edit + * controls. + */ +import { createServer } from 'node:http'; + +const PORT = Number(process.env.MOCK_API_PORT ?? 4010); + +// ── Token helpers ──────────────────────────────────────────────────────────── + +const b64url = (obj) => + Buffer.from(JSON.stringify(obj)).toString('base64url'); + +/** + * A JWT-shaped, unsigned token. The frontend never verifies signatures — it + * only decodes `exp` to schedule refreshes — so this is enough to drive the + * real session code path rather than stubbing around it. + */ +function makeToken(hoursValid = 12) { + const exp = Math.floor(Date.now() / 1000) + hoursValid * 3600; + return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url({ sub: 'mock-sub', exp })}.mock`; +} + +// ── Seed data ──────────────────────────────────────────────────────────────── + +let isAdmin = process.env.MOCK_ADMIN !== 'false'; + +const staff = [ + { user_id: 1, name: 'Ashley Rivera', email: 'ashley@branch.org', profile_image: null }, + { user_id: 2, name: 'Ben Ortiz', email: 'ben@branch.org', profile_image: null }, + { user_id: 3, name: 'Carla Nguyen', email: 'carla@branch.org', profile_image: null }, + { user_id: 4, name: 'Dev Patel', email: 'dev@branch.org', profile_image: null }, + { user_id: 5, name: 'Elena Fischer', email: 'elena@branch.org', profile_image: null }, + { user_id: 6, name: 'Farouk Diallo', email: 'farouk@branch.org', profile_image: null }, +]; + +let nextProjectId = 6; + +/** Fixture dates are relative to today so the active/archived split never goes stale. */ +function daysFromToday(days) { + const date = new Date(); + date.setDate(date.getDate() + days); + return date.toISOString().slice(0, 10); +} + +const projects = [ + { + project_id: 1, + name: 'Clinician Communication Study', + description: + "This project's overview/short description..... (1-2 sentences about the project/what it is for)", + total_budget: '100000.00', + currency: 'USD', + start_date: '2026-01-01', + end_date: null, + created_at: '2026-01-01T00:00:00.000Z', + members: [1, 2, 3, 4, 5], + }, + { + project_id: 2, + name: 'Health Education Initiative', + description: 'Community health workshops across three counties.', + total_budget: '100000.00', + currency: 'USD', + start_date: '2026-02-01', + end_date: null, + created_at: '2026-02-01T00:00:00.000Z', + members: [2, 3, 4], + }, + { + project_id: 3, + name: 'Rural Telehealth Pilot', + description: 'Remote consultations for patients more than 50 miles from a clinic.', + total_budget: '100000.00', + currency: 'USD', + start_date: '2026-03-01', + // Ends in the future, so it stays in the Active section. + end_date: daysFromToday(120), + created_at: '2026-03-01T00:00:00.000Z', + members: [1, 5, 6], + }, + { + project_id: 4, + // Deliberately long, to check the card's two-line clamp. + name: 'Longitudinal Patient Outcomes and Follow-Up Care Coordination Programme', + description: 'Completed programme, retained for reporting.', + total_budget: '100000.00', + currency: 'USD', + start_date: daysFromToday(-400), + end_date: daysFromToday(-60), + created_at: '2025-01-01T00:00:00.000Z', + members: [1, 2, 3], + }, + { + project_id: 5, + name: 'Adolescent Nutrition Survey', + description: 'Closed out last quarter.', + total_budget: '75000.00', + currency: 'USD', + start_date: daysFromToday(-300), + end_date: daysFromToday(-30), + created_at: '2025-06-01T00:00:00.000Z', + members: [4, 5], + }, +]; + +const CATEGORIES = ['Visitor / Honorarium', 'Travel', 'Equipment', 'Catering']; +const STATUSES = ['approved', 'pending', 'needs_more_info', 'pending']; + +/** Deterministic expenditures, so a reload shows the same numbers. */ +const expenditures = projects.flatMap((project) => + Array.from({ length: 6 }, (_, i) => ({ + expenditure_id: project.project_id * 100 + i, + project_id: project.project_id, + entered_by: 1, + amount: ((i + 1) * 2500).toFixed(2), + category: CATEGORIES[i % CATEGORIES.length], + description: 'Mock expenditure', + status: STATUSES[i % STATUSES.length], + receipt_url: i % 2 === 0 ? 'https://example.com/receipt.pdf' : null, + admin_notes: null, + spent_on: `2026-0${(i % 9) + 1}-15`, + created_at: '2026-01-01T00:00:00.000Z', + })), +); + +// ── Derived shapes ─────────────────────────────────────────────────────────── + +const todayIso = () => new Date().toISOString().slice(0, 10); +const isActive = (project) => !project.end_date || project.end_date >= todayIso(); +const spentOn = (projectId) => + expenditures + .filter((e) => e.project_id === projectId) + .reduce((sum, e) => sum + Number(e.amount), 0); + +// Strips the membership list; the API exposes it only via /overview. +function publicProject(project) { + const rest = { ...project }; + delete rest.members; + return rest; +} + +function summarise(project) { + return { + ...publicProject(project), + total_spent: spentOn(project.project_id), + member_count: project.members.length, + is_active: isActive(project), + }; +} + +function overview(project) { + const totalBudget = Number(project.total_budget ?? 0); + const totalSpent = spentOn(project.project_id); + return { + project: publicProject(project), + stats: { + totalBudget, + totalSpent, + totalRemaining: totalBudget - totalSpent, + spentPercentage: totalBudget > 0 ? Number(((totalSpent / totalBudget) * 100).toFixed(2)) : 0, + totalDonated: 0, + memberCount: project.members.length, + expenditureCount: expenditures.filter((e) => e.project_id === project.project_id).length, + }, + members: project.members + .map((userId) => staff.find((s) => s.user_id === userId)) + .filter(Boolean) + .map((person) => ({ ...person, role: 'Student' })), + expenditures: expenditures.filter((e) => e.project_id === project.project_id), + isActive: isActive(project), + canEdit: isAdmin, + }; +} + +// ── Request handling ───────────────────────────────────────────────────────── + +function send(res, status, body) { + const payload = body === undefined ? '' : JSON.stringify(body); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', + }); + res.end(payload); +} + +function readJson(req) { + return new Promise((resolve) => { + let raw = ''; + req.on('data', (chunk) => (raw += chunk)); + req.on('end', () => { + try { + resolve(raw ? JSON.parse(raw) : {}); + } catch { + resolve({}); + } + }); + }); +} + +/** Mirrors the backend's own validation, so the error states are reachable. */ +function validateWrite(body, existing) { + const start = 'start_date' in body ? body.start_date : existing?.start_date; + const end = 'end_date' in body ? body.end_date : existing?.end_date; + if (start && end && end < start) { + return "'end_date' must be on or after 'start_date'"; + } + if (body.members) { + const unknown = body.members.filter((id) => !staff.some((s) => s.user_id === Number(id))); + if (unknown.length) return `Unknown user ids: ${unknown.join(', ')}`; + } + if (!existing && !String(body.name ?? '').trim()) return "'name' is required"; + return null; +} + +const server = createServer(async (req, res) => { + const { pathname } = new URL(req.url, `http://localhost:${PORT}`); + const method = req.method ?? 'GET'; + + if (method === 'OPTIONS') return send(res, 204); + + console.log(`${method} ${pathname}`); + + // ── auth ── + if (pathname === '/auth/login' || pathname === '/auth/refresh') { + const body = await readJson(req); + if (body.admin === false) isAdmin = false; + return send(res, 200, { + AccessToken: makeToken(), + IdToken: makeToken(), + RefreshToken: 'mock-refresh-token', + }); + } + + if (pathname === '/auth/me') { + return send(res, 200, { + userId: 1, + cognitoSub: 'mock-sub', + email: 'ashley@branch.org', + name: 'Ashley Rivera', + isAdmin, + profileImage: null, + }); + } + + if (pathname === '/auth/logout') return send(res, 204); + + // ── projects ── + // Declared before the /{id} routes, as in the real handler. + if (pathname === '/projects/assignable-staff' && method === 'GET') { + return send(res, 200, { staff }); + } + + if (pathname === '/projects' && method === 'GET') { + return send(res, 200, projects.map(summarise)); + } + + if (pathname === '/projects' && method === 'POST') { + const body = await readJson(req); + const error = validateWrite(body); + if (error) return send(res, 400, { message: error }); + + const created = { + project_id: nextProjectId++, + name: body.name, + description: body.description ?? '', + total_budget: body.total_budget != null ? Number(body.total_budget).toFixed(2) : null, + currency: 'USD', + start_date: body.start_date ?? null, + end_date: body.end_date ?? null, + created_at: new Date().toISOString(), + members: (body.members ?? []).map(Number), + }; + projects.push(created); + return send(res, 201, publicProject(created)); + } + + const overviewMatch = /^\/projects\/(\d+)\/overview$/.exec(pathname); + if (overviewMatch && method === 'GET') { + const project = projects.find((p) => p.project_id === Number(overviewMatch[1])); + if (!project) return send(res, 404, { message: 'Project not found' }); + return send(res, 200, overview(project)); + } + + const expendituresMatch = /^\/projects\/(\d+)\/expenditures$/.exec(pathname); + if (expendituresMatch && method === 'GET') { + return send( + res, + 200, + expenditures.filter((e) => e.project_id === Number(expendituresMatch[1])), + ); + } + + const idMatch = /^\/projects\/(\d+)$/.exec(pathname); + if (idMatch) { + const project = projects.find((p) => p.project_id === Number(idMatch[1])); + if (!project) return send(res, 404, { message: 'Project not found' }); + + if (method === 'GET') return send(res, 200, publicProject(project)); + + if (method === 'PUT') { + const body = await readJson(req); + const error = validateWrite(body, project); + if (error) return send(res, 400, { message: error }); + + for (const key of ['name', 'description', 'start_date', 'end_date']) { + if (key in body) project[key] = body[key]; + } + if ('total_budget' in body && body.total_budget != null) { + project.total_budget = Number(body.total_budget).toFixed(2); + } + if (Array.isArray(body.members)) project.members = body.members.map(Number); + + return send(res, 200, publicProject(project)); + } + } + + // Endpoints other pages poll for; enough to keep them from erroring. + // `/projects/dashboard` is the dashboard route on the projects service. + if ((pathname === '/projects/dashboard' || pathname === '/dashboard') && method === 'GET') { + const active = projects.filter(isActive); + return send(res, 200, { + summary: { + totalProjects: projects.length, + totalBudget: projects.reduce((sum, p) => sum + Number(p.total_budget ?? 0), 0), + totalSpent: projects.reduce((sum, p) => sum + spentOn(p.project_id), 0), + totalDonations: 0, + }, + projects: active.map((p) => ({ + project_id: p.project_id, + name: p.name, + total_budget: Number(p.total_budget ?? 0), + spent: spentOn(p.project_id), + staff_count: p.members.length, + })), + monthlyExpenses: [], + }); + } + if (pathname === '/expenditures' && method === 'GET') return send(res, 200, expenditures); + if (pathname === '/health') return send(res, 200, { ok: true }); + + send(res, 404, { message: `No mock route for ${method} ${pathname}` }); +}); + +server.listen(PORT, () => { + console.log(`Mock BRANCH API listening on http://localhost:${PORT}`); + console.log(`Start the frontend with:`); + console.log(` NEXT_PUBLIC_API_BASE_URL=http://localhost:${PORT} npm run dev`); +}); diff --git a/apps/frontend/next.config.ts b/apps/frontend/next.config.ts index 48b9f2b8..41ac1b88 100644 --- a/apps/frontend/next.config.ts +++ b/apps/frontend/next.config.ts @@ -11,8 +11,15 @@ import type { NextConfig } from 'next'; // Unset for prod builds, so the production deploy path is unchanged. const previewBasePath = process.env.PREVIEW_BASE_PATH; +// `next dev` inherits `output: 'export'`, and export rejects any dynamic route +// whose param is not listed in generateStaticParams — so /projects/42 throws +// locally even though production serves it fine via the CloudFront SPA +// fallback. Opt out for local runs (see mock-api/README.md); builds and CI +// never set this, so the deployed output is unchanged. +const disableStaticExport = process.env.NEXT_DISABLE_STATIC_EXPORT === 'true'; + const nextConfig: NextConfig = { - output: 'export', + ...(disableStaticExport ? {} : { output: 'export' }), trailingSlash: true, // emit /route/index.html — clean S3 key mapping images: { unoptimized: true }, // no server image optimizer in export // basePath already prefixes emitted /_next/* asset URLs, but Next does NOT diff --git a/apps/frontend/src/app/components/Button.tsx b/apps/frontend/src/app/components/Button.tsx new file mode 100644 index 00000000..9252739e --- /dev/null +++ b/apps/frontend/src/app/components/Button.tsx @@ -0,0 +1,74 @@ +'use client'; + +import React from 'react'; +import Spinner from './Spinner'; + +/** + * The three button treatments the designs use, named for intent rather than + * colour so a token change does not require renaming call sites. + * + * - `primary` filled green — the one affirmative action on a screen + * - `secondary` outlined — cancel/dismiss beside a primary + * - `ghost` text only — inline navigation such as "View All" + */ +export type ButtonVariant = 'primary' | 'secondary' | 'ghost'; + +// Every colour utility here is `!`-prefixed: Chakra's reset styles `button` +// with `background: transparent` and its own border colour, and it outranks +// unprefixed Tailwind utilities — without this the primary button renders as +// bare text and the secondary loses its outline. +const VARIANT_CLASSES: Record = { + primary: + '!bg-core-green !text-core-white hover:!bg-accent-dark-green disabled:!bg-primary-500', + secondary: + '!border-[1px] !border-solid !border-black-500 !bg-transparent !text-core-black hover:!bg-black-100 disabled:!text-black-500', + ghost: '!bg-transparent !text-core-black hover:!bg-black-100 disabled:!text-black-500', +}; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: ButtonVariant; + /** Rendered at 24px before the label, matching the design's icon slot. */ + icon?: React.ReactNode; + /** Moves the icon after the label, e.g. a trailing chevron. */ + iconPosition?: 'start' | 'end'; + /** Swaps the icon slot for a spinner and blocks further clicks. */ + isLoading?: boolean; + /** Label to show while loading; defaults to keeping the idle one. */ + loadingText?: React.ReactNode; +} + +export default function Button({ + variant = 'primary', + icon, + iconPosition = 'start', + isLoading = false, + loadingText, + children, + className = '', + type = 'button', + disabled, + ...rest +}: ButtonProps) { + // The spinner takes the icon's slot so the button keeps its width, and + // occupies it even when there is no icon so the label does not jump. + const iconSlot = + isLoading || icon ? ( + + {isLoading ? : icon} + + ) : null; + + return ( + + ); +} diff --git a/apps/frontend/src/app/components/DatePickerField.tsx b/apps/frontend/src/app/components/DatePickerField.tsx new file mode 100644 index 00000000..9eac2af3 --- /dev/null +++ b/apps/frontend/src/app/components/DatePickerField.tsx @@ -0,0 +1,245 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { LuCalendar, LuChevronLeft, LuChevronRight } from 'react-icons/lu'; +import { formatDateOrdinal, parseApiDate, toApiDate } from '@/lib/format'; +import { useAnchoredPopover } from '@/hooks/useAnchoredPopover'; + +interface DatePickerFieldProps { + label: string; + /** `YYYY-MM-DD`, or `''` for no selection. */ + value: string; + onChange: (value: string) => void; + placeholder?: string; + required?: boolean; + disabled?: boolean; + isError?: boolean; + errorMessage?: string; +} + +const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; + +const CALENDAR_WIDTH = 264; +/** Tall enough for the header, weekday row, six week rows and the clear button. */ +const CALENDAR_HEIGHT = 320; + +/** The 42 cells of a month grid, including the leading/trailing days that pad it. */ +function buildCalendarGrid(month: Date): { date: Date; inMonth: boolean }[] { + const firstOfMonth = new Date(month.getFullYear(), month.getMonth(), 1); + const start = new Date(firstOfMonth); + start.setDate(start.getDate() - start.getDay()); + + return Array.from({ length: 42 }, (_, i) => { + const date = new Date(start); + date.setDate(start.getDate() + i); + return { date, inMonth: date.getMonth() === month.getMonth() }; + }); +} + +function isSameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +/** + * Date input with a calendar popover. + * + * Hand-rolled rather than a native ``: the design specifies + * an ordinal display format ("May 15th, 2025") and a styled grid, neither of + * which a native picker allows. No date library is a dependency of this app. + */ +export default function DatePickerField({ + label, + value, + onChange, + placeholder = 'Select a date', + required = false, + disabled = false, + isError = false, + errorMessage, +}: DatePickerFieldProps) { + const [open, setOpen] = useState(false); + const { + anchorRef: triggerRef, + popoverRef, + boundaryRef: containerRef, + position, + } = useAnchoredPopover({ + open, + onDismiss: () => setOpen(false), + estimatedHeight: CALENDAR_HEIGHT, + width: CALENDAR_WIDTH, + }); + + const selected = useMemo(() => parseApiDate(value), [value]); + const [viewMonth, setViewMonth] = useState( + () => selected ?? new Date(), + ); + + // Re-centre the grid when the value changes from outside (e.g. opening the + // edit modal on a project that already has dates). + useEffect(() => { + if (selected) + setViewMonth(new Date(selected.getFullYear(), selected.getMonth(), 1)); + }, [selected]); + + const grid = useMemo(() => buildCalendarGrid(viewMonth), [viewMonth]); + const today = new Date(); + + const tone = isError ? 'error' : 'default'; + const labelClass = tone === 'error' ? '!text-error-red' : '!text-core-black'; + const boxClass = + tone === 'error' + ? '!border-error-red !text-error-red !font-bold' + : '!border-black-400 !text-core-black'; + const valueClass = value + ? tone === 'error' + ? '!text-error-red' + : '!text-core-black' + : tone === 'error' + ? '!text-error-red !font-bold' + : '!text-black-700'; + + return ( +
+ + +
+ + + {open && + position && + createPortal( +
+
+ +
+ {viewMonth.toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + })} +
+ +
+ +
+ {WEEKDAYS.map((day) => ( +
+ {day} +
+ ))} + + {grid.map(({ date, inMonth }) => { + const isSelected = selected + ? isSameDay(date, selected) + : false; + const isToday = isSameDay(date, today); + return ( + + ); + })} +
+ + {value && ( + + )} +
, + document.body, + )} +
+ + {isError && errorMessage && ( +

+ {errorMessage} +

+ )} +
+ ); +} diff --git a/apps/frontend/src/app/components/ExpensesTable.tsx b/apps/frontend/src/app/components/ExpensesTable.tsx index 3303a2b8..e354f7d1 100644 --- a/apps/frontend/src/app/components/ExpensesTable.tsx +++ b/apps/frontend/src/app/components/ExpensesTable.tsx @@ -8,6 +8,8 @@ interface ExpensesTableProps { expenditures: Expenditure[]; /** Project detail already scopes to one project, so it hides this column. */ showProject?: boolean; + /** The project page's summary table omits the receipt link. */ + showReceipt?: boolean; projectNames?: Record; onViewReceipt?: (expenditure: Expenditure) => void; onRowClick?: (expenditure: Expenditure) => void; @@ -27,6 +29,7 @@ function formatAmount(amount: string) { export default function ExpensesTable({ expenditures, showProject = true, + showReceipt = true, projectNames = {}, onViewReceipt, onRowClick, @@ -37,7 +40,9 @@ export default function ExpensesTable({ // widens the rest instead of leaving a gap at the end of the row. const widths = showProject ? { id: '11.5%', date: '15.3%', type: '16.6%', project: '21.8%', amount: '14%', receipt: '11%', status: '9.8%' } - : { id: '14%', date: '19%', type: '21%', project: '0', amount: '18%', receipt: '14%', status: '14%' }; + : showReceipt + ? { id: '14%', date: '19%', type: '21%', project: '0', amount: '18%', receipt: '14%', status: '14%' } + : { id: '16%', date: '22%', type: '25%', project: '0', amount: '21%', receipt: '0', status: '16%' }; const columns: DataTableColumn[] = [ { @@ -82,35 +87,39 @@ export default function ExpensesTable({ cell: (e) => formatAmount(e.amount), skeleton: { width: '60%' }, }, - { - key: 'receipt', - header: 'Receipt', - width: widths.receipt, - cell: (e) => - e.receipt_url ? ( - - ) : ( - '---' - ), - skeleton: { width: '70%' }, - }, + ...(showReceipt + ? [ + { + key: 'receipt', + header: 'Receipt', + width: widths.receipt, + cell: (e: Expenditure) => + e.receipt_url ? ( + + ) : ( + '---' + ), + skeleton: { width: '70%' }, + }, + ] + : []), { key: 'status', header: 'Status', diff --git a/apps/frontend/src/app/components/FundingSummary.tsx b/apps/frontend/src/app/components/FundingSummary.tsx new file mode 100644 index 00000000..51f9282a --- /dev/null +++ b/apps/frontend/src/app/components/FundingSummary.tsx @@ -0,0 +1,69 @@ +'use client'; + +import SpendingDonut from './SpendingDonut'; +import { formatCurrency } from '@/lib/format'; +import type { ProjectStats } from '@/types'; + +interface FundingSummaryProps { + stats: Pick< + ProjectStats, + 'totalBudget' | 'totalSpent' | 'totalRemaining' | 'spentPercentage' + >; +} + +/** + * Total funding panel: the donut beside the budget / spent / remaining figures. + * + * Stacks below `sm` so the ring and the numbers each keep a readable size on a + * phone rather than both shrinking to fit the design's fixed 667px row. + */ +export default function FundingSummary({ stats }: FundingSummaryProps) { + return ( +
+ + +
+
+

+ {formatCurrency(stats.totalBudget)} +

+

total

+
+ +
+ +
+
+

+ {formatCurrency(stats.totalSpent)} +

+

spent

+
+
+ {/* Remaining goes green in the design; a negative value means the + project is over budget, so it flips to the error colour rather + than reading as healthy. */} +

+ {formatCurrency(stats.totalRemaining)} +

+

+ {stats.totalRemaining < 0 ? 'over budget' : 'remaining'} +

+
+
+
+
+ ); +} diff --git a/apps/frontend/src/app/components/Header.tsx b/apps/frontend/src/app/components/Header.tsx index 54ead2b6..b538b5c7 100644 --- a/apps/frontend/src/app/components/Header.tsx +++ b/apps/frontend/src/app/components/Header.tsx @@ -27,25 +27,27 @@ const Header: React.FC = ({ return (
{/* Dynamic Text Section */} -
+
{text}
{/* Flexible Icon Section */} -
+
{icon ?? (user ? ( <> -
- {user.name} - {user.email} + {/* Hidden on a phone: the name and email are the first things that + can go when the rail already claims most of the width. */} +
+ {user.name} + {user.email}
{isAdmin && ( Admin diff --git a/apps/frontend/src/app/components/Navbar.tsx b/apps/frontend/src/app/components/Navbar.tsx index cd8bd450..e26d426b 100644 --- a/apps/frontend/src/app/components/Navbar.tsx +++ b/apps/frontend/src/app/components/Navbar.tsx @@ -1,19 +1,30 @@ "use client"; import Image from "next/image"; -import React, { useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { PT_Sans } from "next/font/google"; +import { LuChevronDown, LuChevronRight } from "react-icons/lu"; import { useAuth } from "@/context/AuthContext"; +import { useApi } from "@/hooks/useApi"; import { assetPath } from "@/lib/asset"; import { normalizePath } from "@/lib/routes"; +import type { ProjectSummary } from "@/types"; +import LoadingState from "./LoadingState"; const ptSans = PT_Sans({ subsets: ["latin"], weight: ["400", "700"] }); // ─── Types & Definitions ────────────────────────────────────────────────────── export type UserRole = "admin" | "standard" | "limited"; -interface NavItem { label: string; href?: string; action?: "logout"; roles?: UserRole[]; } +interface NavItem { + label: string; + href?: string; + action?: "logout"; + roles?: UserRole[]; + /** Renders the expandable project list beneath this item. */ + submenu?: "projects"; +} // Every href here must resolve to a real route. "Profile" was removed because // no /profile page exists, and "Log Out" is an action rather than a route — @@ -21,7 +32,7 @@ interface NavItem { label: string; href?: string; action?: "logout"; roles?: Use // silently turn the button back into a dead link. const NAV_ITEMS: NavItem[] = [ { label: "Dashboard", href: "/dashboard", roles: ["admin"] }, - { label: "Projects", href: "/projects" }, + { label: "Projects", href: "/projects", submenu: "projects" }, { label: "Donors", href: "/donors" }, { label: "Donations", href: "/donations" }, { label: "Expenses", href: "/expenses" }, @@ -37,6 +48,109 @@ const COLORS = { hoverBg: "rgba(255, 255, 255, 0.2)", }; +/** Figma sizes every nav row at 37px with 8px/12px padding and 16px type. */ +const ROW_STYLE: React.CSSProperties = { + display: "flex", + alignItems: "center", + width: "100%", + minHeight: 37, + padding: "8px 12px", + fontSize: 16, + textAlign: "left", + textDecoration: "none", + border: "none", + cursor: "pointer", + fontFamily: "inherit", + transition: "background-color 0.2s ease", +}; + +/** Extracts the project id when the current route is a project detail page. */ +function activeProjectIdFrom(path: string): number | null { + const match = /^\/projects\/(\d+)$/.exec(path); + return match ? Number(match[1]) : null; +} + +/** + * Flyout listing every project the user can see, so the sidebar can jump + * straight into a project instead of routing through the list page first. + */ +function ProjectsSubmenu({ + projects, + isLoading, + error, + activeProjectId, + onNavigate, +}: { + projects: ProjectSummary[]; + isLoading: boolean; + error: string | null; + activeProjectId: number | null; + onNavigate: () => void; +}) { + const optionStyle: React.CSSProperties = { + display: "block", + padding: "8px 12px", + minHeight: 37, + fontSize: 16, + textDecoration: "none", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }; + + return ( +
+ + All Projects + + + {isLoading && } + {error && ( +

{error}

+ )} + {!isLoading && !error && projects.length === 0 && ( +

No projects yet

+ )} + + {projects.map((project) => { + const isCurrent = project.project_id === activeProjectId; + return ( + + {project.name} + + ); + })} +
+ ); +} + /** * `roleOverride` exists for tests only — it is named that way so nobody mistakes * it for the source of truth again. The role comes from the session, which comes @@ -52,10 +166,27 @@ export const NavBar: React.FC<{ roleOverride?: UserRole; activePath?: string }> const currentPath = normalizePath(activePath ?? pathname); const router = useRouter(); const { logout, isAdmin } = useAuth(); + const api = useApi(); const [hoveredIndex, setHoveredIndex] = useState(null); const [loggingOut, setLoggingOut] = useState(false); + const activeProjectId = activeProjectIdFrom(currentPath); + // Collapsed by default, including on a project page: the flyout overlaps the + // content beside the rail, so opening it automatically would cover the very + // page the user just navigated to. + const [projectsOpen, setProjectsOpen] = useState(false); + const [projects, setProjects] = useState([]); + // Starts as loading: the menu only renders once expanded, and expanding + // always triggers a load — defaulting to false made "No projects yet" flash + // before the first response arrived. + const [projectsState, setProjectsState] = useState<{ loading: boolean; error: string | null }>({ + loading: true, + error: null, + }); + const hasLoadedProjects = useRef(false); + const submenuRef = useRef(null); + const role: UserRole = roleOverride ?? (isAdmin ? "admin" : "standard"); const visibleItems = NAV_ITEMS.filter(item => !item.roles || item.roles.includes(role)); @@ -68,6 +199,48 @@ export const NavBar: React.FC<{ roleOverride?: UserRole; activePath?: string }> return target !== "/" && currentPath.startsWith(`${target}/`); }; + // Fetched on first expand rather than on mount: the list is only ever read by + // this menu, and eagerly loading it would add a request to every page. + const loadProjects = useCallback(async () => { + if (hasLoadedProjects.current) return; + hasLoadedProjects.current = true; + setProjectsState({ loading: true, error: null }); + try { + const rows = await api.get("/projects"); + setProjects(Array.isArray(rows) ? rows : []); + setProjectsState({ loading: false, error: null }); + } catch { + // Retryable: clearing the latch lets the next expand try again. + hasLoadedProjects.current = false; + setProjectsState({ loading: false, error: "Could not load projects" }); + } + }, [api]); + + useEffect(() => { + if (projectsOpen) void loadProjects(); + }, [projectsOpen, loadProjects]); + + // Dismiss on outside click and Escape, the two things a flyout must honour. + useEffect(() => { + if (!projectsOpen) return; + + const onPointerDown = (event: MouseEvent) => { + if (submenuRef.current && !submenuRef.current.contains(event.target as Node)) { + setProjectsOpen(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setProjectsOpen(false); + }; + + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [projectsOpen]); + const handleLogout = async () => { if (loggingOut) return; setLoggingOut(true); @@ -90,11 +263,13 @@ export const NavBar: React.FC<{ roleOverride?: UserRole; activePath?: string }> flexDirection: "column", fontFamily: ptSans.style.fontFamily, position: "relative", - overflow: "hidden", + // `visible` so the projects flyout can escape the rail; the background + // image is clipped by its own wrapper instead. + overflow: "visible", }} > {/* Background Image Layer */} -
+
const isHovered = hoveredIndex === index; const sharedStyle: React.CSSProperties = { - display: "block", - width: "100%", - textAlign: "left", - padding: "12px 24px", - fontSize: "15px", - textDecoration: "none", - transition: "background-color 0.2s ease", + ...ROW_STYLE, backgroundColor: active ? COLORS.white : (isHovered ? COLORS.hoverBg : "transparent"), - color: active ? COLORS.brandGreen : COLORS.white, + color: active ? "var(--color-core-black)" : COLORS.white, fontWeight: active ? 700 : 400, - border: "none", - cursor: "pointer", - fontFamily: "inherit", }; + if (item.submenu === "projects" && item.href) { + return ( +
  • setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + > + {/* The label navigates and the chevron expands: a single + control cannot do both, and collapsing them would make the + list page unreachable from the sidebar. */} +
    + + {item.label} + + +
    + + {projectsOpen && ( + setProjectsOpen(false)} + /> + )} +
  • + ); + } + return (
  • {loggingOut ? "Logging out…" : item.label} ) : ( - + {item.label} )} diff --git a/apps/frontend/src/app/components/ProjectCard.tsx b/apps/frontend/src/app/components/ProjectCard.tsx index c360ed61..f9c3a5da 100644 --- a/apps/frontend/src/app/components/ProjectCard.tsx +++ b/apps/frontend/src/app/components/ProjectCard.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import { LuDollarSign } from "react-icons/lu"; -import { RxPeople } from "react-icons/rx"; -import { FaArrowRight } from "react-icons/fa6"; +import { LuDollarSign } from 'react-icons/lu'; +import { RxPeople } from 'react-icons/rx'; +import { FaArrowRight } from 'react-icons/fa6'; +import { formatCurrency } from '@/lib/format'; type ActiveProps = { variant: 'active'; @@ -24,68 +25,108 @@ type ArchiveProps = { // for the projects list and would shrink inside a grid cell. type ProjectCardProps = (ActiveProps | ArchiveProps) & { fullWidth?: boolean }; +/** `$ Budget` / `people Staff` — the two stat columns split by a rule. */ +function StatColumn({ + icon, + label, + value, + grow = false, +}: { + icon: React.ReactNode; + label: string; + value: string; + /** Only the Staff column flexes; Budget keeps its natural width so a figure + * like "$52,500/ $100,000" is never ellipsised in favour of "3 members". */ + grow?: boolean; +}) { + return ( +
    +
    + + {icon} + +
    {label}
    +
    +

    {value}

    +
    + ); +} + export default function ProjectCard(props: ProjectCardProps) { const widthClasses = props.fullWidth ? 'w-full' : 'w-full sm:w-[50%] md:w-[35%] lg:w-[25%]'; return ( -
    -
    -

    {props.name}

    -
    -
    -
    - -
    Budget
    -
    -

    - {props.variant === 'active' - ? `$${props.budget_used.toLocaleString()}/$${props.total_budget.toLocaleString()}` - : `$${props.total_budget.toLocaleString()}`} -

    -
    -
    -
    - -
    Staff
    -
    -

    {props.members.toLocaleString()} members

    -
    -
    - {props.variant === 'active' ? ( - // A project with no budget set divides by zero; show 0% rather - // than "NaN%" and a bar of unset width. - (() => { - const percentUsed = props.total_budget > 0 - ? Math.round((props.budget_used / props.total_budget) * 100) - : 0; - return ( -
    -
    -
    -
    -

    {percentUsed}%

    + // A container, not a viewport breakpoint: how much room the stats have + // depends on the card's own width, which varies with the grid track. +
    + {/* Two lines then ellipsis: the design's cards are a fixed height and a + long project name would otherwise push the stats out of alignment + across a row. */} +

    {props.name}

    + +
    + } + label="Budget" + value={ + props.variant === 'active' + ? `${formatCurrency(props.budget_used)}/ ${formatCurrency(props.total_budget)}` + : formatCurrency(props.total_budget) + } + /> +
    + } + label="Staff" + value={`${props.members.toLocaleString()} ${props.members === 1 ? 'member' : 'members'}`} + /> +
    + + {props.variant === 'active' ? ( + // A project with no budget set divides by zero; show 0% rather + // than "NaN%" and a bar of unset width. + (() => { + const percentUsed = props.total_budget > 0 + ? Math.round((props.budget_used / props.total_budget) * 100) + : 0; + return ( +
    +
    +
    - ); - })() - ) : ( -
    -
    -
    Start Date
    -

    {props.start_date}

    +

    {percentUsed}%

    +
    + ); + })() + ) : ( +
    +
    +
    +
    + Start Date +

    {props.start_date}

    - -
    -
    End Date
    -

    {props.end_date}

    + +
    + End Date +

    {props.end_date}

    - )} -
    +
    + )}
    ); -} \ No newline at end of file +} diff --git a/apps/frontend/src/app/components/ProjectFormModal.tsx b/apps/frontend/src/app/components/ProjectFormModal.tsx new file mode 100644 index 00000000..7caeb181 --- /dev/null +++ b/apps/frontend/src/app/components/ProjectFormModal.tsx @@ -0,0 +1,383 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { CloseButton, Dialog, Portal } from '@chakra-ui/react'; +import Button from './Button'; +import TextInputField from './TextInputField'; +import DatePickerField from './DatePickerField'; +import StaffPicker from './StaffPicker'; +import { useApi } from '@/hooks/useApi'; +import type { AssignableStaff, Member, Project } from '@/types'; + +export interface ProjectFormValues { + name: string; + description: string; + budget: string; + startDate: string; + endDate: string; + inProgress: boolean; + memberIds: number[]; +} + +interface ProjectFormModalProps { + open: boolean; + onClose: () => void; + /** Called after a successful save, with the persisted project. */ + onSaved: (project: Project) => void; + /** Absent for "Add New Project"; present switches the modal to edit mode. */ + project?: Project | null; + members?: Member[]; +} + +type FieldErrors = Partial>; + +/** The design tints the header and footer with Core Black/100 at 50%. */ +const CHROME_BG = + 'color-mix(in srgb, var(--color-black-100) 50%, var(--color-core-white))'; + +const EMPTY_VALUES: ProjectFormValues = { + name: '', + description: '', + budget: '', + startDate: '', + endDate: '', + inProgress: false, + memberIds: [], +}; + +/** Strips `$` and thousands separators so `$30,000` is accepted as typed. */ +function parseBudget(raw: string): number | null { + const cleaned = raw.replace(/[$,\s]/g, ''); + if (!cleaned) return null; + const value = Number(cleaned); + return Number.isFinite(value) ? value : null; +} + +/** Error copy is taken verbatim from the design's error-state frame. */ +function validate(values: ProjectFormValues): FieldErrors { + const errors: FieldErrors = {}; + + if (!values.name.trim()) errors.name = 'Enter a valid name'; + if (!values.description.trim()) + errors.description = 'Please enter a valid description'; + + const budget = parseBudget(values.budget); + if (!values.budget.trim() || budget === null || budget < 0) { + errors.budget = 'Enter a valid amount'; + } + + if (!values.startDate) errors.startDate = 'Please select a valid date'; + + // The "in progress" checkbox is what makes an end date optional, so the two + // are validated together rather than independently. + if (!values.inProgress) { + if ( + !values.endDate || + (values.startDate && values.endDate < values.startDate) + ) { + errors.endDate = 'Please select a date AFTER the start date'; + } + } + + if (values.memberIds.length === 0) { + errors.memberIds = 'Select AT LEAST 1 staff member for the project'; + } + + return errors; +} + +export default function ProjectFormModal({ + open, + onClose, + onSaved, + project = null, + members = [], +}: ProjectFormModalProps) { + const api = useApi(); + const isEdit = Boolean(project); + + const [values, setValues] = useState(EMPTY_VALUES); + const [errors, setErrors] = useState({}); + // Errors stay hidden until the first submit: flagging "required" on a field + // the user has not reached yet reads as failure rather than guidance. + const [submitted, setSubmitted] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const [staff, setStaff] = useState([]); + const [staffLoading, setStaffLoading] = useState(false); + + // Re-seed whenever the modal opens so a cancelled edit does not leak into + // the next one. This deliberately runs on the rising edge of `open` only: + // `project` and `members` are usually fresh literals from the parent, so + // reacting to their identity would re-seed on every render and clobber the + // user's in-progress edits (including the post-submit error state). + const seeded = useRef(false); + useEffect(() => { + if (!open) { + seeded.current = false; + return; + } + if (seeded.current) return; + seeded.current = true; + + setSubmitted(false); + setSaveError(null); + setErrors({}); + setValues( + project + ? { + name: project.name ?? '', + description: project.description ?? '', + budget: + project.total_budget != null + ? String(Number(project.total_budget)) + : '', + startDate: project.start_date?.slice(0, 10) ?? '', + endDate: project.end_date?.slice(0, 10) ?? '', + inProgress: !project.end_date, + memberIds: members.map((m) => m.user_id), + } + : EMPTY_VALUES, + ); + }, [open, project, members]); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setStaffLoading(true); + api + .get<{ staff: AssignableStaff[] }>('/projects/assignable-staff') + .then((res) => { + if (!cancelled) setStaff(res?.staff ?? []); + }) + .catch(() => { + // Non-fatal: the rest of the form still works, and the picker shows + // its empty state rather than blocking the whole modal. + if (!cancelled) setStaff([]); + }) + .finally(() => { + if (!cancelled) setStaffLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, api]); + + const update = useCallback( + ( + key: K, + value: ProjectFormValues[K], + ) => { + setValues((prev) => { + const next = { ...prev, [key]: value }; + // Checking "in progress" clears the end date, which is the state the + // backend stores for an open-ended project. + if (key === 'inProgress' && value === true) next.endDate = ''; + return next; + }); + }, + [], + ); + + // Re-validate live once the user has seen the errors, so a fixed field stops + // shouting immediately. + useEffect(() => { + if (!submitted) return; + setErrors(validate(values)); + }, [values, submitted]); + + async function handleSubmit() { + const nextErrors = validate(values); + setSubmitted(true); + setErrors(nextErrors); + if (Object.keys(nextErrors).length > 0) return; + + setSaving(true); + setSaveError(null); + const body = { + name: values.name.trim(), + description: values.description.trim(), + total_budget: parseBudget(values.budget), + start_date: values.startDate || null, + end_date: values.inProgress ? null : values.endDate || null, + members: values.memberIds, + }; + + try { + const saved = isEdit + ? await api.put(`/projects/${project!.project_id}`, body) + : await api.post('/projects', body); + onSaved(saved); + onClose(); + } catch (err) { + setSaveError( + err instanceof Error ? err.message : 'Failed to save project', + ); + } finally { + setSaving(false); + } + } + + const showError = (field: keyof ProjectFormValues) => + submitted && Boolean(errors[field]); + + return ( + { + if (!e.open) onClose(); + }} + scrollBehavior="inside" + > + + + + {/* 625px is the Figma width; it shrinks with the viewport below that. */} + + + + {isEdit ? 'Edit Project' : 'Add New Project'} + + + + + +
    + {/* Paired fields sit side by side from `sm` up and stack on a + phone, where two half-width columns would be unreadable. + Name gets the wider share, as in the design's 337/209 split. */} +
    + update('name', v)} + placeholder="Enter project name" + isError={showError('name')} + errorMessage={errors.name} + disabled={saving} + /> + update('budget', v)} + placeholder="Enter total funding" + isError={showError('budget')} + errorMessage={errors.budget} + disabled={saving} + /> +
    + + update('description', v)} + placeholder="Enter a short project description here" + isError={showError('description')} + errorMessage={errors.description} + disabled={saving} + /> + + {/* The checkbox sits closer to the dates than the 30px rhythm, + because it qualifies the end date rather than standing alone. */} +
    +
    + update('startDate', v)} + isError={showError('startDate')} + errorMessage={errors.startDate} + disabled={saving} + /> + update('endDate', v)} + isError={showError('endDate')} + errorMessage={errors.endDate} + disabled={saving || values.inProgress} + /> +
    + + +
    + + update('memberIds', v)} + isError={showError('memberIds')} + errorMessage={errors.memberIds} + disabled={saving} + /> + + {saveError && ( +

    + {saveError} +

    + )} +
    +
    + + +
    + + {/* Stays enabled while invalid: submitting is how the field + errors are surfaced, so disabling it until the form is + valid would make them undiscoverable. */} + +
    +
    +
    +
    +
    +
    + ); +} diff --git a/apps/frontend/src/app/components/SectionHeading.tsx b/apps/frontend/src/app/components/SectionHeading.tsx new file mode 100644 index 00000000..3ed2cd01 --- /dev/null +++ b/apps/frontend/src/app/components/SectionHeading.tsx @@ -0,0 +1,58 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; +import { LuChevronRight } from 'react-icons/lu'; +import Button from './Button'; + +interface SectionHeadingProps { + /** Uppercased by the `h4` element style, so pass it in natural case. */ + label: string; + icon: React.ReactNode; + /** Renders the "View All" affordance. Omit both to hide it. */ + viewAllHref?: string; + onViewAll?: () => void; +} + +/** + * The `LABEL (icon) ........ > View All` row that heads every panel on the + * project page. The rule underneath is part of the heading in the design, but + * it is left to the caller because only some sections carry one. + */ +export default function SectionHeading({ + label, + icon, + viewAllHref, + onViewAll, +}: SectionHeadingProps) { + const viewAll = ( + <> + + View All + + ); + + return ( +
    +
    +

    {label}

    + + {icon} + +
    + + {viewAllHref ? ( + + {viewAll} + + ) : onViewAll ? ( + + ) : null} +
    + ); +} diff --git a/apps/frontend/src/app/components/SpendingDonut.tsx b/apps/frontend/src/app/components/SpendingDonut.tsx new file mode 100644 index 00000000..772a0982 --- /dev/null +++ b/apps/frontend/src/app/components/SpendingDonut.tsx @@ -0,0 +1,78 @@ +'use client'; + +/** + * The funding donut on the project page. + * + * Hand-rolled SVG: no charting library is a dependency of this app, and a + * single-series ring does not justify adding one. + */ + +interface SpendingDonutProps { + /** 0–100. Values outside the range are clamped so a project that has + * overspent still renders a full ring rather than wrapping past 12 o'clock. */ + percentage: number; + /** Rendered in the middle of the ring. */ + label?: string; +} + +// Figma draws a 276px ring with a 27.6px band. Kept as a viewBox so the chart +// scales with its container instead of pinning the layout to 276px. +const SIZE = 276; +const STROKE = 27.6; +const RADIUS = (SIZE - STROKE) / 2; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +export default function SpendingDonut({ + percentage, + label = 'spent', +}: SpendingDonutProps) { + const clamped = Math.min( + 100, + Math.max(0, Number.isFinite(percentage) ? percentage : 0), + ); + const rounded = Math.round(clamped); + + return ( +
    + + {/* -90deg so the arc starts at 12 o'clock rather than 3 o'clock. */} + + + + + + + {/* Centred in the ring, with the two labels sharing a baseline inside — + the design sets the percentage in heading type and "spent" in body + type, sitting on the same line. */} +
    +
    +

    {rounded}%

    +

    {label}

    +
    +
    +
    + ); +} diff --git a/apps/frontend/src/app/components/StaffCard.tsx b/apps/frontend/src/app/components/StaffCard.tsx index 17cac58e..0d360e78 100644 --- a/apps/frontend/src/app/components/StaffCard.tsx +++ b/apps/frontend/src/app/components/StaffCard.tsx @@ -1,55 +1,96 @@ -'use client' +'use client'; import React from 'react'; import Image from 'next/image'; import { useState } from 'react'; -import { PiUserCircleThin } from "react-icons/pi"; -import { MdOutlineMail } from "react-icons/md"; - +import { PiUserCircleThin } from 'react-icons/pi'; +import { MdOutlineMail } from 'react-icons/md'; interface StaffCardProps { - image?: string; - name: string; - title?: string; - email: string; - } - + image?: string; + name: string; + title?: string; + email: string; + /** + * Sizing for the project page's narrow staff column, where the roomier + * default card leaves no width for the name and email. + */ + compact?: boolean; +} export default function StaffCard({ - image, - name, - title, - email - }: StaffCardProps) { - const [imgError, setImgError] = useState(false); + image, + name, + title, + email, + compact = false, +}: StaffCardProps) { + const [imgError, setImgError] = useState(false); + const avatarSize = compact ? 96 : 120; - return ( -
    -
    - {(image && !imgError) ? ( - Staff setImgError(true)}/> - ) : ( -
    - -
    - )} -
    + return ( +
    +
    + {image && !imgError ? ( + Staff setImgError(true)} + /> + ) : ( +
    + +
    + )} +
    -
    -

    - {name}{title ? `, ${title}` : ''} -

    - +
    +

    + {name} + {title ? `, ${title}` : ''} +

    + +
    - ) + ); } diff --git a/apps/frontend/src/app/components/StaffPicker.tsx b/apps/frontend/src/app/components/StaffPicker.tsx new file mode 100644 index 00000000..82da366c --- /dev/null +++ b/apps/frontend/src/app/components/StaffPicker.tsx @@ -0,0 +1,205 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { LuSearch, LuX } from 'react-icons/lu'; +import type { AssignableStaff } from '@/types'; +import { useAnchoredPopover } from '@/hooks/useAnchoredPopover'; +import LoadingState from './LoadingState'; + +const LISTBOX_MAX_HEIGHT = 220; + +interface StaffPickerProps { + label: string; + options: AssignableStaff[]; + /** Selected user ids, in the order they were added. */ + value: number[]; + onChange: (value: number[]) => void; + placeholder?: string; + required?: boolean; + disabled?: boolean; + isError?: boolean; + errorMessage?: string; + isLoading?: boolean; +} + +/** + * Search-and-select list of staff, rendering the chosen people as removable + * chips beneath the field. + * + * Filtering happens client-side against the full roster: the staff list is + * organisation-sized (tens, not thousands), so a request per keystroke would + * cost more than it saves. + */ +export default function StaffPicker({ + label, + options, + value, + onChange, + placeholder = 'Search by name...', + required = false, + disabled = false, + isError = false, + errorMessage, + isLoading = false, +}: StaffPickerProps) { + const [query, setQuery] = useState(''); + const [open, setOpen] = useState(false); + const { + anchorRef: fieldRef, + popoverRef: listboxRef, + boundaryRef: containerRef, + position, + } = useAnchoredPopover({ + open, + onDismiss: () => setOpen(false), + estimatedHeight: LISTBOX_MAX_HEIGHT, + }); + + const byId = useMemo( + () => new Map(options.map((option) => [option.user_id, option])), + [options], + ); + + const selected = useMemo( + () => + value + .map((id) => byId.get(id)) + .filter((s): s is AssignableStaff => Boolean(s)), + [value, byId], + ); + + const matches = useMemo(() => { + const needle = query.trim().toLowerCase(); + return options.filter((option) => { + if (value.includes(option.user_id)) return false; + if (!needle) return true; + return ( + option.name.toLowerCase().includes(needle) || + option.email.toLowerCase().includes(needle) + ); + }); + }, [options, query, value]); + + const add = (id: number) => { + onChange([...value, id]); + setQuery(''); + }; + + const remove = (id: number) => + onChange(value.filter((existing) => existing !== id)); + + const labelClass = isError ? '!text-error-red' : '!text-core-black'; + const boxClass = isError ? '!border-error-red' : '!border-black-400'; + + return ( +
    + + +
    +
    + + { + setQuery(event.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + placeholder={placeholder} + aria-label={label} + className={`h-full w-full bg-transparent !font-body !text-base outline-none disabled:cursor-not-allowed ${ + isError + ? '!font-bold !text-error-red placeholder:!font-bold placeholder:!text-error-red' + : '!text-core-black placeholder:!text-black-700' + }`} + /> +
    + + {open && + !disabled && + position && + createPortal( +
    + {isLoading && ( + + )} + {!isLoading && matches.length === 0 && ( +

    + {query.trim() + ? 'No matching staff' + : 'Everyone is already assigned'} +

    + )} + {matches.map((option) => ( + + ))} +
    , + document.body, + )} +
    + + {selected.length > 0 && ( +
      + {selected.map((person) => ( +
    • + + + {person.name} + + + +
    • + ))} +
    + )} + + {isError && errorMessage && ( +

    + {errorMessage} +

    + )} +
    + ); +} diff --git a/apps/frontend/src/app/components/TextInputField.tsx b/apps/frontend/src/app/components/TextInputField.tsx index 2d8bd74a..8d7585c5 100644 --- a/apps/frontend/src/app/components/TextInputField.tsx +++ b/apps/frontend/src/app/components/TextInputField.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState } from 'react'; -import { Field, Input } from '@chakra-ui/react'; +import { Field, Input, Textarea } from '@chakra-ui/react'; interface TextInputFieldProps { label: string; @@ -11,6 +11,15 @@ interface TextInputFieldProps { isError?: boolean; errorMessage?: string; isValid?: boolean; + /** Appends the `*` the designs use to mark mandatory fields. */ + required?: boolean; + disabled?: boolean; + /** Renders a `Textarea` instead of an `Input`, for free-text fields. */ + multiline?: boolean; + rows?: number; + /** Rendered before the value, e.g. `$` on the budget field. */ + prefix?: string; + inputMode?: 'text' | 'decimal' | 'numeric'; } export default function TextInputField({ @@ -21,13 +30,21 @@ export default function TextInputField({ isError = false, errorMessage, isValid = false, + required = false, + disabled = false, + multiline = false, + rows = 4, + prefix, + inputMode, }: TextInputFieldProps) { const [internalValue, setInternalValue] = useState(''); const isControlled = value !== undefined; const currentValue = isControlled ? value : internalValue; - function handleChange(e: React.ChangeEvent) { + function handleChange( + e: React.ChangeEvent, + ) { const newValue = e.target.value; if (!isControlled) setInternalValue(newValue); onChange?.(newValue); @@ -36,28 +53,65 @@ export default function TextInputField({ const labelClass = isError ? 'text-error-red' : isValid - ? 'text-core-green' - : 'text-core-black'; + ? 'text-core-green' + : 'text-core-black'; const inputClass = isError ? '!border-error-red !text-error-red !font-bold placeholder:text-error-red placeholder:font-bold' : isValid - ? '!border-core-green !text-core-green !font-bold placeholder:text-core-green placeholder:font-bold' - : '!border-black-200 !text-core-black'; + ? '!border-core-green !text-core-green !font-bold placeholder:text-core-green placeholder:font-bold' + : '!border-black-400 !text-core-black placeholder:!text-black-700'; + + const sharedClass = `!w-full !rounded !px-3 !py-2 !bg-core-white focus:!outline-none focus:!ring-0 !shadow-none !border !font-body !text-body placeholder:font-body ${inputClass}`; return ( - - + + {label} + {required && '*'} - + + {multiline ? ( +