From 4ad83d5fb7c08ac03a028ded1dd40c4d69ea17e6 Mon Sep 17 00:00:00 2001 From: Sishir P Date: Tue, 4 Aug 2026 19:07:46 -0400 Subject: [PATCH] feat(mileage): let caregivers log driving and agencies approve it Home-care caregivers drive between clients all day and are commonly reimbursed for it, but there was nowhere in the platform to record that, so agencies were collecting it on paper or not at all. Caregivers log trips in the mobile app and watch them move through review. Agency staff approve or reject from a new Mileage page. The split is strict: a caregiver may only see, create, or withdraw their own entries, and only staff with staff.write may rule on one. A caregiver approving their own reimbursement would defeat the point of the workflow. Approved and awaiting-review totals are shown separately rather than blended, because agencies pay on approved trips and one combined number would overstate what is actually coming. Miles are stored as integer hundredths for the same reason money is stored in cents: a float odometer difference summed over a month drifts. Rounding happens once at the route boundary so the integer is the only representation the rest of the system sees. Review only matches rows still in 'submitted'. That makes the transition safe under a double click and stops a second coordinator silently overwriting the first decision, and it is why a caregiver cannot delete a trip the agency has already ruled on: the record of that decision is not theirs to erase. Both cases answer 404 without disclosing which one happened. The purpose field is caregiver-authored free text and is treated as potentially PHI, because somebody will eventually type a client's name into it. It stays inside agency-scoped responses and never reaches an audit payload or a notification body. --- packages/app/src/app.ts | 2 + .../routes/__tests__/mileage-routes.test.ts | 214 ++++++++++ packages/app/src/routes/mileage-routes.ts | 175 +++++++++ packages/core/src/index.ts | 1 + .../2026-08-04-add-mileage-entries.ts | 55 +++ packages/core/src/migrations/runner.ts | 2 + .../src/repositories/mileage-repository.ts | 157 ++++++++ packages/mobile/app/mileage.tsx | 2 + .../src/features/mileage/MileageScreen.tsx | 365 ++++++++++++++++++ .../src/features/profile/ProfileScreen.tsx | 7 + packages/mobile/src/lib/mileage.test.ts | 99 +++++ packages/mobile/src/lib/mileage.ts | 76 ++++ packages/web/src/App.tsx | 3 + .../src/features/staff/MileageReviewPage.tsx | 207 ++++++++++ 14 files changed, 1365 insertions(+) create mode 100644 packages/app/src/routes/__tests__/mileage-routes.test.ts create mode 100644 packages/app/src/routes/mileage-routes.ts create mode 100644 packages/core/src/migrations/2026-08-04-add-mileage-entries.ts create mode 100644 packages/core/src/repositories/mileage-repository.ts create mode 100644 packages/mobile/app/mileage.tsx create mode 100644 packages/mobile/src/features/mileage/MileageScreen.tsx create mode 100644 packages/mobile/src/lib/mileage.test.ts create mode 100644 packages/mobile/src/lib/mileage.ts create mode 100644 packages/web/src/features/staff/MileageReviewPage.tsx diff --git a/packages/app/src/app.ts b/packages/app/src/app.ts index 1ebe65af..e2ec1360 100644 --- a/packages/app/src/app.ts +++ b/packages/app/src/app.ts @@ -36,6 +36,7 @@ import onboardingAdminRoutes from './routes/onboarding-admin-routes.js'; import profileRoutes from './routes/profile-routes.js'; import settingsRoutes from './routes/settings-routes.js'; import pushTokenRoutes from './routes/push-token-routes.js'; +import mileageRoutes from './routes/mileage-routes.js'; import agencySandataConfigRoutes from './routes/agency-sandata-config-routes.js'; import agencyHhaexchangeConfigRoutes from './routes/agency-hhaexchange-config-routes.js'; import agencyClearinghouseConfigRoutes from './routes/agency-clearinghouse-config-routes.js'; @@ -348,6 +349,7 @@ export function createApp(options: { mobileSessionStore?: MobileSessionStore } = app.use(`${prefix}/profile`, profileRoutes); app.use(`${prefix}/settings`, settingsRoutes); app.use(`${prefix}/notifications`, pushTokenRoutes); + app.use(`${prefix}/mileage`, mileageRoutes); app.use(`${prefix}/compliance-engine`, complianceEngineRoutes); app.use(`${prefix}/command-center`, copilotLimiter, commandCenterRoutes); app.use(`${prefix}/documents`, documentRoutes); diff --git a/packages/app/src/routes/__tests__/mileage-routes.test.ts b/packages/app/src/routes/__tests__/mileage-routes.test.ts new file mode 100644 index 00000000..ca3795be --- /dev/null +++ b/packages/app/src/routes/__tests__/mileage-routes.test.ts @@ -0,0 +1,214 @@ +import request from 'supertest'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import * as core from '@rayhealth/core'; +import { createApp } from '../../app.js'; +import { makeToken, setTestJwtSecret } from './test-helpers.js'; + +beforeAll(() => setTestJwtSecret()); +afterEach(() => vi.restoreAllMocks()); + +const agencyId = '00000000-0000-4000-8000-0000000000f1'; +const userId = '00000000-0000-4000-8000-0000000000f2'; +const caregiverId = '00000000-0000-4000-8000-0000000000f3'; +const entryId = '00000000-0000-4000-8000-0000000000f4'; + +function mockRepo(overrides: Record) { + vi.spyOn(core, 'MileageRepository').mockImplementation( + () => overrides as unknown as core.MileageRepository, + ); +} + +function caregiverAuth() { + return `Bearer ${makeToken('caregiver', agencyId, userId, caregiverId)}`; +} +function adminAuth() { + return `Bearer ${makeToken('admin', agencyId, userId)}`; +} + +const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); + +describe('POST /mileage', () => { + it('stores miles as hundredths, scoped to the calling caregiver', async () => { + const create = vi.fn().mockResolvedValue({ id: entryId }); + mockRepo({ create }); + + const res = await request(createApp()) + .post('/mileage') + .set('Authorization', caregiverAuth()) + .send({ tripDate: yesterday, miles: 12.34, purpose: 'Between visits' }); + + expect(res.status).toBe(201); + expect(create).toHaveBeenCalledWith({ + agencyId, + caregiverId, + visitId: null, + tripDate: yesterday, + milesHundredths: 1234, + purpose: 'Between visits', + }); + }); + + it('rounds at the boundary so only the integer form reaches storage', async () => { + const create = vi.fn().mockResolvedValue({ id: entryId }); + mockRepo({ create }); + + await request(createApp()) + .post('/mileage') + .set('Authorization', caregiverAuth()) + .send({ tripDate: yesterday, miles: 0.125 }); + + expect(create).toHaveBeenCalledWith(expect.objectContaining({ milesHundredths: 13 })); + }); + + it('refuses a trip dated in the future', async () => { + const create = vi.fn(); + mockRepo({ create }); + const tomorrow = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10); + + const res = await request(createApp()) + .post('/mileage') + .set('Authorization', caregiverAuth()) + .send({ tripDate: tomorrow, miles: 5 }); + + expect(res.status).toBe(400); + expect(create).not.toHaveBeenCalled(); + }); + + it('rejects zero, negative, and absurd distances', async () => { + const create = vi.fn(); + mockRepo({ create }); + + for (const miles of [0, -3, 501]) { + const res = await request(createApp()) + .post('/mileage') + .set('Authorization', caregiverAuth()) + .send({ tripDate: yesterday, miles }); + expect(res.status).toBe(400); + } + expect(create).not.toHaveBeenCalled(); + }); + + it('will not let a non-caregiver log mileage', async () => { + mockRepo({ create: vi.fn() }); + const res = await request(createApp()) + .post('/mileage') + .set('Authorization', adminAuth()) + .send({ tripDate: yesterday, miles: 5 }); + expect(res.status).toBe(403); + }); +}); + +describe('GET /mileage', () => { + it('gives a caregiver only their own trips', async () => { + const listForCaregiver = vi.fn().mockResolvedValue([]); + const listForAgency = vi.fn().mockResolvedValue([]); + mockRepo({ listForCaregiver, listForAgency }); + + await request(createApp()).get('/mileage').set('Authorization', caregiverAuth()); + + expect(listForCaregiver).toHaveBeenCalledWith(caregiverId, agencyId, {}); + expect(listForAgency).not.toHaveBeenCalled(); + }); + + it('gives staff the agency review queue', async () => { + const listForCaregiver = vi.fn().mockResolvedValue([]); + const listForAgency = vi.fn().mockResolvedValue([]); + mockRepo({ listForCaregiver, listForAgency }); + + await request(createApp()) + .get('/mileage?status=submitted') + .set('Authorization', adminAuth()); + + expect(listForAgency).toHaveBeenCalledWith(agencyId, { status: 'submitted' }); + expect(listForCaregiver).not.toHaveBeenCalled(); + }); + + it('rejects a malformed filter', async () => { + mockRepo({ listForCaregiver: vi.fn(), listForAgency: vi.fn() }); + const res = await request(createApp()) + .get('/mileage?from=08-2026') + .set('Authorization', caregiverAuth()); + expect(res.status).toBe(400); + }); +}); + +describe('PATCH /mileage/:id/review', () => { + it('lets staff approve a submitted trip', async () => { + const review = vi.fn().mockResolvedValue({ id: entryId, status: 'approved' }); + mockRepo({ review }); + + const res = await request(createApp()) + .patch(`/mileage/${entryId}/review`) + .set('Authorization', adminAuth()) + .send({ status: 'approved', note: 'ok' }); + + expect(res.status).toBe(200); + expect(review).toHaveBeenCalledWith(entryId, agencyId, 'approved', userId, 'ok'); + }); + + it('will not let a caregiver approve their own reimbursement', async () => { + const review = vi.fn(); + mockRepo({ review }); + + const res = await request(createApp()) + .patch(`/mileage/${entryId}/review`) + .set('Authorization', caregiverAuth()) + .send({ status: 'approved' }); + + expect(res.status).toBe(403); + expect(review).not.toHaveBeenCalled(); + }); + + it('404s on an already-reviewed or foreign entry, without saying which', async () => { + // The repository only matches status='submitted', so a second reviewer + // cannot silently overwrite the first decision. + mockRepo({ review: vi.fn().mockResolvedValue(null) }); + + const res = await request(createApp()) + .patch(`/mileage/${entryId}/review`) + .set('Authorization', adminAuth()) + .send({ status: 'rejected' }); + + expect(res.status).toBe(404); + }); + + it('rejects a status outside the workflow', async () => { + mockRepo({ review: vi.fn() }); + const res = await request(createApp()) + .patch(`/mileage/${entryId}/review`) + .set('Authorization', adminAuth()) + .send({ status: 'paid' }); + expect(res.status).toBe(400); + }); +}); + +describe('DELETE /mileage/:id', () => { + it('withdraws the caregiver own not-yet-reviewed trip', async () => { + const deleteOwnSubmitted = vi.fn().mockResolvedValue(true); + mockRepo({ deleteOwnSubmitted }); + + const res = await request(createApp()) + .delete(`/mileage/${entryId}`) + .set('Authorization', caregiverAuth()); + + expect(res.status).toBe(204); + expect(deleteOwnSubmitted).toHaveBeenCalledWith(entryId, caregiverId, agencyId); + }); + + it('404s once the agency has ruled on the trip', async () => { + // The decision record is not the caregiver's to erase. + mockRepo({ deleteOwnSubmitted: vi.fn().mockResolvedValue(false) }); + + const res = await request(createApp()) + .delete(`/mileage/${entryId}`) + .set('Authorization', caregiverAuth()); + + expect(res.status).toBe(404); + }); + + it('requires authentication', async () => { + mockRepo({ deleteOwnSubmitted: vi.fn() }); + const res = await request(createApp()).delete(`/mileage/${entryId}`); + expect(res.status).toBe(401); + }); +}); diff --git a/packages/app/src/routes/mileage-routes.ts b/packages/app/src/routes/mileage-routes.ts new file mode 100644 index 00000000..008bdd06 --- /dev/null +++ b/packages/app/src/routes/mileage-routes.ts @@ -0,0 +1,175 @@ +/** + * Caregiver mileage. + * + * Caregivers log trips; agency staff approve or reject them. The split is + * strict: a caregiver may only ever see, create, or withdraw their own + * entries, and only staff with `staff.write` may rule on one. A caregiver + * approving their own reimbursement would be the whole point of the workflow + * defeated. + * + * `purpose` is caregiver-authored free text and is treated as potentially + * PHI, because somebody will eventually type a client's name into it. It is + * returned only inside agency-scoped responses and never copied into an audit + * payload or a notification body. + */ +import { Router, type Request, type Response } from 'express'; +import type { Knex } from 'knex'; +import { z } from 'zod'; +import { MileageRepository } from '@rayhealth/core'; +import { requireCapability } from '../middleware/require-capability.js'; +import { safeError } from '../security/safe-log.js'; + +const router = Router(); + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Miles arrive as a decimal from the UI and are stored as hundredths. + * + * The 500-mile ceiling is a typo guard, not a policy: a home-care caregiver + * does not drive 500 miles between clients in a day, and catching a slipped + * decimal here beats explaining a four-figure reimbursement later. Zero is + * rejected because a zero-mile trip is a mistake, not a claim. + */ +const createSchema = z.object({ + tripDate: z.string().regex(DATE_RE, 'tripDate must be YYYY-MM-DD'), + miles: z.number().positive().max(500), + purpose: z.string().max(500).optional(), + visitId: z.string().uuid().optional(), +}); + +const reviewSchema = z.object({ + status: z.enum(['approved', 'rejected']), + note: z.string().max(500).optional(), +}); + +const listQuerySchema = z.object({ + from: z.string().regex(DATE_RE).optional(), + to: z.string().regex(DATE_RE).optional(), + status: z.enum(['submitted', 'approved', 'rejected']).optional(), +}); + +/** Trips cannot be logged in the future; a trip either happened or it didn't. */ +function isFutureDate(tripDate: string): boolean { + return tripDate > new Date().toISOString().slice(0, 10); +} + +// GET /mileage, the caller's own trips (caregiver) or the agency queue (staff) +router.get('/', requireCapability('evv.read'), async (req: Request, res: Response) => { + const parsed = listQuerySchema.safeParse(req.query); + if (!parsed.success) { + return res.status(400).json({ message: 'from/to must be YYYY-MM-DD and status must be valid' }); + } + try { + const db = req.app.get('db') as Knex; + const repo = new MileageRepository(db); + // A caregiver sees only their own trips. Anyone else with evv.read sees + // the agency queue, which is what the review screen needs. + const entries = + req.auth.role === 'caregiver' && req.auth.caregiverId + ? await repo.listForCaregiver(req.auth.caregiverId, req.auth.agencyId, parsed.data) + : await repo.listForAgency(req.auth.agencyId, parsed.data); + res.json({ entries }); + } catch (error) { + safeError('mileage list failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// POST /mileage, log a trip +router.post('/', requireCapability('evv.write'), async (req: Request, res: Response) => { + if (!req.auth.caregiverId) { + return res.status(403).json({ message: 'Only caregivers can log mileage' }); + } + const parsed = createSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ + message: parsed.error.issues[0]?.message ?? 'Invalid mileage entry', + issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })), + }); + } + if (isFutureDate(parsed.data.tripDate)) { + return res.status(400).json({ message: 'tripDate cannot be in the future' }); + } + + try { + const db = req.app.get('db') as Knex; + const entry = await new MileageRepository(db).create({ + agencyId: req.auth.agencyId, + caregiverId: req.auth.caregiverId, + visitId: parsed.data.visitId ?? null, + tripDate: parsed.data.tripDate, + // Rounded at the boundary so the stored integer is the only + // representation the rest of the system ever sees. + milesHundredths: Math.round(parsed.data.miles * 100), + purpose: parsed.data.purpose ?? null, + }); + res.status(201).json(entry); + } catch (error) { + safeError('mileage create failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// PATCH /mileage/:id/review, approve or reject a submitted trip +router.patch('/:id/review', requireCapability('staff.write'), async (req: Request, res: Response) => { + const id = typeof req.params.id === 'string' ? req.params.id : ''; + if (!/^[0-9a-f-]{36}$/i.test(id)) { + return res.status(400).json({ message: 'valid mileage entry id required' }); + } + const parsed = reviewSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ message: 'status must be approved or rejected' }); + } + + try { + const db = req.app.get('db') as Knex; + const updated = await new MileageRepository(db).review( + id, + req.auth.agencyId, + parsed.data.status, + req.auth.userId, + parsed.data.note ?? null, + ); + // Either the entry is not in this agency, or it has already been ruled on. + // A single 404 for both keeps the row from being probed across tenants and + // stops a second reviewer silently overwriting the first decision. + if (!updated) { + return res.status(404).json({ message: 'No submitted mileage entry with that id' }); + } + res.json(updated); + } catch (error) { + safeError('mileage review failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// DELETE /mileage/:id, withdraw one's own not-yet-reviewed trip +router.delete('/:id', requireCapability('evv.write'), async (req: Request, res: Response) => { + if (!req.auth.caregiverId) { + return res.status(403).json({ message: 'Only caregivers can withdraw mileage' }); + } + const id = typeof req.params.id === 'string' ? req.params.id : ''; + if (!/^[0-9a-f-]{36}$/i.test(id)) { + return res.status(400).json({ message: 'valid mileage entry id required' }); + } + try { + const db = req.app.get('db') as Knex; + const removed = await new MileageRepository(db).deleteOwnSubmitted( + id, + req.auth.caregiverId, + req.auth.agencyId, + ); + // Once the agency has ruled on a trip, the record of that decision is not + // the caregiver's to erase. + if (!removed) { + return res.status(404).json({ message: 'No withdrawable mileage entry with that id' }); + } + res.status(204).end(); + } catch (error) { + safeError('mileage delete failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +export default router; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4047a593..186c50dc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -77,3 +77,4 @@ export * from './integrations/index.js'; export * from './domain/user-agency.js'; export * from './repositories/user-agency-repository.js'; export * from './repositories/push-token-repository.js'; +export * from './repositories/mileage-repository.js'; diff --git a/packages/core/src/migrations/2026-08-04-add-mileage-entries.ts b/packages/core/src/migrations/2026-08-04-add-mileage-entries.ts new file mode 100644 index 00000000..bb8fe70d --- /dev/null +++ b/packages/core/src/migrations/2026-08-04-add-mileage-entries.ts @@ -0,0 +1,55 @@ +/** + * Migration: caregiver mileage entries. + * + * Home-care caregivers drive between clients all day and are commonly + * reimbursed for it. Until now there was nowhere to record that, so agencies + * were collecting it on paper or not at all. + * + * Shape notes: + * - `miles_hundredths` is an integer: 12.34 miles stores as 1234. Same + * reason money is stored in cents, a float odometer difference summed + * over a month drifts. + * - `visit_id` is nullable. Not every trip attaches to a visit (a supply + * run, a drive to the office), and a visit-linked trip should survive the + * visit being reviewed, so there is no cascade from it. + * - `status` is the approval workflow: submitted, approved, rejected. An + * agency pays on approved rows; the caregiver sees the state of each. + * - `purpose` is free text and IS potentially PHI, because a caregiver may + * type a client's name into it. It is treated as PHI everywhere it is + * read: agency-scoped queries only, never in a notification body, never + * in an audit payload. + * + * Idempotent via hasTable guards, safe to re-run. Callbacks are synchronous + * on purpose: an async callback is silently dropped by knex. + */ + +import type { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable('mileage_entries'))) { + await knex.schema.createTable('mileage_entries', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')) + table.uuid('agency_id').references('id').inTable('agencies').notNullable().onDelete('CASCADE') + table.uuid('caregiver_id').references('id').inTable('caregivers').notNullable().onDelete('CASCADE') + // Optional visit linkage. No FK cascade: a trip that happened still + // happened even if the visit row is later removed. + table.uuid('visit_id').nullable() + table.date('trip_date').notNullable() + // Miles to two decimal places, stored as hundredths. + table.integer('miles_hundredths').notNullable() + table.string('purpose', 500).nullable() + table.string('status', 16).notNullable().defaultTo('submitted') + table.uuid('reviewed_by').nullable() + table.timestamp('reviewed_at', { useTz: true }).nullable() + table.string('review_note', 500).nullable() + table.timestamps(true, true) + table.index(['agency_id', 'trip_date']) + table.index(['caregiver_id', 'trip_date']) + table.index(['status']) + }) + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('mileage_entries') +} diff --git a/packages/core/src/migrations/runner.ts b/packages/core/src/migrations/runner.ts index 1bed2538..69c7eed6 100644 --- a/packages/core/src/migrations/runner.ts +++ b/packages/core/src/migrations/runner.ts @@ -35,6 +35,7 @@ import * as addDenialWorklist from './2026-07-21-add-denial-worklist.js'; import * as addCourseResumeState from './2026-08-04-add-course-resume-state.js'; import * as addPushTokens from './2026-08-04-add-push-tokens.js'; import * as addCaregiverPayRate from './2026-08-04-add-caregiver-pay-rate.js'; +import * as addMileageEntries from './2026-08-04-add-mileage-entries.js'; async function run(): Promise { const db = createDb(); @@ -53,6 +54,7 @@ async function run(): Promise { await addCourseResumeState.up(db); await addPushTokens.up(db); await addCaregiverPayRate.up(db); + await addMileageEntries.up(db); process.stderr.write('Migrations complete.\n'); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'unknown error'; diff --git a/packages/core/src/repositories/mileage-repository.ts b/packages/core/src/repositories/mileage-repository.ts new file mode 100644 index 00000000..800cdbe9 --- /dev/null +++ b/packages/core/src/repositories/mileage-repository.ts @@ -0,0 +1,157 @@ +/** + * Repository for `mileage_entries`. + * + * Tenancy: every read and write takes an agencyId and filters on it. Caregiver + * reads additionally filter on caregiver_id, because a caregiver may see only + * their own trips. + * + * `purpose` is caregiver-authored free text and must be treated as potentially + * PHI: somebody will eventually type a client's name into it. It never leaves + * an agency-scoped query and never lands in a notification or audit payload. + */ + +import type { Knex } from 'knex' + +export type MileageStatus = 'submitted' | 'approved' | 'rejected' + +export interface MileageEntry { + id: string + agencyId: string + caregiverId: string + visitId: string | null + tripDate: string + /** Miles to two decimals, stored as hundredths (12.34 mi = 1234). */ + milesHundredths: number + purpose: string | null + status: MileageStatus + reviewedAt: string | null + reviewNote: string | null + createdAt: string | null +} + +export interface NewMileageEntry { + agencyId: string + caregiverId: string + visitId?: string | null + tripDate: string + milesHundredths: number + purpose?: string | null +} + +function toIso(value: unknown): string | null { + if (!value) return null + return value instanceof Date ? value.toISOString() : String(value) +} + +/** Dates come back as Date or string depending on driver; normalize to YYYY-MM-DD. */ +function toYmd(value: unknown): string { + if (value instanceof Date) return value.toISOString().slice(0, 10) + return String(value).slice(0, 10) +} + +function mapRow(row: Record): MileageEntry { + return { + id: String(row.id), + agencyId: String(row.agency_id), + caregiverId: String(row.caregiver_id), + visitId: row.visit_id ? String(row.visit_id) : null, + tripDate: toYmd(row.trip_date), + milesHundredths: Number(row.miles_hundredths), + purpose: row.purpose ? String(row.purpose) : null, + status: (row.status as MileageStatus) ?? 'submitted', + reviewedAt: toIso(row.reviewed_at), + reviewNote: row.review_note ? String(row.review_note) : null, + createdAt: toIso(row.created_at), + } +} + +export class MileageRepository { + constructor(private readonly db: Knex) {} + + async create(input: NewMileageEntry): Promise { + const [row] = await this.db('mileage_entries') + .insert({ + agency_id: input.agencyId, + caregiver_id: input.caregiverId, + visit_id: input.visitId ?? null, + trip_date: input.tripDate, + miles_hundredths: input.milesHundredths, + purpose: input.purpose ?? null, + status: 'submitted', + }) + .returning('*') + return mapRow(row as Record) + } + + /** One caregiver's own trips, newest first, bounded by an optional range. */ + async listForCaregiver( + caregiverId: string, + agencyId: string, + options: { from?: string; to?: string; limit?: number } = {}, + ): Promise { + let q = this.db('mileage_entries') + .where({ caregiver_id: caregiverId, agency_id: agencyId }) + .orderBy('trip_date', 'desc') + .orderBy('created_at', 'desc') + if (options.from) q = q.andWhere('trip_date', '>=', options.from) + if (options.to) q = q.andWhere('trip_date', '<=', options.to) + const rows = await q.limit(Math.min(options.limit ?? 200, 500)).select('*') + return (rows as Record[]).map(mapRow) + } + + /** Agency-wide review queue, optionally filtered by status. */ + async listForAgency( + agencyId: string, + options: { status?: MileageStatus; from?: string; to?: string; limit?: number } = {}, + ): Promise { + let q = this.db('mileage_entries').where({ agency_id: agencyId }) + if (options.status) q = q.andWhere('status', options.status) + if (options.from) q = q.andWhere('trip_date', '>=', options.from) + if (options.to) q = q.andWhere('trip_date', '<=', options.to) + const rows = await q + .orderBy('trip_date', 'desc') + .limit(Math.min(options.limit ?? 500, 1000)) + .select('*') + return (rows as Record[]).map(mapRow) + } + + /** + * Approve or reject a submitted entry. + * + * Only a `submitted` row can be reviewed. That is what makes the transition + * idempotent-safe: two coordinators clicking approve at once cannot produce + * two approvals, and a rejected entry cannot be quietly flipped to approved + * without the caregiver resubmitting. + */ + async review( + id: string, + agencyId: string, + status: 'approved' | 'rejected', + reviewerId: string, + note?: string | null, + ): Promise { + const [row] = await this.db('mileage_entries') + .where({ id, agency_id: agencyId, status: 'submitted' }) + .update({ + status, + reviewed_by: reviewerId, + reviewed_at: this.db.fn.now(), + review_note: note ?? null, + updated_at: this.db.fn.now(), + }) + .returning('*') + return row ? mapRow(row as Record) : null + } + + /** + * Delete a caregiver's own entry. Scoped to the caregiver AND to + * `submitted`: once an agency has ruled on a trip, the record of that + * decision is not the caregiver's to erase. + */ + async deleteOwnSubmitted(id: string, caregiverId: string, agencyId: string): Promise { + const deleted = await this.db('mileage_entries') + .where({ id, caregiver_id: caregiverId, agency_id: agencyId, status: 'submitted' }) + .del() + return deleted > 0 + } +} diff --git a/packages/mobile/app/mileage.tsx b/packages/mobile/app/mileage.tsx new file mode 100644 index 00000000..fb3c4f01 --- /dev/null +++ b/packages/mobile/app/mileage.tsx @@ -0,0 +1,2 @@ +import MileageScreen from '../src/features/mileage/MileageScreen'; +export default MileageScreen; diff --git a/packages/mobile/src/features/mileage/MileageScreen.tsx b/packages/mobile/src/features/mileage/MileageScreen.tsx new file mode 100644 index 00000000..6c4b1d76 --- /dev/null +++ b/packages/mobile/src/features/mileage/MileageScreen.tsx @@ -0,0 +1,365 @@ +import React, { useCallback, useState } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + Pressable, + RefreshControl, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import * as Haptics from 'expo-haptics'; +import Animated, { FadeInDown } from 'react-native-reanimated'; +import { useFocusEffect } from 'expo-router'; +import apiClient from '../../lib/api-client'; +import ScreenHeader from '../common/ScreenHeader'; +import ErrorRetry from '../common/ErrorRetry'; +import { SkeletonList } from '../common/Skeleton'; +import { showAppAlert } from '../common/alerts/appAlert'; +import { alpha, colors, radii, shadow, typography } from '../common/tokens'; +import { + formatMiles, + parseMiles, + summarize, + todayYmd, + type MileageEntry, +} from '../../lib/mileage'; + +/** + * Mileage screen. + * + * Caregivers log the driving they do between clients and watch it move + * through agency review. Approved and still-pending totals are shown + * separately, because agencies pay on approved trips and one blended number + * would overstate what is actually coming. + */ + +const STATUS_META: Record< + MileageEntry['status'], + { label: string; color: string; icon: keyof typeof Ionicons.glyphMap } +> = { + submitted: { label: 'Pending review', color: colors.amber, icon: 'time-outline' }, + approved: { label: 'Approved', color: colors.success, icon: 'checkmark-circle-outline' }, + rejected: { label: 'Not approved', color: colors.danger, icon: 'close-circle-outline' }, +}; + +function formatTripDate(ymd: string): string { + const d = new Date(`${ymd}T00:00:00.000Z`); + return Number.isFinite(d.getTime()) + ? d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }) + : ymd; +} + +export default function MileageScreen() { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + + const [miles, setMiles] = useState(''); + const [purpose, setPurpose] = useState(''); + const [saving, setSaving] = useState(false); + const [formError, setFormError] = useState(null); + + const load = useCallback(async () => { + try { + const res = await apiClient.get<{ entries: MileageEntry[] }>('/api/mileage'); + setEntries(res.data?.entries ?? []); + setError(null); + } catch { + setError('Could not load your mileage.'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useFocusEffect( + useCallback(() => { + void load(); + }, [load]), + ); + + const totals = summarize(entries); + + const submit = async () => { + const parsed = parseMiles(miles); + if (!parsed.ok) { + setFormError(parsed.error); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + return; + } + setFormError(null); + setSaving(true); + try { + await apiClient.post('/api/mileage', { + tripDate: todayYmd(new Date()), + miles: parsed.miles, + ...(purpose.trim() ? { purpose: purpose.trim() } : {}), + }); + setMiles(''); + setPurpose(''); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + await load(); + } catch { + showAppAlert( + 'Could not save that trip', + 'Please check your connection and try again.', + undefined, + { variant: 'error' }, + ); + } finally { + setSaving(false); + } + }; + + const withdraw = (entry: MileageEntry) => { + showAppAlert( + 'Remove this trip?', + `${formatMiles(entry.milesHundredths)} on ${formatTripDate(entry.tripDate)} will be deleted.`, + [ + { text: 'Keep it' }, + { + text: 'Remove', + onPress: () => { + void (async () => { + try { + await apiClient.delete(`/api/mileage/${entry.id}`); + await load(); + } catch { + showAppAlert('Could not remove that trip', 'Please try again.', undefined, { + variant: 'error', + }); + } + })(); + }, + }, + ], + { variant: 'warning' }, + ); + }; + + return ( + + + { + setRefreshing(true); + void load(); + }} + tintColor={colors.brandBlue} + /> + } + > + + + {formatMiles(totals.approvedHundredths)} + Approved + + + + {formatMiles(totals.submittedHundredths)} + + Awaiting review + + + + + Log today's driving + + Miles + { + setMiles(t); + if (formError) setFormError(null); + }} + placeholder="12.4" + placeholderTextColor={colors.textMuted} + keyboardType="decimal-pad" + style={styles.input} + accessibilityLabel="Miles driven" + /> + + + Purpose (optional) + + + {formError ? {formError} : null} + void submit()} + disabled={saving} + style={({ pressed }) => [styles.submitBtn, pressed && !saving && { opacity: 0.9 }]} + accessibilityRole="button" + accessibilityLabel="Save trip" + accessibilityState={{ disabled: saving }} + > + {saving ? ( + + ) : ( + Save trip + )} + + + + Recent trips + + {loading ? ( + + ) : error ? ( + { setLoading(true); void load(); }} /> + ) : entries.length === 0 ? ( + + + + No trips logged yet. Add the driving you do between clients and your agency will + review it. + + + ) : ( + entries.map((entry, index) => { + const meta = STATUS_META[entry.status]; + return ( + + + {formatMiles(entry.milesHundredths)} + + + {meta.label} + + + {formatTripDate(entry.tripDate)} + {entry.purpose ? ( + + {entry.purpose} + + ) : null} + {entry.status === 'rejected' && entry.reviewNote ? ( + {entry.reviewNote} + ) : null} + {entry.status === 'submitted' ? ( + withdraw(entry)} + hitSlop={8} + style={styles.withdrawBtn} + accessibilityRole="button" + accessibilityLabel="Remove this trip" + > + Remove + + ) : null} + + ); + }) + )} + + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1, backgroundColor: colors.screenBg }, + body: { flex: 1 }, + bodyContent: { padding: 16, paddingBottom: 48, gap: 12 }, + totalsRow: { flexDirection: 'row', gap: 12 }, + totalCard: { + backgroundColor: colors.cardBg, + borderRadius: radii.lg, + padding: 16, + gap: 2, + ...shadow.card, + }, + totalValue: { fontSize: 20, fontWeight: '800', color: colors.textPrimary }, + totalLabel: { ...typography.caption, color: colors.textMuted }, + formCard: { + backgroundColor: colors.cardBg, + borderRadius: radii.lg, + padding: 16, + gap: 12, + ...shadow.card, + }, + formTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, + field: { gap: 5 }, + fieldLabel: { ...typography.caption, color: colors.textSecondary }, + input: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: radii.md, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + color: colors.textPrimary, + backgroundColor: colors.screenBg, + }, + formError: { ...typography.caption, color: colors.danger }, + submitBtn: { + backgroundColor: colors.brandBlue, + borderRadius: radii.md, + paddingVertical: 13, + alignItems: 'center', + }, + submitText: { color: colors.onGradient, fontWeight: '700', fontSize: 15 }, + sectionLabel: { + ...typography.caption, + color: colors.textMuted, + textTransform: 'uppercase', + marginTop: 4, + }, + emptyCard: { + backgroundColor: colors.cardBg, + borderRadius: radii.lg, + padding: 24, + alignItems: 'center', + gap: 10, + ...shadow.card, + }, + emptyText: { ...typography.caption, color: colors.textSecondary, textAlign: 'center', lineHeight: 17 }, + entryCard: { + backgroundColor: colors.cardBg, + borderRadius: radii.lg, + padding: 14, + gap: 4, + ...shadow.card, + }, + entryTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + entryMiles: { fontSize: 16, fontWeight: '700', color: colors.textPrimary }, + statusPill: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: radii.pill, + }, + statusText: { ...typography.caption }, + entryDate: { ...typography.caption, color: colors.textMuted }, + entryPurpose: { fontSize: 13, color: colors.textSecondary }, + reviewNote: { ...typography.caption, color: colors.danger, marginTop: 2 }, + withdrawBtn: { alignSelf: 'flex-start', marginTop: 6 }, + withdrawText: { ...typography.caption, color: colors.brandBlue, fontWeight: '700' }, +}); diff --git a/packages/mobile/src/features/profile/ProfileScreen.tsx b/packages/mobile/src/features/profile/ProfileScreen.tsx index 791801ad..7475d7fd 100644 --- a/packages/mobile/src/features/profile/ProfileScreen.tsx +++ b/packages/mobile/src/features/profile/ProfileScreen.tsx @@ -208,6 +208,13 @@ export default function ProfileScreen() { subtitle="Estimated pay from verified visits" onPress={() => router.push('/earnings')} /> + router.push('/mileage')} + /> { + it('accepts whole and decimal miles', () => { + expect(parseMiles('12')).toEqual({ ok: true, miles: 12 }); + expect(parseMiles('12.4')).toEqual({ ok: true, miles: 12.4 }); + expect(parseMiles('12.45')).toEqual({ ok: true, miles: 12.45 }); + expect(parseMiles(' 8.5 ')).toEqual({ ok: true, miles: 8.5 }); + }); + + it('asks for a number rather than failing silently', () => { + const result = parseMiles(''); + expect(result.ok).toBe(false); + // Actionable, not a generic "invalid input". + if (!result.ok) expect(result.error).toMatch(/Enter how many miles/); + }); + + it('rejects non-numeric and over-precise input', () => { + expect(parseMiles('twelve').ok).toBe(false); + expect(parseMiles('12.456').ok).toBe(false); + expect(parseMiles('1,200').ok).toBe(false); + }); + + it('rejects zero and negative trips, which are mistakes not claims', () => { + expect(parseMiles('0').ok).toBe(false); + expect(parseMiles('0.00').ok).toBe(false); + expect(parseMiles('-5').ok).toBe(false); + }); + + it('catches a slipped decimal before the server has to', () => { + expect(parseMiles('500').ok).toBe(true); + const tooHigh = parseMiles('501'); + expect(tooHigh.ok).toBe(false); + if (!tooHigh.ok) expect(tooHigh.error).toMatch(/too high/); + }); +}); + +describe('formatMiles', () => { + it('always shows two decimals', () => { + expect(formatMiles(1234)).toBe('12.34 mi'); + expect(formatMiles(1200)).toBe('12.00 mi'); + expect(formatMiles(0)).toBe('0.00 mi'); + expect(formatMiles(5)).toBe('0.05 mi'); + }); +}); + +describe('totalHundredths', () => { + it('sums integrally, with no float drift', () => { + const entries = Array.from({ length: 10 }, () => entry('approved', 1010)); + expect(totalHundredths(entries)).toBe(10100); + }); + + it('is zero for no entries', () => { + expect(totalHundredths([])).toBe(0); + }); +}); + +describe('summarize', () => { + it('keeps approved and pending apart rather than blending them', () => { + // A blended total would overstate what is actually coming. + const result = summarize([ + entry('approved', 1000), + entry('approved', 500), + entry('submitted', 2000), + entry('rejected', 900), + ]); + expect(result).toEqual({ + approvedHundredths: 1500, + submittedHundredths: 2000, + rejectedCount: 1, + }); + }); + + it('handles an empty list', () => { + expect(summarize([])).toEqual({ + approvedHundredths: 0, + submittedHundredths: 0, + rejectedCount: 0, + }); + }); +}); + +describe('todayYmd', () => { + it('formats the date portion only', () => { + expect(todayYmd(new Date('2026-08-04T22:15:00.000Z'))).toBe('2026-08-04'); + }); +}); diff --git a/packages/mobile/src/lib/mileage.ts b/packages/mobile/src/lib/mileage.ts new file mode 100644 index 00000000..f7d2571c --- /dev/null +++ b/packages/mobile/src/lib/mileage.ts @@ -0,0 +1,76 @@ +/** + * Pure helpers for the mileage screen: input parsing and display formatting. + * No React Native imports, so it is unit-testable. + */ + +export type MileageStatus = 'submitted' | 'approved' | 'rejected'; + +export interface MileageEntry { + id: string; + tripDate: string; + milesHundredths: number; + purpose: string | null; + status: MileageStatus; + reviewNote: string | null; +} + +export type ParsedMiles = { ok: true; miles: number } | { ok: false; error: string }; + +/** + * Parse what a caregiver typed into the miles box. + * + * Deliberately strict about the failure messages: "Enter miles" and "That + * looks too high" are actionable, where a generic "invalid input" leaves + * somebody staring at a form they cannot submit. The 500 ceiling matches the + * server's typo guard, so the client never sends something the API will + * bounce. + */ +export function parseMiles(raw: string): ParsedMiles { + const trimmed = raw.trim(); + if (trimmed === '') return { ok: false, error: 'Enter how many miles you drove.' }; + if (!/^\d{0,4}(\.\d{1,2})?$/.test(trimmed)) { + return { ok: false, error: 'Enter miles as a number, like 12.4.' }; + } + const miles = Number(trimmed); + if (!Number.isFinite(miles) || miles <= 0) { + return { ok: false, error: 'Miles must be more than zero.' }; + } + if (miles > 500) return { ok: false, error: 'That looks too high. Check the number.' }; + return { ok: true, miles }; +} + +/** Hundredths of a mile to a display string, e.g. 1234 becomes "12.34 mi". */ +export function formatMiles(hundredths: number): string { + return `${(hundredths / 100).toFixed(2)} mi`; +} + +/** Total of a set of entries, in hundredths, so callers stay integral. */ +export function totalHundredths(entries: MileageEntry[]): number { + return entries.reduce((sum, e) => sum + e.milesHundredths, 0); +} + +/** + * Totals split by review state. Agencies pay on approved trips, so a + * caregiver needs to see approved and still-pending as separate numbers + * rather than one blended figure that overstates what is actually coming. + */ +export function summarize(entries: MileageEntry[]): { + approvedHundredths: number; + submittedHundredths: number; + rejectedCount: number; +} { + let approvedHundredths = 0; + let submittedHundredths = 0; + let rejectedCount = 0; + for (const e of entries) { + if (e.status === 'approved') approvedHundredths += e.milesHundredths; + else if (e.status === 'submitted') submittedHundredths += e.milesHundredths; + else rejectedCount += 1; + } + return { approvedHundredths, submittedHundredths, rejectedCount }; +} + +/** Today as YYYY-MM-DD, the default trip date. */ +export function todayYmd(now: Date): string { + return now.toISOString().slice(0, 10); +} diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 4d350f9f..75efd35b 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -16,6 +16,7 @@ import { RouteErrorBoundary } from './components/RouteErrorBoundary.js'; const AgencySetupPage = lazy(() => import('./features/agency/AgencySetupPage.js').then((m) => ({ default: m.AgencySetupPage }))); const GoLiveReadinessPage = lazy(() => import('./features/agency/GoLiveReadinessPage.js').then((m) => ({ default: m.GoLiveReadinessPage }))); const StaffPage = lazy(() => import('./features/staff/StaffPage.js').then((m) => ({ default: m.StaffPage }))); +const MileageReviewPage = lazy(() => import('./features/staff/MileageReviewPage.js').then((m) => ({ default: m.MileageReviewPage }))); const CaregiverActivityPage = lazy(() => import('./features/staff/CaregiverActivityPage.js').then((m) => ({ default: m.CaregiverActivityPage }))); const ClientsPage = lazy(() => import('./features/clients/ClientsPage.js').then((m) => ({ default: m.ClientsPage }))); const AuthorizationsPage = lazy(() => import('./features/authorizations/AuthorizationsPage.js').then((m) => ({ default: m.AuthorizationsPage }))); @@ -301,6 +302,7 @@ const navGroupDefs: NavGroupDef[] = [ { to: '/admin/readiness', label: 'Go-Live Checklist', icon: icons.dashboard }, { to: '/admin/agency', label: 'Agency Setup', icon: icons.agency }, { to: '/admin/staff', label: 'Staff', icon: icons.staff }, + { to: '/admin/mileage', label: 'Mileage', icon: icons.staff }, { to: '/admin/clients', label: 'Clients', icon: icons.clients }, { to: '/admin/authorizations', label: 'Authorizations', icon: icons.auth }, { to: '/admin/import', label: 'Data Import', icon: icons.agency }, @@ -576,6 +578,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/web/src/features/staff/MileageReviewPage.tsx b/packages/web/src/features/staff/MileageReviewPage.tsx new file mode 100644 index 00000000..6240d8d3 --- /dev/null +++ b/packages/web/src/features/staff/MileageReviewPage.tsx @@ -0,0 +1,207 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { getJson, patchJson } from '../../lib/api-client.js'; +import { EmptyState, LoadingSkeleton, ErrorRetry } from '../../components/state/index.js'; + +/** + * Mileage review. + * + * The agency side of caregiver mileage: approve or reject what caregivers + * logged. Defaults to the pending queue, because that is the only list with + * work in it; approved and rejected are there for looking something up, not + * for daily use. + */ + +type MileageStatus = 'submitted' | 'approved' | 'rejected'; + +interface MileageEntry { + id: string; + caregiverId: string; + tripDate: string; + milesHundredths: number; + purpose: string | null; + status: MileageStatus; + reviewNote: string | null; + createdAt: string | null; +} + +const STATUS_TABS: Array<{ key: MileageStatus; label: string }> = [ + { key: 'submitted', label: 'Pending' }, + { key: 'approved', label: 'Approved' }, + { key: 'rejected', label: 'Not approved' }, +]; + +const STATUS_COLOR: Record = { + submitted: 'var(--color-warning)', + approved: 'var(--color-success)', + rejected: 'var(--color-danger-text)', +}; + +function formatMiles(hundredths: number): string { + return `${(hundredths / 100).toFixed(2)} mi`; +} + +function formatDate(ymd: string): string { + const d = new Date(`${ymd}T00:00:00.000Z`); + return Number.isFinite(d.getTime()) + ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }) + : ymd; +} + +export function MileageReviewPage() { + const [status, setStatus] = useState('submitted'); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busy, setBusy] = useState>({}); + const [notes, setNotes] = useState>({}); + + const load = useCallback(() => { + setLoading(true); + setError(null); + getJson<{ entries: MileageEntry[] }>(`/api/mileage?status=${status}`) + .then((data) => setEntries(data?.entries ?? [])) + .catch((err: Error) => setError(err.message || 'Failed to load mileage')) + .finally(() => setLoading(false)); + }, [status]); + + useEffect(() => { load(); }, [load]); + + const totalHundredths = useMemo( + () => entries.reduce((sum, e) => sum + e.milesHundredths, 0), + [entries], + ); + + const review = async (entry: MileageEntry, next: 'approved' | 'rejected') => { + setBusy((prev) => ({ ...prev, [entry.id]: true })); + try { + await patchJson(`/api/mileage/${encodeURIComponent(entry.id)}/review`, { + status: next, + ...(notes[entry.id]?.trim() ? { note: notes[entry.id].trim() } : {}), + }); + // Drop the row from the current tab rather than refetching: it no + // longer belongs to the list being viewed. + setEntries((prev) => prev.filter((e) => e.id !== entry.id)); + setNotes((prev) => { const n = { ...prev }; delete n[entry.id]; return n; }); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to update that trip'); + } finally { + setBusy((prev) => { const n = { ...prev }; delete n[entry.id]; return n; }); + } + }; + + return ( +
+
+

