diff --git a/packages/app/src/app.ts b/packages/app/src/app.ts index e2ec136..0ccdb4f 100644 --- a/packages/app/src/app.ts +++ b/packages/app/src/app.ts @@ -37,6 +37,7 @@ 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 availabilityRoutes from './routes/availability-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'; @@ -350,6 +351,7 @@ export function createApp(options: { mobileSessionStore?: MobileSessionStore } = app.use(`${prefix}/settings`, settingsRoutes); app.use(`${prefix}/notifications`, pushTokenRoutes); app.use(`${prefix}/mileage`, mileageRoutes); + app.use(`${prefix}/availability`, availabilityRoutes); 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__/assignment-routes.test.ts b/packages/app/src/routes/__tests__/assignment-routes.test.ts index 964535a..3c4b801 100644 --- a/packages/app/src/routes/__tests__/assignment-routes.test.ts +++ b/packages/app/src/routes/__tests__/assignment-routes.test.ts @@ -1,5 +1,5 @@ import request from 'supertest'; -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { createApp } from '../../app.js'; import * as core from '@rayhealth/core'; import { makeToken, setTestJwtSecret } from './test-helpers.js'; @@ -7,6 +7,17 @@ import { makeToken, setTestJwtSecret } from './test-helpers.js'; beforeAll(() => setTestJwtSecret()); describe('assignment routes', () => { + // Default: no approved leave and no declared availability, so these tests + // exercise the paths they were written for. The time-off lookup is a + // blocking check and deliberately fails closed, so it has to be stubbed + // rather than left to hit a database. Individual tests override this. + beforeEach(() => { + vi.spyOn(core, 'AvailabilityRepository').mockImplementation(() => ({ + findApprovedTimeOffOn: vi.fn().mockResolvedValue(null), + listAvailability: vi.fn().mockResolvedValue([]), + } as unknown as core.AvailabilityRepository)); + }); + afterEach(() => { vi.restoreAllMocks(); }); diff --git a/packages/app/src/routes/__tests__/availability-routes.test.ts b/packages/app/src/routes/__tests__/availability-routes.test.ts new file mode 100644 index 0000000..e4b87f0 --- /dev/null +++ b/packages/app/src/routes/__tests__/availability-routes.test.ts @@ -0,0 +1,328 @@ +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-00000000a001'; +const userId = '00000000-0000-4000-8000-00000000a002'; +const caregiverId = '00000000-0000-4000-8000-00000000a003'; +const requestId = '00000000-0000-4000-8000-00000000a004'; + +function mockRepo(overrides: Record) { + vi.spyOn(core, 'AvailabilityRepository').mockImplementation( + () => overrides as unknown as core.AvailabilityRepository, + ); +} + +const caregiverAuth = () => `Bearer ${makeToken('caregiver', agencyId, userId, caregiverId)}`; +const adminAuth = () => `Bearer ${makeToken('admin', agencyId, userId)}`; + +describe('PUT /availability', () => { + it('replaces the whole weekly pattern for the calling caregiver', async () => { + const replaceAvailability = vi.fn().mockResolvedValue([]); + mockRepo({ replaceAvailability }); + + const res = await request(createApp()) + .put('/availability') + .set('Authorization', caregiverAuth()) + .send({ slots: [{ dayOfWeek: 2, startTime: '09:00', endTime: '17:00' }] }); + + expect(res.status).toBe(200); + expect(replaceAvailability).toHaveBeenCalledWith(caregiverId, agencyId, [ + { dayOfWeek: 2, startTime: '09:00', endTime: '17:00' }, + ]); + }); + + it('accepts an empty pattern, which clears availability', async () => { + const replaceAvailability = vi.fn().mockResolvedValue([]); + mockRepo({ replaceAvailability }); + + const res = await request(createApp()) + .put('/availability') + .set('Authorization', caregiverAuth()) + .send({ slots: [] }); + + expect(res.status).toBe(200); + expect(replaceAvailability).toHaveBeenCalledWith(caregiverId, agencyId, []); + }); + + it('rejects a window that ends before it starts', async () => { + const replaceAvailability = vi.fn(); + mockRepo({ replaceAvailability }); + + const res = await request(createApp()) + .put('/availability') + .set('Authorization', caregiverAuth()) + .send({ slots: [{ dayOfWeek: 2, startTime: '17:00', endTime: '09:00' }] }); + + expect(res.status).toBe(400); + expect(replaceAvailability).not.toHaveBeenCalled(); + }); + + it('rejects a malformed time or weekday', async () => { + mockRepo({ replaceAvailability: vi.fn() }); + + expect( + (await request(createApp()).put('/availability').set('Authorization', caregiverAuth()) + .send({ slots: [{ dayOfWeek: 2, startTime: '9:00', endTime: '17:00' }] })).status, + ).toBe(400); + expect( + (await request(createApp()).put('/availability').set('Authorization', caregiverAuth()) + .send({ slots: [{ dayOfWeek: 7, startTime: '09:00', endTime: '17:00' }] })).status, + ).toBe(400); + }); + + it('is caregiver-only', async () => { + mockRepo({ replaceAvailability: vi.fn() }); + const res = await request(createApp()) + .put('/availability') + .set('Authorization', adminAuth()) + .send({ slots: [] }); + expect(res.status).toBe(403); + }); +}); + +describe('POST /availability/time-off', () => { + it('creates a request for the calling caregiver', async () => { + const createTimeOff = vi.fn().mockResolvedValue({ id: requestId }); + mockRepo({ createTimeOff }); + + const res = await request(createApp()) + .post('/availability/time-off') + .set('Authorization', caregiverAuth()) + .send({ startDate: '2026-09-01', endDate: '2026-09-03', reason: 'Family' }); + + expect(res.status).toBe(201); + expect(createTimeOff).toHaveBeenCalledWith({ + agencyId, + caregiverId, + startDate: '2026-09-01', + endDate: '2026-09-03', + reason: 'Family', + }); + }); + + it('accepts a single-day request', async () => { + const createTimeOff = vi.fn().mockResolvedValue({ id: requestId }); + mockRepo({ createTimeOff }); + + const res = await request(createApp()) + .post('/availability/time-off') + .set('Authorization', caregiverAuth()) + .send({ startDate: '2026-09-01', endDate: '2026-09-01' }); + + expect(res.status).toBe(201); + }); + + it('rejects an inverted range', async () => { + const createTimeOff = vi.fn(); + mockRepo({ createTimeOff }); + + const res = await request(createApp()) + .post('/availability/time-off') + .set('Authorization', caregiverAuth()) + .send({ startDate: '2026-09-05', endDate: '2026-09-01' }); + + expect(res.status).toBe(400); + expect(createTimeOff).not.toHaveBeenCalled(); + }); +}); + +describe('PATCH /availability/time-off/:id/review', () => { + it('lets staff approve and notifies the caregiver', async () => { + const reviewTimeOff = vi + .fn() + .mockResolvedValue({ id: requestId, caregiverId, status: 'approved' }); + mockRepo({ reviewTimeOff }); + + const res = await request(createApp()) + .patch(`/availability/time-off/${requestId}/review`) + .set('Authorization', adminAuth()) + .send({ status: 'approved' }); + + expect(res.status).toBe(200); + expect(reviewTimeOff).toHaveBeenCalledWith(requestId, agencyId, 'approved', userId, null); + }); + + it('will not let a caregiver approve their own time off', async () => { + const reviewTimeOff = vi.fn(); + mockRepo({ reviewTimeOff }); + + const res = await request(createApp()) + .patch(`/availability/time-off/${requestId}/review`) + .set('Authorization', caregiverAuth()) + .send({ status: 'approved' }); + + expect(res.status).toBe(403); + expect(reviewTimeOff).not.toHaveBeenCalled(); + }); + + it('404s on an already-answered request', async () => { + // Only 'requested' rows match, so a second reviewer cannot overturn the + // first answer. + mockRepo({ reviewTimeOff: vi.fn().mockResolvedValue(null) }); + + const res = await request(createApp()) + .patch(`/availability/time-off/${requestId}/review`) + .set('Authorization', adminAuth()) + .send({ status: 'denied' }); + + expect(res.status).toBe(404); + }); +}); + +describe('GET /availability/time-off', () => { + it('gives a caregiver only their own requests', async () => { + const listTimeOffForCaregiver = vi.fn().mockResolvedValue([]); + const listTimeOffForAgency = vi.fn().mockResolvedValue([]); + mockRepo({ listTimeOffForCaregiver, listTimeOffForAgency }); + + await request(createApp()) + .get('/availability/time-off') + .set('Authorization', caregiverAuth()); + + expect(listTimeOffForCaregiver).toHaveBeenCalledWith(caregiverId, agencyId); + expect(listTimeOffForAgency).not.toHaveBeenCalled(); + }); + + it('gives staff the agency queue', async () => { + const listTimeOffForCaregiver = vi.fn().mockResolvedValue([]); + const listTimeOffForAgency = vi.fn().mockResolvedValue([]); + mockRepo({ listTimeOffForCaregiver, listTimeOffForAgency }); + + await request(createApp()) + .get('/availability/time-off?status=requested') + .set('Authorization', adminAuth()); + + expect(listTimeOffForAgency).toHaveBeenCalledWith(agencyId, { status: 'requested' }); + }); +}); + +describe('DELETE /availability/time-off/:id', () => { + it('cancels the caregiver own request', async () => { + const cancelOwnTimeOff = vi.fn().mockResolvedValue(true); + mockRepo({ cancelOwnTimeOff }); + + const res = await request(createApp()) + .delete(`/availability/time-off/${requestId}`) + .set('Authorization', caregiverAuth()); + + expect(res.status).toBe(204); + expect(cancelOwnTimeOff).toHaveBeenCalledWith(requestId, caregiverId, agencyId); + }); + + it('404s when there is nothing cancellable', async () => { + mockRepo({ cancelOwnTimeOff: vi.fn().mockResolvedValue(false) }); + + const res = await request(createApp()) + .delete(`/availability/time-off/${requestId}`) + .set('Authorization', caregiverAuth()); + + expect(res.status).toBe(404); + }); +}); + +describe('approved time off gates scheduling', () => { + /** Minimal mocks for the assignment create path, plus a leave calendar. */ + function mockAssignmentDeps(approvedLeave: unknown) { + const createAssignment = vi.fn().mockResolvedValue({ id: 'a-1', caregiverId, visitTemplateId: 't-1' }); + vi.spyOn(core, 'ScheduleRepository').mockImplementation(() => ({ + createAssignment, + getTemplateClient: vi.fn().mockResolvedValue({ clientId: 'client-1' }), + getCaregiverScheduleForConflict: vi.fn().mockResolvedValue([]), + } as unknown as core.ScheduleRepository)); + vi.spyOn(core, 'CaregiverRepository').mockImplementation(() => ({ + findById: vi.fn().mockResolvedValue({ id: caregiverId, status: 'active' }), + getCredentials: vi.fn().mockResolvedValue([]), + } as unknown as core.CaregiverRepository)); + vi.spyOn(core, 'ClientRepository').mockImplementation(() => ({ + getAuthorizations: vi.fn().mockResolvedValue([]), + } as unknown as core.ClientRepository)); + vi.spyOn(core, 'ClaimRepository').mockImplementation(() => ({ + getBilledLineUnits: vi.fn().mockResolvedValue([]), + } as unknown as core.ClaimRepository)); + vi.spyOn(core, 'AuditEventRepository').mockImplementation(() => ({ + create: vi.fn().mockResolvedValue({}), + } as unknown as core.AuditEventRepository)); + mockRepo({ + findApprovedTimeOffOn: vi.fn().mockResolvedValue(approvedLeave), + listAvailability: vi.fn().mockResolvedValue([]), + }); + return createAssignment; + } + + it('refuses to book a caregiver over their approved leave', async () => { + const createAssignment = mockAssignmentDeps({ + id: requestId, + startDate: '2026-09-01', + endDate: '2026-09-05', + reason: 'Surgery', + }); + + const res = await request(createApp()) + .post('/assignments') + .set('Authorization', adminAuth()) + .send({ caregiverId, visitTemplateId: 't-1', visitDate: '2026-09-02' }); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('SCHEDULE_CONFLICT'); + // The reason may name a medical situation and must not reach the response. + expect(JSON.stringify(res.body)).not.toContain('Surgery'); + expect(createAssignment).not.toHaveBeenCalled(); + }); + + it('books normally when there is no approved leave that day', async () => { + const createAssignment = mockAssignmentDeps(null); + + const res = await request(createApp()) + .post('/assignments') + .set('Authorization', adminAuth()) + .send({ caregiverId, visitTemplateId: 't-1', visitDate: '2026-09-02' }); + + expect(res.status).toBe(201); + expect(createAssignment).toHaveBeenCalled(); + }); + + it('warns but still books outside declared availability', async () => { + // Availability is a preference, not a contract. + const createAssignment = vi.fn().mockResolvedValue({ id: 'a-1', caregiverId, visitTemplateId: 't-1' }); + vi.spyOn(core, 'ScheduleRepository').mockImplementation(() => ({ + createAssignment, + getTemplateClient: vi.fn().mockResolvedValue({ clientId: 'client-1' }), + getCaregiverScheduleForConflict: vi.fn().mockResolvedValue([]), + } as unknown as core.ScheduleRepository)); + vi.spyOn(core, 'CaregiverRepository').mockImplementation(() => ({ + findById: vi.fn().mockResolvedValue({ id: caregiverId, status: 'active' }), + getCredentials: vi.fn().mockResolvedValue([]), + } as unknown as core.CaregiverRepository)); + vi.spyOn(core, 'ClientRepository').mockImplementation(() => ({ + getAuthorizations: vi.fn().mockResolvedValue([]), + } as unknown as core.ClientRepository)); + vi.spyOn(core, 'ClaimRepository').mockImplementation(() => ({ + getBilledLineUnits: vi.fn().mockResolvedValue([]), + } as unknown as core.ClaimRepository)); + vi.spyOn(core, 'AuditEventRepository').mockImplementation(() => ({ + create: vi.fn().mockResolvedValue({}), + } as unknown as core.AuditEventRepository)); + mockRepo({ + findApprovedTimeOffOn: vi.fn().mockResolvedValue(null), + // Only Mondays; 2026-09-02 is a Wednesday. + listAvailability: vi.fn().mockResolvedValue([ + { id: 's1', caregiverId, dayOfWeek: 1, startTime: '09:00', endTime: '17:00' }, + ]), + }); + + const res = await request(createApp()) + .post('/assignments') + .set('Authorization', adminAuth()) + .send({ caregiverId, visitTemplateId: 't-1', visitDate: '2026-09-02' }); + + expect(res.status).toBe(201); + expect(createAssignment).toHaveBeenCalled(); + expect(String(res.body.warnings)).toContain('Wednesday'); + }); +}); diff --git a/packages/app/src/routes/assignment-checks.ts b/packages/app/src/routes/assignment-checks.ts index 6004be9..d807155 100644 --- a/packages/app/src/routes/assignment-checks.ts +++ b/packages/app/src/routes/assignment-checks.ts @@ -9,14 +9,17 @@ */ import type { Knex } from 'knex'; import { + AvailabilityRepository, CaregiverRepository, ClaimRepository, ClientRepository, CredentialComplianceService, ScheduleRepository, + checkAvailability, checkScheduleConflicts, type ConflictAuthorization, } from '@rayhealth/core'; +import { safeError } from '../security/safe-log.js'; export interface AssignmentCheckInput { caregiverId: string; @@ -112,11 +115,63 @@ export async function evaluateAssignmentChecks( authorizations, }); + // Availability and leave. Two different weights on purpose: + // + // Approved time off HARD-BLOCKS. Approving somebody's leave and then + // booking the shift anyway is how an agency loses staff, so it belongs + // with the other blocking conflicts. Only 'approved' counts; a request + // nobody has answered yet must not block a schedule the agency has not + // agreed to. + // + // Declared availability only WARNS. It is a preference, not a contract, + // and agencies cover shifts outside someone's usual window constantly. A + // hard block would just get worked around by editing the availability, + // which would make the data worse rather than the schedule better. + const scheduleWarnings: string[] = []; + const scheduleBlocks: string[] = []; + if (input.visitDate) { + const availabilityRepo = new AvailabilityRepository(db); + + // Time off is a BLOCKING check, so this lookup is deliberately NOT + // wrapped. If we cannot read the leave calendar we cannot honestly say + // there is no conflict, and failing the request is far better than + // booking a caregiver over leave the agency already approved. + const approvedLeave = await availabilityRepo.findApprovedTimeOffOn( + input.caregiverId, + agencyId, + input.visitDate, + ); + if (approvedLeave) { + // The reason is deliberately not echoed: it may name a medical or + // family situation and this string lands in an API response. + scheduleBlocks.push( + `Caregiver has approved time off covering ${input.visitDate} (${approvedLeave.startDate} to ${approvedLeave.endDate}).`, + ); + } + + // Availability is only ADVISORY, so a failure here degrades to "no + // warning" rather than blocking a booking the agency is entitled to make. + try { + const slots = await availabilityRepo.listAvailability(input.caregiverId, agencyId); + const verdict = checkAvailability({ + visitDate: input.visitDate, + startTime: input.startTime, + endTime: input.endTime, + slots, + }); + if (verdict.kind === 'day_unavailable' || verdict.kind === 'outside_hours') { + scheduleWarnings.push(verdict.message); + } + } catch (err) { + safeError('Could not evaluate caregiver availability', err); + } + } + return { caregiver: { id: input.caregiverId }, templateClient: { clientId: templateClient.clientId }, - hardConflicts: conflicts.hardConflicts, + hardConflicts: [...conflicts.hardConflicts, ...scheduleBlocks], credentialBlocks: credentialGate.blocks, - warnings: [...conflicts.warnings, ...credentialGate.warnings], + warnings: [...conflicts.warnings, ...credentialGate.warnings, ...scheduleWarnings], }; } diff --git a/packages/app/src/routes/availability-routes.ts b/packages/app/src/routes/availability-routes.ts new file mode 100644 index 0000000..d785f51 --- /dev/null +++ b/packages/app/src/routes/availability-routes.ts @@ -0,0 +1,240 @@ +/** + * Caregiver availability and time off. + * + * Availability is the weekly pattern a caregiver says they can normally work. + * Time off is specific dates they have asked not to work, plus the agency's + * answer. Scheduling treats them differently: approved leave blocks a booking, + * declared availability only warns. See assignment-checks.ts for why. + * + * Access: a caregiver manages their own availability and their own requests. + * Only staff with `staff.write` may approve or deny. `reason` and + * `review_note` may name a medical or family situation, so they stay inside + * agency-scoped responses and never reach an audit payload or notification. + */ +import { Router, type Request, type Response } from 'express'; +import type { Knex } from 'knex'; +import { z } from 'zod'; +import { AvailabilityRepository } from '@rayhealth/core'; +import { requireCapability } from '../middleware/require-capability.js'; +import { safeError } from '../security/safe-log.js'; +import { notifyCaregivers } from '../services/notification-service.js'; + +const router = Router(); + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; + +const availabilitySchema = z.object({ + slots: z + .array( + z + .object({ + dayOfWeek: z.number().int().min(0).max(6), + startTime: z.string().regex(TIME_RE, 'startTime must be HH:MM'), + endTime: z.string().regex(TIME_RE, 'endTime must be HH:MM'), + }) + .refine((s) => s.endTime > s.startTime, { + message: 'endTime must be after startTime', + path: ['endTime'], + }), + ) + // A generous ceiling: a caregiver splitting every day into a few windows + // still lands well under it, and it stops a runaway client writing + // thousands of rows. + .max(50), +}); + +const timeOffSchema = z + .object({ + startDate: z.string().regex(DATE_RE, 'startDate must be YYYY-MM-DD'), + endDate: z.string().regex(DATE_RE, 'endDate must be YYYY-MM-DD'), + reason: z.string().max(500).optional(), + }) + .refine((r) => r.endDate >= r.startDate, { + message: 'endDate must be on or after startDate', + path: ['endDate'], + }); + +const reviewSchema = z.object({ + status: z.enum(['approved', 'denied']), + note: z.string().max(500).optional(), +}); + +// ── Availability ──────────────────────────────────────────────────────────── + +// GET /availability, the calling caregiver's weekly pattern +router.get('/', requireCapability('evv.read'), async (req: Request, res: Response) => { + if (!req.auth.caregiverId) { + return res.status(403).json({ message: 'Only caregivers have an availability pattern' }); + } + try { + const db = req.app.get('db') as Knex; + const slots = await new AvailabilityRepository(db).listAvailability( + req.auth.caregiverId, + req.auth.agencyId, + ); + res.json({ slots }); + } catch (error) { + safeError('availability list failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// PUT /availability, replace the whole weekly pattern +router.put('/', requireCapability('evv.write'), async (req: Request, res: Response) => { + if (!req.auth.caregiverId) { + return res.status(403).json({ message: 'Only caregivers have an availability pattern' }); + } + const parsed = availabilitySchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ + message: parsed.error.issues[0]?.message ?? 'Invalid availability', + issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })), + }); + } + try { + const db = req.app.get('db') as Knex; + // Whole-pattern replace: the UI is a weekly grid, and a half-applied + // update would be worse than either the old or the new week. + const slots = await new AvailabilityRepository(db).replaceAvailability( + req.auth.caregiverId, + req.auth.agencyId, + parsed.data.slots, + ); + res.json({ slots }); + } catch (error) { + safeError('availability save failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// ── Time off ──────────────────────────────────────────────────────────────── + +// GET /availability/time-off, own requests (caregiver) or the queue (staff) +router.get('/time-off', requireCapability('evv.read'), async (req: Request, res: Response) => { + const status = typeof req.query.status === 'string' ? req.query.status : undefined; + if (status && !['requested', 'approved', 'denied', 'cancelled'].includes(status)) { + return res.status(400).json({ message: 'invalid status filter' }); + } + try { + const db = req.app.get('db') as Knex; + const repo = new AvailabilityRepository(db); + const requests = + req.auth.role === 'caregiver' && req.auth.caregiverId + ? await repo.listTimeOffForCaregiver(req.auth.caregiverId, req.auth.agencyId) + : await repo.listTimeOffForAgency(req.auth.agencyId, { + status: status as 'requested' | undefined, + }); + res.json({ requests }); + } catch (error) { + safeError('time off list failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// POST /availability/time-off, request days off +router.post('/time-off', requireCapability('evv.write'), async (req: Request, res: Response) => { + if (!req.auth.caregiverId) { + return res.status(403).json({ message: 'Only caregivers can request time off' }); + } + const parsed = timeOffSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ + message: parsed.error.issues[0]?.message ?? 'Invalid time off request', + issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })), + }); + } + try { + const db = req.app.get('db') as Knex; + const created = await new AvailabilityRepository(db).createTimeOff({ + agencyId: req.auth.agencyId, + caregiverId: req.auth.caregiverId, + startDate: parsed.data.startDate, + endDate: parsed.data.endDate, + reason: parsed.data.reason ?? null, + }); + res.status(201).json(created); + } catch (error) { + safeError('time off create failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// PATCH /availability/time-off/:id/review, approve or deny +router.patch( + '/time-off/: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 time off request id required' }); + } + const parsed = reviewSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ message: 'status must be approved or denied' }); + } + try { + const db = req.app.get('db') as Knex; + const updated = await new AvailabilityRepository(db).reviewTimeOff( + id, + req.auth.agencyId, + parsed.data.status, + req.auth.userId, + parsed.data.note ?? null, + ); + // Either not in this agency or already answered. One 404 for both keeps + // the row unprobeable and stops a second reviewer overturning the first. + if (!updated) { + return res.status(404).json({ message: 'No pending time off request with that id' }); + } + + // Worth interrupting for: a caregiver who does not hear the answer + // either shows up on a day they thought was off, or misses a shift they + // assumed was covered. Contentless, as always: no dates, no reason. + void notifyCaregivers(db, { + agencyId: req.auth.agencyId, + caregiverIds: [updated.caregiverId], + category: 'scheduleChanges', + title: 'Time off updated', + body: 'Your agency answered a time off request. Open RayHealth to see the details.', + data: { kind: 'timeOff.reviewed', requestId: updated.id }, + }); + + res.json(updated); + } catch (error) { + safeError('time off review failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } + }, +); + +// DELETE /availability/time-off/:id, caregiver withdraws their own request +router.delete('/time-off/:id', requireCapability('evv.write'), async (req: Request, res: Response) => { + if (!req.auth.caregiverId) { + return res.status(403).json({ message: 'Only caregivers can withdraw time off' }); + } + 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 time off request id required' }); + } + try { + const db = req.app.get('db') as Knex; + // Cancellable from requested OR approved: plans change, and giving a day + // back should not need an awkward phone call. Marked cancelled rather than + // deleted so the agency can see what happened. + const cancelled = await new AvailabilityRepository(db).cancelOwnTimeOff( + id, + req.auth.caregiverId, + req.auth.agencyId, + ); + if (!cancelled) { + return res.status(404).json({ message: 'No cancellable time off request with that id' }); + } + res.status(204).end(); + } catch (error) { + safeError('time off cancel 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 186c50d..8307f60 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -39,6 +39,7 @@ export * from './services/claim-generation-service.js'; export * from './services/edi-837p.js'; export * from './services/payroll-export-service.js'; export * from './services/earnings-service.js'; +export * from './services/availability-service.js'; export * from './services/command-center-service.js'; export * from './security/cell-cipher.js'; export * from './security/geofence.js'; @@ -78,3 +79,4 @@ 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'; +export * from './repositories/availability-repository.js'; diff --git a/packages/core/src/migrations/2026-08-04-add-availability-and-time-off.ts b/packages/core/src/migrations/2026-08-04-add-availability-and-time-off.ts new file mode 100644 index 0000000..d4226c5 --- /dev/null +++ b/packages/core/src/migrations/2026-08-04-add-availability-and-time-off.ts @@ -0,0 +1,83 @@ +/** + * Migration: caregiver availability and time-off requests. + * + * Two tables, deliberately separate because they mean different things to a + * scheduler: + * + * caregiver_availability , the weekly hours a caregiver says they can + * normally work. A PREFERENCE. Booking outside it is allowed and produces + * a warning, because real agencies cover shifts outside someone's usual + * window all the time and a hard block would just get worked around. + * + * time_off_requests , specific dates a caregiver has asked not to work, and + * the agency's answer. An APPROVED request is a COMMITMENT: scheduling + * over it is a hard conflict, because approving time off and then booking + * the shift anyway is how an agency loses staff. + * + * Shape notes: + * - Availability stores day_of_week 0..6 (Sunday..Saturday) plus HH:MM + * strings, matching how assignments already carry start_time/end_time. + * - Time off is whole days (start_date..end_date inclusive). Partial-day + * leave is not modeled; an agency that needs it can approve a day and + * schedule around it, which is honest, rather than have a half-supported + * hours field that scheduling ignores. + * - `reason` and `review_note` are free text and may name a medical or + * family situation, so both are treated as sensitive: agency-scoped reads + * only, never in a notification body or 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('caregiver_availability'))) { + await knex.schema.createTable('caregiver_availability', (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') + // 0 = Sunday .. 6 = Saturday, matching JavaScript's getDay(). + table.integer('day_of_week').notNullable() + table.string('start_time', 5).notNullable() + table.string('end_time', 5).notNullable() + table.timestamps(true, true) + table.index(['agency_id']) + table.index(['caregiver_id']) + }) + } + + if (!(await knex.schema.hasTable('time_off_requests'))) { + await knex.schema.createTable('time_off_requests', (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') + table.date('start_date').notNullable() + table.date('end_date').notNullable() + table.string('reason', 500).nullable() + // requested | approved | denied | cancelled + table.string('status', 16).notNullable().defaultTo('requested') + 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', 'status']) + table.index(['caregiver_id', 'start_date']) + }) + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('time_off_requests') + await knex.schema.dropTableIfExists('caregiver_availability') +} diff --git a/packages/core/src/migrations/runner.ts b/packages/core/src/migrations/runner.ts index 69c7eed..105fe7b 100644 --- a/packages/core/src/migrations/runner.ts +++ b/packages/core/src/migrations/runner.ts @@ -36,6 +36,7 @@ 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'; +import * as addAvailabilityAndTimeOff from './2026-08-04-add-availability-and-time-off.js'; async function run(): Promise { const db = createDb(); @@ -55,6 +56,7 @@ async function run(): Promise { await addPushTokens.up(db); await addCaregiverPayRate.up(db); await addMileageEntries.up(db); + await addAvailabilityAndTimeOff.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/availability-repository.ts b/packages/core/src/repositories/availability-repository.ts new file mode 100644 index 0000000..9bbb96b --- /dev/null +++ b/packages/core/src/repositories/availability-repository.ts @@ -0,0 +1,225 @@ +/** + * Repository for `caregiver_availability` and `time_off_requests`. + * + * Tenancy: every read and write takes an agencyId. Caregiver-facing reads also + * filter on caregiver_id, because availability and leave are personal. + * + * `reason` and `review_note` may name a medical or family situation, so they + * are treated as sensitive: agency-scoped queries only, never copied into a + * notification body or an audit payload. + */ + +import type { Knex } from 'knex' + +export type TimeOffStatus = 'requested' | 'approved' | 'denied' | 'cancelled' + +export interface AvailabilitySlot { + id: string + caregiverId: string + /** 0 = Sunday .. 6 = Saturday. */ + dayOfWeek: number + startTime: string + endTime: string +} + +export interface TimeOffRequest { + id: string + agencyId: string + caregiverId: string + startDate: string + endDate: string + reason: string | null + status: TimeOffStatus + reviewedAt: string | null + reviewNote: string | null + createdAt: string | null +} + +function toIso(value: unknown): string | null { + if (!value) return null + return value instanceof Date ? value.toISOString() : String(value) +} + +function toYmd(value: unknown): string { + if (value instanceof Date) return value.toISOString().slice(0, 10) + return String(value).slice(0, 10) +} + +function mapSlot(row: Record): AvailabilitySlot { + return { + id: String(row.id), + caregiverId: String(row.caregiver_id), + dayOfWeek: Number(row.day_of_week), + startTime: String(row.start_time), + endTime: String(row.end_time), + } +} + +function mapRequest(row: Record): TimeOffRequest { + return { + id: String(row.id), + agencyId: String(row.agency_id), + caregiverId: String(row.caregiver_id), + startDate: toYmd(row.start_date), + endDate: toYmd(row.end_date), + reason: row.reason ? String(row.reason) : null, + status: (row.status as TimeOffStatus) ?? 'requested', + reviewedAt: toIso(row.reviewed_at), + reviewNote: row.review_note ? String(row.review_note) : null, + createdAt: toIso(row.created_at), + } +} + +export class AvailabilityRepository { + constructor(private readonly db: Knex) {} + + // ── Weekly availability ────────────────────────────────────────────────── + + async listAvailability(caregiverId: string, agencyId: string): Promise { + const rows = await this.db('caregiver_availability') + .where({ caregiver_id: caregiverId, agency_id: agencyId }) + .orderBy('day_of_week') + .orderBy('start_time') + .select('*') + return (rows as Record[]).map(mapSlot) + } + + /** + * Replace a caregiver's whole weekly pattern in one transaction. + * + * Whole-pattern replace rather than per-slot edits: the UI is a weekly grid, + * and a partial failure that left half the old week and half the new one + * would be worse than either. The transaction means a caregiver never ends + * up with no availability because a later insert failed. + */ + async replaceAvailability( + caregiverId: string, + agencyId: string, + slots: Array<{ dayOfWeek: number; startTime: string; endTime: string }>, + ): Promise { + return this.db.transaction(async (trx) => { + await trx('caregiver_availability') + .where({ caregiver_id: caregiverId, agency_id: agencyId }) + .del() + if (slots.length > 0) { + await trx('caregiver_availability').insert( + slots.map((s) => ({ + agency_id: agencyId, + caregiver_id: caregiverId, + day_of_week: s.dayOfWeek, + start_time: s.startTime, + end_time: s.endTime, + })), + ) + } + const rows = await trx('caregiver_availability') + .where({ caregiver_id: caregiverId, agency_id: agencyId }) + .orderBy('day_of_week') + .orderBy('start_time') + .select('*') + return (rows as Record[]).map(mapSlot) + }) + } + + // ── Time off ───────────────────────────────────────────────────────────── + + async createTimeOff(input: { + agencyId: string + caregiverId: string + startDate: string + endDate: string + reason?: string | null + }): Promise { + const [row] = await this.db('time_off_requests') + .insert({ + agency_id: input.agencyId, + caregiver_id: input.caregiverId, + start_date: input.startDate, + end_date: input.endDate, + reason: input.reason ?? null, + status: 'requested', + }) + .returning('*') + return mapRequest(row as Record) + } + + async listTimeOffForCaregiver(caregiverId: string, agencyId: string): Promise { + const rows = await this.db('time_off_requests') + .where({ caregiver_id: caregiverId, agency_id: agencyId }) + .orderBy('start_date', 'desc') + .limit(200) + .select('*') + return (rows as Record[]).map(mapRequest) + } + + async listTimeOffForAgency( + agencyId: string, + options: { status?: TimeOffStatus; limit?: number } = {}, + ): Promise { + let q = this.db('time_off_requests').where({ agency_id: agencyId }) + if (options.status) q = q.andWhere('status', options.status) + const rows = await q + .orderBy('start_date', 'desc') + .limit(Math.min(options.limit ?? 500, 1000)) + .select('*') + return (rows as Record[]).map(mapRequest) + } + + /** + * Approve or deny. Only a `requested` row can be reviewed, which makes the + * transition safe under a double click and stops a second reviewer silently + * overturning the first answer. + */ + async reviewTimeOff( + id: string, + agencyId: string, + status: 'approved' | 'denied', + reviewerId: string, + note?: string | null, + ): Promise { + const [row] = await this.db('time_off_requests') + .where({ id, agency_id: agencyId, status: 'requested' }) + .update({ + status, + reviewed_by: reviewerId, + reviewed_at: this.db.fn.now(), + review_note: note ?? null, + updated_at: this.db.fn.now(), + }) + .returning('*') + return row ? mapRequest(row as Record) : null + } + + /** + * A caregiver withdrawing their own request. Allowed from `requested` OR + * `approved`: plans change, and someone who no longer needs the day off + * should be able to give it back without an awkward phone call. Marked + * cancelled rather than deleted so the agency can see what happened. + */ + async cancelOwnTimeOff(id: string, caregiverId: string, agencyId: string): Promise { + const updated = await this.db('time_off_requests') + .where({ id, caregiver_id: caregiverId, agency_id: agencyId }) + .whereIn('status', ['requested', 'approved']) + .update({ status: 'cancelled', updated_at: this.db.fn.now() }) + return updated > 0 + } + + /** + * Approved leave overlapping a date, for the scheduling gate. Only + * `approved` counts: a request nobody has answered yet must not silently + * block the schedule, or an agency could be blocked by a request it has + * never seen. + */ + async findApprovedTimeOffOn( + caregiverId: string, + agencyId: string, + date: string, + ): Promise { + const row = await this.db('time_off_requests') + .where({ caregiver_id: caregiverId, agency_id: agencyId, status: 'approved' }) + .andWhere('start_date', '<=', date) + .andWhere('end_date', '>=', date) + .first('*') + return row ? mapRequest(row as Record) : null + } +} diff --git a/packages/core/src/services/__tests__/availability-service.test.ts b/packages/core/src/services/__tests__/availability-service.test.ts new file mode 100644 index 0000000..b2e3f58 --- /dev/null +++ b/packages/core/src/services/__tests__/availability-service.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { checkAvailability, minutesOfDay, weekdayOf } from '../availability-service.js' + +// 2026-08-04 is a Tuesday (weekday 2). +const TUESDAY = '2026-08-04' +const SATURDAY = '2026-08-08' + +const weekdaySlots = [ + { dayOfWeek: 1, startTime: '09:00', endTime: '17:00' }, + { dayOfWeek: 2, startTime: '09:00', endTime: '17:00' }, +] + +describe('minutesOfDay', () => { + it('converts HH:MM to minutes since midnight', () => { + expect(minutesOfDay('00:00')).toBe(0) + expect(minutesOfDay('09:30')).toBe(570) + expect(minutesOfDay('23:59')).toBe(1439) + }) + + it('rejects malformed or impossible times', () => { + expect(minutesOfDay('9:00')).toBeNull() + expect(minutesOfDay('24:00')).toBeNull() + expect(minutesOfDay('12:60')).toBeNull() + expect(minutesOfDay('noon')).toBeNull() + }) +}) + +describe('weekdayOf', () => { + it('reads the weekday from a date string', () => { + expect(weekdayOf(TUESDAY)).toBe(2) + expect(weekdayOf(SATURDAY)).toBe(6) + expect(weekdayOf('2026-08-02')).toBe(0) // Sunday + }) + + it('is not thrown off by timezone interpretation of a bare date', () => { + // Parsed at UTC noon: midnight would render as the previous evening in a + // negative-offset zone and report the wrong day. + expect(weekdayOf('2026-08-03')).toBe(1) + }) + + it('rejects a malformed date', () => { + expect(weekdayOf('08/04/2026')).toBeNull() + expect(weekdayOf('')).toBeNull() + }) +}) + +describe('checkAvailability', () => { + it('says nothing when the caregiver has declared no pattern', () => { + // No pattern is not the same as unavailable. + expect(checkAvailability({ visitDate: TUESDAY, slots: [] })).toEqual({ kind: 'unknown' }) + }) + + it('accepts a booking inside a declared window', () => { + const verdict = checkAvailability({ + visitDate: TUESDAY, + startTime: '10:00', + endTime: '14:00', + slots: weekdaySlots, + }) + expect(verdict.kind).toBe('inside') + }) + + it('accepts a booking exactly filling the window', () => { + const verdict = checkAvailability({ + visitDate: TUESDAY, + startTime: '09:00', + endTime: '17:00', + slots: weekdaySlots, + }) + expect(verdict.kind).toBe('inside') + }) + + it('flags a day the caregiver never marked available', () => { + const verdict = checkAvailability({ + visitDate: SATURDAY, + startTime: '10:00', + endTime: '14:00', + slots: weekdaySlots, + }) + expect(verdict.kind).toBe('day_unavailable') + if (verdict.kind === 'day_unavailable') expect(verdict.message).toContain('Saturday') + }) + + it('flags a booking that runs past the declared window', () => { + const verdict = checkAvailability({ + visitDate: TUESDAY, + startTime: '16:00', + endTime: '19:00', + slots: weekdaySlots, + }) + expect(verdict.kind).toBe('outside_hours') + if (verdict.kind === 'outside_hours') expect(verdict.message).toContain('Tuesday') + }) + + it('flags a booking that starts before the declared window', () => { + const verdict = checkAvailability({ + visitDate: TUESDAY, + startTime: '07:00', + endTime: '10:00', + slots: weekdaySlots, + }) + expect(verdict.kind).toBe('outside_hours') + }) + + it('accepts a booking covered by one of several windows that day', () => { + const split = [ + { dayOfWeek: 2, startTime: '06:00', endTime: '10:00' }, + { dayOfWeek: 2, startTime: '16:00', endTime: '20:00' }, + ] + expect(checkAvailability({ visitDate: TUESDAY, startTime: '17:00', endTime: '19:00', slots: split }).kind).toBe('inside') + // Spanning the gap between two windows is not covered by either. + expect(checkAvailability({ visitDate: TUESDAY, startTime: '09:00', endTime: '17:00', slots: split }).kind).toBe('outside_hours') + }) + + it('checks only the weekday when no time window was supplied', () => { + // We cannot say anything about hours we were not given. + expect(checkAvailability({ visitDate: TUESDAY, slots: weekdaySlots }).kind).toBe('inside') + expect(checkAvailability({ visitDate: SATURDAY, slots: weekdaySlots }).kind).toBe('day_unavailable') + }) + + it('degrades to unknown rather than guessing on a malformed time', () => { + const verdict = checkAvailability({ + visitDate: TUESDAY, + startTime: '9am', + endTime: '5pm', + slots: weekdaySlots, + }) + expect(verdict.kind).toBe('unknown') + }) +}) diff --git a/packages/core/src/services/availability-service.ts b/packages/core/src/services/availability-service.ts new file mode 100644 index 0000000..26e06fa --- /dev/null +++ b/packages/core/src/services/availability-service.ts @@ -0,0 +1,110 @@ +/** + * Availability matching (pure). + * + * Answers one question for the scheduler: does a proposed booking fall inside + * the hours this caregiver said they normally work? + * + * The answer is advisory. Availability is a PREFERENCE, not a contract: real + * agencies cover shifts outside someone's usual window constantly, and a hard + * block would simply be worked around by editing the availability. Approved + * time off is the thing that hard-blocks, and it lives in its own table with + * its own approval trail. + * + * Pure + deterministic: no DB/IO; the caller fetches slots and hands them in. + */ + +export interface AvailabilityWindow { + /** 0 = Sunday .. 6 = Saturday, matching JavaScript's getDay(). */ + dayOfWeek: number + /** HH:MM, 24-hour. */ + startTime: string + endTime: string +} + +export interface AvailabilityCheckInput { + /** YYYY-MM-DD. */ + visitDate: string + /** HH:MM pair. Omitted when the assignment has no time window yet. */ + startTime?: string + endTime?: string + slots: AvailabilityWindow[] +} + +export type AvailabilityVerdict = + /** No pattern on file; nothing to check against. */ + | { kind: 'unknown' } + /** The booking sits inside a declared window. */ + | { kind: 'inside' } + /** The caregiver declared no hours at all on that weekday. */ + | { kind: 'day_unavailable'; message: string } + /** The day is worked, but not at this time. */ + | { kind: 'outside_hours'; message: string } + +const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] + +/** Minutes since midnight for an HH:MM string, or null when unparseable. */ +export function minutesOfDay(hhmm: string): number | null { + const match = /^(\d{2}):(\d{2})$/.exec(hhmm) + if (!match) return null + const hours = Number(match[1]) + const minutes = Number(match[2]) + if (hours > 23 || minutes > 59) return null + return hours * 60 + minutes +} + +/** + * Weekday index for a YYYY-MM-DD date. + * + * Parsed as UTC noon rather than midnight. A bare date string is treated as + * UTC midnight, which in a negative-offset timezone renders as the previous + * evening and would report the wrong weekday. Noon has no such edge. + */ +export function weekdayOf(visitDate: string): number | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(visitDate)) return null + const date = new Date(`${visitDate}T12:00:00.000Z`) + return Number.isFinite(date.getTime()) ? date.getUTCDay() : null +} + +/** + * Check a proposed booking against declared availability. + * + * With no time window supplied, only the weekday is checked: an all-day + * assignment on a day the caregiver never works is still worth flagging, but + * we cannot say anything about hours we were not given. + */ +export function checkAvailability(input: AvailabilityCheckInput): AvailabilityVerdict { + if (input.slots.length === 0) return { kind: 'unknown' } + + const weekday = weekdayOf(input.visitDate) + if (weekday == null) return { kind: 'unknown' } + + const daySlots = input.slots.filter((s) => s.dayOfWeek === weekday) + const dayName = DAY_NAMES[weekday] ?? 'that day' + if (daySlots.length === 0) { + return { + kind: 'day_unavailable', + message: `Caregiver has not marked themselves available on ${dayName}.`, + } + } + + // No proposed window: the weekday is worked, which is all we can assert. + if (!input.startTime || !input.endTime) return { kind: 'inside' } + + const start = minutesOfDay(input.startTime) + const end = minutesOfDay(input.endTime) + if (start == null || end == null) return { kind: 'unknown' } + + const covered = daySlots.some((slot) => { + const slotStart = minutesOfDay(slot.startTime) + const slotEnd = minutesOfDay(slot.endTime) + if (slotStart == null || slotEnd == null) return false + return start >= slotStart && end <= slotEnd + }) + if (covered) return { kind: 'inside' } + + const windows = daySlots.map((s) => `${s.startTime}, ${s.endTime}`).join(', ') + return { + kind: 'outside_hours', + message: `Booking is outside the caregiver's stated ${dayName} availability (${windows}).`, + } +} diff --git a/packages/mobile/app/availability.tsx b/packages/mobile/app/availability.tsx new file mode 100644 index 0000000..7337e0d --- /dev/null +++ b/packages/mobile/app/availability.tsx @@ -0,0 +1,2 @@ +import AvailabilityScreen from '../src/features/availability/AvailabilityScreen'; +export default AvailabilityScreen; diff --git a/packages/mobile/src/features/availability/AvailabilityScreen.tsx b/packages/mobile/src/features/availability/AvailabilityScreen.tsx new file mode 100644 index 0000000..0b6c6e2 --- /dev/null +++ b/packages/mobile/src/features/availability/AvailabilityScreen.tsx @@ -0,0 +1,481 @@ +import React, { useCallback, useState } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StyleSheet, + Switch, + 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 { + DAY_LABELS, + DEFAULT_END, + DEFAULT_START, + dayCount, + emptyGrid, + formatDateRange, + gridFromSlots, + slotsFromGrid, + validateGrid, + type AvailabilitySlot, + type TimeOffRequest, + type WeekGrid, +} from '../../lib/availability'; + +/** + * Availability and time off. + * + * Two things a caregiver controls about their own schedule: the hours they + * normally work, and specific days they need off. The screen is explicit that + * these carry different weight, because scheduling treats them differently: + * availability is a preference the agency can book around, approved time off + * is a commitment it will not book over. + */ + +const STATUS_META: Record< + TimeOffRequest['status'], + { label: string; color: string; icon: keyof typeof Ionicons.glyphMap } +> = { + requested: { label: 'Awaiting answer', color: colors.amber, icon: 'time-outline' }, + approved: { label: 'Approved', color: colors.success, icon: 'checkmark-circle-outline' }, + denied: { label: 'Not approved', color: colors.danger, icon: 'close-circle-outline' }, + cancelled: { label: 'Cancelled', color: colors.textMuted, icon: 'remove-circle-outline' }, +}; + +export default function AvailabilityScreen() { + const [grid, setGrid] = useState(emptyGrid()); + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [savingWeek, setSavingWeek] = useState(false); + const [weekError, setWeekError] = useState(null); + + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [reason, setReason] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [formError, setFormError] = useState(null); + + const load = useCallback(async () => { + try { + const [availability, timeOff] = await Promise.all([ + apiClient.get<{ slots: AvailabilitySlot[] }>('/api/availability'), + apiClient.get<{ requests: TimeOffRequest[] }>('/api/availability/time-off'), + ]); + setGrid(gridFromSlots(availability.data?.slots ?? [])); + setRequests(timeOff.data?.requests ?? []); + setError(null); + } catch { + setError('Could not load your availability.'); + } finally { + setLoading(false); + } + }, []); + + useFocusEffect( + useCallback(() => { + void load(); + }, [load]), + ); + + const toggleDay = (day: number, enabled: boolean) => { + void Haptics.selectionAsync(); + setWeekError(null); + setGrid((prev) => { + const next = [...prev]; + next[day] = enabled + ? { enabled: true, startTime: prev[day].startTime || DEFAULT_START, endTime: prev[day].endTime || DEFAULT_END } + : { ...prev[day], enabled: false }; + return next; + }); + }; + + const setDayTime = (day: number, field: 'startTime' | 'endTime', value: string) => { + setWeekError(null); + setGrid((prev) => { + const next = [...prev]; + next[day] = { ...prev[day], [field]: value }; + return next; + }); + }; + + const saveWeek = async () => { + const problem = validateGrid(grid); + if (problem) { + setWeekError(problem); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + return; + } + setSavingWeek(true); + try { + await apiClient.put('/api/availability', { slots: slotsFromGrid(grid) }); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } catch { + showAppAlert('Could not save your availability', 'Please try again.', undefined, { + variant: 'error', + }); + } finally { + setSavingWeek(false); + } + }; + + const submitTimeOff = async () => { + const start = startDate.trim(); + const end = (endDate.trim() || start); + if (!/^\d{4}-\d{2}-\d{2}$/.test(start) || !/^\d{4}-\d{2}-\d{2}$/.test(end)) { + setFormError('Enter dates as YYYY-MM-DD, for example 2026-09-01.'); + return; + } + if (end < start) { + setFormError('The last day cannot be before the first day.'); + return; + } + setFormError(null); + setSubmitting(true); + try { + await apiClient.post('/api/availability/time-off', { + startDate: start, + endDate: end, + ...(reason.trim() ? { reason: reason.trim() } : {}), + }); + setStartDate(''); + setEndDate(''); + setReason(''); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + await load(); + } catch { + showAppAlert('Could not send that request', 'Please try again.', undefined, { variant: 'error' }); + } finally { + setSubmitting(false); + } + }; + + const cancelRequest = (req: TimeOffRequest) => { + showAppAlert( + 'Cancel this request?', + `${formatDateRange(req.startDate, req.endDate)} will be withdrawn.`, + [ + { text: 'Keep it' }, + { + text: 'Cancel it', + onPress: () => { + void (async () => { + try { + await apiClient.delete(`/api/availability/time-off/${req.id}`); + await load(); + } catch { + showAppAlert('Could not cancel that request', 'Please try again.', undefined, { + variant: 'error', + }); + } + })(); + }, + }, + ], + { variant: 'warning' }, + ); + }; + + return ( + + + + {loading ? ( + + ) : error ? ( + { setLoading(true); void load(); }} /> + ) : ( + <> + + My usual week + + The hours you can normally work. Your agency uses this as a guide, so a shift + outside these hours is still possible. + + + {DAY_LABELS.map((label, day) => ( + + + toggleDay(day, v)} + trackColor={{ true: colors.brandBlue, false: colors.border }} + accessibilityLabel={`Available on ${label}`} + /> + {label} + + {grid[day].enabled ? ( + + setDayTime(day, 'startTime', t)} + placeholder="09:00" + placeholderTextColor={colors.textMuted} + style={styles.timeInput} + maxLength={5} + accessibilityLabel={`${label} start time`} + /> + to + setDayTime(day, 'endTime', t)} + placeholder="17:00" + placeholderTextColor={colors.textMuted} + style={styles.timeInput} + maxLength={5} + accessibilityLabel={`${label} end time`} + /> + + ) : ( + Not available + )} + + ))} + + {weekError ? {weekError} : null} + void saveWeek()} + disabled={savingWeek} + style={({ pressed }) => [styles.primaryBtn, pressed && !savingWeek && { opacity: 0.9 }]} + accessibilityRole="button" + accessibilityLabel="Save my weekly availability" + > + {savingWeek ? ( + + ) : ( + Save my week + )} + + + + + Request time off + + Once approved, your agency will not schedule you on these days. + + + First day + { setStartDate(t); if (formError) setFormError(null); }} + placeholder="2026-09-01" + placeholderTextColor={colors.textMuted} + style={styles.input} + maxLength={10} + accessibilityLabel="First day off" + /> + + + Last day (leave blank for one day) + { setEndDate(t); if (formError) setFormError(null); }} + placeholder="2026-09-03" + placeholderTextColor={colors.textMuted} + style={styles.input} + maxLength={10} + accessibilityLabel="Last day off" + /> + + + Reason (optional) + + + {formError ? {formError} : null} + void submitTimeOff()} + disabled={submitting} + style={({ pressed }) => [styles.primaryBtn, pressed && !submitting && { opacity: 0.9 }]} + accessibilityRole="button" + accessibilityLabel="Send time off request" + > + {submitting ? ( + + ) : ( + Send request + )} + + + + My requests + {requests.length === 0 ? ( + + + No time off requested yet. + + ) : ( + requests.map((req, index) => { + const meta = STATUS_META[req.status]; + const days = dayCount(req.startDate, req.endDate); + return ( + + + + {formatDateRange(req.startDate, req.endDate)} + + + + {meta.label} + + + + {days} {days === 1 ? 'day' : 'days'} + + {req.reason ? ( + {req.reason} + ) : null} + {req.reviewNote ? ( + {req.reviewNote} + ) : null} + {req.status === 'requested' || req.status === 'approved' ? ( + cancelRequest(req)} + hitSlop={8} + style={styles.cancelBtn} + accessibilityRole="button" + accessibilityLabel="Cancel this time off request" + > + Cancel request + + ) : null} + + ); + }) + )} + + )} + + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1, backgroundColor: colors.screenBg }, + body: { flex: 1 }, + bodyContent: { padding: 16, paddingBottom: 48, gap: 12 }, + card: { + backgroundColor: colors.cardBg, + borderRadius: radii.lg, + padding: 16, + gap: 10, + ...shadow.card, + }, + cardTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, + cardHint: { ...typography.caption, color: colors.textSecondary, lineHeight: 16 }, + dayRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 4, + gap: 8, + }, + dayLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + dayLabel: { fontSize: 14, fontWeight: '600', color: colors.textPrimary, width: 34 }, + dayOff: { ...typography.caption, color: colors.textMuted }, + timeInputs: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + timeInput: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: radii.sm, + paddingHorizontal: 8, + paddingVertical: 6, + fontSize: 13, + color: colors.textPrimary, + backgroundColor: colors.screenBg, + width: 66, + textAlign: 'center', + }, + timeDash: { ...typography.caption, color: colors.textMuted }, + 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 }, + primaryBtn: { + backgroundColor: colors.brandBlue, + borderRadius: radii.md, + paddingVertical: 13, + alignItems: 'center', + marginTop: 2, + }, + primaryBtnText: { 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: 8, + ...shadow.card, + }, + emptyText: { ...typography.caption, color: colors.textSecondary }, + requestCard: { + backgroundColor: colors.cardBg, + borderRadius: radii.lg, + padding: 14, + gap: 4, + ...shadow.card, + }, + requestTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + requestDates: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, + requestDays: { ...typography.caption, color: colors.textMuted }, + requestReason: { fontSize: 13, color: colors.textSecondary }, + reviewNote: { ...typography.caption, color: colors.textSecondary, marginTop: 2 }, + statusPill: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: radii.pill, + }, + statusText: { ...typography.caption }, + cancelBtn: { alignSelf: 'flex-start', marginTop: 6 }, + cancelText: { ...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 7475d7f..e82ae02 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('/availability')} + /> { + it('round-trips a simple weekday pattern', () => { + const slots = [ + { dayOfWeek: 1, startTime: '09:00', endTime: '17:00' }, + { dayOfWeek: 3, startTime: '08:00', endTime: '12:00' }, + ]; + expect(slotsFromGrid(gridFromSlots(slots))).toEqual(slots); + }); + + it('starts from an all-off week', () => { + const grid = emptyGrid(); + expect(grid).toHaveLength(7); + expect(grid.every((d) => !d.enabled)).toBe(true); + expect(slotsFromGrid(grid)).toEqual([]); + }); + + it('keeps the first window when a day has several stored', () => { + // The phone editor holds one window per day; the extra is preserved in + // storage until the caregiver edits that day. + const grid = gridFromSlots([ + { dayOfWeek: 2, startTime: '06:00', endTime: '10:00' }, + { dayOfWeek: 2, startTime: '16:00', endTime: '20:00' }, + ]); + expect(grid[2]).toEqual({ enabled: true, startTime: '06:00', endTime: '10:00' }); + }); + + it('ignores an out-of-range weekday rather than crashing', () => { + const grid = gridFromSlots([ + { dayOfWeek: 9, startTime: '09:00', endTime: '17:00' }, + { dayOfWeek: -1, startTime: '09:00', endTime: '17:00' }, + ]); + expect(grid.every((d) => !d.enabled)).toBe(true); + }); + + it('emits slots in weekday order', () => { + const grid = emptyGrid(); + grid[5] = { enabled: true, startTime: '09:00', endTime: '17:00' }; + grid[1] = { enabled: true, startTime: '09:00', endTime: '17:00' }; + expect(slotsFromGrid(grid).map((s) => s.dayOfWeek)).toEqual([1, 5]); + }); +}); + +describe('validateGrid', () => { + it('passes a sane week', () => { + const grid = emptyGrid(); + grid[2] = { enabled: true, startTime: '09:00', endTime: '17:00' }; + expect(validateGrid(grid)).toBeNull(); + }); + + it('names the day with the problem', () => { + const grid = emptyGrid(); + grid[4] = { enabled: true, startTime: '17:00', endTime: '09:00' }; + expect(validateGrid(grid)).toContain('Thu'); + }); + + it('rejects a zero-length window', () => { + const grid = emptyGrid(); + grid[0] = { enabled: true, startTime: '09:00', endTime: '09:00' }; + expect(validateGrid(grid)).not.toBeNull(); + }); + + it('ignores a disabled day with nonsense times', () => { + const grid = emptyGrid(); + grid[3] = { enabled: false, startTime: '20:00', endTime: '08:00' }; + expect(validateGrid(grid)).toBeNull(); + }); +}); + +describe('formatDateRange', () => { + it('collapses a single day', () => { + expect(formatDateRange('2026-09-01', '2026-09-01')).toBe('Sep 1'); + }); + + it('shows both ends of a range', () => { + expect(formatDateRange('2026-09-01', '2026-09-04')).toBe('Sep 1 - Sep 4'); + }); +}); + +describe('dayCount', () => { + it('counts inclusively', () => { + expect(dayCount('2026-09-01', '2026-09-01')).toBe(1); + expect(dayCount('2026-09-01', '2026-09-03')).toBe(3); + }); + + it('spans a month boundary', () => { + expect(dayCount('2026-08-30', '2026-09-02')).toBe(4); + }); + + it('is zero for an inverted or unparseable range', () => { + expect(dayCount('2026-09-05', '2026-09-01')).toBe(0); + expect(dayCount('nonsense', '2026-09-01')).toBe(0); + }); +}); diff --git a/packages/mobile/src/lib/availability.ts b/packages/mobile/src/lib/availability.ts new file mode 100644 index 0000000..0e305db --- /dev/null +++ b/packages/mobile/src/lib/availability.ts @@ -0,0 +1,105 @@ +/** + * Pure helpers for the availability screen. No React Native imports, so it is + * unit-testable. + */ + +export interface AvailabilitySlot { + dayOfWeek: number; + startTime: string; + endTime: string; +} + +export type TimeOffStatus = 'requested' | 'approved' | 'denied' | 'cancelled'; + +export interface TimeOffRequest { + id: string; + startDate: string; + endDate: string; + reason: string | null; + status: TimeOffStatus; + reviewNote: string | null; +} + +export const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +/** The window offered when a caregiver first switches a day on. */ +export const DEFAULT_START = '09:00'; +export const DEFAULT_END = '17:00'; + +/** + * The weekly grid the screen edits: one optional window per weekday. + * + * One window per day rather than many, deliberately. A split shift is real but + * uncommon, and a repeating add/remove UI on a phone is fiddly enough that + * most people would abandon it. The API and storage accept several windows per + * day, so a richer editor can land later without a migration; a caregiver who + * already has split windows keeps them until they edit that day. + */ +export type WeekGrid = Array<{ enabled: boolean; startTime: string; endTime: string }>; + +export function emptyGrid(): WeekGrid { + return Array.from({ length: 7 }, () => ({ + enabled: false, + startTime: DEFAULT_START, + endTime: DEFAULT_END, + })); +} + +/** Build the editable grid from stored slots, taking the first window per day. */ +export function gridFromSlots(slots: AvailabilitySlot[]): WeekGrid { + const grid = emptyGrid(); + for (const slot of slots) { + if (slot.dayOfWeek < 0 || slot.dayOfWeek > 6) continue; + if (grid[slot.dayOfWeek].enabled) continue; + grid[slot.dayOfWeek] = { + enabled: true, + startTime: slot.startTime, + endTime: slot.endTime, + }; + } + return grid; +} + +/** Flatten the grid back into the slot list the API takes. */ +export function slotsFromGrid(grid: WeekGrid): AvailabilitySlot[] { + const slots: AvailabilitySlot[] = []; + grid.forEach((day, dayOfWeek) => { + if (!day.enabled) return; + slots.push({ dayOfWeek, startTime: day.startTime, endTime: day.endTime }); + }); + return slots; +} + +/** + * Validate the grid before sending. Returns the first problem in weekday + * order so the message points at one fixable thing rather than a list. + */ +export function validateGrid(grid: WeekGrid): string | null { + for (let day = 0; day < grid.length; day += 1) { + const entry = grid[day]; + if (!entry.enabled) continue; + if (entry.endTime <= entry.startTime) { + return `${DAY_LABELS[day]}: end time must be after start time.`; + } + } + return null; +} + +/** Human-readable date range, collapsing a single day to one date. */ +export function formatDateRange(startDate: string, endDate: string): string { + const fmt = (ymd: string) => { + const d = new Date(`${ymd}T12:00:00.000Z`); + return Number.isFinite(d.getTime()) + ? d.toLocaleDateString([], { month: 'short', day: 'numeric', timeZone: 'UTC' }) + : ymd; + }; + return startDate === endDate ? fmt(startDate) : `${fmt(startDate)} - ${fmt(endDate)}`; +} + +/** Inclusive day count for a request, for the "3 days" summary line. */ +export function dayCount(startDate: string, endDate: string): number { + const start = Date.parse(`${startDate}T00:00:00.000Z`); + const end = Date.parse(`${endDate}T00:00:00.000Z`); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return 0; + return Math.round((end - start) / 86_400_000) + 1; +} diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 75efd35..d2370e6 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -17,6 +17,7 @@ const AgencySetupPage = lazy(() => import('./features/agency/AgencySetupPage.js' 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 TimeOffReviewPage = lazy(() => import('./features/staff/TimeOffReviewPage.js').then((m) => ({ default: m.TimeOffReviewPage }))); 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 }))); @@ -303,6 +304,7 @@ const navGroupDefs: NavGroupDef[] = [ { 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/time-off', label: 'Time Off', 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 }, @@ -579,6 +581,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/web/src/features/staff/TimeOffReviewPage.tsx b/packages/web/src/features/staff/TimeOffReviewPage.tsx new file mode 100644 index 0000000..f775768 --- /dev/null +++ b/packages/web/src/features/staff/TimeOffReviewPage.tsx @@ -0,0 +1,202 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { getJson, patchJson } from '../../lib/api-client.js'; +import { EmptyState, LoadingSkeleton, ErrorRetry } from '../../components/state/index.js'; + +/** + * Time off review. + * + * Approving a request is a commitment, not a note: scheduling treats approved + * leave as a hard conflict and will refuse to book over it. The page says so, + * because a coordinator clicking approve should know it changes what the + * assignment screen will let them do. + */ + +type TimeOffStatus = 'requested' | 'approved' | 'denied' | 'cancelled'; + +interface TimeOffRequest { + id: string; + caregiverId: string; + startDate: string; + endDate: string; + reason: string | null; + status: TimeOffStatus; + reviewNote: string | null; +} + +const TABS: Array<{ key: TimeOffStatus; label: string }> = [ + { key: 'requested', label: 'Pending' }, + { key: 'approved', label: 'Approved' }, + { key: 'denied', label: 'Denied' }, + { key: 'cancelled', label: 'Cancelled' }, +]; + +const STATUS_COLOR: Record = { + requested: 'var(--color-warning)', + approved: 'var(--color-success)', + denied: 'var(--color-danger-text)', + cancelled: 'var(--color-text-subtle)', +}; + +function formatDate(ymd: string): string { + const d = new Date(`${ymd}T12:00:00.000Z`); + return Number.isFinite(d.getTime()) + ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }) + : ymd; +} + +function dayCount(startDate: string, endDate: string): number { + const start = Date.parse(`${startDate}T00:00:00.000Z`); + const end = Date.parse(`${endDate}T00:00:00.000Z`); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return 0; + return Math.round((end - start) / 86_400_000) + 1; +} + +export function TimeOffReviewPage() { + const [status, setStatus] = useState('requested'); + const [requests, setRequests] = 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<{ requests: TimeOffRequest[] }>(`/api/availability/time-off?status=${status}`) + .then((data) => setRequests(data?.requests ?? [])) + .catch((err: Error) => setError(err.message || 'Failed to load time off')) + .finally(() => setLoading(false)); + }, [status]); + + useEffect(() => { load(); }, [load]); + + const review = async (req: TimeOffRequest, next: 'approved' | 'denied') => { + setBusy((prev) => ({ ...prev, [req.id]: true })); + try { + await patchJson(`/api/availability/time-off/${encodeURIComponent(req.id)}/review`, { + status: next, + ...(notes[req.id]?.trim() ? { note: notes[req.id].trim() } : {}), + }); + setRequests((prev) => prev.filter((r) => r.id !== req.id)); + setNotes((prev) => { const n = { ...prev }; delete n[req.id]; return n; }); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to update that request'); + } finally { + setBusy((prev) => { const n = { ...prev }; delete n[req.id]; return n; }); + } + }; + + return ( +
+
+

Time Off

+

+ Approving a request is a commitment: scheduling will refuse to book that caregiver on + those days. +

+
+ +
+ {TABS.map((tab) => ( + + ))} +
+ + {loading ? ( + + ) : error ? ( + + ) : requests.length === 0 ? ( + + ) : ( +
+ {requests.map((req) => ( +
+
+
+
+ {formatDate(req.startDate)} + {req.endDate !== req.startDate ? ` — ${formatDate(req.endDate)}` : ''} +
+
+ {dayCount(req.startDate, req.endDate)} day(s) +
+
+ + {TABS.find((t) => t.key === req.status)?.label ?? req.status} + +
+ + {req.reason ? ( +
{req.reason}
+ ) : null} + + {req.reviewNote ? ( +
+ Note: {req.reviewNote} +
+ ) : null} + + {req.status === 'requested' ? ( +
+ setNotes((prev) => ({ ...prev, [req.id]: e.target.value }))} + placeholder="Note (optional, shown to the caregiver)" + className="input-field" + style={{ fontSize: '0.8125rem', padding: '0.3rem 0.6rem', flex: '1 1 240px', minWidth: 0 }} + maxLength={500} + /> + + +
+ ) : null} +
+ ))} +
+ )} +
+ ); +} + +export default TimeOffReviewPage;