Mileage

+

+ Driving logged by caregivers between clients. Approved trips are what your payroll or + reimbursement run should pay. +

+
+ +
+ {STATUS_TABS.map((tab) => ( + + ))} +
+ + {loading ? ( + + ) : error ? ( + + ) : entries.length === 0 ? ( + + ) : ( + <> +
+ {entries.length} {entries.length === 1 ? 'trip' : 'trips'} · {formatMiles(totalHundredths)} total +
+
+ {entries.map((entry) => ( +
+
+
+
{formatMiles(entry.milesHundredths)}
+
+ {formatDate(entry.tripDate)} +
+
+ + {STATUS_TABS.find((t) => t.key === entry.status)?.label ?? entry.status} + +
+ + {entry.purpose ? ( +
{entry.purpose}
+ ) : null} + + {entry.reviewNote ? ( +
+ Note: {entry.reviewNote} +
+ ) : null} + + {entry.status === 'submitted' ? ( +
+ setNotes((prev) => ({ ...prev, [entry.id]: e.target.value }))} + placeholder="Note (optional, shown to the caregiver if not approved)" + className="input-field" + style={{ fontSize: '0.8125rem', padding: '0.3rem 0.6rem', flex: '1 1 240px', minWidth: 0 }} + maxLength={500} + /> + + +
+ ) : null} +
+ ))} +
+ + )} +
+ ); +} + +export default MileageReviewPage;