From 4b2ef9d843b99ae61e1aaa8e44f68f8435459c41 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:28:13 +0100 Subject: [PATCH 01/29] fix(api): close M5 frontend contract gaps --- .../src/services/command-service.js | 83 +++++++++--- backend/query-api/src/routes/mobile.js | 11 ++ .../query-api/src/services/actor-context.js | 2 + .../src/services/mobile-query-service.js | 119 +++++++++++++++++- backend/query-api/test/mobile-routes.test.js | 12 ++ .../scripts/live-smoke-v2-unified.mjs | 35 ++++++ docs/BACKEND/API_GUIDES/V2/README.md | 23 ++++ .../API_GUIDES/V2/mobile-coding-agent-spec.md | 6 + docs/BACKEND/API_GUIDES/V2/unified-api.md | 23 ++++ 9 files changed, 293 insertions(+), 21 deletions(-) diff --git a/backend/command-api/src/services/command-service.js b/backend/command-api/src/services/command-service.js index ba257a12..d8fb721e 100644 --- a/backend/command-api/src/services/command-service.js +++ b/backend/command-api/src/services/command-service.js @@ -1287,18 +1287,43 @@ async function createAttendanceEvent(actor, payload, eventType) { }); } + async function loadExistingAttendanceSession() { + const existing = await client.query( + ` + SELECT id, status, check_in_at AS "clockInAt" + FROM attendance_sessions + WHERE assignment_id = $1 + ORDER BY updated_at DESC + LIMIT 1 + `, + [assignment.id] + ); + return existing.rows[0] || null; + } + const sessionResult = await client.query( ` - SELECT id, status + SELECT id, status, check_in_at AS "clockInAt" FROM attendance_sessions WHERE assignment_id = $1 `, [assignment.id] ); - if (eventType === 'CLOCK_IN' && sessionResult.rowCount > 0 && sessionResult.rows[0].status === 'OPEN') { - throw new AppError('ATTENDANCE_ALREADY_OPEN', 'Assignment already has an open attendance session', 409, { + if (eventType === 'CLOCK_IN' && sessionResult.rowCount > 0) { + const existingSession = sessionResult.rows[0]; + if (existingSession.status === 'OPEN') { + throw new AppError('ALREADY_CLOCKED_IN', 'An active attendance session already exists for this assignment', 409, { + assignmentId: assignment.id, + sessionId: existingSession.id, + clockInAt: existingSession.clockInAt || null, + }); + } + + throw new AppError('ATTENDANCE_SESSION_EXISTS', 'Attendance session already exists for this assignment', 409, { assignmentId: assignment.id, + sessionId: existingSession.id, + clockInAt: existingSession.clockInAt || null, }); } @@ -1414,21 +1439,43 @@ async function createAttendanceEvent(actor, payload, eventType) { let sessionId; if (eventType === 'CLOCK_IN') { - const insertedSession = await client.query( - ` - INSERT INTO attendance_sessions ( - tenant_id, - assignment_id, - staff_id, - clock_in_event_id, - status, - check_in_at - ) - VALUES ($1, $2, $3, $4, 'OPEN', $5) - RETURNING id - `, - [assignment.tenant_id, assignment.id, assignment.staff_id, eventResult.rows[0].id, capturedAt] - ); + let insertedSession; + try { + insertedSession = await client.query( + ` + INSERT INTO attendance_sessions ( + tenant_id, + assignment_id, + staff_id, + clock_in_event_id, + status, + check_in_at + ) + VALUES ($1, $2, $3, $4, 'OPEN', $5) + RETURNING id + `, + [assignment.tenant_id, assignment.id, assignment.staff_id, eventResult.rows[0].id, capturedAt] + ); + } catch (error) { + if (error?.code !== '23505') { + throw error; + } + + const existingSession = await loadExistingAttendanceSession(); + if (existingSession?.status === 'OPEN') { + throw new AppError('ALREADY_CLOCKED_IN', 'An active attendance session already exists for this assignment', 409, { + assignmentId: assignment.id, + sessionId: existingSession.id, + clockInAt: existingSession.clockInAt || null, + }); + } + + throw new AppError('ATTENDANCE_SESSION_EXISTS', 'Attendance session already exists for this assignment', 409, { + assignmentId: assignment.id, + sessionId: existingSession?.id || null, + clockInAt: existingSession?.clockInAt || null, + }); + } sessionId = insertedSession.rows[0].id; await client.query( ` diff --git a/backend/query-api/src/routes/mobile.js b/backend/query-api/src/routes/mobile.js index 8bdc257a..947c2bc1 100644 --- a/backend/query-api/src/routes/mobile.js +++ b/backend/query-api/src/routes/mobile.js @@ -21,6 +21,7 @@ import { getReportSummary, getSavings, getStaffDashboard, + getStaffReliabilityStats, getStaffProfileCompletion, getStaffSession, getStaffShiftDetail, @@ -89,6 +90,7 @@ const defaultQueryService = { getSpendBreakdown, getSpendReport, getStaffDashboard, + getStaffReliabilityStats, getStaffProfileCompletion, getStaffSession, getStaffShiftDetail, @@ -443,6 +445,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/staff/profile/stats', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const data = await queryService.getStaffReliabilityStats(req.actor.uid); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + router.get('/staff/dashboard', requireAuth, requirePolicy('staff.dashboard.read', 'dashboard'), async (req, res, next) => { try { const data = await queryService.getStaffDashboard(req.actor.uid); diff --git a/backend/query-api/src/services/actor-context.js b/backend/query-api/src/services/actor-context.js index 30d23aa5..efb118c0 100644 --- a/backend/query-api/src/services/actor-context.js +++ b/backend/query-api/src/services/actor-context.js @@ -70,6 +70,8 @@ export async function loadActorContext(uid) { s.phone, s.primary_role AS "primaryRole", s.onboarding_status AS "onboardingStatus", + s.average_rating AS "averageRating", + s.rating_count AS "ratingCount", s.status, s.metadata, w.id AS "workforceId", diff --git a/backend/query-api/src/services/mobile-query-service.js b/backend/query-api/src/services/mobile-query-service.js index e6e54e9b..e8942dff 100644 --- a/backend/query-api/src/services/mobile-query-service.js +++ b/backend/query-api/src/services/mobile-query-service.js @@ -52,6 +52,37 @@ function metadataBoolean(metadata, key, fallback = false) { return fallback; } +function roundToOneDecimal(value) { + return Math.round(value * 10) / 10; +} + +function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} + +function computeReliabilityScore({ + totalShifts, + noShowCount, + cancellationCount, + averageRating, + onTimeRate, +}) { + const participationBase = totalShifts + noShowCount + cancellationCount; + const completionRate = participationBase === 0 + ? 100 + : (totalShifts / participationBase) * 100; + const ratingScore = clamp((averageRating / 5) * 100, 0, 100); + return roundToOneDecimal( + clamp( + (onTimeRate * 0.45) + + (completionRate * 0.35) + + (ratingScore * 0.20), + 0, + 100 + ) + ); +} + function membershipDisplayName(row) { const firstName = row?.firstName || row?.metadata?.firstName || null; const lastName = row?.lastName || row?.metadata?.lastName || null; @@ -96,6 +127,68 @@ export async function getStaffSession(actorUid) { return context; } +export async function getStaffReliabilityStats(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + COUNT(*) FILTER (WHERE a.status IN ('CHECKED_OUT', 'COMPLETED'))::INTEGER AS "totalShifts", + COUNT(*) FILTER (WHERE attendance_sessions.check_in_at IS NOT NULL)::INTEGER AS "clockedInCount", + COUNT(*) FILTER ( + WHERE attendance_sessions.check_in_at IS NOT NULL + AND attendance_sessions.check_in_at <= s.starts_at + INTERVAL '5 minutes' + )::INTEGER AS "onTimeShiftCount", + COUNT(*) FILTER (WHERE a.status = 'NO_SHOW')::INTEGER AS "noShowCount", + COUNT(*) FILTER ( + WHERE a.status = 'CANCELLED' + AND ( + COALESCE(a.metadata->>'declinedBy', '') = $3 + OR COALESCE(a.metadata->>'cancelledBy', '') = $3 + ) + )::INTEGER AS "cancellationCount", + COALESCE(st.average_rating, 0)::NUMERIC(3, 2) AS "averageRating", + COALESCE(st.rating_count, 0)::INTEGER AS "ratingCount" + FROM staffs st + LEFT JOIN assignments a ON a.staff_id = st.id AND a.tenant_id = st.tenant_id + LEFT JOIN shifts s ON s.id = a.shift_id + LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.id + WHERE st.tenant_id = $1 + AND st.id = $2 + GROUP BY st.id + `, + [context.tenant.tenantId, context.staff.staffId, actorUid] + ); + + const metrics = result.rows[0] || {}; + const totalShifts = Number(metrics.totalShifts || 0); + const clockedInCount = Number(metrics.clockedInCount || 0); + const onTimeShiftCount = Number(metrics.onTimeShiftCount || 0); + const noShowCount = Number(metrics.noShowCount || 0); + const cancellationCount = Number(metrics.cancellationCount || 0); + const averageRating = Number(metrics.averageRating || 0); + const ratingCount = Number(metrics.ratingCount || 0); + const onTimeRate = clockedInCount === 0 + ? 0 + : roundToOneDecimal((onTimeShiftCount / clockedInCount) * 100); + + return { + staffId: context.staff.staffId, + totalShifts, + averageRating, + ratingCount, + onTimeRate, + noShowCount, + cancellationCount, + reliabilityScore: computeReliabilityScore({ + totalShifts, + noShowCount, + cancellationCount, + averageRating, + onTimeRate, + }), + }; +} + export async function getClientDashboard(actorUid) { const context = await requireClientContext(actorUid); const businessId = context.business.businessId; @@ -353,19 +446,33 @@ export async function listCoverageByDate(actorUid, { date }) { s.title, s.starts_at AS "startsAt", s.ends_at AS "endsAt", + COALESCE(cp.label, s.location_name) AS "locationName", + COALESCE(s.location_address, cp.address) AS "locationAddress", s.required_workers AS "requiredWorkers", s.assigned_workers AS "assignedWorkers", - sr.role_name AS "roleName", + COALESCE(sr_assigned.role_name, sr_primary.role_name) AS "roleName", a.id AS "assignmentId", a.status AS "assignmentStatus", st.id AS "staffId", st.full_name AS "staffName", - attendance_sessions.check_in_at AS "checkInAt" + attendance_sessions.check_in_at AS "checkInAt", + COALESCE(staff_reviews.id IS NOT NULL, FALSE) AS "hasReview" FROM shifts s - LEFT JOIN shift_roles sr ON sr.shift_id = s.id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + LEFT JOIN LATERAL ( + SELECT role_name + FROM shift_roles + WHERE shift_id = s.id + ORDER BY created_at ASC, role_name ASC + LIMIT 1 + ) sr_primary ON TRUE LEFT JOIN assignments a ON a.shift_id = s.id + LEFT JOIN shift_roles sr_assigned ON sr_assigned.id = a.shift_role_id LEFT JOIN staffs st ON st.id = a.staff_id LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.id + LEFT JOIN staff_reviews ON staff_reviews.business_id = s.business_id + AND staff_reviews.assignment_id = a.id + AND staff_reviews.staff_id = a.staff_id WHERE s.tenant_id = $1 AND s.business_id = $2 AND s.starts_at >= $3::timestamptz @@ -380,6 +487,8 @@ export async function listCoverageByDate(actorUid, { date }) { const current = grouped.get(row.shiftId) || { shiftId: row.shiftId, roleName: row.roleName, + locationName: row.locationName, + locationAddress: row.locationAddress, timeRange: { startsAt: row.startsAt, endsAt: row.endsAt, @@ -388,6 +497,9 @@ export async function listCoverageByDate(actorUid, { date }) { assignedWorkerCount: row.assignedWorkers, assignedWorkers: [], }; + if (!current.roleName && row.roleName) current.roleName = row.roleName; + if (!current.locationName && row.locationName) current.locationName = row.locationName; + if (!current.locationAddress && row.locationAddress) current.locationAddress = row.locationAddress; if (row.staffId) { current.assignedWorkers.push({ assignmentId: row.assignmentId, @@ -395,6 +507,7 @@ export async function listCoverageByDate(actorUid, { date }) { fullName: row.staffName, status: row.assignmentStatus, checkInAt: row.checkInAt, + hasReview: Boolean(row.hasReview), }); } grouped.set(row.shiftId, current); diff --git a/backend/query-api/test/mobile-routes.test.js b/backend/query-api/test/mobile-routes.test.js index 8a7946dc..f810029d 100644 --- a/backend/query-api/test/mobile-routes.test.js +++ b/backend/query-api/test/mobile-routes.test.js @@ -27,6 +27,7 @@ function createMobileQueryService() { getSpendReport: async () => ({ totals: { amountCents: 2000 } }), getSpendBreakdown: async () => ([{ category: 'Barista', amountCents: 1000 }]), getStaffDashboard: async () => ({ staffName: 'Ana Barista' }), + getStaffReliabilityStats: async () => ({ totalShifts: 12, reliabilityScore: 96.4 }), getStaffProfileCompletion: async () => ({ completed: true }), getStaffSession: async () => ({ staff: { staffId: 's1' } }), getStaffShiftDetail: async () => ({ shiftId: 'shift-1' }), @@ -101,6 +102,17 @@ test('GET /query/staff/dashboard returns injected dashboard', async () => { assert.equal(res.body.staffName, 'Ana Barista'); }); +test('GET /query/staff/profile/stats returns injected reliability stats', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/staff/profile/stats') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.totalShifts, 12); + assert.equal(res.body.reliabilityScore, 96.4); +}); + test('GET /query/staff/shifts/:shiftId returns injected shift detail', async () => { const app = createApp({ mobileQueryService: createMobileQueryService() }); const res = await request(app) diff --git a/backend/unified-api/scripts/live-smoke-v2-unified.mjs b/backend/unified-api/scripts/live-smoke-v2-unified.mjs index f6936a38..d3080cb4 100644 --- a/backend/unified-api/scripts/live-smoke-v2-unified.mjs +++ b/backend/unified-api/scripts/live-smoke-v2-unified.mjs @@ -343,6 +343,12 @@ async function main() { token: ownerSession.sessionToken, }); assert.ok(Array.isArray(coverage.items)); + const seededCoverageShift = coverage.items.find((item) => item.shiftId === fixture.shifts.assigned.id) || coverage.items[0]; + assert.ok(seededCoverageShift); + assert.ok(seededCoverageShift.locationName); + if (seededCoverageShift.assignedWorkers?.[0]) { + assert.equal(typeof seededCoverageShift.assignedWorkers[0].hasReview, 'boolean'); + } logStep('client.coverage.ok', { count: coverage.items.length }); const coverageStats = await apiCall(`/client/coverage/stats?date=${isoDate(0)}`, { @@ -753,6 +759,13 @@ async function main() { }); logStep('staff.profile-completion.ok', staffProfileCompletion); + const staffProfileStats = await apiCall('/staff/profile/stats', { + token: staffAuth.idToken, + }); + assert.equal(typeof staffProfileStats.totalShifts, 'number'); + assert.equal(typeof staffProfileStats.reliabilityScore, 'number'); + logStep('staff.profile.stats.ok', staffProfileStats); + const staffAvailability = await apiCall('/staff/availability', { token: staffAuth.idToken, }); @@ -1129,6 +1142,28 @@ async function main() { assert.ok(clockIn.securityProofId); logStep('staff.clock-in.ok', clockIn); + const duplicateClockIn = await apiCall('/staff/clock-in', { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-clock-in-duplicate'), + body: { + shiftId: fixture.shifts.assigned.id, + sourceType: 'GEO', + deviceId: 'smoke-iphone-15-pro', + latitude: fixture.clockPoint.latitude, + longitude: fixture.clockPoint.longitude, + accuracyMeters: 8, + proofNonce: uniqueKey('geo-proof-clock-in-duplicate'), + proofTimestamp: isoTimestamp(0), + capturedAt: isoTimestamp(0), + }, + expectedStatus: 409, + allowFailure: true, + }); + assert.equal(duplicateClockIn.statusCode, 409); + assert.equal(duplicateClockIn.body.code, 'ALREADY_CLOCKED_IN'); + logStep('staff.clock-in.duplicate.ok', duplicateClockIn.body); + const attendanceStatusAfterClockIn = await apiCall('/staff/clock-in/status', { token: staffAuth.idToken, }); diff --git a/docs/BACKEND/API_GUIDES/V2/README.md b/docs/BACKEND/API_GUIDES/V2/README.md index acc94c0c..1b812994 100644 --- a/docs/BACKEND/API_GUIDES/V2/README.md +++ b/docs/BACKEND/API_GUIDES/V2/README.md @@ -81,6 +81,29 @@ All routes return the same error envelope: } ``` +## 3.1) Time handling + +V2 stores operational timestamps in UTC using PostgreSQL `TIMESTAMPTZ`. + +Rules: + +- frontend sends UTC timestamps to backend +- backend returns ISO 8601 UTC timestamps for source-of-truth fields +- frontend converts those timestamps to local time for display + +Source-of-truth timestamp fields include: + +- `startsAt` +- `endsAt` +- `startTime` +- `endTime` +- `clockInAt` +- `clockOutAt` +- `createdAt` +- `updatedAt` + +Helper fields like `date` are UTC-derived helpers and should not replace the raw timestamp fields. + ## 4) Attendance policy and monitoring V2 now supports an explicit attendance proof policy: diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md b/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md index a369f422..cb3f91d5 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md @@ -23,6 +23,7 @@ Supporting docs: - Send `Idempotency-Key` on every write route. - Treat `order`, `shift`, `shiftRole`, and `assignment` as different objects. - For staff shift applications, `roleId` must come from the response of `GET /staff/shifts/open`. +- Treat API timestamp fields as UTC and convert them to local time in the app. ## 2) What is implemented now @@ -145,6 +146,8 @@ Rules: - worker rating happens through `POST /client/coverage/reviews` - the same endpoint also supports `markAsFavorite` to add or remove a worker from business favorites - blocking a worker is done through the same endpoint using `markAsBlocked` +- coverage shift items now include `locationName` and `locationAddress` +- assigned worker items now include `hasReview` - dispatch ranking order is: 1. `CORE` 2. `CERTIFIED_LOCATION` @@ -216,6 +219,7 @@ Important: - `GET /staff/session` - `GET /staff/dashboard` +- `GET /staff/profile/stats` - `GET /staff/profile-completion` ### Availability @@ -250,6 +254,7 @@ Staff shift detail and list rules: - assigned shifts include `clientName`, `hourlyRate`, `totalRate`, `startTime`, `endTime` - shift detail includes `clientName`, `latitude`, `longitude`, `hourlyRate`, `totalRate` - completed shifts include `date`, `clientName`, `startTime`, `endTime`, `hourlyRate`, `totalRate` +- `GET /staff/profile/stats` returns `totalShifts`, `averageRating`, `ratingCount`, `onTimeRate`, `noShowCount`, `cancellationCount`, `reliabilityScore` ### Clock in / clock out @@ -266,6 +271,7 @@ Clock-in payload rules: - send `overrideReason` only when geo override is allowed - send `proofNonce` and `proofTimestamp` on attendance writes - send `attestationProvider` and `attestationToken` only if the device has them +- if backend returns `ALREADY_CLOCKED_IN`, treat it as a valid retry-state signal and refresh attendance/session state Clock-in read rules: diff --git a/docs/BACKEND/API_GUIDES/V2/unified-api.md b/docs/BACKEND/API_GUIDES/V2/unified-api.md index 9e160f04..06153c0f 100644 --- a/docs/BACKEND/API_GUIDES/V2/unified-api.md +++ b/docs/BACKEND/API_GUIDES/V2/unified-api.md @@ -104,6 +104,11 @@ Coverage-review request payload may also send: If `markAsFavorite` is `true`, backend adds that worker to the business favorites list. If `markAsFavorite` is `false`, backend removes them from that list. If `markAsBlocked` is `true`, backend adds that staff member to the business-level blocked list and future apply or assign attempts are rejected until a later review sends `markAsBlocked: false`. +`GET /client/coverage` response notes: + +- each shift item includes `locationName` and `locationAddress` +- each assigned worker item includes `hasReview` + Swap-review routes: - `GET /client/coverage/swap-requests?status=OPEN` @@ -163,6 +168,7 @@ The manager is created as an invited business membership. If `hubId` is present, - `GET /staff/session` - `GET /staff/dashboard` +- `GET /staff/profile/stats` - `GET /staff/profile-completion` - `GET /staff/availability` - `GET /staff/clock-in/shifts/today` @@ -218,6 +224,21 @@ Example `GET /staff/clock-in/shifts/today` item: } ``` +Example `GET /staff/profile/stats` response: + +```json +{ + "staffId": "uuid", + "totalShifts": 12, + "averageRating": 4.8, + "ratingCount": 7, + "onTimeRate": 91.7, + "noShowCount": 1, + "cancellationCount": 0, + "reliabilityScore": 92.3 +} +``` + ### Staff writes - `POST /staff/profile/setup` @@ -296,12 +317,14 @@ These are exposed as direct unified aliases even though they are backed by `core - `NFC_REQUIRED` - `GEO_REQUIRED` - `EITHER` +- all source-of-truth timestamps are UTC ISO 8601 values. Frontend should convert them to local time for display. - For `POST /staff/clock-in` and `POST /staff/clock-out`: - send `nfcTagId` when clocking with NFC - send `latitude`, `longitude`, and `accuracyMeters` when clocking with geolocation - send `proofNonce` and `proofTimestamp` for attendance-proof logging; these are most important on NFC paths - send `attestationProvider` and `attestationToken` only when the device has a real attestation result to forward - send `overrideReason` only when the worker is bypassing a geofence failure and the shift/hub allows overrides + - if the worker is already clocked in, backend returns `409` with code `ALREADY_CLOCKED_IN` - `POST /staff/location-streams` is for the background tracking loop after a worker is already clocked in. - `GET /client/coverage/incidents` is the review feed for geofence breaches, missing-location batches, and clock-in overrides. - `GET /client/coverage/blocked-staff` is the review feed for workers currently blocked by that business. From 493891eea03bad3964dae049f150e896da0b581c Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 10:53:37 -0400 Subject: [PATCH 02/29] Add staff reliability stats & shift location Introduce staff reliability statistics and location fields across domain, data, and UI. Changes include: - New API endpoint StaffEndpoints.profileStats ('/staff/profile/stats'). - New domain entity StaffReliabilityStats with JSON (de)serialization and export. - Profile repository: getReliabilityStats implementation and interface addition. - New GetReliabilityStatsUseCase and DI registration in StaffProfileModule. - ProfileCubit/state: load and store reliabilityStats; UI wired to display ReliabilityStatsCard and ReliabilityScoreBar using state values. - Coverage/shift updates: Added AssignedWorker.hasReview to track if a worker was reviewed; added locationName/locationAddress to ShiftWithWorkers and show location in ShiftHeader; hide rate button if worker.hasReview. - Clock-in handling: treat backend ALREADY_CLOCKED_IN (409) as idempotent by re-fetching attendance and emitting success when appropriate. These changes wire backend stats through repository/usecase/cubit to the profile UI and add shift location and review-awareness to client views. --- .../endpoints/staff_endpoints.dart | 4 + .../packages/domain/lib/krow_domain.dart | 1 + .../coverage_domain/assigned_worker.dart | 7 ++ .../coverage_domain/shift_with_workers.dart | 14 ++++ .../ratings/staff_reliability_stats.dart | 84 +++++++++++++++++++ .../widgets/coverage_shift_list.dart | 8 +- .../presentation/widgets/shift_header.dart | 27 ++++++ .../bloc/clock_in/clock_in_bloc.dart | 18 ++-- .../repositories/profile_repository_impl.dart | 9 ++ .../profile_repository_interface.dart | 5 +- .../get_reliability_stats_usecase.dart | 18 ++++ .../src/presentation/blocs/profile_cubit.dart | 19 ++++- .../src/presentation/blocs/profile_state.dart | 46 +++++----- .../pages/staff_profile_page.dart | 17 ++-- .../profile/lib/src/staff_profile_module.dart | 7 ++ 15 files changed, 247 insertions(+), 37 deletions(-) create mode 100644 apps/mobile/packages/domain/lib/src/entities/ratings/staff_reliability_stats.dart create mode 100644 apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_reliability_stats_usecase.dart diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart index 6955b964..d6fb3634 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart @@ -14,6 +14,10 @@ abstract final class StaffEndpoints { static const ApiEndpoint profileCompletion = ApiEndpoint('/staff/profile-completion'); + /// Staff reliability and performance statistics. + static const ApiEndpoint profileStats = + ApiEndpoint('/staff/profile/stats'); + /// Staff availability schedule. static const ApiEndpoint availability = ApiEndpoint('/staff/availability'); diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index 62f8dd73..c772ba45 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -99,6 +99,7 @@ export 'src/entities/profile/accessibility.dart'; // Ratings export 'src/entities/ratings/staff_rating.dart'; +export 'src/entities/ratings/staff_reliability_stats.dart'; // Home export 'src/entities/home/client_dashboard.dart'; diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart index 88ee6ebc..a16e8a41 100644 --- a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart +++ b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart @@ -13,6 +13,7 @@ class AssignedWorker extends Equatable { required this.fullName, required this.status, this.checkInAt, + this.hasReview = false, }); /// Deserialises an [AssignedWorker] from a V2 API JSON map. @@ -25,6 +26,7 @@ class AssignedWorker extends Equatable { checkInAt: json['checkInAt'] != null ? DateTime.parse(json['checkInAt'] as String) : null, + hasReview: json['hasReview'] as bool? ?? false, ); } @@ -43,6 +45,9 @@ class AssignedWorker extends Equatable { /// When the worker clocked in (null if not yet). final DateTime? checkInAt; + /// Whether this worker has already been reviewed for this assignment. + final bool hasReview; + /// Serialises this [AssignedWorker] to a JSON map. Map toJson() { return { @@ -51,6 +56,7 @@ class AssignedWorker extends Equatable { 'fullName': fullName, 'status': status.toJson(), 'checkInAt': checkInAt?.toIso8601String(), + 'hasReview': hasReview, }; } @@ -61,5 +67,6 @@ class AssignedWorker extends Equatable { fullName, status, checkInAt, + hasReview, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/shift_with_workers.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/shift_with_workers.dart index 476334f8..9a91f6b6 100644 --- a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/shift_with_workers.dart +++ b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/shift_with_workers.dart @@ -15,6 +15,8 @@ class ShiftWithWorkers extends Equatable { required this.requiredWorkerCount, required this.assignedWorkerCount, this.assignedWorkers = const [], + this.locationName = '', + this.locationAddress = '', }); /// Deserialises a [ShiftWithWorkers] from a V2 API JSON map. @@ -30,6 +32,8 @@ class ShiftWithWorkers extends Equatable { return ShiftWithWorkers( shiftId: json['shiftId'] as String, roleName: json['roleName'] as String? ?? '', + locationName: json['locationName'] as String? ?? '', + locationAddress: json['locationAddress'] as String? ?? '', timeRange: TimeRange.fromJson(json['timeRange'] as Map), requiredWorkerCount: (json['requiredWorkerCount'] as num).toInt(), assignedWorkerCount: (json['assignedWorkerCount'] as num).toInt(), @@ -55,11 +59,19 @@ class ShiftWithWorkers extends Equatable { /// List of assigned workers with their statuses. final List assignedWorkers; + /// Location or hub name for this shift. + final String locationName; + + /// Street address for this shift. + final String locationAddress; + /// Serialises this [ShiftWithWorkers] to a JSON map. Map toJson() { return { 'shiftId': shiftId, 'roleName': roleName, + 'locationName': locationName, + 'locationAddress': locationAddress, 'timeRange': timeRange.toJson(), 'requiredWorkerCount': requiredWorkerCount, 'assignedWorkerCount': assignedWorkerCount, @@ -76,5 +88,7 @@ class ShiftWithWorkers extends Equatable { requiredWorkerCount, assignedWorkerCount, assignedWorkers, + locationName, + locationAddress, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/ratings/staff_reliability_stats.dart b/apps/mobile/packages/domain/lib/src/entities/ratings/staff_reliability_stats.dart new file mode 100644 index 00000000..e0e22601 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/ratings/staff_reliability_stats.dart @@ -0,0 +1,84 @@ +import 'package:equatable/equatable.dart'; + +/// Aggregated reliability and performance statistics for a staff member. +/// +/// Returned by `GET /staff/profile/stats`. +class StaffReliabilityStats extends Equatable { + /// Creates a [StaffReliabilityStats] instance. + const StaffReliabilityStats({ + required this.staffId, + this.totalShifts = 0, + this.averageRating = 0, + this.ratingCount = 0, + this.onTimeRate = 0, + this.noShowCount = 0, + this.cancellationCount = 0, + this.reliabilityScore = 0, + }); + + /// Deserialises from a V2 API JSON map. + factory StaffReliabilityStats.fromJson(Map json) { + return StaffReliabilityStats( + staffId: json['staffId'] as String, + totalShifts: (json['totalShifts'] as num?)?.toInt() ?? 0, + averageRating: (json['averageRating'] as num?)?.toDouble() ?? 0, + ratingCount: (json['ratingCount'] as num?)?.toInt() ?? 0, + onTimeRate: (json['onTimeRate'] as num?)?.toDouble() ?? 0, + noShowCount: (json['noShowCount'] as num?)?.toInt() ?? 0, + cancellationCount: (json['cancellationCount'] as num?)?.toInt() ?? 0, + reliabilityScore: (json['reliabilityScore'] as num?)?.toDouble() ?? 0, + ); + } + + /// The staff member's unique identifier. + final String staffId; + + /// Total completed shifts. + final int totalShifts; + + /// Average rating from client reviews (0-5). + final double averageRating; + + /// Number of ratings received. + final int ratingCount; + + /// Percentage of shifts clocked in on time (0-100). + final double onTimeRate; + + /// Number of no-show incidents. + final int noShowCount; + + /// Number of worker-initiated cancellations. + final int cancellationCount; + + /// Composite reliability score (0-100). + /// + /// Weighted: 45% on-time rate + 35% completion rate + 20% rating score. + final double reliabilityScore; + + /// Serialises to a JSON map. + Map toJson() { + return { + 'staffId': staffId, + 'totalShifts': totalShifts, + 'averageRating': averageRating, + 'ratingCount': ratingCount, + 'onTimeRate': onTimeRate, + 'noShowCount': noShowCount, + 'cancellationCount': cancellationCount, + 'reliabilityScore': reliabilityScore, + }; + } + + @override + List get props => [ + staffId, + totalShifts, + averageRating, + ratingCount, + onTimeRate, + noShowCount, + cancellationCount, + reliabilityScore, + ]; +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart index 8e284dc1..1c305986 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart @@ -161,6 +161,7 @@ class _CoverageShiftListState extends State { children: [ ShiftHeader( title: shift.roleName, + locationName: shift.locationName, startTime: _formatTime(shift.timeRange.startsAt), current: shift.assignedWorkerCount, total: shift.requiredWorkerCount, @@ -226,9 +227,10 @@ class _CoverageShiftListState extends State { worker: worker, shiftStartTime: _formatTime(shift.timeRange.startsAt), showRateButton: - worker.status == AssignmentStatus.checkedIn || - worker.status == AssignmentStatus.checkedOut || - worker.status == AssignmentStatus.completed, + !worker.hasReview && + (worker.status == AssignmentStatus.checkedIn || + worker.status == AssignmentStatus.checkedOut || + worker.status == AssignmentStatus.completed), showCancelButton: DateTime.now().isAfter(shift.timeRange.startsAt) && (worker.status == AssignmentStatus.noShow || diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart index b0a81658..3027449b 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart @@ -21,6 +21,7 @@ class ShiftHeader extends StatelessWidget { required this.lateCount, required this.isExpanded, required this.onToggle, + this.locationName, super.key, }); @@ -57,6 +58,9 @@ class ShiftHeader extends StatelessWidget { /// Callback invoked when the header is tapped to expand or collapse. final VoidCallback onToggle; + /// Optional location or hub name for the shift. + final String? locationName; + /// Returns the status colour based on [coveragePercent]. /// /// Green for >= 100 %, yellow for >= 80 %, red otherwise. @@ -110,6 +114,29 @@ class ShiftHeader extends StatelessWidget { title, style: UiTypography.body1b.textPrimary, ), + if (locationName != null && + locationName!.isNotEmpty) ...[ + const SizedBox(height: 2), + Row( + children: [ + const Icon( + UiIcons.mapPin, + size: 10, + color: UiColors.textSecondary, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + locationName!, + style: UiTypography.body3r.copyWith( + color: UiColors.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], const SizedBox(height: UiConstants.space1), Row( children: [ diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart index 9c107915..f911f200 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart @@ -228,12 +228,20 @@ class ClockInBloc extends Bloc event: event, activeShiftId: newStatus.activeShiftId, ); - } on AppException catch (_) { - // The clock-in API call failed. Re-fetch attendance status to - // reconcile: if the worker is already clocked in (e.g. duplicate - // session from Postgres constraint 23505), treat it as success. + } on AppException catch (e) { + // The backend returns 409 ALREADY_CLOCKED_IN when the worker has + // an active attendance session. This is a normal idempotency + // signal — re-fetch the authoritative status and emit success + // without surfacing an error snackbar. + final bool isAlreadyClockedIn = + e is ApiException && e.apiCode == 'ALREADY_CLOCKED_IN'; + + // Re-fetch attendance status to reconcile local state with + // the backend (handles both ALREADY_CLOCKED_IN and legacy + // Postgres constraint 23505 duplicates). final AttendanceStatus currentStatus = await _getAttendanceStatus(); - if (currentStatus.isClockedIn) { + + if (isAlreadyClockedIn || currentStatus.isClockedIn) { emit(state.copyWith( status: ClockInStatus.success, attendance: currentStatus, diff --git a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart index f8d3020d..6d913283 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart @@ -31,6 +31,15 @@ class ProfileRepositoryImpl implements ProfileRepositoryInterface { return ProfileSectionStatus.fromJson(json); } + @override + Future getReliabilityStats() async { + final ApiResponse response = + await _api.get(StaffEndpoints.profileStats); + final Map json = + response.data as Map; + return StaffReliabilityStats.fromJson(json); + } + @override Future signOut() async { await _api.post(AuthEndpoints.signOut); diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository_interface.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository_interface.dart index b28b963d..55cd30de 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository_interface.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository_interface.dart @@ -3,7 +3,7 @@ import 'package:krow_domain/krow_domain.dart'; /// Abstract interface for the staff profile repository. /// /// Defines the contract for fetching staff profile data, -/// section completion statuses, and signing out. +/// section completion statuses, reliability stats, and signing out. abstract interface class ProfileRepositoryInterface { /// Fetches the staff profile from the backend. Future getStaffProfile(); @@ -11,6 +11,9 @@ abstract interface class ProfileRepositoryInterface { /// Fetches the profile section completion statuses. Future getProfileSections(); + /// Fetches reliability and performance statistics for the staff member. + Future getReliabilityStats(); + /// Signs out the current user. Future signOut(); } diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_reliability_stats_usecase.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_reliability_stats_usecase.dart new file mode 100644 index 00000000..5c77f6e4 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_reliability_stats_usecase.dart @@ -0,0 +1,18 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart'; + +/// Use case for retrieving the staff member's reliability statistics. +class GetReliabilityStatsUseCase + implements NoInputUseCase { + /// Creates a [GetReliabilityStatsUseCase] with the required [repository]. + GetReliabilityStatsUseCase(this._repository); + + final ProfileRepositoryInterface _repository; + + @override + Future call() { + return _repository.getReliabilityStats(); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart index 4b68e2ee..2b1b220e 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart @@ -3,6 +3,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_profile/src/domain/usecases/get_profile_sections_usecase.dart'; +import 'package:staff_profile/src/domain/usecases/get_reliability_stats_usecase.dart'; import 'package:staff_profile/src/domain/usecases/get_staff_profile_usecase.dart'; import 'package:staff_profile/src/domain/usecases/sign_out_usecase.dart'; import 'package:staff_profile/src/presentation/blocs/profile_state.dart'; @@ -10,21 +11,24 @@ import 'package:staff_profile/src/presentation/blocs/profile_state.dart'; /// Cubit for managing the Profile feature state. /// /// Delegates all data fetching to use cases, following Clean Architecture. -/// Loads the staff profile and section completion statuses in a single flow. +/// Loads the staff profile, section statuses, and reliability stats. class ProfileCubit extends Cubit with BlocErrorHandler { /// Creates a [ProfileCubit] with the required use cases. ProfileCubit({ required GetStaffProfileUseCase getStaffProfileUseCase, required GetProfileSectionsUseCase getProfileSectionsUseCase, + required GetReliabilityStatsUseCase getReliabilityStatsUseCase, required SignOutUseCase signOutUseCase, }) : _getStaffProfileUseCase = getStaffProfileUseCase, _getProfileSectionsUseCase = getProfileSectionsUseCase, + _getReliabilityStatsUseCase = getReliabilityStatsUseCase, _signOutUseCase = signOutUseCase, super(const ProfileState()); final GetStaffProfileUseCase _getStaffProfileUseCase; final GetProfileSectionsUseCase _getProfileSectionsUseCase; + final GetReliabilityStatsUseCase _getReliabilityStatsUseCase; final SignOutUseCase _signOutUseCase; /// Loads the staff member's profile. @@ -62,6 +66,19 @@ class ProfileCubit extends Cubit ); } + /// Loads reliability and performance statistics for the staff member. + Future loadReliabilityStats() async { + await handleError( + emit: emit, + action: () async { + final StaffReliabilityStats stats = + await _getReliabilityStatsUseCase(); + emit(state.copyWith(reliabilityStats: stats)); + }, + onError: (String _) => state, + ); + } + /// Signs out the current user. Future signOut() async { if (state.status == ProfileStatus.loading) { diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart index 5c0b3903..f6c76068 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart @@ -28,6 +28,7 @@ class ProfileState extends Equatable { const ProfileState({ this.status = ProfileStatus.initial, this.profile, + this.reliabilityStats, this.errorMessage, this.personalInfoComplete, this.emergencyContactsComplete, @@ -37,40 +38,45 @@ class ProfileState extends Equatable { this.documentsComplete, this.certificatesComplete, }); - /// Current status of the profile feature + + /// Current status of the profile feature. final ProfileStatus status; - - /// The staff member's profile object (null if not loaded) + + /// The staff member's profile object (null if not loaded). final Staff? profile; - - /// Error message if status is error + + /// Reliability and performance statistics (null if not loaded). + final StaffReliabilityStats? reliabilityStats; + + /// Error message if status is error. final String? errorMessage; - - /// Whether personal information is complete + + /// Whether personal information is complete. final bool? personalInfoComplete; - - /// Whether emergency contacts are complete + + /// Whether emergency contacts are complete. final bool? emergencyContactsComplete; - - /// Whether experience information is complete + + /// Whether experience information is complete. final bool? experienceComplete; - - /// Whether tax forms are complete + + /// Whether tax forms are complete. final bool? taxFormsComplete; - - /// Whether attire options are complete + + /// Whether attire options are complete. final bool? attireComplete; - - /// Whether documents are complete + + /// Whether documents are complete. final bool? documentsComplete; - - /// Whether certificates are complete + + /// Whether certificates are complete. final bool? certificatesComplete; /// Creates a copy of this state with updated values. ProfileState copyWith({ ProfileStatus? status, Staff? profile, + StaffReliabilityStats? reliabilityStats, String? errorMessage, bool? personalInfoComplete, bool? emergencyContactsComplete, @@ -83,6 +89,7 @@ class ProfileState extends Equatable { return ProfileState( status: status ?? this.status, profile: profile ?? this.profile, + reliabilityStats: reliabilityStats ?? this.reliabilityStats, errorMessage: errorMessage ?? this.errorMessage, personalInfoComplete: personalInfoComplete ?? this.personalInfoComplete, emergencyContactsComplete: emergencyContactsComplete ?? this.emergencyContactsComplete, @@ -98,6 +105,7 @@ class ProfileState extends Equatable { List get props => [ status, profile, + reliabilityStats, errorMessage, personalInfoComplete, emergencyContactsComplete, diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart index 5dc8ef39..97c69e9b 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart @@ -37,10 +37,11 @@ class StaffProfilePage extends StatelessWidget { value: cubit, child: BlocConsumer( listener: (BuildContext context, ProfileState state) { - // Load section statuses when profile loads successfully + // Load section statuses and reliability stats when profile loads if (state.status == ProfileStatus.loaded && state.personalInfoComplete == null) { cubit.loadSectionStatuses(); + cubit.loadReliabilityStats(); } if (state.status == ProfileStatus.signedOut) { @@ -100,16 +101,16 @@ class StaffProfilePage extends StatelessWidget { children: [ // Reliability Stats ReliabilityStatsCard( - totalShifts: 0, - averageRating: profile.averageRating, - onTimeRate: 0, - noShowCount: 0, - cancellationCount: 0, + totalShifts: state.reliabilityStats?.totalShifts, + averageRating: state.reliabilityStats?.averageRating, + onTimeRate: state.reliabilityStats?.onTimeRate.round(), + noShowCount: state.reliabilityStats?.noShowCount, + cancellationCount: state.reliabilityStats?.cancellationCount, ), // Reliability Score Bar - const ReliabilityScoreBar( - reliabilityScore: 0, + ReliabilityScoreBar( + reliabilityScore: state.reliabilityStats?.reliabilityScore.round(), ), // Ordered sections diff --git a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart index e9854ab8..c118900c 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart @@ -6,6 +6,7 @@ import 'package:krow_domain/krow_domain.dart'; import 'package:staff_profile/src/data/repositories/profile_repository_impl.dart'; import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart'; import 'package:staff_profile/src/domain/usecases/get_profile_sections_usecase.dart'; +import 'package:staff_profile/src/domain/usecases/get_reliability_stats_usecase.dart'; import 'package:staff_profile/src/domain/usecases/get_staff_profile_usecase.dart'; import 'package:staff_profile/src/domain/usecases/sign_out_usecase.dart'; import 'package:staff_profile/src/presentation/blocs/profile_cubit.dart'; @@ -44,12 +45,18 @@ class StaffProfileModule extends Module { i.get(), ), ); + i.addLazySingleton( + () => GetReliabilityStatsUseCase( + i.get(), + ), + ); // Cubit i.addLazySingleton( () => ProfileCubit( getStaffProfileUseCase: i.get(), getProfileSectionsUseCase: i.get(), + getReliabilityStatsUseCase: i.get(), signOutUseCase: i.get(), ), ); From 1d5c0e3b807c7a5c890d9b5553ecbd5b2bc3d53d Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:07:25 +0100 Subject: [PATCH 03/29] feat(api): add staff order booking contract and shift timeline alias --- .../core/lib/src/config/app_config.dart | 2 +- .../src/contracts/commands/mobile.js | 5 + backend/command-api/src/routes/mobile.js | 11 + .../src/services/mobile-command-service.js | 293 ++++++++++++++++++ .../command-api/test/mobile-routes.test.js | 25 ++ backend/query-api/src/routes/mobile.js | 22 ++ .../src/services/mobile-query-service.js | 221 +++++++++++++ backend/query-api/test/mobile-routes.test.js | 34 ++ .../scripts/live-smoke-v2-unified.mjs | 40 +++ docs/BACKEND/API_GUIDES/V2/README.md | 6 +- docs/BACKEND/API_GUIDES/V2/authentication.md | 2 +- .../API_GUIDES/V2/mobile-api-gap-analysis.md | 2 +- .../API_GUIDES/V2/mobile-coding-agent-spec.md | 17 +- .../V2/mobile-frontend-implementation-spec.md | 18 +- docs/BACKEND/API_GUIDES/V2/staff-shifts.md | 66 +++- docs/BACKEND/API_GUIDES/V2/unified-api.md | 21 +- 16 files changed, 766 insertions(+), 19 deletions(-) diff --git a/apps/mobile/packages/core/lib/src/config/app_config.dart b/apps/mobile/packages/core/lib/src/config/app_config.dart index 1dab4a2b..55dc47fb 100644 --- a/apps/mobile/packages/core/lib/src/config/app_config.dart +++ b/apps/mobile/packages/core/lib/src/config/app_config.dart @@ -17,6 +17,6 @@ class AppConfig { /// The base URL for the V2 Unified API gateway. static const String v2ApiBaseUrl = String.fromEnvironment( 'V2_API_BASE_URL', - defaultValue: 'https://krow-api-v2-933560802882.us-central1.run.app', + defaultValue: 'https://krow-api-v2-e3g6witsvq-uc.a.run.app', ); } diff --git a/backend/command-api/src/contracts/commands/mobile.js b/backend/command-api/src/contracts/commands/mobile.js index f4f0d567..181850ae 100644 --- a/backend/command-api/src/contracts/commands/mobile.js +++ b/backend/command-api/src/contracts/commands/mobile.js @@ -202,6 +202,11 @@ export const shiftApplySchema = z.object({ instantBook: z.boolean().optional(), }); +export const orderBookSchema = z.object({ + orderId: z.string().uuid(), + roleId: z.string().uuid(), +}); + export const shiftDecisionSchema = z.object({ shiftId: z.string().uuid(), reason: z.string().max(1000).optional(), diff --git a/backend/command-api/src/routes/mobile.js b/backend/command-api/src/routes/mobile.js index 99bb8c8b..309ff014 100644 --- a/backend/command-api/src/routes/mobile.js +++ b/backend/command-api/src/routes/mobile.js @@ -7,6 +7,7 @@ import { addStaffBankAccount, approveInvoice, applyForShift, + bookOrder, assignHubManager, assignHubNfc, cancelLateWorker, @@ -76,6 +77,7 @@ import { profileExperienceSchema, pushTokenDeleteSchema, pushTokenRegisterSchema, + orderBookSchema, shiftManagerCreateSchema, shiftApplySchema, shiftDecisionSchema, @@ -95,6 +97,7 @@ const defaultHandlers = { addStaffBankAccount, approveInvoice, applyForShift, + bookOrder, assignHubManager, assignHubNfc, cancelLateWorker, @@ -438,6 +441,14 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) { paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }), })); + router.post(...mobileCommand('/staff/orders/:orderId/book', { + schema: orderBookSchema, + policyAction: 'staff.orders.book', + resource: 'order', + handler: handlers.bookOrder, + paramShape: (req) => ({ ...req.body, orderId: req.params.orderId }), + })); + router.post(...mobileCommand('/staff/shifts/:shiftId/accept', { schema: shiftDecisionSchema, policyAction: 'staff.shifts.accept', diff --git a/backend/command-api/src/services/mobile-command-service.js b/backend/command-api/src/services/mobile-command-service.js index 377ab9af..def1d189 100644 --- a/backend/command-api/src/services/mobile-command-service.js +++ b/backend/command-api/src/services/mobile-command-service.js @@ -2883,6 +2883,299 @@ export async function applyForShift(actor, payload) { }); } +export async function bookOrder(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + + if (!staff.workforce_id) { + throw new AppError('UNPROCESSABLE_ENTITY', 'Staff must have an active workforce profile before booking an order', 422, { + orderId: payload.orderId, + staffId: staff.id, + }); + } + + const roleLookup = await client.query( + ` + SELECT id, code, name + FROM roles_catalog + WHERE tenant_id = $1 + AND id = $2 + AND status = 'ACTIVE' + LIMIT 1 + `, + [context.tenant.tenantId, payload.roleId] + ); + if (roleLookup.rowCount === 0) { + throw new AppError('VALIDATION_ERROR', 'roleId must reference an active role in the tenant catalog', 400, { + roleId: payload.roleId, + }); + } + const selectedRole = roleLookup.rows[0]; + + const orderLookup = await client.query( + ` + SELECT id, business_id, metadata + FROM orders + WHERE tenant_id = $1 + AND id = $2 + LIMIT 1 + FOR UPDATE + `, + [context.tenant.tenantId, payload.orderId] + ); + if (orderLookup.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Order not found', 404, { + orderId: payload.orderId, + }); + } + + const existingOrderParticipation = await client.query( + ` + SELECT + s.id AS shift_id, + sr.id AS shift_role_id, + a.id AS assignment_id, + app.id AS application_id + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + LEFT JOIN assignments a + ON a.shift_role_id = sr.id + AND a.staff_id = $3 + AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + LEFT JOIN applications app + ON app.shift_role_id = sr.id + AND app.staff_id = $3 + AND app.status IN ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'COMPLETED') + WHERE s.tenant_id = $1 + AND s.order_id = $2 + AND s.starts_at > NOW() + AND (a.id IS NOT NULL OR app.id IS NOT NULL) + LIMIT 1 + `, + [context.tenant.tenantId, payload.orderId, staff.id] + ); + if (existingOrderParticipation.rowCount > 0) { + throw new AppError('CONFLICT', 'Staff already has participation on this order', 409, { + orderId: payload.orderId, + shiftId: existingOrderParticipation.rows[0].shift_id, + shiftRoleId: existingOrderParticipation.rows[0].shift_role_id, + }); + } + + const candidateRoles = await client.query( + ` + SELECT + s.id AS shift_id, + s.order_id, + s.business_id, + s.vendor_id, + s.clock_point_id, + s.status AS shift_status, + s.starts_at, + s.ends_at, + COALESCE(s.timezone, 'UTC') AS timezone, + to_char(s.starts_at AT TIME ZONE COALESCE(s.timezone, 'UTC'), 'YYYY-MM-DD') AS local_date, + to_char(s.starts_at AT TIME ZONE COALESCE(s.timezone, 'UTC'), 'HH24:MI') AS local_start_time, + to_char(s.ends_at AT TIME ZONE COALESCE(s.timezone, 'UTC'), 'HH24:MI') AS local_end_time, + sr.id AS shift_role_id, + COALESCE(sr.role_id, rc.id) AS catalog_role_id, + COALESCE(sr.role_code, rc.code) AS role_code, + COALESCE(sr.role_name, rc.name) AS role_name, + sr.workers_needed, + sr.assigned_count, + COALESCE((sr.metadata->>'instantBook')::boolean, FALSE) AS instant_book + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + LEFT JOIN roles_catalog rc + ON rc.tenant_id = s.tenant_id + AND (rc.id = sr.role_id OR (sr.role_id IS NULL AND rc.code = sr.role_code)) + WHERE s.tenant_id = $1 + AND s.order_id = $2 + AND s.starts_at > NOW() + AND COALESCE(sr.role_id, rc.id) = $3 + ORDER BY s.starts_at ASC, sr.created_at ASC + FOR UPDATE OF s, sr + `, + [context.tenant.tenantId, payload.orderId, payload.roleId] + ); + + if (candidateRoles.rowCount === 0) { + throw new AppError('UNPROCESSABLE_ENTITY', 'Order has no future shifts available for this role', 422, { + orderId: payload.orderId, + roleId: payload.roleId, + }); + } + + const blockedOrUnavailable = candidateRoles.rows.find((row) => row.shift_status !== 'OPEN' || row.assigned_count >= row.workers_needed); + if (blockedOrUnavailable) { + throw new AppError('UNPROCESSABLE_ENTITY', 'Order is no longer fully bookable', 422, { + orderId: payload.orderId, + roleId: payload.roleId, + shiftId: blockedOrUnavailable.shift_id, + shiftRoleId: blockedOrUnavailable.shift_role_id, + }); + } + + await ensureStaffNotBlockedByBusiness(client, { + tenantId: context.tenant.tenantId, + businessId: candidateRoles.rows[0].business_id, + staffId: staff.id, + }); + + const bookingId = crypto.randomUUID(); + const assignedShifts = []; + + for (const row of candidateRoles.rows) { + const dispatchMembership = await loadDispatchMembership(client, { + tenantId: context.tenant.tenantId, + businessId: row.business_id, + hubId: row.clock_point_id, + staffId: staff.id, + }); + const instantBook = Boolean(row.instant_book); + + const applicationResult = await client.query( + ` + INSERT INTO applications ( + tenant_id, + shift_id, + shift_role_id, + staff_id, + status, + origin, + metadata + ) + VALUES ($1, $2, $3, $4, $5, 'STAFF', $6::jsonb) + ON CONFLICT (shift_role_id, staff_id) DO NOTHING + RETURNING id, status + `, + [ + context.tenant.tenantId, + row.shift_id, + row.shift_role_id, + staff.id, + instantBook ? 'CONFIRMED' : 'PENDING', + JSON.stringify({ + bookingId, + bookedBy: actor.uid, + source: 'staff-order-booking', + orderId: payload.orderId, + catalogRoleId: payload.roleId, + roleCode: selectedRole.code, + dispatchTeamType: dispatchMembership.teamType, + dispatchPriority: dispatchMembership.priority, + dispatchTeamMembershipId: dispatchMembership.membershipId, + dispatchTeamScopeHubId: dispatchMembership.scopedHubId, + }), + ] + ); + if (applicationResult.rowCount === 0) { + throw new AppError('CONFLICT', 'Order booking conflicted with an existing application', 409, { + orderId: payload.orderId, + shiftId: row.shift_id, + shiftRoleId: row.shift_role_id, + }); + } + + const assignmentResult = await client.query( + ` + INSERT INTO assignments ( + tenant_id, + business_id, + vendor_id, + shift_id, + shift_role_id, + workforce_id, + staff_id, + application_id, + status, + assigned_at, + accepted_at, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), CASE WHEN $10::boolean THEN NOW() ELSE NULL END, $11::jsonb) + ON CONFLICT (shift_role_id, workforce_id) DO NOTHING + RETURNING id, status + `, + [ + context.tenant.tenantId, + row.business_id, + row.vendor_id, + row.shift_id, + row.shift_role_id, + staff.workforce_id, + staff.id, + applicationResult.rows[0].id, + instantBook ? 'ACCEPTED' : 'ASSIGNED', + instantBook, + JSON.stringify({ + bookingId, + bookedBy: actor.uid, + source: 'staff-order-booking', + orderId: payload.orderId, + catalogRoleId: payload.roleId, + roleCode: selectedRole.code, + pendingApproval: !instantBook, + dispatchTeamType: dispatchMembership.teamType, + dispatchPriority: dispatchMembership.priority, + dispatchTeamMembershipId: dispatchMembership.membershipId, + dispatchTeamScopeHubId: dispatchMembership.scopedHubId, + }), + ] + ); + if (assignmentResult.rowCount === 0) { + throw new AppError('CONFLICT', 'Order booking conflicted with an existing assignment', 409, { + orderId: payload.orderId, + shiftId: row.shift_id, + shiftRoleId: row.shift_role_id, + }); + } + + await refreshShiftRoleCounts(client, row.shift_role_id); + await refreshShiftCounts(client, row.shift_id); + + assignedShifts.push({ + shiftId: row.shift_id, + date: row.local_date, + startsAt: row.starts_at, + endsAt: row.ends_at, + startTime: row.local_start_time, + endTime: row.local_end_time, + timezone: row.timezone, + assignmentId: assignmentResult.rows[0].id, + assignmentStatus: assignmentResult.rows[0].status, + }); + } + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'order', + aggregateId: payload.orderId, + eventType: candidateRoles.rows.every((row) => row.instant_book) ? 'STAFF_ORDER_BOOKED_CONFIRMED' : 'STAFF_ORDER_BOOKED_PENDING', + actorUserId: actor.uid, + payload: { + bookingId, + roleId: payload.roleId, + roleCode: selectedRole.code, + assignedShiftCount: assignedShifts.length, + }, + }); + + return { + bookingId, + orderId: payload.orderId, + roleId: payload.roleId, + roleCode: selectedRole.code, + roleName: selectedRole.name, + assignedShiftCount: assignedShifts.length, + status: candidateRoles.rows.every((row) => row.instant_book) ? 'CONFIRMED' : 'PENDING', + assignedShifts, + }; + }); +} + export async function acceptPendingShift(actor, payload) { const context = await requireStaffContext(actor.uid); return withTransaction(async (client) => { diff --git a/backend/command-api/test/mobile-routes.test.js b/backend/command-api/test/mobile-routes.test.js index 276c9a11..4328dfff 100644 --- a/backend/command-api/test/mobile-routes.test.js +++ b/backend/command-api/test/mobile-routes.test.js @@ -65,6 +65,14 @@ function createMobileHandlers() { invoiceId: payload.invoiceId, status: 'APPROVED', }), + bookOrder: async (_actor, payload) => ({ + bookingId: 'booking-1', + orderId: payload.orderId, + roleId: payload.roleId, + assignedShiftCount: 3, + status: 'PENDING', + assignedShifts: [], + }), registerClientPushToken: async (_actor, payload) => ({ tokenId: 'push-token-client-1', platform: payload.platform, @@ -410,6 +418,23 @@ test('POST /commands/staff/shifts/:shiftId/submit-for-approval injects shift id assert.equal(res.body.submitted, true); }); +test('POST /commands/staff/orders/:orderId/book injects order id from params', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/staff/orders/88888888-8888-4888-8888-888888888888/book') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'staff-order-book-1') + .send({ + roleId: '99999999-9999-4999-8999-999999999999', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.orderId, '88888888-8888-4888-8888-888888888888'); + assert.equal(res.body.roleId, '99999999-9999-4999-8999-999999999999'); + assert.equal(res.body.assignedShiftCount, 3); + assert.equal(res.body.status, 'PENDING'); +}); + test('POST /commands/client/coverage/swap-requests/:swapRequestId/resolve injects swap request id from params', async () => { const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); const res = await request(app) diff --git a/backend/query-api/src/routes/mobile.js b/backend/query-api/src/routes/mobile.js index 947c2bc1..31bbd090 100644 --- a/backend/query-api/src/routes/mobile.js +++ b/backend/query-api/src/routes/mobile.js @@ -44,6 +44,7 @@ import { listHubs, listIndustries, listInvoiceHistory, + listAvailableOrders, listOpenShifts, listTaxForms, listTimeCardEntries, @@ -113,6 +114,7 @@ const defaultQueryService = { listHubs, listIndustries, listInvoiceHistory, + listAvailableOrders, listOpenShifts, listTaxForms, listTimeCardEntries, @@ -355,9 +357,20 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/client/shifts/scheduled', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => { + try { + const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + router.get('/client/orders/view', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => { try { const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query); + res.set('Deprecation', 'true'); + res.set('Link', '; rel="successor-version"'); return res.status(200).json({ items, requestId: req.requestId }); } catch (error) { return next(error); @@ -544,6 +557,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/staff/orders/available', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => { + try { + const items = await queryService.listAvailableOrders(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + router.get('/staff/shifts/pending', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => { try { const items = await queryService.listPendingAssignments(req.actor.uid); diff --git a/backend/query-api/src/services/mobile-query-service.js b/backend/query-api/src/services/mobile-query-service.js index e8942dff..9ed9ae15 100644 --- a/backend/query-api/src/services/mobile-query-service.js +++ b/backend/query-api/src/services/mobile-query-service.js @@ -60,6 +60,44 @@ function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } +function resolveTimeZone(value) { + try { + return new Intl.DateTimeFormat('en-US', { timeZone: value || 'UTC' }).resolvedOptions().timeZone; + } catch { + return 'UTC'; + } +} + +function formatDateInTimeZone(value, timeZone = 'UTC') { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: resolveTimeZone(timeZone), + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date(value)); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${map.year}-${map.month}-${map.day}`; +} + +function formatTimeInTimeZone(value, timeZone = 'UTC') { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: resolveTimeZone(timeZone), + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(new Date(value)); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${map.hour}:${map.minute}`; +} + +function weekdayCodeInTimeZone(value, timeZone = 'UTC') { + const label = new Intl.DateTimeFormat('en-US', { + timeZone: resolveTimeZone(timeZone), + weekday: 'short', + }).format(new Date(value)); + return label.slice(0, 3).toUpperCase(); +} + function computeReliabilityScore({ totalShifts, noShowCount, @@ -1011,6 +1049,189 @@ export async function listAssignedShifts(actorUid, { startDate, endDate }) { return result.rows; } +export async function listAvailableOrders(actorUid, { limit, search } = {}) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + o.id AS "orderId", + COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", + COALESCE(sr.role_id, rc.id) AS "roleId", + COALESCE(sr.role_code, rc.code) AS "roleCode", + COALESCE(sr.role_name, rc.name) AS "roleName", + b.business_name AS "clientName", + COALESCE(cp.label, s.location_name) AS location, + COALESCE(s.location_address, cp.address) AS "locationAddress", + COALESCE(s.timezone, 'UTC') AS timezone, + s.id AS "shiftId", + s.status AS "shiftStatus", + s.starts_at AS "startsAt", + s.ends_at AS "endsAt", + sr.workers_needed AS "requiredWorkerCount", + sr.assigned_count AS "filledCount", + COALESCE(sr.pay_rate_cents, 0)::INTEGER AS "hourlyRateCents", + COALESCE((sr.metadata->>'instantBook')::boolean, FALSE) AS "instantBook", + COALESCE(dispatch.team_type, 'MARKETPLACE') AS "dispatchTeam", + COALESCE(dispatch.priority, 3) AS "dispatchPriority" + FROM orders o + JOIN shifts s ON s.order_id = o.id + JOIN shift_roles sr ON sr.shift_id = s.id + LEFT JOIN roles_catalog rc + ON rc.tenant_id = o.tenant_id + AND (rc.id = sr.role_id OR (sr.role_id IS NULL AND rc.code = sr.role_code)) + JOIN businesses b ON b.id = o.business_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + LEFT JOIN LATERAL ( + SELECT + dtm.team_type, + CASE dtm.team_type + WHEN 'CORE' THEN 1 + WHEN 'CERTIFIED_LOCATION' THEN 2 + ELSE 3 + END AS priority + FROM dispatch_team_memberships dtm + WHERE dtm.tenant_id = $1 + AND dtm.business_id = s.business_id + AND dtm.staff_id = $3 + AND dtm.status = 'ACTIVE' + AND dtm.effective_at <= NOW() + AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW()) + AND (dtm.hub_id IS NULL OR dtm.hub_id = s.clock_point_id) + ORDER BY + CASE dtm.team_type + WHEN 'CORE' THEN 1 + WHEN 'CERTIFIED_LOCATION' THEN 2 + ELSE 3 + END ASC, + CASE WHEN dtm.hub_id = s.clock_point_id THEN 0 ELSE 1 END ASC, + dtm.created_at ASC + LIMIT 1 + ) dispatch ON TRUE + WHERE o.tenant_id = $1 + AND s.starts_at > NOW() + AND COALESCE(sr.role_code, rc.code) = $4 + AND ($2::text IS NULL OR COALESCE(sr.role_name, rc.name) ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%' OR b.business_name ILIKE '%' || $2 || '%') + AND NOT EXISTS ( + SELECT 1 + FROM staff_blocks sb + WHERE sb.tenant_id = o.tenant_id + AND sb.business_id = o.business_id + AND sb.staff_id = $3 + ) + AND NOT EXISTS ( + SELECT 1 + FROM applications a + JOIN shifts sx ON sx.id = a.shift_id + WHERE sx.order_id = o.id + AND a.staff_id = $3 + AND a.status IN ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'COMPLETED') + ) + AND NOT EXISTS ( + SELECT 1 + FROM assignments a + JOIN shifts sx ON sx.id = a.shift_id + WHERE sx.order_id = o.id + AND a.staff_id = $3 + AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + ) + ORDER BY COALESCE(dispatch.priority, 3) ASC, s.starts_at ASC + LIMIT $5 + `, + [ + context.tenant.tenantId, + search || null, + context.staff.staffId, + context.staff.primaryRole || 'BARISTA', + parseLimit(limit, 50, 250), + ] + ); + + const grouped = new Map(); + for (const row of result.rows) { + const key = `${row.orderId}:${row.roleId}`; + const existing = grouped.get(key) || { + orderId: row.orderId, + orderType: row.orderType, + roleId: row.roleId, + roleCode: row.roleCode, + roleName: row.roleName, + clientName: row.clientName, + location: row.location, + locationAddress: row.locationAddress, + hourlyRateCents: row.hourlyRateCents, + hourlyRate: Number((row.hourlyRateCents / 100).toFixed(2)), + requiredWorkerCount: row.requiredWorkerCount, + filledCount: row.filledCount, + instantBook: Boolean(row.instantBook), + dispatchTeam: row.dispatchTeam, + dispatchPriority: row.dispatchPriority, + timezone: resolveTimeZone(row.timezone), + shifts: [], + }; + existing.requiredWorkerCount = Math.max(existing.requiredWorkerCount, row.requiredWorkerCount); + existing.filledCount = Math.max(existing.filledCount, row.filledCount); + existing.instantBook = existing.instantBook && Boolean(row.instantBook); + existing.dispatchPriority = Math.min(existing.dispatchPriority, row.dispatchPriority); + existing.dispatchTeam = existing.dispatchPriority === 1 + ? 'CORE' + : existing.dispatchPriority === 2 + ? 'CERTIFIED_LOCATION' + : 'MARKETPLACE'; + existing.shifts.push({ + shiftId: row.shiftId, + shiftStatus: row.shiftStatus, + startsAt: row.startsAt, + endsAt: row.endsAt, + }); + grouped.set(key, existing); + } + + return Array.from(grouped.values()) + .filter((item) => item.shifts.length > 0) + .filter((item) => item.shifts.every((shift) => shift.shiftStatus === 'OPEN')) + .filter((item) => item.filledCount < item.requiredWorkerCount) + .sort((left, right) => { + if (left.dispatchPriority !== right.dispatchPriority) { + return left.dispatchPriority - right.dispatchPriority; + } + return new Date(left.shifts[0].startsAt).getTime() - new Date(right.shifts[0].startsAt).getTime(); + }) + .slice(0, parseLimit(limit, 20, 100)) + .map((item) => { + const firstShift = item.shifts[0]; + const lastShift = item.shifts[item.shifts.length - 1]; + const daysOfWeek = [...new Set(item.shifts.map((shift) => weekdayCodeInTimeZone(shift.startsAt, item.timezone)))]; + return { + orderId: item.orderId, + orderType: item.orderType, + roleId: item.roleId, + roleCode: item.roleCode, + roleName: item.roleName, + clientName: item.clientName, + location: item.location, + locationAddress: item.locationAddress, + hourlyRateCents: item.hourlyRateCents, + hourlyRate: item.hourlyRate, + requiredWorkerCount: item.requiredWorkerCount, + filledCount: item.filledCount, + instantBook: item.instantBook, + dispatchTeam: item.dispatchTeam, + dispatchPriority: item.dispatchPriority, + schedule: { + totalShifts: item.shifts.length, + startDate: formatDateInTimeZone(firstShift.startsAt, item.timezone), + endDate: formatDateInTimeZone(lastShift.startsAt, item.timezone), + daysOfWeek, + startTime: formatTimeInTimeZone(firstShift.startsAt, item.timezone), + endTime: formatTimeInTimeZone(firstShift.endsAt, item.timezone), + timezone: item.timezone, + firstShiftStartsAt: firstShift.startsAt, + lastShiftEndsAt: lastShift.endsAt, + }, + }; + }); +} + export async function listOpenShifts(actorUid, { limit, search } = {}) { const context = await requireStaffContext(actorUid); const result = await query( diff --git a/backend/query-api/test/mobile-routes.test.js b/backend/query-api/test/mobile-routes.test.js index f810029d..428163de 100644 --- a/backend/query-api/test/mobile-routes.test.js +++ b/backend/query-api/test/mobile-routes.test.js @@ -48,6 +48,7 @@ function createMobileQueryService() { listHubs: async () => ([{ hubId: 'hub-1' }]), listIndustries: async () => (['CATERING']), listInvoiceHistory: async () => ([{ invoiceId: 'inv-1' }]), + listAvailableOrders: async () => ([{ orderId: 'order-available-1', roleId: 'role-catalog-1' }]), listOpenShifts: async () => ([{ shiftId: 'open-1' }]), getOrderReorderPreview: async () => ({ orderId: 'order-1', lines: 2 }), listOrderItemsByDateRange: async () => ([{ itemId: 'item-1' }]), @@ -123,6 +124,39 @@ test('GET /query/staff/shifts/:shiftId returns injected shift detail', async () assert.equal(res.body.shiftId, 'shift-1'); }); +test('GET /query/staff/orders/available returns injected order-level opportunities', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/staff/orders/available') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.items[0].orderId, 'order-available-1'); + assert.equal(res.body.items[0].roleId, 'role-catalog-1'); +}); + +test('GET /query/client/shifts/scheduled returns injected shift timeline items', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/client/shifts/scheduled?startDate=2026-03-13T00:00:00.000Z&endDate=2026-03-20T00:00:00.000Z') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.items[0].itemId, 'item-1'); +}); + +test('GET /query/client/orders/view remains as deprecated compatibility alias', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/client/orders/view?startDate=2026-03-13T00:00:00.000Z&endDate=2026-03-20T00:00:00.000Z') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.headers.deprecation, 'true'); + assert.equal(res.headers.link, '; rel="successor-version"'); + assert.equal(res.body.items[0].itemId, 'item-1'); +}); + test('GET /query/client/reports/summary returns injected report summary', async () => { const app = createApp({ mobileQueryService: createMobileQueryService() }); const res = await request(app) diff --git a/backend/unified-api/scripts/live-smoke-v2-unified.mjs b/backend/unified-api/scripts/live-smoke-v2-unified.mjs index d3080cb4..f67e2503 100644 --- a/backend/unified-api/scripts/live-smoke-v2-unified.mjs +++ b/backend/unified-api/scripts/live-smoke-v2-unified.mjs @@ -449,6 +449,16 @@ async function main() { } logStep('client.orders.view.ok', { count: viewedOrders.items.length }); + const scheduledShifts = await apiCall(`/client/shifts/scheduled?${reportWindow}`, { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(scheduledShifts.items)); + assert.equal(scheduledShifts.items.length, viewedOrders.items.length); + if (viewedOrders.items[0] && scheduledShifts.items[0]) { + assert.equal(scheduledShifts.items[0].itemId, viewedOrders.items[0].itemId); + } + logStep('client.shifts.scheduled.ok', { count: scheduledShifts.items.length }); + const reorderPreview = await apiCall(`/client/orders/${fixture.orders.completed.id}/reorder-preview`, { token: ownerSession.sessionToken, }); @@ -814,6 +824,33 @@ async function main() { assert.ok(Array.isArray(assignedShifts.items)); logStep('staff.shifts.assigned.ok', { count: assignedShifts.items.length }); + const availableOrders = await apiCall('/staff/orders/available?limit=20', { + token: staffAuth.idToken, + }); + const availableOrder = availableOrders.items.find((item) => item.orderId === createdRecurringOrder.orderId) + || availableOrders.items[0]; + assert.ok(availableOrder); + assert.ok(availableOrder.roleId); + logStep('staff.orders.available.ok', { count: availableOrders.items.length, orderId: availableOrder.orderId }); + + const bookedOrder = await apiCall(`/staff/orders/${availableOrder.orderId}/book`, { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-order-book'), + body: { + roleId: availableOrder.roleId, + }, + }); + assert.equal(bookedOrder.orderId, availableOrder.orderId); + assert.ok(bookedOrder.assignedShiftCount >= 1); + assert.equal(bookedOrder.status, 'PENDING'); + assert.ok(Array.isArray(bookedOrder.assignedShifts)); + logStep('staff.orders.book.ok', { + orderId: bookedOrder.orderId, + assignedShiftCount: bookedOrder.assignedShiftCount, + status: bookedOrder.status, + }); + const openShifts = await apiCall('/staff/shifts/open', { token: staffAuth.idToken, }); @@ -827,6 +864,9 @@ async function main() { const pendingShifts = await apiCall('/staff/shifts/pending', { token: staffAuth.idToken, }); + assert.ok( + bookedOrder.assignedShifts.some((shift) => pendingShifts.items.some((item) => item.shiftId === shift.shiftId)) + ); const pendingShift = pendingShifts.items.find((item) => item.shiftId === fixture.shifts.available.id) || pendingShifts.items[0]; assert.ok(pendingShift); diff --git a/docs/BACKEND/API_GUIDES/V2/README.md b/docs/BACKEND/API_GUIDES/V2/README.md index 1b812994..4d8eb6d6 100644 --- a/docs/BACKEND/API_GUIDES/V2/README.md +++ b/docs/BACKEND/API_GUIDES/V2/README.md @@ -7,7 +7,7 @@ This is the frontend-facing source of truth for the v2 backend. Frontend should call one public gateway: ```env -API_V2_BASE_URL=https://krow-api-v2-933560802882.us-central1.run.app +API_V2_BASE_URL=https://krow-api-v2-e3g6witsvq-uc.a.run.app ``` Frontend should not call the internal `core`, `command`, or `query` Cloud Run services directly. @@ -95,14 +95,12 @@ Source-of-truth timestamp fields include: - `startsAt` - `endsAt` -- `startTime` -- `endTime` - `clockInAt` - `clockOutAt` - `createdAt` - `updatedAt` -Helper fields like `date` are UTC-derived helpers and should not replace the raw timestamp fields. +Helper fields like `date`, `startTime`, and `endTime` are display helpers and should not replace the raw timestamp fields. ## 4) Attendance policy and monitoring diff --git a/docs/BACKEND/API_GUIDES/V2/authentication.md b/docs/BACKEND/API_GUIDES/V2/authentication.md index c45cf5de..b644210f 100644 --- a/docs/BACKEND/API_GUIDES/V2/authentication.md +++ b/docs/BACKEND/API_GUIDES/V2/authentication.md @@ -5,7 +5,7 @@ This document is the source of truth for V2 authentication. Base URL: ```env -API_V2_BASE_URL=https://krow-api-v2-933560802882.us-central1.run.app +API_V2_BASE_URL=https://krow-api-v2-e3g6witsvq-uc.a.run.app ``` ## 1) What is implemented diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md b/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md index b594eb5b..977b8fa8 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md @@ -22,7 +22,7 @@ That includes: The live smoke executed successfully against: -- `https://krow-api-v2-933560802882.us-central1.run.app` +- `https://krow-api-v2-e3g6witsvq-uc.a.run.app` - Firebase demo users - `krow-sql-v2` - `krow-core-api-v2` diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md b/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md index cb3f91d5..a7a2f940 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md @@ -6,7 +6,7 @@ Use this as the primary implementation brief. Base URL: -- `https://krow-api-v2-933560802882.us-central1.run.app` +- `https://krow-api-v2-e3g6witsvq-uc.a.run.app` Supporting docs: @@ -23,6 +23,7 @@ Supporting docs: - Send `Idempotency-Key` on every write route. - Treat `order`, `shift`, `shiftRole`, and `assignment` as different objects. - For staff shift applications, `roleId` must come from the response of `GET /staff/shifts/open`. +- For staff order booking, `roleId` must come from the response of `GET /staff/orders/available`. - Treat API timestamp fields as UTC and convert them to local time in the app. ## 2) What is implemented now @@ -90,8 +91,10 @@ Do not assume staff auth is a fully backend-managed OTP flow. Rules: - `GET /staff/shifts/open` returns opportunities, not assignments +- `GET /staff/orders/available` returns grouped order opportunities for booking - `GET /staff/shifts/assigned` returns active assigned shifts -- `GET /client/orders/view` is the timeline/read model for client +- `GET /client/shifts/scheduled` is the canonical timeline/read model for client +- `GET /client/orders/view` is now a deprecated compatibility alias - `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` apply to future shifts only ## 5) Client app screen mapping @@ -165,7 +168,8 @@ Swap management flow: ### Orders -- `GET /client/orders/view` +- `GET /client/shifts/scheduled` +- `GET /client/orders/view` deprecated alias - `GET /client/orders/:orderId/reorder-preview` - `POST /client/orders/one-time` - `POST /client/orders/recurring` @@ -230,12 +234,17 @@ Important: ### Find shifts +- `GET /staff/orders/available` +- `POST /staff/orders/:orderId/book` - `GET /staff/shifts/open` - `POST /staff/shifts/:shiftId/apply` Rule: -- use `roleId` from the open-shifts response +- use `roleId` from the order-available response when booking an order +- that `roleId` is the role catalog id for the grouped order booking flow +- use `roleId` from the open-shifts response only for shift-level apply +- that `roleId` is the concrete `shift_roles.id` ### My shifts diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md b/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md index a72f198a..a4847d66 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md @@ -4,7 +4,7 @@ This is the shortest path for frontend to implement the v2 mobile clients agains Base URL: -- `https://krow-api-v2-933560802882.us-central1.run.app` +- `https://krow-api-v2-e3g6witsvq-uc.a.run.app` Use this doc together with: @@ -30,7 +30,10 @@ Important consequences: - `GET /staff/shifts/open` returns open shift-role opportunities. - `POST /staff/shifts/:shiftId/apply` must send the `roleId` from that response. -- `GET /client/orders/view` is the timeline/read model for the client app. +- `GET /staff/orders/available` returns grouped order opportunities for atomic booking. +- `POST /staff/orders/:orderId/book` must send the `roleId` from that response. +- `GET /client/shifts/scheduled` is the canonical timeline/read model for the client app. +- `GET /client/orders/view` is a deprecated compatibility alias. - `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` only affect future shifts. ## 3) Auth implementation @@ -122,7 +125,8 @@ Dispatch-priority rule: ### Orders -- `GET /client/orders/view` +- `GET /client/shifts/scheduled` +- `GET /client/orders/view` deprecated alias - `GET /client/orders/:orderId/reorder-preview` - `POST /client/orders/one-time` - `POST /client/orders/recurring` @@ -175,13 +179,17 @@ Rapid-order flow: ### Find shifts +- `GET /staff/orders/available` +- `POST /staff/orders/:orderId/book` - `GET /staff/shifts/open` - `POST /staff/shifts/:shiftId/apply` Rule: -- send the `roleId` from the open-shifts response -- this is the concrete `shift_roles.id` +- send the `roleId` from the order-available response when booking an order +- this `roleId` is the role catalog id for grouped order booking +- send the `roleId` from the open-shifts response only when applying to one shift +- that route still uses the concrete `shift_roles.id` ### My shifts diff --git a/docs/BACKEND/API_GUIDES/V2/staff-shifts.md b/docs/BACKEND/API_GUIDES/V2/staff-shifts.md index 802cdf75..d881449d 100644 --- a/docs/BACKEND/API_GUIDES/V2/staff-shifts.md +++ b/docs/BACKEND/API_GUIDES/V2/staff-shifts.md @@ -4,10 +4,11 @@ This document is the frontend handoff for the `staff/shifts/*` routes on the uni Base URL: -- `https://krow-api-v2-933560802882.us-central1.run.app` +- `https://krow-api-v2-e3g6witsvq-uc.a.run.app` ## Read routes +- `GET /staff/orders/available` - `GET /staff/shifts/assigned` - `GET /staff/shifts/open` - `GET /staff/shifts/pending` @@ -17,6 +18,7 @@ Base URL: ## Write routes +- `POST /staff/orders/:orderId/book` - `POST /staff/shifts/:shiftId/apply` - `POST /staff/shifts/:shiftId/accept` - `POST /staff/shifts/:shiftId/decline` @@ -30,6 +32,68 @@ All write routes require: ## Shift lifecycle +### Find work by order + +`GET /staff/orders/available` + +- use this for grouped recurring or permanent work cards +- each item represents one order plus one role +- this feed is already filtered to the current worker context +- `schedule` gives the preview for the whole booking window + +Example response: + +```json +{ + "orderId": "uuid", + "orderType": "RECURRING", + "roleId": "uuid", + "roleCode": "BARISTA", + "roleName": "Barista", + "clientName": "Google Mountain View Cafes", + "location": "Google MV Cafe Clock Point", + "locationAddress": "1600 Amphitheatre Pkwy, Mountain View, CA", + "hourlyRateCents": 2300, + "hourlyRate": 23, + "requiredWorkerCount": 1, + "filledCount": 0, + "instantBook": false, + "dispatchTeam": "CORE", + "dispatchPriority": 1, + "schedule": { + "totalShifts": 3, + "startDate": "2026-03-24", + "endDate": "2026-03-28", + "daysOfWeek": ["WED", "FRI"], + "startTime": "09:00", + "endTime": "15:00", + "timezone": "America/Los_Angeles", + "firstShiftStartsAt": "2026-03-25T16:00:00.000Z", + "lastShiftEndsAt": "2026-03-27T22:00:00.000Z" + } +} +``` + +`POST /staff/orders/:orderId/book` + +- use this when the worker books the full order instead of one shift +- booking is atomic across the future shifts in that order for the selected role +- backend returns `PENDING` when the booking is reserved but not instant-booked +- backend returns `CONFIRMED` when every future shift in that booking path is instant-booked + +Example request: + +```json +{ + "roleId": "uuid" +} +``` + +Important: + +- `roleId` for the order-booking flow is the role catalog id returned by `GET /staff/orders/available` +- it is not the same thing as the per-shift `shift_roles.id` + ### Find shifts `GET /staff/shifts/open` diff --git a/docs/BACKEND/API_GUIDES/V2/unified-api.md b/docs/BACKEND/API_GUIDES/V2/unified-api.md index 06153c0f..dcbf40c7 100644 --- a/docs/BACKEND/API_GUIDES/V2/unified-api.md +++ b/docs/BACKEND/API_GUIDES/V2/unified-api.md @@ -2,7 +2,7 @@ Frontend should use this service as the single base URL: -- `https://krow-api-v2-933560802882.us-central1.run.app` +- `https://krow-api-v2-e3g6witsvq-uc.a.run.app` The gateway keeps backend services separate internally, but frontend should treat it as one API. @@ -54,7 +54,8 @@ Full auth behavior, including staff phone flow and refresh rules, is documented - `GET /client/vendors/:vendorId/roles` - `GET /client/hubs/:hubId/managers` - `GET /client/team-members` -- `GET /client/orders/view` +- `GET /client/shifts/scheduled` +- `GET /client/orders/view` deprecated compatibility alias - `GET /client/orders/:orderId/reorder-preview` - `GET /client/reports/summary` - `GET /client/reports/daily-ops` @@ -88,6 +89,12 @@ Full auth behavior, including staff phone flow and refresh rules, is documented - `POST /client/coverage/dispatch-teams/memberships` - `DELETE /client/coverage/dispatch-teams/memberships/:membershipId` +Timeline route naming: + +- `GET /client/shifts/scheduled` is the canonical client timeline route +- it returns shift-level scheduled items, not order headers +- `GET /client/orders/view` still returns the same payload for compatibility, but now emits a deprecation header + Coverage-review request payload may also send: ```json @@ -176,6 +183,7 @@ The manager is created as an invited business membership. If `hubId` is present, - `GET /staff/payments/summary` - `GET /staff/payments/history` - `GET /staff/payments/chart` +- `GET /staff/orders/available` - `GET /staff/shifts/assigned` - `GET /staff/shifts/open` - `GET /staff/shifts/pending` @@ -239,6 +247,14 @@ Example `GET /staff/profile/stats` response: } ``` +Order booking route notes: + +- `GET /staff/orders/available` is the canonical order-level marketplace feed for recurring and grouped work +- `GET /staff/shifts/open` remains available for shift-level opportunities and swap coverage +- `POST /staff/orders/:orderId/book` books the future shifts of an order atomically for one role +- the `roleId` returned by `GET /staff/orders/available` is the role catalog id for the order booking flow +- the `roleId` returned by `GET /staff/shifts/open` is still the concrete `shift_roles.id` for shift-level apply + ### Staff writes - `POST /staff/profile/setup` @@ -249,6 +265,7 @@ Example `GET /staff/profile/stats` response: - `POST /staff/location-streams` - `PUT /staff/availability` - `POST /staff/availability/quick-set` +- `POST /staff/orders/:orderId/book` - `POST /staff/shifts/:shiftId/apply` - `POST /staff/shifts/:shiftId/accept` - `POST /staff/shifts/:shiftId/decline` From 2f25d10368b54f62469e4d102828210b5057f6f7 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:36:28 +0100 Subject: [PATCH 04/29] fix(backend): harden runtime config and verification access --- backend/command-api/src/app.js | 2 + backend/command-api/src/lib/runtime-safety.js | 44 ++++++++++++++++++ backend/command-api/src/worker-app.js | 2 + backend/command-api/test/app.test.js | 10 +++++ .../test/notification-worker.test.js | 10 +++++ backend/core-api/src/app.js | 2 + backend/core-api/src/lib/runtime-safety.js | 45 +++++++++++++++++++ .../src/services/verification-jobs.js | 43 +++++++++++++----- backend/core-api/test/app.test.js | 39 +++++++++++++++- backend/query-api/src/app.js | 2 + backend/query-api/src/lib/runtime-safety.js | 17 +++++++ backend/query-api/test/app.test.js | 10 +++++ backend/unified-api/src/app.js | 2 + backend/unified-api/src/lib/runtime-safety.js | 35 +++++++++++++++ backend/unified-api/test/app.test.js | 13 ++++++ 15 files changed, 262 insertions(+), 14 deletions(-) create mode 100644 backend/command-api/src/lib/runtime-safety.js create mode 100644 backend/core-api/src/lib/runtime-safety.js create mode 100644 backend/query-api/src/lib/runtime-safety.js create mode 100644 backend/unified-api/src/lib/runtime-safety.js diff --git a/backend/command-api/src/app.js b/backend/command-api/src/app.js index 0c6fa44a..aca81b20 100644 --- a/backend/command-api/src/app.js +++ b/backend/command-api/src/app.js @@ -6,10 +6,12 @@ import { errorHandler, notFoundHandler } from './middleware/error-handler.js'; import { healthRouter } from './routes/health.js'; import { createCommandsRouter } from './routes/commands.js'; import { createMobileCommandsRouter } from './routes/mobile.js'; +import { assertSafeRuntimeConfig } from './lib/runtime-safety.js'; const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); export function createApp(options = {}) { + assertSafeRuntimeConfig(); const app = express(); app.use(requestContext); diff --git a/backend/command-api/src/lib/runtime-safety.js b/backend/command-api/src/lib/runtime-safety.js new file mode 100644 index 00000000..74bbf0ca --- /dev/null +++ b/backend/command-api/src/lib/runtime-safety.js @@ -0,0 +1,44 @@ +function runtimeEnvName() { + return `${process.env.APP_ENV || process.env.NODE_ENV || ''}`.trim().toLowerCase(); +} + +function isProtectedEnv() { + return ['staging', 'prod', 'production'].includes(runtimeEnvName()); +} + +export function assertSafeRuntimeConfig() { + if (!isProtectedEnv()) { + return; + } + + const errors = []; + + if (process.env.AUTH_BYPASS === 'true') { + errors.push('AUTH_BYPASS must be disabled'); + } + + if (`${process.env.IDEMPOTENCY_STORE || ''}`.trim().toLowerCase() === 'memory') { + errors.push('IDEMPOTENCY_STORE must not be memory'); + } + + if (errors.length > 0) { + throw new Error(`Unsafe command-api runtime config for ${runtimeEnvName()}: ${errors.join('; ')}`); + } +} + +export function assertSafeWorkerRuntimeConfig() { + if (!isProtectedEnv()) { + return; + } + + const errors = []; + const deliveryMode = `${process.env.PUSH_DELIVERY_MODE || 'live'}`.trim().toLowerCase(); + + if (deliveryMode !== 'live') { + errors.push('PUSH_DELIVERY_MODE must be live'); + } + + if (errors.length > 0) { + throw new Error(`Unsafe notification-worker runtime config for ${runtimeEnvName()}: ${errors.join('; ')}`); + } +} diff --git a/backend/command-api/src/worker-app.js b/backend/command-api/src/worker-app.js index 8ccd96ed..08b42c2c 100644 --- a/backend/command-api/src/worker-app.js +++ b/backend/command-api/src/worker-app.js @@ -1,10 +1,12 @@ import express from 'express'; import pino from 'pino'; import pinoHttp from 'pino-http'; +import { assertSafeWorkerRuntimeConfig } from './lib/runtime-safety.js'; const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); export function createWorkerApp({ dispatch = async () => ({}) } = {}) { + assertSafeWorkerRuntimeConfig(); const app = express(); app.use( diff --git a/backend/command-api/test/app.test.js b/backend/command-api/test/app.test.js index ad1a91c3..dc9c6f7e 100644 --- a/backend/command-api/test/app.test.js +++ b/backend/command-api/test/app.test.js @@ -63,6 +63,16 @@ test('GET /readyz reports database not configured when no database env is presen assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED'); }); +test('createApp fails fast in protected env when auth bypass is enabled', async () => { + process.env.APP_ENV = 'staging'; + process.env.AUTH_BYPASS = 'true'; + + assert.throws(() => createApp(), /AUTH_BYPASS must be disabled/); + + delete process.env.APP_ENV; + process.env.AUTH_BYPASS = 'true'; +}); + test('command route requires idempotency key', async () => { const app = createApp(); const res = await request(app) diff --git a/backend/command-api/test/notification-worker.test.js b/backend/command-api/test/notification-worker.test.js index a4865b55..2816c9bf 100644 --- a/backend/command-api/test/notification-worker.test.js +++ b/backend/command-api/test/notification-worker.test.js @@ -12,6 +12,16 @@ test('GET /readyz returns healthy response', async () => { assert.equal(res.body.service, 'notification-worker-v2'); }); +test('createWorkerApp fails fast in protected env when push delivery is not live', async () => { + process.env.APP_ENV = 'staging'; + process.env.PUSH_DELIVERY_MODE = 'log-only'; + + assert.throws(() => createWorkerApp(), /PUSH_DELIVERY_MODE must be live/); + + delete process.env.APP_ENV; + delete process.env.PUSH_DELIVERY_MODE; +}); + test('POST /tasks/dispatch-notifications returns dispatch summary', async () => { const app = createWorkerApp({ dispatch: async () => ({ diff --git a/backend/core-api/src/app.js b/backend/core-api/src/app.js index af2f1a13..dd5e6a14 100644 --- a/backend/core-api/src/app.js +++ b/backend/core-api/src/app.js @@ -5,10 +5,12 @@ import { requestContext } from './middleware/request-context.js'; import { errorHandler, notFoundHandler } from './middleware/error-handler.js'; import { healthRouter } from './routes/health.js'; import { createCoreRouter, createLegacyCoreRouter } from './routes/core.js'; +import { assertSafeRuntimeConfig } from './lib/runtime-safety.js'; const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); export function createApp() { + assertSafeRuntimeConfig(); const app = express(); app.use(requestContext); diff --git a/backend/core-api/src/lib/runtime-safety.js b/backend/core-api/src/lib/runtime-safety.js new file mode 100644 index 00000000..31aa0c71 --- /dev/null +++ b/backend/core-api/src/lib/runtime-safety.js @@ -0,0 +1,45 @@ +function runtimeEnvName() { + return `${process.env.APP_ENV || process.env.NODE_ENV || ''}`.trim().toLowerCase(); +} + +function isProtectedEnv() { + return ['staging', 'prod', 'production'].includes(runtimeEnvName()); +} + +export function assertSafeRuntimeConfig() { + if (!isProtectedEnv()) { + return; + } + + const errors = []; + + if (process.env.AUTH_BYPASS === 'true') { + errors.push('AUTH_BYPASS must be disabled'); + } + + if (process.env.UPLOAD_MOCK !== 'false') { + errors.push('UPLOAD_MOCK must be false'); + } + + if (process.env.SIGNED_URL_MOCK !== 'false') { + errors.push('SIGNED_URL_MOCK must be false'); + } + + if (process.env.LLM_MOCK !== 'false') { + errors.push('LLM_MOCK must be false'); + } + + const verificationStore = `${process.env.VERIFICATION_STORE || 'sql'}`.trim().toLowerCase(); + if (verificationStore !== 'sql') { + errors.push('VERIFICATION_STORE must be sql'); + } + + const verificationAccessMode = `${process.env.VERIFICATION_ACCESS_MODE || 'tenant'}`.trim().toLowerCase(); + if (verificationAccessMode === 'authenticated') { + errors.push('VERIFICATION_ACCESS_MODE must not be authenticated'); + } + + if (errors.length > 0) { + throw new Error(`Unsafe core-api runtime config for ${runtimeEnvName()}: ${errors.join('; ')}`); + } +} diff --git a/backend/core-api/src/services/verification-jobs.js b/backend/core-api/src/services/verification-jobs.js index ce46679b..ac70aab8 100644 --- a/backend/core-api/src/services/verification-jobs.js +++ b/backend/core-api/src/services/verification-jobs.js @@ -1,6 +1,6 @@ import { AppError } from '../lib/errors.js'; import { isDatabaseConfigured, query, withTransaction } from './db.js'; -import { requireTenantContext } from './actor-context.js'; +import { loadActorContext, requireTenantContext } from './actor-context.js'; import { invokeVertexMultimodalModel } from './llm.js'; export const VerificationStatus = Object.freeze({ @@ -95,7 +95,11 @@ async function processVerificationJobInMemory(verificationId) { } function accessMode() { - return process.env.VERIFICATION_ACCESS_MODE || 'authenticated'; + const mode = `${process.env.VERIFICATION_ACCESS_MODE || 'tenant'}`.trim().toLowerCase(); + if (mode === 'owner' || mode === 'tenant' || mode === 'authenticated') { + return mode; + } + return 'tenant'; } function providerTimeoutMs() { @@ -156,12 +160,27 @@ function toPublicJob(row) { }; } -function assertAccess(row, actorUid) { - if (accessMode() === 'authenticated') { +async function assertAccess(row, actorUid) { + if (row.owner_user_id === actorUid) { return; } - if (row.owner_user_id !== actorUid) { - throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403); + + const mode = accessMode(); + if (mode === 'authenticated') { + return; + } + + if (mode === 'owner' || !row.tenant_id) { + throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403, { + verificationId: row.id, + }); + } + + const actorContext = await loadActorContext(actorUid); + if (actorContext.tenant?.tenantId !== row.tenant_id) { + throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403, { + verificationId: row.id, + }); } } @@ -614,19 +633,19 @@ export async function createVerificationJob({ actorUid, payload }) { export async function getVerificationJob(verificationId, actorUid) { if (useMemoryStore()) { const job = loadMemoryJob(verificationId); - assertAccess(job, actorUid); + await assertAccess(job, actorUid); return toPublicJob(job); } const job = await loadJob(verificationId); - assertAccess(job, actorUid); + await assertAccess(job, actorUid); return toPublicJob(job); } export async function reviewVerificationJob(verificationId, actorUid, review) { if (useMemoryStore()) { const job = loadMemoryJob(verificationId); - assertAccess(job, actorUid); + await assertAccess(job, actorUid); if (HUMAN_TERMINAL_STATUSES.has(job.status)) { throw new AppError('CONFLICT', 'Verification already finalized', 409, { verificationId, @@ -668,7 +687,7 @@ export async function reviewVerificationJob(verificationId, actorUid, review) { } const job = result.rows[0]; - assertAccess(job, actorUid); + await assertAccess(job, actorUid); if (HUMAN_TERMINAL_STATUSES.has(job.status)) { throw new AppError('CONFLICT', 'Verification already finalized', 409, { verificationId, @@ -735,7 +754,7 @@ export async function reviewVerificationJob(verificationId, actorUid, review) { export async function retryVerificationJob(verificationId, actorUid) { if (useMemoryStore()) { const job = loadMemoryJob(verificationId); - assertAccess(job, actorUid); + await assertAccess(job, actorUid); if (job.status === VerificationStatus.PROCESSING) { throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, { verificationId, @@ -774,7 +793,7 @@ export async function retryVerificationJob(verificationId, actorUid) { } const job = result.rows[0]; - assertAccess(job, actorUid); + await assertAccess(job, actorUid); if (job.status === VerificationStatus.PROCESSING) { throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, { verificationId, diff --git a/backend/core-api/test/app.test.js b/backend/core-api/test/app.test.js index f2193843..fafed45b 100644 --- a/backend/core-api/test/app.test.js +++ b/backend/core-api/test/app.test.js @@ -3,7 +3,11 @@ import assert from 'node:assert/strict'; import request from 'supertest'; import { createApp } from '../src/app.js'; import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js'; -import { __resetVerificationJobsForTests } from '../src/services/verification-jobs.js'; +import { + __resetVerificationJobsForTests, + createVerificationJob, + getVerificationJob, +} from '../src/services/verification-jobs.js'; beforeEach(async () => { process.env.AUTH_BYPASS = 'true'; @@ -13,7 +17,7 @@ beforeEach(async () => { process.env.MAX_SIGNED_URL_SECONDS = '900'; process.env.LLM_RATE_LIMIT_PER_MINUTE = '20'; process.env.VERIFICATION_REQUIRE_FILE_EXISTS = 'false'; - process.env.VERIFICATION_ACCESS_MODE = 'authenticated'; + process.env.VERIFICATION_ACCESS_MODE = 'tenant'; process.env.VERIFICATION_ATTIRE_PROVIDER = 'mock'; process.env.VERIFICATION_STORE = 'memory'; __resetLlmRateLimitForTests(); @@ -66,6 +70,16 @@ test('GET /readyz reports database not configured when env is absent', async () assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED'); }); +test('createApp fails fast in protected env when unsafe core flags are enabled', async () => { + process.env.APP_ENV = 'staging'; + process.env.AUTH_BYPASS = 'true'; + + assert.throws(() => createApp(), /AUTH_BYPASS must be disabled/); + + delete process.env.APP_ENV; + process.env.AUTH_BYPASS = 'true'; +}); + test('POST /core/create-signed-url requires auth', async () => { process.env.AUTH_BYPASS = 'false'; const app = createApp(); @@ -404,3 +418,24 @@ test('POST /core/verifications/:id/retry requeues verification', async () => { assert.equal(retried.status, 202); assert.equal(retried.body.status, 'PENDING'); }); + +test('verification access is denied to a different actor by default', async () => { + const created = await createVerificationJob({ + actorUid: 'owner-user', + payload: { + type: 'attire', + subjectType: 'staff', + subjectId: 'staff_1', + fileUri: 'gs://krow-workforce-dev-private/uploads/owner-user/attire.jpg', + rules: { attireType: 'shoes' }, + }, + }); + + await assert.rejects( + () => getVerificationJob(created.verificationId, 'foreign-user'), + (error) => { + assert.equal(error.code, 'FORBIDDEN'); + return true; + } + ); +}); diff --git a/backend/query-api/src/app.js b/backend/query-api/src/app.js index 43ff81da..1e363455 100644 --- a/backend/query-api/src/app.js +++ b/backend/query-api/src/app.js @@ -6,10 +6,12 @@ import { errorHandler, notFoundHandler } from './middleware/error-handler.js'; import { healthRouter } from './routes/health.js'; import { createQueryRouter } from './routes/query.js'; import { createMobileQueryRouter } from './routes/mobile.js'; +import { assertSafeRuntimeConfig } from './lib/runtime-safety.js'; const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); export function createApp(options = {}) { + assertSafeRuntimeConfig(); const app = express(); app.use(requestContext); diff --git a/backend/query-api/src/lib/runtime-safety.js b/backend/query-api/src/lib/runtime-safety.js new file mode 100644 index 00000000..4b45bf08 --- /dev/null +++ b/backend/query-api/src/lib/runtime-safety.js @@ -0,0 +1,17 @@ +function runtimeEnvName() { + return `${process.env.APP_ENV || process.env.NODE_ENV || ''}`.trim().toLowerCase(); +} + +function isProtectedEnv() { + return ['staging', 'prod', 'production'].includes(runtimeEnvName()); +} + +export function assertSafeRuntimeConfig() { + if (!isProtectedEnv()) { + return; + } + + if (process.env.AUTH_BYPASS === 'true') { + throw new Error(`Unsafe query-api runtime config for ${runtimeEnvName()}: AUTH_BYPASS must be disabled`); + } +} diff --git a/backend/query-api/test/app.test.js b/backend/query-api/test/app.test.js index f2a5e9d7..26f751e2 100644 --- a/backend/query-api/test/app.test.js +++ b/backend/query-api/test/app.test.js @@ -37,6 +37,16 @@ test('GET /readyz reports database not configured when no database env is presen assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED'); }); +test('createApp fails fast in protected env when auth bypass is enabled', async () => { + process.env.APP_ENV = 'staging'; + process.env.AUTH_BYPASS = 'true'; + + assert.throws(() => createApp(), /AUTH_BYPASS must be disabled/); + + delete process.env.APP_ENV; + process.env.AUTH_BYPASS = 'true'; +}); + test('GET unknown route returns not found envelope', async () => { const app = createApp(); const res = await request(app).get('/query/unknown'); diff --git a/backend/unified-api/src/app.js b/backend/unified-api/src/app.js index a30f8657..82d23bec 100644 --- a/backend/unified-api/src/app.js +++ b/backend/unified-api/src/app.js @@ -6,10 +6,12 @@ import { errorHandler, notFoundHandler } from './middleware/error-handler.js'; import { healthRouter } from './routes/health.js'; import { createAuthRouter } from './routes/auth.js'; import { createProxyRouter } from './routes/proxy.js'; +import { assertSafeRuntimeConfig } from './lib/runtime-safety.js'; const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); export function createApp(options = {}) { + assertSafeRuntimeConfig(); const app = express(); app.use(requestContext); diff --git a/backend/unified-api/src/lib/runtime-safety.js b/backend/unified-api/src/lib/runtime-safety.js new file mode 100644 index 00000000..587d8666 --- /dev/null +++ b/backend/unified-api/src/lib/runtime-safety.js @@ -0,0 +1,35 @@ +function runtimeEnvName() { + return `${process.env.APP_ENV || process.env.NODE_ENV || ''}`.trim().toLowerCase(); +} + +function isProtectedEnv() { + return ['staging', 'prod', 'production'].includes(runtimeEnvName()); +} + +export function assertSafeRuntimeConfig() { + if (!isProtectedEnv()) { + return; + } + + const errors = []; + + if (process.env.AUTH_BYPASS === 'true') { + errors.push('AUTH_BYPASS must be disabled'); + } + + if (!process.env.CORE_API_BASE_URL) { + errors.push('CORE_API_BASE_URL is required'); + } + + if (!process.env.COMMAND_API_BASE_URL) { + errors.push('COMMAND_API_BASE_URL is required'); + } + + if (!process.env.QUERY_API_BASE_URL) { + errors.push('QUERY_API_BASE_URL is required'); + } + + if (errors.length > 0) { + throw new Error(`Unsafe unified-api runtime config for ${runtimeEnvName()}: ${errors.join('; ')}`); + } +} diff --git a/backend/unified-api/test/app.test.js b/backend/unified-api/test/app.test.js index 02c42355..fb271c47 100644 --- a/backend/unified-api/test/app.test.js +++ b/backend/unified-api/test/app.test.js @@ -29,6 +29,19 @@ test('GET /readyz reports database not configured when env is absent', async () assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED'); }); +test('createApp fails fast in protected env when upstream config is unsafe', async () => { + process.env.APP_ENV = 'staging'; + process.env.AUTH_BYPASS = 'true'; + delete process.env.CORE_API_BASE_URL; + delete process.env.COMMAND_API_BASE_URL; + delete process.env.QUERY_API_BASE_URL; + + assert.throws(() => createApp(), /AUTH_BYPASS must be disabled/); + + delete process.env.APP_ENV; + process.env.AUTH_BYPASS = 'true'; +}); + test('POST /auth/client/sign-in validates payload', async () => { const app = createApp(); const res = await request(app).post('/auth/client/sign-in').send({ From a4ac0b2a6b416797d871ff453b4f34a7d7f5c286 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:48:43 +0100 Subject: [PATCH 05/29] fix(authz): tighten policy scope enforcement --- backend/command-api/src/middleware/auth.js | 38 +++++- .../src/services/command-service.js | 68 ++++++++++ backend/command-api/src/services/policy.js | 128 +++++++++++++++++- backend/command-api/test/app.test.js | 34 +++++ backend/command-api/test/policy.test.js | 86 ++++++++++++ backend/core-api/src/middleware/auth.js | 36 ++++- backend/core-api/src/services/policy.js | 49 ++++++- backend/core-api/test/policy.test.js | 33 +++++ backend/query-api/src/middleware/auth.js | 38 +++++- backend/query-api/src/routes/query.js | 26 +++- backend/query-api/src/services/policy.js | 121 ++++++++++++++++- .../query-api/src/services/query-service.js | 1 + backend/query-api/test/app.test.js | 29 ++++ backend/query-api/test/policy.test.js | 86 ++++++++++++ 14 files changed, 743 insertions(+), 30 deletions(-) create mode 100644 backend/command-api/test/policy.test.js create mode 100644 backend/core-api/test/policy.test.js create mode 100644 backend/query-api/test/policy.test.js diff --git a/backend/command-api/src/middleware/auth.js b/backend/command-api/src/middleware/auth.js index 9c62c86d..d6fa04f8 100644 --- a/backend/command-api/src/middleware/auth.js +++ b/backend/command-api/src/middleware/auth.js @@ -9,6 +9,30 @@ function getBearerToken(header) { return token; } +function buildBypassActor() { + let policyContext = { + user: { userId: 'test-user' }, + tenant: { tenantId: '*' }, + business: { businessId: '*' }, + staff: { staffId: '*', workforceId: '*' }, + }; + + if (process.env.AUTH_BYPASS_CONTEXT) { + try { + policyContext = JSON.parse(process.env.AUTH_BYPASS_CONTEXT); + } catch (_error) { + policyContext = { + user: { userId: 'test-user' }, + tenant: { tenantId: '*' }, + business: { businessId: '*' }, + staff: { staffId: '*', workforceId: '*' }, + }; + } + } + + return { uid: 'test-user', email: 'test@krow.local', role: 'TEST', policyContext }; +} + export async function requireAuth(req, _res, next) { try { const token = getBearerToken(req.get('Authorization')); @@ -17,7 +41,7 @@ export async function requireAuth(req, _res, next) { } if (process.env.AUTH_BYPASS === 'true') { - req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' }; + req.actor = buildBypassActor(); return next(); } @@ -36,10 +60,14 @@ export async function requireAuth(req, _res, next) { } export function requirePolicy(action, resource) { - return (req, _res, next) => { - if (!can(action, resource, req.actor)) { - return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); + return async (req, _res, next) => { + try { + if (!(await can(action, resource, req.actor, req))) { + return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); + } + return next(); + } catch (error) { + return next(error); } - return next(); }; } diff --git a/backend/command-api/src/services/command-service.js b/backend/command-api/src/services/command-service.js index d8fb721e..ce516acb 100644 --- a/backend/command-api/src/services/command-service.js +++ b/backend/command-api/src/services/command-service.js @@ -4,6 +4,10 @@ import { recordGeofenceIncident } from './attendance-monitoring.js'; import { recordAttendanceSecurityProof } from './attendance-security.js'; import { evaluateClockInAttempt } from './clock-in-policy.js'; import { enqueueHubManagerAlert } from './notification-outbox.js'; +import { + requireClientContext as requireActorClientContext, + requireStaffContext as requireActorStaffContext, +} from './actor-context.js'; function toIsoOrNull(value) { return value ? new Date(value).toISOString() : null; @@ -68,6 +72,33 @@ async function ensureStaffNotBlockedByBusiness(client, { tenantId, businessId, s } } +function assertTenantScope(context, tenantId) { + if (context.tenant.tenantId !== tenantId) { + throw new AppError('FORBIDDEN', 'Resource is outside actor tenant scope', 403, { + tenantId, + actorTenantId: context.tenant.tenantId, + }); + } +} + +function assertBusinessScope(context, businessId) { + if (context.business && context.business.businessId !== businessId) { + throw new AppError('FORBIDDEN', 'Resource is outside actor business scope', 403, { + businessId, + actorBusinessId: context.business.businessId, + }); + } +} + +function assertStaffScope(context, staffId) { + if (context.staff.staffId !== staffId) { + throw new AppError('FORBIDDEN', 'Resource is outside actor staff scope', 403, { + staffId, + actorStaffId: context.staff.staffId, + }); + } +} + async function insertDomainEvent(client, { tenantId, aggregateType, @@ -451,6 +482,9 @@ function buildOrderUpdateStatement(payload) { export async function createOrder(actor, payload) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorClientContext(actor.uid); + assertTenantScope(actorContext, payload.tenantId); + assertBusinessScope(actorContext, payload.businessId); await requireBusiness(client, payload.tenantId, payload.businessId); if (payload.vendorId) { await requireVendor(client, payload.tenantId, payload.vendorId); @@ -620,8 +654,10 @@ export async function createOrder(actor, payload) { export async function acceptShift(actor, payload) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorStaffContext(actor.uid); const shiftRole = await requireShiftRole(client, payload.shiftRoleId); + assertTenantScope(actorContext, shiftRole.tenant_id); if (payload.shiftId && shiftRole.shift_id !== payload.shiftId) { throw new AppError('VALIDATION_ERROR', 'shiftId does not match shiftRoleId', 400, { shiftId: payload.shiftId, @@ -629,6 +665,13 @@ export async function acceptShift(actor, payload) { }); } + if (!actorContext.staff.workforceId || actorContext.staff.workforceId !== payload.workforceId) { + throw new AppError('FORBIDDEN', 'Staff can only accept shifts for their own workforce record', 403, { + workforceId: payload.workforceId, + actorWorkforceId: actorContext.staff.workforceId || null, + }); + } + if (shiftRole.assigned_count >= shiftRole.workers_needed) { const existingFilledAssignment = await findAssignmentForShiftRoleWorkforce( client, @@ -736,7 +779,10 @@ export async function acceptShift(actor, payload) { export async function updateOrder(actor, payload) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorClientContext(actor.uid); + assertTenantScope(actorContext, payload.tenantId); const existingOrder = await requireOrder(client, payload.tenantId, payload.orderId); + assertBusinessScope(actorContext, existingOrder.business_id); if (Object.prototype.hasOwnProperty.call(payload, 'vendorId') && payload.vendorId) { await requireVendor(client, payload.tenantId, payload.vendorId); @@ -787,7 +833,10 @@ export async function updateOrder(actor, payload) { export async function cancelOrder(actor, payload) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorClientContext(actor.uid); + assertTenantScope(actorContext, payload.tenantId); const order = await requireOrder(client, payload.tenantId, payload.orderId); + assertBusinessScope(actorContext, order.business_id); if (order.status === 'CANCELLED') { return { @@ -910,7 +959,10 @@ export async function cancelOrder(actor, payload) { export async function changeShiftStatus(actor, payload) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorClientContext(actor.uid); + assertTenantScope(actorContext, payload.tenantId); const shift = await requireShift(client, payload.tenantId, payload.shiftId); + assertBusinessScope(actorContext, shift.business_id); if (payload.status === 'COMPLETED') { const openSession = await client.query( @@ -999,7 +1051,10 @@ export async function changeShiftStatus(actor, payload) { export async function assignStaffToShift(actor, payload) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorClientContext(actor.uid); + assertTenantScope(actorContext, payload.tenantId); const shift = await requireShift(client, payload.tenantId, payload.shiftId); + assertBusinessScope(actorContext, shift.business_id); const shiftRole = await requireShiftRole(client, payload.shiftRoleId); if (shiftRole.shift_id !== shift.id) { @@ -1120,7 +1175,10 @@ export async function assignStaffToShift(actor, payload) { async function createAttendanceEvent(actor, payload, eventType) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorStaffContext(actor.uid); const assignment = await requireAssignment(client, payload.assignmentId); + assertTenantScope(actorContext, assignment.tenant_id); + assertStaffScope(actorContext, assignment.staff_id); const capturedAt = toIsoOrNull(payload.capturedAt) || new Date().toISOString(); let securityProof = null; @@ -1553,6 +1611,9 @@ export async function clockOut(actor, payload) { export async function addFavoriteStaff(actor, payload) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorClientContext(actor.uid); + assertTenantScope(actorContext, payload.tenantId); + assertBusinessScope(actorContext, payload.businessId); await requireBusiness(client, payload.tenantId, payload.businessId); const staffResult = await client.query( @@ -1605,6 +1666,9 @@ export async function addFavoriteStaff(actor, payload) { export async function removeFavoriteStaff(actor, payload) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorClientContext(actor.uid); + assertTenantScope(actorContext, payload.tenantId); + assertBusinessScope(actorContext, payload.businessId); const deleted = await client.query( ` DELETE FROM staff_favorites @@ -1640,7 +1704,11 @@ export async function removeFavoriteStaff(actor, payload) { export async function createStaffReview(actor, payload) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorClientContext(actor.uid); + assertTenantScope(actorContext, payload.tenantId); + assertBusinessScope(actorContext, payload.businessId); const assignment = await requireAssignment(client, payload.assignmentId); + assertBusinessScope(actorContext, assignment.business_id); if (assignment.business_id !== payload.businessId || assignment.staff_id !== payload.staffId) { throw new AppError('VALIDATION_ERROR', 'Assignment does not match business/staff review target', 400, { assignmentId: payload.assignmentId, diff --git a/backend/command-api/src/services/policy.js b/backend/command-api/src/services/policy.js index 44e7e371..ec31b98a 100644 --- a/backend/command-api/src/services/policy.js +++ b/backend/command-api/src/services/policy.js @@ -1,5 +1,125 @@ -export function can(action, resource, actor) { - void action; - void resource; - return Boolean(actor?.uid); +import { loadActorContext } from './actor-context.js'; + +const TENANT_ADMIN_ROLES = new Set(['OWNER', 'ADMIN']); + +function normalize(value) { + return `${value || ''}`.trim(); +} + +function requestField(req, field) { + return normalize( + req?.params?.[field] + ?? req?.body?.[field] + ?? req?.query?.[field] + ); +} + +function isTenantAdmin(context) { + return TENANT_ADMIN_ROLES.has(normalize(context?.tenant?.role).toUpperCase()); +} + +function hasTenantScope(context) { + return Boolean(context?.user && context?.tenant); +} + +function hasClientScope(context) { + return hasTenantScope(context) && Boolean(context?.business || isTenantAdmin(context)); +} + +function hasStaffScope(context) { + return hasTenantScope(context) && Boolean(context?.staff); +} + +function requiredScopeFor(action) { + if (action === 'notifications.device.write') { + return 'tenant'; + } + + if ( + action === 'orders.create' + || action === 'orders.update' + || action === 'orders.cancel' + || action === 'shifts.change-status' + || action === 'shifts.assign-staff' + || action === 'business.favorite-staff' + || action === 'business.unfavorite-staff' + || action === 'assignments.review-staff' + || action.startsWith('client.') + || action.startsWith('billing.') + || action.startsWith('coverage.') + || action.startsWith('hubs.') + || action.startsWith('vendors.') + || action.startsWith('reports.') + ) { + return 'client'; + } + + if ( + action === 'shifts.accept' + || action === 'attendance.clock-in' + || action === 'attendance.clock-out' + || action === 'attendance.location-stream.write' + || action.startsWith('staff.') + || action.startsWith('payments.') + ) { + return 'staff'; + } + + return 'deny'; +} + +async function resolveActorContext(actor) { + if (!actor?.uid) { + return null; + } + if (actor.policyContext) { + return actor.policyContext; + } + const context = await loadActorContext(actor.uid); + actor.policyContext = context; + return context; +} + +function requestScopeMatches(req, context, requiredScope) { + const tenantId = requestField(req, 'tenantId'); + if (tenantId && context?.tenant?.tenantId !== '*' && context?.tenant?.tenantId !== tenantId) { + return false; + } + + const businessId = requestField(req, 'businessId'); + if ( + requiredScope === 'client' + && businessId + && context?.business?.businessId + && context.business.businessId !== '*' + && context.business.businessId !== businessId + ) { + return false; + } + + return true; +} + +export async function can(action, resource, actor, req) { + void resource; + const context = await resolveActorContext(actor); + const requiredScope = requiredScopeFor(action); + + if (requiredScope === 'deny' || !context?.user) { + return false; + } + + if (requiredScope === 'tenant') { + return hasTenantScope(context) && requestScopeMatches(req, context, requiredScope); + } + + if (requiredScope === 'client') { + return hasClientScope(context) && requestScopeMatches(req, context, requiredScope); + } + + if (requiredScope === 'staff') { + return hasStaffScope(context) && requestScopeMatches(req, context, requiredScope); + } + + return false; } diff --git a/backend/command-api/test/app.test.js b/backend/command-api/test/app.test.js index dc9c6f7e..e9362286 100644 --- a/backend/command-api/test/app.test.js +++ b/backend/command-api/test/app.test.js @@ -40,6 +40,7 @@ function validOrderCreatePayload() { beforeEach(() => { process.env.IDEMPOTENCY_STORE = 'memory'; + delete process.env.AUTH_BYPASS_CONTEXT; delete process.env.IDEMPOTENCY_DATABASE_URL; delete process.env.DATABASE_URL; __resetIdempotencyStoreForTests(); @@ -126,3 +127,36 @@ test('command route is idempotent by key and only executes handler once', async assert.equal(first.body.idempotencyKey, 'abc-123'); assert.equal(second.body.idempotencyKey, 'abc-123'); }); + +test('client command routes deny mismatched business scope before handler execution', async () => { + process.env.AUTH_BYPASS_CONTEXT = JSON.stringify({ + user: { userId: 'test-user' }, + tenant: { tenantId, role: 'MANAGER' }, + business: { businessId: '99999999-9999-4999-8999-999999999999' }, + }); + + const app = createApp({ + commandHandlers: { + createOrder: async () => assert.fail('createOrder should not be called'), + acceptShift: async () => assert.fail('acceptShift should not be called'), + clockIn: async () => assert.fail('clockIn should not be called'), + clockOut: async () => assert.fail('clockOut should not be called'), + addFavoriteStaff: async () => assert.fail('addFavoriteStaff should not be called'), + removeFavoriteStaff: async () => assert.fail('removeFavoriteStaff should not be called'), + createStaffReview: async () => assert.fail('createStaffReview should not be called'), + updateOrder: async () => assert.fail('updateOrder should not be called'), + cancelOrder: async () => assert.fail('cancelOrder should not be called'), + changeShiftStatus: async () => assert.fail('changeShiftStatus should not be called'), + assignStaffToShift: async () => assert.fail('assignStaffToShift should not be called'), + }, + }); + + const res = await request(app) + .post('/commands/orders/create') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'scope-mismatch') + .send(validOrderCreatePayload()); + + assert.equal(res.status, 403); + assert.equal(res.body.code, 'FORBIDDEN'); +}); diff --git a/backend/command-api/test/policy.test.js b/backend/command-api/test/policy.test.js new file mode 100644 index 00000000..d5e30b65 --- /dev/null +++ b/backend/command-api/test/policy.test.js @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { can } from '../src/services/policy.js'; + +test('client actions require business scope and matching business id', async () => { + const allowed = await can( + 'orders.create', + 'order', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1', role: 'MANAGER' }, + business: { businessId: 'business-1' }, + }, + }, + { body: { tenantId: 'tenant-1', businessId: 'business-1' } } + ); + + const denied = await can( + 'orders.create', + 'order', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1', role: 'MANAGER' }, + business: { businessId: 'business-1' }, + }, + }, + { body: { tenantId: 'tenant-1', businessId: 'business-2' } } + ); + + assert.equal(allowed, true); + assert.equal(denied, false); +}); + +test('staff actions require staff scope', async () => { + const allowed = await can( + 'shifts.accept', + 'shift', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1' }, + staff: { staffId: 'staff-1', workforceId: 'workforce-1' }, + }, + }, + { body: { tenantId: 'tenant-1' } } + ); + + const denied = await can( + 'shifts.accept', + 'shift', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1' }, + business: { businessId: 'business-1' }, + }, + }, + { body: { tenantId: 'tenant-1' } } + ); + + assert.equal(allowed, true); + assert.equal(denied, false); +}); + +test('notifications.device.write allows tenant-scoped actor', async () => { + const allowed = await can( + 'notifications.device.write', + 'device', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1' }, + }, + }, + { body: { tenantId: 'tenant-1' } } + ); + + assert.equal(allowed, true); +}); diff --git a/backend/core-api/src/middleware/auth.js b/backend/core-api/src/middleware/auth.js index 9c62c86d..d2aa36ff 100644 --- a/backend/core-api/src/middleware/auth.js +++ b/backend/core-api/src/middleware/auth.js @@ -9,6 +9,28 @@ function getBearerToken(header) { return token; } +function buildBypassActor() { + let policyContext = { + user: { userId: 'test-user' }, + tenant: { tenantId: '*' }, + staff: { staffId: '*', workforceId: '*' }, + }; + + if (process.env.AUTH_BYPASS_CONTEXT) { + try { + policyContext = JSON.parse(process.env.AUTH_BYPASS_CONTEXT); + } catch (_error) { + policyContext = { + user: { userId: 'test-user' }, + tenant: { tenantId: '*' }, + staff: { staffId: '*', workforceId: '*' }, + }; + } + } + + return { uid: 'test-user', email: 'test@krow.local', role: 'TEST', policyContext }; +} + export async function requireAuth(req, _res, next) { try { const token = getBearerToken(req.get('Authorization')); @@ -17,7 +39,7 @@ export async function requireAuth(req, _res, next) { } if (process.env.AUTH_BYPASS === 'true') { - req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' }; + req.actor = buildBypassActor(); return next(); } @@ -36,10 +58,14 @@ export async function requireAuth(req, _res, next) { } export function requirePolicy(action, resource) { - return (req, _res, next) => { - if (!can(action, resource, req.actor)) { - return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); + return async (req, _res, next) => { + try { + if (!(await can(action, resource, req.actor, req))) { + return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); + } + return next(); + } catch (error) { + return next(error); } - return next(); }; } diff --git a/backend/core-api/src/services/policy.js b/backend/core-api/src/services/policy.js index 44e7e371..12fa6e2f 100644 --- a/backend/core-api/src/services/policy.js +++ b/backend/core-api/src/services/policy.js @@ -1,5 +1,46 @@ -export function can(action, resource, actor) { - void action; - void resource; - return Boolean(actor?.uid); +import { loadActorContext } from './actor-context.js'; + +function normalize(value) { + return `${value || ''}`.trim(); +} + +function requestField(req, field) { + return normalize( + req?.params?.[field] + ?? req?.body?.[field] + ?? req?.query?.[field] + ); +} + +async function resolveActorContext(actor) { + if (!actor?.uid) { + return null; + } + if (actor.policyContext) { + return actor.policyContext; + } + const context = await loadActorContext(actor.uid); + actor.policyContext = context; + return context; +} + +export async function can(action, resource, actor, req) { + void resource; + if (!action.startsWith('core.')) { + return false; + } + + const context = await resolveActorContext(actor); + if (!context?.user || !context?.tenant) { + return false; + } + + const tenantId = requestField(req, 'tenantId'); + if (!tenantId) { + return true; + } + if (context.tenant.tenantId === '*') { + return true; + } + return context.tenant.tenantId === tenantId; } diff --git a/backend/core-api/test/policy.test.js b/backend/core-api/test/policy.test.js new file mode 100644 index 00000000..d4beef5d --- /dev/null +++ b/backend/core-api/test/policy.test.js @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { can } from '../src/services/policy.js'; + +test('core actions require tenant scope', async () => { + const allowed = await can( + 'core.verification.read', + 'verification', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1' }, + }, + }, + {} + ); + + const denied = await can( + 'core.verification.read', + 'verification', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + }, + }, + {} + ); + + assert.equal(allowed, true); + assert.equal(denied, false); +}); diff --git a/backend/query-api/src/middleware/auth.js b/backend/query-api/src/middleware/auth.js index 9c62c86d..d6fa04f8 100644 --- a/backend/query-api/src/middleware/auth.js +++ b/backend/query-api/src/middleware/auth.js @@ -9,6 +9,30 @@ function getBearerToken(header) { return token; } +function buildBypassActor() { + let policyContext = { + user: { userId: 'test-user' }, + tenant: { tenantId: '*' }, + business: { businessId: '*' }, + staff: { staffId: '*', workforceId: '*' }, + }; + + if (process.env.AUTH_BYPASS_CONTEXT) { + try { + policyContext = JSON.parse(process.env.AUTH_BYPASS_CONTEXT); + } catch (_error) { + policyContext = { + user: { userId: 'test-user' }, + tenant: { tenantId: '*' }, + business: { businessId: '*' }, + staff: { staffId: '*', workforceId: '*' }, + }; + } + } + + return { uid: 'test-user', email: 'test@krow.local', role: 'TEST', policyContext }; +} + export async function requireAuth(req, _res, next) { try { const token = getBearerToken(req.get('Authorization')); @@ -17,7 +41,7 @@ export async function requireAuth(req, _res, next) { } if (process.env.AUTH_BYPASS === 'true') { - req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' }; + req.actor = buildBypassActor(); return next(); } @@ -36,10 +60,14 @@ export async function requireAuth(req, _res, next) { } export function requirePolicy(action, resource) { - return (req, _res, next) => { - if (!can(action, resource, req.actor)) { - return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); + return async (req, _res, next) => { + try { + if (!(await can(action, resource, req.actor, req))) { + return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); + } + return next(); + } catch (error) { + return next(error); } - return next(); }; } diff --git a/backend/query-api/src/routes/query.js b/backend/query-api/src/routes/query.js index 5fe33090..46109012 100644 --- a/backend/query-api/src/routes/query.js +++ b/backend/query-api/src/routes/query.js @@ -27,6 +27,11 @@ function requireUuid(value, field) { export function createQueryRouter(queryService = defaultQueryService) { const router = Router(); + function actorBusinessId(actor) { + const businessId = actor?.policyContext?.business?.businessId; + return businessId && businessId !== '*' ? businessId : null; + } + router.get( '/tenants/:tenantId/orders', requireAuth, @@ -34,9 +39,10 @@ export function createQueryRouter(queryService = defaultQueryService) { async (req, res, next) => { try { const tenantId = requireUuid(req.params.tenantId, 'tenantId'); + const scopedBusinessId = actorBusinessId(req.actor); const orders = await queryService.listOrders({ tenantId, - businessId: req.query.businessId, + businessId: scopedBusinessId || req.query.businessId, status: req.query.status, limit: req.query.limit, offset: req.query.offset, @@ -57,10 +63,16 @@ export function createQueryRouter(queryService = defaultQueryService) { requirePolicy('orders.read', 'order'), async (req, res, next) => { try { + const scopedBusinessId = actorBusinessId(req.actor); const order = await queryService.getOrderDetail({ tenantId: requireUuid(req.params.tenantId, 'tenantId'), orderId: requireUuid(req.params.orderId, 'orderId'), }); + if (scopedBusinessId && order.businessId !== scopedBusinessId) { + throw new AppError('FORBIDDEN', 'Order is outside actor business scope', 403, { + orderId: req.params.orderId, + }); + } return res.status(200).json({ ...order, requestId: req.requestId, @@ -77,9 +89,10 @@ export function createQueryRouter(queryService = defaultQueryService) { requirePolicy('business.favorite-staff.read', 'staff'), async (req, res, next) => { try { + const scopedBusinessId = actorBusinessId(req.actor); const items = await queryService.listFavoriteStaff({ tenantId: requireUuid(req.params.tenantId, 'tenantId'), - businessId: requireUuid(req.params.businessId, 'businessId'), + businessId: requireUuid(scopedBusinessId || req.params.businessId, 'businessId'), limit: req.query.limit, offset: req.query.offset, }); @@ -120,12 +133,19 @@ export function createQueryRouter(queryService = defaultQueryService) { requirePolicy('attendance.read', 'attendance'), async (req, res, next) => { try { + const scopedBusinessId = actorBusinessId(req.actor); const attendance = await queryService.getAssignmentAttendance({ tenantId: requireUuid(req.params.tenantId, 'tenantId'), assignmentId: requireUuid(req.params.assignmentId, 'assignmentId'), }); + if (scopedBusinessId && attendance.businessId !== scopedBusinessId) { + throw new AppError('FORBIDDEN', 'Assignment attendance is outside actor business scope', 403, { + assignmentId: req.params.assignmentId, + }); + } + const { businessId: _businessId, ...publicAttendance } = attendance; return res.status(200).json({ - ...attendance, + ...publicAttendance, requestId: req.requestId, }); } catch (error) { diff --git a/backend/query-api/src/services/policy.js b/backend/query-api/src/services/policy.js index 44e7e371..22e3d9c9 100644 --- a/backend/query-api/src/services/policy.js +++ b/backend/query-api/src/services/policy.js @@ -1,5 +1,118 @@ -export function can(action, resource, actor) { - void action; - void resource; - return Boolean(actor?.uid); +import { loadActorContext } from './actor-context.js'; + +const TENANT_ADMIN_ROLES = new Set(['OWNER', 'ADMIN']); + +function normalize(value) { + return `${value || ''}`.trim(); +} + +function requestField(req, field) { + return normalize( + req?.params?.[field] + ?? req?.body?.[field] + ?? req?.query?.[field] + ); +} + +function isTenantAdmin(context) { + return TENANT_ADMIN_ROLES.has(normalize(context?.tenant?.role).toUpperCase()); +} + +function hasTenantScope(context) { + return Boolean(context?.user && context?.tenant); +} + +function hasClientScope(context) { + return hasTenantScope(context) && Boolean(context?.business || isTenantAdmin(context)); +} + +function hasStaffScope(context) { + return hasTenantScope(context) && Boolean(context?.staff); +} + +function requiredScopeFor(action) { + if (action === 'attendance.read') { + return 'tenant'; + } + + if ( + action === 'orders.read' + || action === 'orders.reorder.read' + || action === 'business.favorite-staff.read' + || action === 'staff.reviews.read' + || action.startsWith('client.') + || action.startsWith('billing.') + || action.startsWith('coverage.') + || action.startsWith('hubs.') + || action.startsWith('vendors.') + || action.startsWith('reports.') + ) { + return 'client'; + } + + if ( + action === 'shifts.read' + || action.startsWith('staff.') + || action.startsWith('payments.') + ) { + return 'staff'; + } + + return 'deny'; +} + +async function resolveActorContext(actor) { + if (!actor?.uid) { + return null; + } + if (actor.policyContext) { + return actor.policyContext; + } + const context = await loadActorContext(actor.uid); + actor.policyContext = context; + return context; +} + +function requestScopeMatches(req, context, requiredScope) { + const tenantId = requestField(req, 'tenantId'); + if (tenantId && context?.tenant?.tenantId !== '*' && context?.tenant?.tenantId !== tenantId) { + return false; + } + + const businessId = requestField(req, 'businessId'); + if ( + requiredScope === 'client' + && businessId + && context?.business?.businessId + && context.business.businessId !== '*' + && context.business.businessId !== businessId + ) { + return false; + } + + return true; +} + +export async function can(action, resource, actor, req) { + void resource; + const context = await resolveActorContext(actor); + const requiredScope = requiredScopeFor(action); + + if (requiredScope === 'deny' || !context?.user) { + return false; + } + + if (requiredScope === 'tenant') { + return hasTenantScope(context) && requestScopeMatches(req, context, requiredScope); + } + + if (requiredScope === 'client') { + return hasClientScope(context) && requestScopeMatches(req, context, requiredScope); + } + + if (requiredScope === 'staff') { + return hasStaffScope(context) && requestScopeMatches(req, context, requiredScope); + } + + return false; } diff --git a/backend/query-api/src/services/query-service.js b/backend/query-api/src/services/query-service.js index 02a5e795..54db3274 100644 --- a/backend/query-api/src/services/query-service.js +++ b/backend/query-api/src/services/query-service.js @@ -233,6 +233,7 @@ export async function getAssignmentAttendance({ tenantId, assignmentId }) { SELECT a.id AS "assignmentId", a.status, + a.business_id AS "businessId", a.shift_id AS "shiftId", a.staff_id AS "staffId", s.title AS "shiftTitle", diff --git a/backend/query-api/test/app.test.js b/backend/query-api/test/app.test.js index 26f751e2..35444559 100644 --- a/backend/query-api/test/app.test.js +++ b/backend/query-api/test/app.test.js @@ -37,6 +37,10 @@ test('GET /readyz reports database not configured when no database env is presen assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED'); }); +test.afterEach(() => { + delete process.env.AUTH_BYPASS_CONTEXT; +}); + test('createApp fails fast in protected env when auth bypass is enabled', async () => { process.env.APP_ENV = 'staging'; process.env.AUTH_BYPASS = 'true'; @@ -134,3 +138,28 @@ test('GET /query/tenants/:tenantId/businesses/:businessId/favorite-staff validat assert.equal(res.status, 200); assert.equal(res.body.items[0].staffId, staffId); }); + +test('GET /query/tenants/:tenantId/orders denies mismatched tenant scope before handler execution', async () => { + process.env.AUTH_BYPASS_CONTEXT = JSON.stringify({ + user: { userId: 'test-user' }, + tenant: { tenantId: '99999999-9999-4999-8999-999999999999', role: 'MANAGER' }, + business: { businessId }, + }); + + const app = createApp({ + queryService: { + listOrders: async () => assert.fail('listOrders should not be called'), + getOrderDetail: async () => assert.fail('getOrderDetail should not be called'), + listFavoriteStaff: async () => assert.fail('listFavoriteStaff should not be called'), + getStaffReviewSummary: async () => assert.fail('getStaffReviewSummary should not be called'), + getAssignmentAttendance: async () => assert.fail('getAssignmentAttendance should not be called'), + }, + }); + + const res = await request(app) + .get(`/query/tenants/${tenantId}/orders`) + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 403); + assert.equal(res.body.code, 'FORBIDDEN'); +}); diff --git a/backend/query-api/test/policy.test.js b/backend/query-api/test/policy.test.js new file mode 100644 index 00000000..0ac9c357 --- /dev/null +++ b/backend/query-api/test/policy.test.js @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { can } from '../src/services/policy.js'; + +test('orders.read requires client scope and matching tenant/business scope', async () => { + const allowed = await can( + 'orders.read', + 'order', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1', role: 'MANAGER' }, + business: { businessId: 'business-1' }, + }, + }, + { params: { tenantId: 'tenant-1' }, query: { businessId: 'business-1' } } + ); + + const denied = await can( + 'orders.read', + 'order', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1', role: 'MANAGER' }, + business: { businessId: 'business-1' }, + }, + }, + { params: { tenantId: 'tenant-2' }, query: { businessId: 'business-1' } } + ); + + assert.equal(allowed, true); + assert.equal(denied, false); +}); + +test('shifts.read requires staff scope', async () => { + const allowed = await can( + 'shifts.read', + 'shift', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1' }, + staff: { staffId: 'staff-1' }, + }, + }, + { params: {} } + ); + + const denied = await can( + 'shifts.read', + 'shift', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1' }, + business: { businessId: 'business-1' }, + }, + }, + { params: {} } + ); + + assert.equal(allowed, true); + assert.equal(denied, false); +}); + +test('attendance.read allows tenant-scoped actor', async () => { + const allowed = await can( + 'attendance.read', + 'attendance', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1' }, + }, + }, + { params: { tenantId: 'tenant-1' } } + ); + + assert.equal(allowed, true); +}); From 5792aa6e9855da07a9ae87851b4d7456cb2991e7 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 13:00:17 -0400 Subject: [PATCH 06/29] feat: add UTC parsing utilities and update date handling across entities - Introduced `utc_parser.dart` with functions to convert UTC timestamps to local time. - Updated date parsing in various entities to use the new utility functions for consistency. - Refactored date handling in `BenefitHistory`, `Business`, `AttendanceStatus`, `AssignedWorker`, `TimeRange`, `Invoice`, `PaymentChartPoint`, `StaffPayment`, `TimeCardEntry`, `OrderItem`, `OrderPreview`, `RecentOrder`, `StaffRating`, `CoverageDayPoint`, `ForecastWeek`, `NoShowIncident`, `SpendDataPoint`, `AssignedShift`, `CancelledShift`, `CompletedShift`, `OpenShift`, `PendingAssignment`, `Shift`, `ShiftDetail`, `TodayShift`, `BusinessMembership`, and `Staff`. - Updated `ReorderWidget` and `OrderEditSheet` to handle date formatting correctly. --- .../api_service/inspectors/auth_interceptor.dart | 2 +- .../core/lib/src/utils/date_time_utils.dart | 5 +++++ apps/mobile/packages/domain/lib/krow_domain.dart | 3 +++ .../domain/lib/src/core/utils/utc_parser.dart | 6 ++++++ .../src/entities/benefits/benefit_history.dart | 8 ++++---- .../lib/src/entities/business/business.dart | 10 ++++------ .../src/entities/clock_in/attendance_status.dart | 6 +++--- .../coverage_domain/assigned_worker.dart | 6 +++--- .../src/entities/coverage_domain/time_range.dart | 6 ++++-- .../lib/src/entities/financial/invoice.dart | 10 ++++------ .../entities/financial/payment_chart_point.dart | 4 +++- .../src/entities/financial/staff_payment.dart | 4 +++- .../lib/src/entities/financial/time_card.dart | 12 +++++------- .../lib/src/entities/orders/order_item.dart | 7 ++++--- .../lib/src/entities/orders/order_preview.dart | 14 ++++++-------- .../lib/src/entities/orders/recent_order.dart | 6 +++--- .../lib/src/entities/profile/certificate.dart | 6 ++++-- .../src/entities/profile/profile_document.dart | 4 +++- .../lib/src/entities/ratings/staff_rating.dart | 6 +++--- .../src/entities/reports/coverage_report.dart | 4 +++- .../src/entities/reports/forecast_report.dart | 4 +++- .../lib/src/entities/reports/no_show_report.dart | 4 +++- .../src/entities/reports/spend_data_point.dart | 3 ++- .../lib/src/entities/shifts/assigned_shift.dart | 7 ++++--- .../lib/src/entities/shifts/cancelled_shift.dart | 4 +++- .../lib/src/entities/shifts/completed_shift.dart | 11 +++++++---- .../lib/src/entities/shifts/open_shift.dart | 7 ++++--- .../src/entities/shifts/pending_assignment.dart | 8 +++++--- .../domain/lib/src/entities/shifts/shift.dart | 5 +++-- .../lib/src/entities/shifts/shift_detail.dart | 7 ++++--- .../lib/src/entities/shifts/today_shift.dart | 9 ++++----- .../lib/src/entities/users/biz_member.dart | 6 ++++-- .../domain/lib/src/entities/users/staff.dart | 6 ++++-- .../domain/lib/src/entities/users/user.dart | 6 ++++-- .../src/presentation/widgets/reorder_widget.dart | 16 ++-------------- .../presentation/widgets/order_edit_sheet.dart | 8 ++++---- .../presentation/widgets/view_order_card.dart | 5 ++--- 37 files changed, 136 insertions(+), 109 deletions(-) create mode 100644 apps/mobile/packages/domain/lib/src/core/utils/utc_parser.dart diff --git a/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart index 2d094d7c..bd1020bd 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart @@ -32,7 +32,7 @@ class AuthInterceptor extends Interceptor { if (!skipAuth) { final User? user = FirebaseAuth.instance.currentUser; if (user != null) { - final String? token = await user.getIdToken(); + final String? token = await user.getIdToken(); if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } diff --git a/apps/mobile/packages/core/lib/src/utils/date_time_utils.dart b/apps/mobile/packages/core/lib/src/utils/date_time_utils.dart index 1d142b33..d18d076d 100644 --- a/apps/mobile/packages/core/lib/src/utils/date_time_utils.dart +++ b/apps/mobile/packages/core/lib/src/utils/date_time_utils.dart @@ -4,4 +4,9 @@ class DateTimeUtils { static DateTime toDeviceTime(DateTime date) { return date.toLocal(); } + + /// Converts a local [DateTime] back to UTC for API payloads. + static String toUtcIso(DateTime local) { + return local.toUtc().toIso8601String(); + } } diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index c772ba45..c1f7814f 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -25,6 +25,9 @@ export 'src/entities/enums/staff_skill.dart'; export 'src/entities/enums/staff_status.dart'; export 'src/entities/enums/user_role.dart'; +// Utils +export 'src/core/utils/utc_parser.dart'; + // Core export 'src/core/services/api_services/api_endpoint.dart'; export 'src/core/services/api_services/api_response.dart'; diff --git a/apps/mobile/packages/domain/lib/src/core/utils/utc_parser.dart b/apps/mobile/packages/domain/lib/src/core/utils/utc_parser.dart new file mode 100644 index 00000000..8ec3572e --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/utils/utc_parser.dart @@ -0,0 +1,6 @@ +/// Parses a UTC ISO 8601 timestamp and converts to local device time. +DateTime parseUtcToLocal(String value) => DateTime.parse(value).toLocal(); + +/// Parses a nullable UTC ISO 8601 timestamp. Returns null if input is null. +DateTime? tryParseUtcToLocal(String? value) => + value != null ? DateTime.parse(value).toLocal() : null; diff --git a/apps/mobile/packages/domain/lib/src/entities/benefits/benefit_history.dart b/apps/mobile/packages/domain/lib/src/entities/benefits/benefit_history.dart index f9933a37..6ca1629d 100644 --- a/apps/mobile/packages/domain/lib/src/entities/benefits/benefit_history.dart +++ b/apps/mobile/packages/domain/lib/src/entities/benefits/benefit_history.dart @@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/enums/benefit_status.dart'; +import '../../core/utils/utc_parser.dart'; + /// A historical record of a staff benefit accrual period. /// /// Returned by `GET /staff/profile/benefits/history`. @@ -28,10 +30,8 @@ class BenefitHistory extends Equatable { benefitType: json['benefitType'] as String, title: json['title'] as String, status: BenefitStatus.fromJson(json['status'] as String?), - effectiveAt: DateTime.parse(json['effectiveAt'] as String), - endedAt: json['endedAt'] != null - ? DateTime.parse(json['endedAt'] as String) - : null, + effectiveAt: parseUtcToLocal(json['effectiveAt'] as String), + endedAt: tryParseUtcToLocal(json['endedAt'] as String?), trackedHours: (json['trackedHours'] as num).toInt(), targetHours: (json['targetHours'] as num).toInt(), notes: json['notes'] as String?, diff --git a/apps/mobile/packages/domain/lib/src/entities/business/business.dart b/apps/mobile/packages/domain/lib/src/entities/business/business.dart index 36339f32..2c658828 100644 --- a/apps/mobile/packages/domain/lib/src/entities/business/business.dart +++ b/apps/mobile/packages/domain/lib/src/entities/business/business.dart @@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/enums/business_status.dart'; +import '../../core/utils/utc_parser.dart'; + /// A client company registered on the platform. /// /// Maps to the V2 `businesses` table. @@ -35,12 +37,8 @@ class Business extends Equatable { metadata: json['metadata'] is Map ? Map.from(json['metadata'] as Map) : const {}, - createdAt: json['createdAt'] != null - ? DateTime.parse(json['createdAt'] as String) - : null, - updatedAt: json['updatedAt'] != null - ? DateTime.parse(json['updatedAt'] as String) - : null, + createdAt: tryParseUtcToLocal(json['createdAt'] as String?), + updatedAt: tryParseUtcToLocal(json['updatedAt'] as String?), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/clock_in/attendance_status.dart b/apps/mobile/packages/domain/lib/src/entities/clock_in/attendance_status.dart index 043179d7..b71f28e7 100644 --- a/apps/mobile/packages/domain/lib/src/entities/clock_in/attendance_status.dart +++ b/apps/mobile/packages/domain/lib/src/entities/clock_in/attendance_status.dart @@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/enums/attendance_status_type.dart'; +import '../../core/utils/utc_parser.dart'; + /// Current clock-in / attendance status of the staff member. /// /// Returned by `GET /staff/clock-in/status`. When no open session exists @@ -20,9 +22,7 @@ class AttendanceStatus extends Equatable { activeShiftId: json['activeShiftId'] as String?, attendanceStatus: AttendanceStatusType.fromJson(json['attendanceStatus'] as String?), - clockInAt: json['clockInAt'] != null - ? DateTime.parse(json['clockInAt'] as String) - : null, + clockInAt: tryParseUtcToLocal(json['clockInAt'] as String?), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart index a16e8a41..a0d82248 100644 --- a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart +++ b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart @@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/enums/assignment_status.dart'; +import '../../core/utils/utc_parser.dart'; + /// A worker assigned to a coverage shift. /// /// Nested within [ShiftWithWorkers]. @@ -23,9 +25,7 @@ class AssignedWorker extends Equatable { staffId: json['staffId'] as String, fullName: json['fullName'] as String, status: AssignmentStatus.fromJson(json['status'] as String?), - checkInAt: json['checkInAt'] != null - ? DateTime.parse(json['checkInAt'] as String) - : null, + checkInAt: tryParseUtcToLocal(json['checkInAt'] as String?), hasReview: json['hasReview'] as bool? ?? false, ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/time_range.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/time_range.dart index 543deccd..144d8194 100644 --- a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/time_range.dart +++ b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/time_range.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// A time range with start and end timestamps. /// /// Used within [ShiftWithWorkers] for shift time windows. @@ -13,8 +15,8 @@ class TimeRange extends Equatable { /// Deserialises a [TimeRange] from a V2 API JSON map. factory TimeRange.fromJson(Map json) { return TimeRange( - startsAt: DateTime.parse(json['startsAt'] as String), - endsAt: DateTime.parse(json['endsAt'] as String), + startsAt: parseUtcToLocal(json['startsAt'] as String), + endsAt: parseUtcToLocal(json['endsAt'] as String), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart b/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart index 7d370fd3..27afa489 100644 --- a/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart +++ b/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart @@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/enums/invoice_status.dart'; +import '../../core/utils/utc_parser.dart'; + /// An invoice issued to a business for services rendered. /// /// Returned by `GET /client/billing/invoices/*`. @@ -25,12 +27,8 @@ class Invoice extends Equatable { invoiceNumber: json['invoiceNumber'] as String, amountCents: (json['amountCents'] as num).toInt(), status: InvoiceStatus.fromJson(json['status'] as String?), - dueDate: json['dueDate'] != null - ? DateTime.parse(json['dueDate'] as String) - : null, - paymentDate: json['paymentDate'] != null - ? DateTime.parse(json['paymentDate'] as String) - : null, + dueDate: tryParseUtcToLocal(json['dueDate'] as String?), + paymentDate: tryParseUtcToLocal(json['paymentDate'] as String?), vendorId: json['vendorId'] as String?, vendorName: json['vendorName'] as String?, ); diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/payment_chart_point.dart b/apps/mobile/packages/domain/lib/src/entities/financial/payment_chart_point.dart index 2e9f92f0..ac5dfbc5 100644 --- a/apps/mobile/packages/domain/lib/src/entities/financial/payment_chart_point.dart +++ b/apps/mobile/packages/domain/lib/src/entities/financial/payment_chart_point.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// A single data point in the staff payment chart. /// /// Returned by `GET /staff/payments/chart`. @@ -13,7 +15,7 @@ class PaymentChartPoint extends Equatable { /// Deserialises a [PaymentChartPoint] from a V2 API JSON map. factory PaymentChartPoint.fromJson(Map json) { return PaymentChartPoint( - bucket: DateTime.parse(json['bucket'] as String), + bucket: parseUtcToLocal(json['bucket'] as String), amountCents: (json['amountCents'] as num).toInt(), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/staff_payment.dart b/apps/mobile/packages/domain/lib/src/entities/financial/staff_payment.dart index 3df1b383..159a7ef3 100644 --- a/apps/mobile/packages/domain/lib/src/entities/financial/staff_payment.dart +++ b/apps/mobile/packages/domain/lib/src/entities/financial/staff_payment.dart @@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/enums/payment_status.dart'; +import '../../core/utils/utc_parser.dart'; + /// A single payment record for a staff member. /// /// Returned by `GET /staff/payments/history`. @@ -23,7 +25,7 @@ class PaymentRecord extends Equatable { return PaymentRecord( paymentId: json['paymentId'] as String, amountCents: (json['amountCents'] as num).toInt(), - date: DateTime.parse(json['date'] as String), + date: parseUtcToLocal(json['date'] as String), status: PaymentStatus.fromJson(json['status'] as String?), shiftName: json['shiftName'] as String?, location: json['location'] as String?, diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/time_card.dart b/apps/mobile/packages/domain/lib/src/entities/financial/time_card.dart index a5d459ec..2447d686 100644 --- a/apps/mobile/packages/domain/lib/src/entities/financial/time_card.dart +++ b/apps/mobile/packages/domain/lib/src/entities/financial/time_card.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// A single time-card entry for a completed shift. /// /// Returned by `GET /staff/profile/time-card`. @@ -19,15 +21,11 @@ class TimeCardEntry extends Equatable { /// Deserialises a [TimeCardEntry] from a V2 API JSON map. factory TimeCardEntry.fromJson(Map json) { return TimeCardEntry( - date: DateTime.parse(json['date'] as String), + date: parseUtcToLocal(json['date'] as String), shiftName: json['shiftName'] as String, location: json['location'] as String?, - clockInAt: json['clockInAt'] != null - ? DateTime.parse(json['clockInAt'] as String) - : null, - clockOutAt: json['clockOutAt'] != null - ? DateTime.parse(json['clockOutAt'] as String) - : null, + clockInAt: tryParseUtcToLocal(json['clockInAt'] as String?), + clockOutAt: tryParseUtcToLocal(json['clockOutAt'] as String?), minutesWorked: (json['minutesWorked'] as num).toInt(), hourlyRateCents: json['hourlyRateCents'] != null ? (json['hourlyRateCents'] as num).toInt() diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart b/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart index 473495ca..dfcd6072 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart @@ -3,6 +3,7 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/enums/order_type.dart'; import 'package:krow_domain/src/entities/enums/shift_status.dart'; +import '../../core/utils/utc_parser.dart'; import 'assigned_worker_summary.dart'; /// A line item within an order, representing a role needed for a shift. @@ -42,9 +43,9 @@ class OrderItem extends Equatable { orderId: json['orderId'] as String, orderType: OrderType.fromJson(json['orderType'] as String?), roleName: json['roleName'] as String, - date: DateTime.parse(json['date'] as String), - startsAt: DateTime.parse(json['startsAt'] as String), - endsAt: DateTime.parse(json['endsAt'] as String), + date: parseUtcToLocal(json['date'] as String), + startsAt: parseUtcToLocal(json['startsAt'] as String), + endsAt: parseUtcToLocal(json['endsAt'] as String), requiredWorkerCount: (json['requiredWorkerCount'] as num).toInt(), filledCount: (json['filledCount'] as num).toInt(), hourlyRateCents: (json['hourlyRateCents'] as num).toInt(), diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/order_preview.dart b/apps/mobile/packages/domain/lib/src/entities/orders/order_preview.dart index 3bc41648..0ece8974 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/order_preview.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/order_preview.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// A preview of an order for reordering purposes. /// /// Returned by `GET /client/orders/:id/reorder-preview`. @@ -31,12 +33,8 @@ class OrderPreview extends Equatable { orderId: json['orderId'] as String, title: json['title'] as String, description: json['description'] as String?, - startsAt: json['startsAt'] != null - ? DateTime.parse(json['startsAt'] as String) - : null, - endsAt: json['endsAt'] != null - ? DateTime.parse(json['endsAt'] as String) - : null, + startsAt: tryParseUtcToLocal(json['startsAt'] as String?), + endsAt: tryParseUtcToLocal(json['endsAt'] as String?), locationName: json['locationName'] as String?, locationAddress: json['locationAddress'] as String?, metadata: json['metadata'] is Map @@ -128,8 +126,8 @@ class OrderPreviewShift extends Equatable { shiftId: json['shiftId'] as String, shiftCode: json['shiftCode'] as String, title: json['title'] as String, - startsAt: DateTime.parse(json['startsAt'] as String), - endsAt: DateTime.parse(json['endsAt'] as String), + startsAt: parseUtcToLocal(json['startsAt'] as String), + endsAt: parseUtcToLocal(json['endsAt'] as String), roles: rolesList, ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/recent_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/recent_order.dart index 453a048b..f3096033 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/recent_order.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/recent_order.dart @@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/enums/order_type.dart'; +import '../../core/utils/utc_parser.dart'; + /// A recently completed order available for reordering. /// /// Returned by `GET /client/reorders`. @@ -21,9 +23,7 @@ class RecentOrder extends Equatable { return RecentOrder( id: json['id'] as String, title: json['title'] as String, - date: json['date'] != null - ? DateTime.parse(json['date'] as String) - : null, + date: tryParseUtcToLocal(json['date'] as String?), hubName: json['hubName'] as String?, positionCount: (json['positionCount'] as num).toInt(), orderType: OrderType.fromJson(json['orderType'] as String?), diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/certificate.dart b/apps/mobile/packages/domain/lib/src/entities/profile/certificate.dart index 388e154a..7a629fe5 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/certificate.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/certificate.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// Status of a staff certificate. enum CertificateStatus { /// Certificate uploaded, pending verification. @@ -45,8 +47,8 @@ class StaffCertificate extends Equatable { fileUri: json['fileUri'] as String?, issuer: json['issuer'] as String?, certificateNumber: json['certificateNumber'] as String?, - issuedAt: json['issuedAt'] != null ? DateTime.parse(json['issuedAt'] as String) : null, - expiresAt: json['expiresAt'] != null ? DateTime.parse(json['expiresAt'] as String) : null, + issuedAt: tryParseUtcToLocal(json['issuedAt'] as String?), + expiresAt: tryParseUtcToLocal(json['expiresAt'] as String?), status: _parseStatus(json['status'] as String?), verificationStatus: json['verificationStatus'] as String?, ); diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/profile_document.dart b/apps/mobile/packages/domain/lib/src/entities/profile/profile_document.dart index d044f4e5..97028fcf 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/profile_document.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/profile_document.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// Status of a profile document. enum ProfileDocumentStatus { /// Document has not been uploaded yet. @@ -59,7 +61,7 @@ class ProfileDocument extends Equatable { staffDocumentId: json['staffDocumentId'] as String?, fileUri: json['fileUri'] as String?, status: _parseStatus(json['status'] as String?), - expiresAt: json['expiresAt'] != null ? DateTime.parse(json['expiresAt'] as String) : null, + expiresAt: tryParseUtcToLocal(json['expiresAt'] as String?), metadata: (json['metadata'] as Map?) ?? const {}, ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/ratings/staff_rating.dart b/apps/mobile/packages/domain/lib/src/entities/ratings/staff_rating.dart index a1a70391..a7977548 100644 --- a/apps/mobile/packages/domain/lib/src/entities/ratings/staff_rating.dart +++ b/apps/mobile/packages/domain/lib/src/entities/ratings/staff_rating.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// A review left for a staff member after an assignment. /// /// Maps to the V2 `staff_reviews` table. @@ -35,9 +37,7 @@ class StaffRating extends Equatable { rating: (json['rating'] as num).toInt(), reviewText: json['reviewText'] as String?, tags: tagsList, - createdAt: json['createdAt'] != null - ? DateTime.parse(json['createdAt'] as String) - : null, + createdAt: tryParseUtcToLocal(json['createdAt'] as String?), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/coverage_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/coverage_report.dart index 9ed90277..5930090d 100644 --- a/apps/mobile/packages/domain/lib/src/entities/reports/coverage_report.dart +++ b/apps/mobile/packages/domain/lib/src/entities/reports/coverage_report.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// Coverage report with daily breakdown. /// /// Returned by `GET /client/reports/coverage`. @@ -75,7 +77,7 @@ class CoverageDayPoint extends Equatable { /// Deserialises a [CoverageDayPoint] from a V2 API JSON map. factory CoverageDayPoint.fromJson(Map json) { return CoverageDayPoint( - day: DateTime.parse(json['day'] as String), + day: parseUtcToLocal(json['day'] as String), needed: (json['needed'] as num).toInt(), filled: (json['filled'] as num).toInt(), coveragePercentage: (json['coveragePercentage'] as num).toDouble(), diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/forecast_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/forecast_report.dart index 20c7a3a1..da7d7c1c 100644 --- a/apps/mobile/packages/domain/lib/src/entities/reports/forecast_report.dart +++ b/apps/mobile/packages/domain/lib/src/entities/reports/forecast_report.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// Staffing and spend forecast report. /// /// Returned by `GET /client/reports/forecast`. @@ -83,7 +85,7 @@ class ForecastWeek extends Equatable { /// Deserialises a [ForecastWeek] from a V2 API JSON map. factory ForecastWeek.fromJson(Map json) { return ForecastWeek( - week: DateTime.parse(json['week'] as String), + week: parseUtcToLocal(json['week'] as String), shiftCount: (json['shiftCount'] as num).toInt(), workerHours: (json['workerHours'] as num).toDouble(), forecastSpendCents: (json['forecastSpendCents'] as num).toInt(), diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/no_show_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/no_show_report.dart index f4f9047c..a9f13f6b 100644 --- a/apps/mobile/packages/domain/lib/src/entities/reports/no_show_report.dart +++ b/apps/mobile/packages/domain/lib/src/entities/reports/no_show_report.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// No-show report with per-worker incident details. /// /// Returned by `GET /client/reports/no-show`. @@ -143,7 +145,7 @@ class NoShowIncident extends Equatable { shiftId: json['shiftId'] as String, shiftTitle: json['shiftTitle'] as String, roleName: json['roleName'] as String, - date: DateTime.parse(json['date'] as String), + date: parseUtcToLocal(json['date'] as String), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/spend_data_point.dart b/apps/mobile/packages/domain/lib/src/entities/reports/spend_data_point.dart index 30480fae..0e39e55f 100644 --- a/apps/mobile/packages/domain/lib/src/entities/reports/spend_data_point.dart +++ b/apps/mobile/packages/domain/lib/src/entities/reports/spend_data_point.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; import '../financial/spend_item.dart'; /// Spend report with total, chart data points, and category breakdown. @@ -71,7 +72,7 @@ class SpendDataPoint extends Equatable { /// Deserialises a [SpendDataPoint] from a V2 API JSON map. factory SpendDataPoint.fromJson(Map json) { return SpendDataPoint( - bucket: DateTime.parse(json['bucket'] as String), + bucket: parseUtcToLocal(json['bucket'] as String), amountCents: (json['amountCents'] as num).toInt(), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart index 11b27bc1..1ab5f69e 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/src/core/utils/utc_parser.dart'; import 'package:krow_domain/src/entities/enums/assignment_status.dart'; import 'package:krow_domain/src/entities/enums/order_type.dart'; @@ -33,9 +34,9 @@ class AssignedShift extends Equatable { shiftId: json['shiftId'] as String, roleName: json['roleName'] as String, location: json['location'] as String? ?? '', - date: DateTime.parse(json['date'] as String), - startTime: DateTime.parse(json['startTime'] as String), - endTime: DateTime.parse(json['endTime'] as String), + date: parseUtcToLocal(json['date'] as String), + startTime: parseUtcToLocal(json['startTime'] as String), + endTime: parseUtcToLocal(json['endTime'] as String), hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0, totalRateCents: json['totalRateCents'] as int? ?? 0, diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart index 6fb4741d..d2cff728 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/src/core/utils/utc_parser.dart'; + /// A shift whose assignment was cancelled. /// /// Returned by `GET /staff/shifts/cancelled`. Shows past assignments @@ -22,7 +24,7 @@ class CancelledShift extends Equatable { shiftId: json['shiftId'] as String, title: json['title'] as String? ?? '', location: json['location'] as String? ?? '', - date: DateTime.parse(json['date'] as String), + date: parseUtcToLocal(json['date'] as String), cancellationReason: json['cancellationReason'] as String?, ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart index 54f29d7d..8f99fc47 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart @@ -1,5 +1,8 @@ import 'package:equatable/equatable.dart'; -import 'package:krow_domain/krow_domain.dart'; + +import 'package:krow_domain/src/core/utils/utc_parser.dart'; +import 'package:krow_domain/src/entities/enums/assignment_status.dart'; +import 'package:krow_domain/src/entities/enums/payment_status.dart'; /// A shift the staff member has completed. /// @@ -34,12 +37,12 @@ class CompletedShift extends Equatable { title: json['title'] as String? ?? '', location: json['location'] as String? ?? '', clientName: json['clientName'] as String? ?? '', - date: DateTime.parse(json['date'] as String), + date: parseUtcToLocal(json['date'] as String), startTime: json['startTime'] != null - ? DateTime.parse(json['startTime'] as String) + ? parseUtcToLocal(json['startTime'] as String) : DateTime.now(), endTime: json['endTime'] != null - ? DateTime.parse(json['endTime'] as String) + ? parseUtcToLocal(json['endTime'] as String) : DateTime.now(), minutesWorked: json['minutesWorked'] as int? ?? 0, hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart index 856deef5..f2b5c9a6 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/src/core/utils/utc_parser.dart'; import 'package:krow_domain/src/entities/enums/order_type.dart'; /// An open shift available for the staff member to apply to. @@ -32,9 +33,9 @@ class OpenShift extends Equatable { roleName: json['roleName'] as String, clientName: json['clientName'] as String? ?? '', location: json['location'] as String? ?? '', - date: DateTime.parse(json['date'] as String), - startTime: DateTime.parse(json['startTime'] as String), - endTime: DateTime.parse(json['endTime'] as String), + date: parseUtcToLocal(json['date'] as String), + startTime: parseUtcToLocal(json['startTime'] as String), + endTime: parseUtcToLocal(json['endTime'] as String), hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0, orderType: OrderType.fromJson(json['orderType'] as String?), diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart index b22d6bc4..c96c5810 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/src/core/utils/utc_parser.dart'; + /// An assignment awaiting the staff member's acceptance. /// /// Returned by `GET /staff/shifts/pending`. These are assignments with @@ -24,10 +26,10 @@ class PendingAssignment extends Equatable { shiftId: json['shiftId'] as String, title: json['title'] as String? ?? '', roleName: json['roleName'] as String, - startTime: DateTime.parse(json['startTime'] as String), - endTime: DateTime.parse(json['endTime'] as String), + startTime: parseUtcToLocal(json['startTime'] as String), + endTime: parseUtcToLocal(json['endTime'] as String), location: json['location'] as String? ?? '', - responseDeadline: DateTime.parse(json['responseDeadline'] as String), + responseDeadline: parseUtcToLocal(json['responseDeadline'] as String), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart index 8b45cf75..69900461 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/src/core/utils/utc_parser.dart'; import 'package:krow_domain/src/entities/enums/shift_status.dart'; /// Core shift entity aligned with the V2 `shifts` table. @@ -48,10 +49,10 @@ class Shift extends Equatable { clientName ?? '', status: ShiftStatus.fromJson(json['status'] as String?), - startsAt: DateTime.parse( + startsAt: parseUtcToLocal( json['startsAt'] as String? ?? json['startTime'] as String, ), - endsAt: DateTime.parse( + endsAt: parseUtcToLocal( json['endsAt'] as String? ?? json['endTime'] as String, ), timezone: json['timezone'] as String? ?? 'UTC', diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart index c4082982..38e2dc23 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/src/core/utils/utc_parser.dart'; import 'package:krow_domain/src/entities/enums/application_status.dart'; import 'package:krow_domain/src/entities/enums/assignment_status.dart'; import 'package:krow_domain/src/entities/enums/order_type.dart'; @@ -53,9 +54,9 @@ class ShiftDetail extends Equatable { clientName: json['clientName'] as String? ?? '', latitude: Shift.parseDouble(json['latitude']), longitude: Shift.parseDouble(json['longitude']), - date: DateTime.parse(json['date'] as String), - startTime: DateTime.parse(json['startTime'] as String), - endTime: DateTime.parse(json['endTime'] as String), + date: parseUtcToLocal(json['date'] as String), + startTime: parseUtcToLocal(json['startTime'] as String), + endTime: parseUtcToLocal(json['endTime'] as String), roleId: json['roleId'] as String, roleName: json['roleName'] as String, hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/today_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/today_shift.dart index 01248ff3..cefd2f26 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/today_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/today_shift.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/src/core/utils/utc_parser.dart'; import 'package:krow_domain/src/entities/enums/attendance_status_type.dart'; /// A shift assigned to the staff member for today. @@ -33,8 +34,8 @@ class TodayShift extends Equatable { shiftId: json['shiftId'] as String, roleName: json['roleName'] as String, location: json['location'] as String? ?? '', - startTime: DateTime.parse(json['startTime'] as String), - endTime: DateTime.parse(json['endTime'] as String), + startTime: parseUtcToLocal(json['startTime'] as String), + endTime: parseUtcToLocal(json['endTime'] as String), attendanceStatus: AttendanceStatusType.fromJson(json['attendanceStatus'] as String?), clientName: json['clientName'] as String? ?? '', hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, @@ -42,9 +43,7 @@ class TodayShift extends Equatable { totalRateCents: json['totalRateCents'] as int? ?? 0, totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0, locationAddress: json['locationAddress'] as String?, - clockInAt: json['clockInAt'] != null - ? DateTime.parse(json['clockInAt'] as String) - : null, + clockInAt: tryParseUtcToLocal(json['clockInAt'] as String?), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/users/biz_member.dart b/apps/mobile/packages/domain/lib/src/entities/users/biz_member.dart index ee071a75..fce809bc 100644 --- a/apps/mobile/packages/domain/lib/src/entities/users/biz_member.dart +++ b/apps/mobile/packages/domain/lib/src/entities/users/biz_member.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// Membership status within a business. enum BusinessMembershipStatus { /// The user has been invited but has not accepted. @@ -63,8 +65,8 @@ class BusinessMembership extends Equatable { businessName: json['businessName'] as String?, businessSlug: json['businessSlug'] as String?, metadata: (json['metadata'] as Map?) ?? const {}, - createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'] as String) : null, - updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'] as String) : null, + createdAt: tryParseUtcToLocal(json['createdAt'] as String?), + updatedAt: tryParseUtcToLocal(json['updatedAt'] as String?), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/users/staff.dart b/apps/mobile/packages/domain/lib/src/entities/users/staff.dart index d46849a0..bf2c9d89 100644 --- a/apps/mobile/packages/domain/lib/src/entities/users/staff.dart +++ b/apps/mobile/packages/domain/lib/src/entities/users/staff.dart @@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart' show OnboardingStatus, StaffStatus; +import '../../core/utils/utc_parser.dart'; + /// Represents a worker profile in the KROW platform. /// /// Maps to the V2 `staffs` table. Linked to a [User] via [userId]. @@ -47,8 +49,8 @@ class Staff extends Equatable { workforceId: json['workforceId'] as String?, vendorId: json['vendorId'] as String?, workforceNumber: json['workforceNumber'] as String?, - createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'] as String) : null, - updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'] as String) : null, + createdAt: tryParseUtcToLocal(json['createdAt'] as String?), + updatedAt: tryParseUtcToLocal(json['updatedAt'] as String?), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/users/user.dart b/apps/mobile/packages/domain/lib/src/entities/users/user.dart index fe2c5785..13eacc21 100644 --- a/apps/mobile/packages/domain/lib/src/entities/users/user.dart +++ b/apps/mobile/packages/domain/lib/src/entities/users/user.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// Account status for a platform user. enum UserStatus { /// User is active and can sign in. @@ -37,8 +39,8 @@ class User extends Equatable { phone: json['phone'] as String?, status: _parseUserStatus(json['status'] as String?), metadata: (json['metadata'] as Map?) ?? const {}, - createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'] as String) : null, - updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'] as String) : null, + createdAt: tryParseUtcToLocal(json['createdAt'] as String?), + updatedAt: tryParseUtcToLocal(json['updatedAt'] as String?), ); } diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart index c3cf54d2..eace8942 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart @@ -75,7 +75,7 @@ class ReorderWidget extends StatelessWidget { borderRadius: UiConstants.radiusLg, ), child: const Icon( - UiIcons.building, + UiIcons.briefcase, size: 16, color: UiColors.primary, ), @@ -104,18 +104,6 @@ class ReorderWidget extends StatelessWidget { ], ), ), - // Column( - // crossAxisAlignment: CrossAxisAlignment.end, - // children: [ - // // ASSUMPTION: No i18n key for 'positions' under - // // reorder section — carrying forward existing - // // hardcoded string pattern for this migration. - // Text( - // '${order.positionCount} positions', - // style: UiTypography.footnote2r.textSecondary, - // ), - // ], - // ), ], ), const SizedBox(height: UiConstants.space3), @@ -130,7 +118,7 @@ class ReorderWidget extends StatelessWidget { ), const SizedBox(width: UiConstants.space2), _Badge( - icon: UiIcons.building, + icon: UiIcons.users, text: '${order.positionCount}', color: UiColors.textSecondary, bg: UiColors.buttonSecondaryStill, diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart index 44add689..7ba84dc8 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart @@ -48,13 +48,13 @@ class OrderEditSheetState extends State { _orderNameController = TextEditingController(text: widget.order.roleName); final String startHH = - widget.order.startsAt.toLocal().hour.toString().padLeft(2, '0'); + widget.order.startsAt.hour.toString().padLeft(2, '0'); final String startMM = - widget.order.startsAt.toLocal().minute.toString().padLeft(2, '0'); + widget.order.startsAt.minute.toString().padLeft(2, '0'); final String endHH = - widget.order.endsAt.toLocal().hour.toString().padLeft(2, '0'); + widget.order.endsAt.hour.toString().padLeft(2, '0'); final String endMM = - widget.order.endsAt.toLocal().minute.toString().padLeft(2, '0'); + widget.order.endsAt.minute.toString().padLeft(2, '0'); _positions = >[ { diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart index fa9fdd1a..969aed43 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -77,9 +77,8 @@ class _ViewOrderCardState extends State { /// Formats a [DateTime] to a display time string (e.g. "9:00 AM"). String _formatTime({required DateTime dateTime}) { - final DateTime local = dateTime.toLocal(); - final int hour24 = local.hour; - final int minute = local.minute; + final int hour24 = dateTime.hour; + final int minute = dateTime.minute; final String ampm = hour24 >= 12 ? 'PM' : 'AM'; int hour = hour24 % 12; if (hour == 0) hour = 12; From 96056d0170d936cde3c4dbcd54596df8dd8f3ab7 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 13:23:28 -0400 Subject: [PATCH 07/29] feat: Implement available orders feature in staff marketplace - Added `AvailableOrder` and `AvailableOrderSchedule` entities to represent available orders and their schedules. - Introduced `GetAvailableOrdersUseCase` and `BookOrderUseCase` for fetching and booking orders. - Created `AvailableOrdersBloc` to manage the state of available orders and handle booking actions. - Developed UI components including `AvailableOrderCard` to display order details and booking options. - Added necessary events and states for the BLoC architecture to support loading and booking orders. - Integrated new enums and utility functions for handling order types and scheduling. --- .../endpoints/client_endpoints.dart | 2 +- .../endpoints/staff_endpoints.dart | 8 + .../lib/src/l10n/en.i18n.json | 13 + .../lib/src/l10n/es.i18n.json | 13 + .../packages/domain/lib/krow_domain.dart | 7 +- .../lib/src/entities/enums/day_of_week.dart | 46 ++ .../src/entities/orders/available_order.dart | 145 ++++++ .../orders/available_order_schedule.dart | 99 +++++ .../orders/booking_assigned_shift.dart | 92 ++++ .../src/entities/orders/order_booking.dart | 94 ++++ .../shifts_repository_impl.dart | 34 ++ .../shifts_repository_interface.dart | 12 + .../domain/usecases/book_order_usecase.dart | 23 + .../get_available_orders_usecase.dart | 20 + .../available_orders_bloc.dart | 97 ++++ .../available_orders_event.dart | 45 ++ .../available_orders_state.dart | 74 ++++ .../src/presentation/pages/shifts_page.dart | 386 ++++++++-------- .../widgets/available_order_card.dart | 415 ++++++++++++++++++ .../widgets/tabs/find_shifts_tab.dart | 221 ++-------- .../shifts/lib/src/staff_shifts_module.dart | 11 + 21 files changed, 1498 insertions(+), 359 deletions(-) create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/day_of_week.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/available_order.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/available_order_schedule.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/booking_assigned_shift.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/order_booking.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/book_order_usecase.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_available_orders_usecase.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_bloc.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_event.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_state.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart index aeb0f45f..d541c8ef 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart @@ -92,7 +92,7 @@ abstract final class ClientEndpoints { /// View orders. static const ApiEndpoint ordersView = - ApiEndpoint('/client/orders/view'); + ApiEndpoint('/client/shifts/scheduled'); /// Order reorder preview. static ApiEndpoint orderReorderPreview(String orderId) => diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart index d6fb3634..9f0f07aa 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart @@ -130,6 +130,10 @@ abstract final class StaffEndpoints { /// FAQs search. static const ApiEndpoint faqsSearch = ApiEndpoint('/staff/faqs/search'); + /// Available orders for the marketplace. + static const ApiEndpoint ordersAvailable = + ApiEndpoint('/staff/orders/available'); + // ── Write ───────────────────────────────────────────────────────────── /// Staff profile setup. @@ -198,6 +202,10 @@ abstract final class StaffEndpoints { static const ApiEndpoint locationStreams = ApiEndpoint('/staff/location-streams'); + /// Book an available order. + static ApiEndpoint orderBook(String orderId) => + ApiEndpoint('/staff/orders/$orderId/book'); + /// Register or delete device push token (POST to register, DELETE to remove). static const ApiEndpoint devicesPushTokens = ApiEndpoint('/staff/devices/push-tokens'); diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 4b63ce6b..cd8d5e29 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -1877,5 +1877,18 @@ "success_message": "Cash out request submitted!", "fee_notice": "A small fee of \\$1.99 may apply for instant transfers." } + }, + "available_orders": { + "book_order": "Book Order", + "apply": "Apply", + "spots_left": "${count} spot(s) left", + "shifts_count": "${count} shift(s)", + "booking_success": "Order booked successfully!", + "booking_pending": "Your booking is pending approval", + "booking_confirmed": "Your booking has been confirmed!", + "no_orders": "No orders available", + "no_orders_subtitle": "Check back later for new opportunities", + "instant_book": "Instant Book", + "per_hour": "/hr" } } \ No newline at end of file diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 731896fd..b7f47371 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -1877,5 +1877,18 @@ "success_message": "\u00a1Solicitud de retiro enviada!", "fee_notice": "Puede aplicarse una peque\u00f1a tarifa de \\$1.99 para transferencias instant\u00e1neas." } + }, + "available_orders": { + "book_order": "Reservar Orden", + "apply": "Aplicar", + "spots_left": "${count} puesto(s) disponible(s)", + "shifts_count": "${count} turno(s)", + "booking_success": "\u00a1Orden reservada con \u00e9xito!", + "booking_pending": "Tu reserva est\u00e1 pendiente de aprobaci\u00f3n", + "booking_confirmed": "\u00a1Tu reserva ha sido confirmada!", + "no_orders": "No hay \u00f3rdenes disponibles", + "no_orders_subtitle": "Vuelve m\u00e1s tarde para nuevas oportunidades", + "instant_book": "Reserva Instant\u00e1nea", + "per_hour": "/hr" } } \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index c1f7814f..c3e3db24 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -16,6 +16,7 @@ export 'src/entities/enums/benefit_status.dart'; export 'src/entities/enums/business_status.dart'; export 'src/entities/enums/invoice_status.dart'; export 'src/entities/enums/onboarding_status.dart'; +export 'src/entities/enums/day_of_week.dart'; export 'src/entities/enums/order_type.dart'; export 'src/entities/enums/payment_status.dart'; export 'src/entities/enums/review_issue_flag.dart'; @@ -69,8 +70,12 @@ export 'src/entities/shifts/completed_shift.dart'; export 'src/entities/shifts/shift_detail.dart'; // Orders -export 'src/entities/orders/order_item.dart'; +export 'src/entities/orders/available_order.dart'; +export 'src/entities/orders/available_order_schedule.dart'; export 'src/entities/orders/assigned_worker_summary.dart'; +export 'src/entities/orders/booking_assigned_shift.dart'; +export 'src/entities/orders/order_booking.dart'; +export 'src/entities/orders/order_item.dart'; export 'src/entities/orders/order_preview.dart'; export 'src/entities/orders/recent_order.dart'; diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/day_of_week.dart b/apps/mobile/packages/domain/lib/src/entities/enums/day_of_week.dart new file mode 100644 index 00000000..2c4620b6 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/day_of_week.dart @@ -0,0 +1,46 @@ +/// Day of the week for order scheduling. +/// +/// Maps to the `day_of_week` values used in V2 order schedule responses. +enum DayOfWeek { + /// Monday. + mon('MON'), + + /// Tuesday. + tue('TUE'), + + /// Wednesday. + wed('WED'), + + /// Thursday. + thu('THU'), + + /// Friday. + fri('FRI'), + + /// Saturday. + sat('SAT'), + + /// Sunday. + sun('SUN'), + + /// Fallback for unrecognised API values. + unknown('UNKNOWN'); + + const DayOfWeek(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static DayOfWeek fromJson(String? value) { + if (value == null) return DayOfWeek.unknown; + final String upper = value.toUpperCase(); + for (final DayOfWeek day in DayOfWeek.values) { + if (day.value == upper) return day; + } + return DayOfWeek.unknown; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/available_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/available_order.dart new file mode 100644 index 00000000..e2886c3c --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/available_order.dart @@ -0,0 +1,145 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/entities/enums/order_type.dart'; +import 'package:krow_domain/src/entities/orders/available_order_schedule.dart'; + +/// An available order in the staff marketplace. +/// +/// Returned by `GET /staff/orders/available`. Represents an order-level card +/// that a staff member can book into, containing role, location, pay rate, +/// and schedule details. +class AvailableOrder extends Equatable { + /// Creates an [AvailableOrder]. + const AvailableOrder({ + required this.orderId, + required this.orderType, + required this.roleId, + required this.roleCode, + required this.roleName, + this.clientName = '', + this.location = '', + this.locationAddress = '', + required this.hourlyRateCents, + required this.hourlyRate, + required this.requiredWorkerCount, + required this.filledCount, + required this.instantBook, + this.dispatchTeam = '', + this.dispatchPriority = 0, + required this.schedule, + }); + + /// Deserialises from the V2 API JSON response. + factory AvailableOrder.fromJson(Map json) { + return AvailableOrder( + orderId: json['orderId'] as String, + orderType: OrderType.fromJson(json['orderType'] as String?), + roleId: json['roleId'] as String, + roleCode: json['roleCode'] as String? ?? '', + roleName: json['roleName'] as String? ?? '', + clientName: json['clientName'] as String? ?? '', + location: json['location'] as String? ?? '', + locationAddress: json['locationAddress'] as String? ?? '', + hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, + hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0, + requiredWorkerCount: json['requiredWorkerCount'] as int? ?? 1, + filledCount: json['filledCount'] as int? ?? 0, + instantBook: json['instantBook'] as bool? ?? false, + dispatchTeam: json['dispatchTeam'] as String? ?? '', + dispatchPriority: json['dispatchPriority'] as int? ?? 0, + schedule: AvailableOrderSchedule.fromJson( + json['schedule'] as Map, + ), + ); + } + + /// The order row id. + final String orderId; + + /// Type of order (one-time, recurring, permanent, etc.). + final OrderType orderType; + + /// The shift-role row id. + final String roleId; + + /// Machine-readable role code. + final String roleCode; + + /// Display name of the role. + final String roleName; + + /// Name of the client/business offering this order. + final String clientName; + + /// Human-readable location label. + final String location; + + /// Full street address of the location. + final String locationAddress; + + /// Pay rate in cents per hour. + final int hourlyRateCents; + + /// Pay rate in dollars per hour. + final double hourlyRate; + + /// Total number of workers required for this role. + final int requiredWorkerCount; + + /// Number of positions already filled. + final int filledCount; + + /// Whether the order supports instant booking (no approval needed). + final bool instantBook; + + /// Dispatch team identifier. + final String dispatchTeam; + + /// Priority level for dispatch ordering. + final int dispatchPriority; + + /// Schedule details including recurrence, times, and bounding timestamps. + final AvailableOrderSchedule schedule; + + /// Serialises to JSON. + Map toJson() { + return { + 'orderId': orderId, + 'orderType': orderType.toJson(), + 'roleId': roleId, + 'roleCode': roleCode, + 'roleName': roleName, + 'clientName': clientName, + 'location': location, + 'locationAddress': locationAddress, + 'hourlyRateCents': hourlyRateCents, + 'hourlyRate': hourlyRate, + 'requiredWorkerCount': requiredWorkerCount, + 'filledCount': filledCount, + 'instantBook': instantBook, + 'dispatchTeam': dispatchTeam, + 'dispatchPriority': dispatchPriority, + 'schedule': schedule.toJson(), + }; + } + + @override + List get props => [ + orderId, + orderType, + roleId, + roleCode, + roleName, + clientName, + location, + locationAddress, + hourlyRateCents, + hourlyRate, + requiredWorkerCount, + filledCount, + instantBook, + dispatchTeam, + dispatchPriority, + schedule, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/available_order_schedule.dart b/apps/mobile/packages/domain/lib/src/entities/orders/available_order_schedule.dart new file mode 100644 index 00000000..56c0704c --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/available_order_schedule.dart @@ -0,0 +1,99 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/core/utils/utc_parser.dart'; +import 'package:krow_domain/src/entities/enums/day_of_week.dart'; + +/// Schedule details for an available order in the marketplace. +/// +/// Contains the recurrence pattern, time window, and bounding timestamps +/// for the order's shifts. +class AvailableOrderSchedule extends Equatable { + /// Creates an [AvailableOrderSchedule]. + const AvailableOrderSchedule({ + required this.totalShifts, + required this.startDate, + required this.endDate, + required this.daysOfWeek, + required this.startTime, + required this.endTime, + required this.timezone, + required this.firstShiftStartsAt, + required this.lastShiftEndsAt, + }); + + /// Deserialises from the V2 API JSON response. + factory AvailableOrderSchedule.fromJson(Map json) { + return AvailableOrderSchedule( + totalShifts: json['totalShifts'] as int? ?? 0, + startDate: json['startDate'] as String? ?? '', + endDate: json['endDate'] as String? ?? '', + daysOfWeek: (json['daysOfWeek'] as List?) + ?.map( + (dynamic e) => DayOfWeek.fromJson(e as String), + ) + .toList() ?? + [], + startTime: json['startTime'] as String? ?? '', + endTime: json['endTime'] as String? ?? '', + timezone: json['timezone'] as String? ?? 'UTC', + firstShiftStartsAt: + parseUtcToLocal(json['firstShiftStartsAt'] as String), + lastShiftEndsAt: parseUtcToLocal(json['lastShiftEndsAt'] as String), + ); + } + + /// Total number of shifts in this schedule. + final int totalShifts; + + /// Date-only start string (e.g. "2026-03-24"). + final String startDate; + + /// Date-only end string. + final String endDate; + + /// Days of the week the order repeats on. + final List daysOfWeek; + + /// Daily start time display string (e.g. "09:00"). + final String startTime; + + /// Daily end time display string (e.g. "15:00"). + final String endTime; + + /// IANA timezone identifier (e.g. "America/Los_Angeles"). + final String timezone; + + /// UTC timestamp of the first shift's start, converted to local time. + final DateTime firstShiftStartsAt; + + /// UTC timestamp of the last shift's end, converted to local time. + final DateTime lastShiftEndsAt; + + /// Serialises to JSON. + Map toJson() => { + 'totalShifts': totalShifts, + 'startDate': startDate, + 'endDate': endDate, + 'daysOfWeek': + daysOfWeek.map((DayOfWeek e) => e.toJson()).toList(), + 'startTime': startTime, + 'endTime': endTime, + 'timezone': timezone, + 'firstShiftStartsAt': + firstShiftStartsAt.toUtc().toIso8601String(), + 'lastShiftEndsAt': lastShiftEndsAt.toUtc().toIso8601String(), + }; + + @override + List get props => [ + totalShifts, + startDate, + endDate, + daysOfWeek, + startTime, + endTime, + timezone, + firstShiftStartsAt, + lastShiftEndsAt, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/booking_assigned_shift.dart b/apps/mobile/packages/domain/lib/src/entities/orders/booking_assigned_shift.dart new file mode 100644 index 00000000..e4b2c8a3 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/booking_assigned_shift.dart @@ -0,0 +1,92 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/core/utils/utc_parser.dart'; + +/// A shift assigned to a staff member as part of an order booking. +/// +/// Returned within the `assignedShifts` array of the +/// `POST /staff/orders/:orderId/book` response. +class BookingAssignedShift extends Equatable { + /// Creates a [BookingAssignedShift]. + const BookingAssignedShift({ + required this.shiftId, + required this.date, + required this.startsAt, + required this.endsAt, + required this.startTime, + required this.endTime, + required this.timezone, + required this.assignmentId, + this.assignmentStatus = '', + }); + + /// Deserialises from the V2 API JSON response. + factory BookingAssignedShift.fromJson(Map json) { + return BookingAssignedShift( + shiftId: json['shiftId'] as String, + date: json['date'] as String? ?? '', + startsAt: parseUtcToLocal(json['startsAt'] as String), + endsAt: parseUtcToLocal(json['endsAt'] as String), + startTime: json['startTime'] as String? ?? '', + endTime: json['endTime'] as String? ?? '', + timezone: json['timezone'] as String? ?? 'UTC', + assignmentId: json['assignmentId'] as String, + assignmentStatus: json['assignmentStatus'] as String? ?? '', + ); + } + + /// The shift row id. + final String shiftId; + + /// Date-only display string (e.g. "2026-03-24"). + final String date; + + /// UTC start timestamp converted to local time. + final DateTime startsAt; + + /// UTC end timestamp converted to local time. + final DateTime endsAt; + + /// Display start time string (e.g. "09:00"). + final String startTime; + + /// Display end time string (e.g. "15:00"). + final String endTime; + + /// IANA timezone identifier. + final String timezone; + + /// The assignment row id linking staff to this shift. + final String assignmentId; + + /// Current status of the assignment (e.g. "ASSIGNED"). + final String assignmentStatus; + + /// Serialises to JSON. + Map toJson() { + return { + 'shiftId': shiftId, + 'date': date, + 'startsAt': startsAt.toUtc().toIso8601String(), + 'endsAt': endsAt.toUtc().toIso8601String(), + 'startTime': startTime, + 'endTime': endTime, + 'timezone': timezone, + 'assignmentId': assignmentId, + 'assignmentStatus': assignmentStatus, + }; + } + + @override + List get props => [ + shiftId, + date, + startsAt, + endsAt, + startTime, + endTime, + timezone, + assignmentId, + assignmentStatus, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/order_booking.dart b/apps/mobile/packages/domain/lib/src/entities/orders/order_booking.dart new file mode 100644 index 00000000..d4db906a --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/order_booking.dart @@ -0,0 +1,94 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/entities/orders/booking_assigned_shift.dart'; + +/// Result of booking an order via `POST /staff/orders/:orderId/book`. +/// +/// Contains the booking metadata and the list of shifts assigned to the +/// staff member as part of this booking. +class OrderBooking extends Equatable { + /// Creates an [OrderBooking]. + const OrderBooking({ + required this.bookingId, + required this.orderId, + required this.roleId, + this.roleCode = '', + this.roleName = '', + required this.assignedShiftCount, + this.status = 'PENDING', + this.assignedShifts = const [], + }); + + /// Deserialises from the V2 API JSON response. + factory OrderBooking.fromJson(Map json) { + return OrderBooking( + bookingId: json['bookingId'] as String, + orderId: json['orderId'] as String, + roleId: json['roleId'] as String, + roleCode: json['roleCode'] as String? ?? '', + roleName: json['roleName'] as String? ?? '', + assignedShiftCount: json['assignedShiftCount'] as int? ?? 0, + status: json['status'] as String? ?? 'PENDING', + assignedShifts: (json['assignedShifts'] as List?) + ?.map( + (dynamic e) => BookingAssignedShift.fromJson( + e as Map, + ), + ) + .toList() ?? + [], + ); + } + + /// Unique booking identifier. + final String bookingId; + + /// The order this booking belongs to. + final String orderId; + + /// The role row id within the order. + final String roleId; + + /// Machine-readable role code. + final String roleCode; + + /// Display name of the role. + final String roleName; + + /// Number of shifts assigned in this booking. + final int assignedShiftCount; + + /// Booking status (e.g. "PENDING", "CONFIRMED"). + final String status; + + /// The individual shifts assigned as part of this booking. + final List assignedShifts; + + /// Serialises to JSON. + Map toJson() { + return { + 'bookingId': bookingId, + 'orderId': orderId, + 'roleId': roleId, + 'roleCode': roleCode, + 'roleName': roleName, + 'assignedShiftCount': assignedShiftCount, + 'status': status, + 'assignedShifts': assignedShifts + .map((BookingAssignedShift e) => e.toJson()) + .toList(), + }; + } + + @override + List get props => [ + bookingId, + orderId, + roleId, + roleCode, + roleName, + assignedShiftCount, + status, + assignedShifts, + ]; +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart index 6f474dfd..2ade65ba 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart @@ -165,4 +165,38 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { final ProfileCompletion completion = ProfileCompletion.fromJson(data); return completion.completed; } + + @override + Future> getAvailableOrders({ + String? search, + int limit = 20, + }) async { + final Map params = { + 'limit': limit, + }; + if (search != null && search.isNotEmpty) { + params['search'] = search; + } + final ApiResponse response = await _apiService.get( + StaffEndpoints.ordersAvailable, + params: params, + ); + final List items = _extractItems(response.data); + return items + .map((dynamic json) => + AvailableOrder.fromJson(json as Map)) + .toList(); + } + + @override + Future bookOrder({ + required String orderId, + required String roleId, + }) async { + final ApiResponse response = await _apiService.post( + StaffEndpoints.orderBook(orderId), + data: {'roleId': roleId}, + ); + return OrderBooking.fromJson(response.data as Map); + } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart index d6583347..7d6cdab9 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart @@ -52,4 +52,16 @@ abstract interface class ShiftsRepositoryInterface { /// /// Only allowed for shifts in CHECKED_OUT or COMPLETED status. Future submitForApproval(String shiftId, {String? note}); + + /// Retrieves available orders from the staff marketplace. + Future> getAvailableOrders({ + String? search, + int limit, + }); + + /// Books an order by placing the staff member into a role. + Future bookOrder({ + required String orderId, + required String roleId, + }); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/book_order_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/book_order_usecase.dart new file mode 100644 index 00000000..697ea030 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/book_order_usecase.dart @@ -0,0 +1,23 @@ +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; + +/// Books an available order for the current staff member. +/// +/// Delegates to [ShiftsRepositoryInterface.bookOrder] with the order and +/// role identifiers. +class BookOrderUseCase { + /// Creates a [BookOrderUseCase]. + BookOrderUseCase(this._repository); + + /// The shifts repository. + final ShiftsRepositoryInterface _repository; + + /// Executes the use case, returning the [OrderBooking] result. + Future call({ + required String orderId, + required String roleId, + }) { + return _repository.bookOrder(orderId: orderId, roleId: roleId); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_available_orders_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_available_orders_usecase.dart new file mode 100644 index 00000000..2e411223 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_available_orders_usecase.dart @@ -0,0 +1,20 @@ +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; + +/// Retrieves available orders from the staff marketplace. +/// +/// Delegates to [ShiftsRepositoryInterface.getAvailableOrders] with an +/// optional search filter. +class GetAvailableOrdersUseCase { + /// Creates a [GetAvailableOrdersUseCase]. + GetAvailableOrdersUseCase(this._repository); + + /// The shifts repository. + final ShiftsRepositoryInterface _repository; + + /// Executes the use case, returning a list of [AvailableOrder]. + Future> call({String? search, int limit = 20}) { + return _repository.getAvailableOrders(search: search, limit: limit); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_bloc.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_bloc.dart new file mode 100644 index 00000000..af857667 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_bloc.dart @@ -0,0 +1,97 @@ +import 'package:bloc/bloc.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_shifts/src/domain/usecases/book_order_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_available_orders_usecase.dart'; + +import 'available_orders_event.dart'; +import 'available_orders_state.dart'; + +/// Manages the state for the available-orders marketplace tab. +/// +/// Loads order-level cards from `GET /staff/orders/available` and handles +/// booking via `POST /staff/orders/:orderId/book`. +class AvailableOrdersBloc + extends Bloc + with BlocErrorHandler { + /// Creates an [AvailableOrdersBloc]. + AvailableOrdersBloc({ + required GetAvailableOrdersUseCase getAvailableOrders, + required BookOrderUseCase bookOrder, + }) : _getAvailableOrders = getAvailableOrders, + _bookOrder = bookOrder, + super(const AvailableOrdersState()) { + on(_onLoadAvailableOrders); + on(_onBookOrder); + on(_onClearBookingResult); + } + + /// Use case for fetching available orders. + final GetAvailableOrdersUseCase _getAvailableOrders; + + /// Use case for booking an order. + final BookOrderUseCase _bookOrder; + + Future _onLoadAvailableOrders( + LoadAvailableOrdersEvent event, + Emitter emit, + ) async { + emit(state.copyWith( + status: AvailableOrdersStatus.loading, + clearErrorMessage: true, + )); + + await handleError( + emit: emit.call, + action: () async { + final List orders = + await _getAvailableOrders(search: event.search); + emit(state.copyWith( + status: AvailableOrdersStatus.loaded, + orders: orders, + clearErrorMessage: true, + )); + }, + onError: (String errorKey) => state.copyWith( + status: AvailableOrdersStatus.error, + errorMessage: errorKey, + ), + ); + } + + Future _onBookOrder( + BookOrderEvent event, + Emitter emit, + ) async { + emit(state.copyWith(bookingInProgress: true, clearErrorMessage: true)); + + await handleError( + emit: emit.call, + action: () async { + final OrderBooking booking = await _bookOrder( + orderId: event.orderId, + roleId: event.roleId, + ); + emit(state.copyWith( + bookingInProgress: false, + lastBooking: booking, + clearErrorMessage: true, + )); + // Reload orders after successful booking. + add(const LoadAvailableOrdersEvent()); + }, + onError: (String errorKey) => state.copyWith( + bookingInProgress: false, + errorMessage: errorKey, + ), + ); + } + + void _onClearBookingResult( + ClearBookingResultEvent event, + Emitter emit, + ) { + emit(state.copyWith(clearLastBooking: true, clearErrorMessage: true)); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_event.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_event.dart new file mode 100644 index 00000000..7958152d --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_event.dart @@ -0,0 +1,45 @@ +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; + +/// Base class for all available-orders events. +@immutable +sealed class AvailableOrdersEvent extends Equatable { + /// Creates an [AvailableOrdersEvent]. + const AvailableOrdersEvent(); + + @override + List get props => []; +} + +/// Loads available orders from the staff marketplace. +class LoadAvailableOrdersEvent extends AvailableOrdersEvent { + /// Creates a [LoadAvailableOrdersEvent]. + const LoadAvailableOrdersEvent({this.search}); + + /// Optional search query to filter orders. + final String? search; + + @override + List get props => [search]; +} + +/// Books the staff member into an order for a specific role. +class BookOrderEvent extends AvailableOrdersEvent { + /// Creates a [BookOrderEvent]. + const BookOrderEvent({required this.orderId, required this.roleId}); + + /// The order to book. + final String orderId; + + /// The role within the order to fill. + final String roleId; + + @override + List get props => [orderId, roleId]; +} + +/// Clears the last booking result so the UI can dismiss confirmation. +class ClearBookingResultEvent extends AvailableOrdersEvent { + /// Creates a [ClearBookingResultEvent]. + const ClearBookingResultEvent(); +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_state.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_state.dart new file mode 100644 index 00000000..dfccd245 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_state.dart @@ -0,0 +1,74 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Lifecycle status for the available-orders list. +enum AvailableOrdersStatus { + /// No data has been requested yet. + initial, + + /// A load is in progress. + loading, + + /// Data has been loaded successfully. + loaded, + + /// An error occurred during loading. + error, +} + +/// State for the available-orders marketplace tab. +class AvailableOrdersState extends Equatable { + /// Creates an [AvailableOrdersState]. + const AvailableOrdersState({ + this.status = AvailableOrdersStatus.initial, + this.orders = const [], + this.bookingInProgress = false, + this.lastBooking, + this.errorMessage, + }); + + /// Current lifecycle status. + final AvailableOrdersStatus status; + + /// The list of available orders. + final List orders; + + /// Whether a booking request is currently in flight. + final bool bookingInProgress; + + /// The result of the most recent booking, if any. + final OrderBooking? lastBooking; + + /// Error message key for display. + final String? errorMessage; + + /// Creates a copy with the given fields replaced. + AvailableOrdersState copyWith({ + AvailableOrdersStatus? status, + List? orders, + bool? bookingInProgress, + OrderBooking? lastBooking, + bool clearLastBooking = false, + String? errorMessage, + bool clearErrorMessage = false, + }) { + return AvailableOrdersState( + status: status ?? this.status, + orders: orders ?? this.orders, + bookingInProgress: bookingInProgress ?? this.bookingInProgress, + lastBooking: + clearLastBooking ? null : (lastBooking ?? this.lastBooking), + errorMessage: + clearErrorMessage ? null : (errorMessage ?? this.errorMessage), + ); + } + + @override + List get props => [ + status, + orders, + bookingInProgress, + lastBooking, + errorMessage, + ]; +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart index bb0bc006..7f08bbaa 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart @@ -5,6 +5,9 @@ import 'package:design_system/design_system.dart'; import 'package:core_localization/core_localization.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_bloc.dart'; +import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_event.dart'; +import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_state.dart'; import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; import 'package:staff_shifts/src/presentation/utils/shift_tab_type.dart'; import 'package:staff_shifts/src/presentation/widgets/shifts_page_skeleton.dart'; @@ -14,7 +17,8 @@ import 'package:staff_shifts/src/presentation/widgets/tabs/history_shifts_tab.da /// Tabbed page for browsing staff shifts (My Shifts, Find Work, History). /// -/// Manages tab state locally and delegates data loading to [ShiftsBloc]. +/// Manages tab state locally and delegates data loading to [ShiftsBloc] +/// and [AvailableOrdersBloc]. class ShiftsPage extends StatefulWidget { /// Creates a [ShiftsPage]. /// @@ -45,9 +49,9 @@ class _ShiftsPageState extends State { late ShiftTabType _activeTab; DateTime? _selectedDate; bool _prioritizeFind = false; - bool _refreshAvailable = false; bool _pendingAvailableRefresh = false; final ShiftsBloc _bloc = Modular.get(); + final AvailableOrdersBloc _ordersBloc = Modular.get(); @override void initState() { @@ -55,7 +59,6 @@ class _ShiftsPageState extends State { _activeTab = widget.initialTab ?? ShiftTabType.find; _selectedDate = widget.selectedDate; _prioritizeFind = _activeTab == ShiftTabType.find; - _refreshAvailable = widget.refreshAvailable; _pendingAvailableRefresh = widget.refreshAvailable; if (_prioritizeFind) { _bloc.add(LoadFindFirstEvent()); @@ -66,9 +69,8 @@ class _ShiftsPageState extends State { _bloc.add(LoadHistoryShiftsEvent()); } if (_activeTab == ShiftTabType.find) { - if (!_prioritizeFind) { - _bloc.add(LoadAvailableShiftsEvent(force: _refreshAvailable)); - } + // Load available orders via the new BLoC. + _ordersBloc.add(const LoadAvailableOrdersEvent()); } // Check profile completion @@ -90,160 +92,193 @@ class _ShiftsPageState extends State { }); } if (widget.refreshAvailable) { - _refreshAvailable = true; _pendingAvailableRefresh = true; } } @override Widget build(BuildContext context) { - final t = Translations.of(context); - return BlocProvider.value( - value: _bloc, - child: BlocConsumer( - listener: (context, state) { - if (state.status == ShiftsStatus.error && - state.errorMessage != null) { + final Translations t = Translations.of(context); + return MultiBlocProvider( + providers: >[ + BlocProvider.value(value: _bloc), + BlocProvider.value(value: _ordersBloc), + ], + child: BlocListener( + listener: (BuildContext context, AvailableOrdersState ordersState) { + // Show booking success / error snackbar. + if (ordersState.lastBooking != null) { + final OrderBooking booking = ordersState.lastBooking!; + final String message = + booking.status.toUpperCase() == 'CONFIRMED' + ? t.available_orders.booking_confirmed + : t.available_orders.booking_pending; UiSnackbar.show( context, - message: translateErrorKey(state.errorMessage!), + message: message, + type: UiSnackbarType.success, + ); + _ordersBloc.add(const ClearBookingResultEvent()); + } + if (ordersState.errorMessage != null) { + UiSnackbar.show( + context, + message: translateErrorKey(ordersState.errorMessage!), type: UiSnackbarType.error, ); } }, - builder: (context, state) { - if (_pendingAvailableRefresh && state.status == ShiftsStatus.loaded) { - _pendingAvailableRefresh = false; - _bloc.add(const LoadAvailableShiftsEvent(force: true)); - } - final bool baseLoaded = state.status == ShiftsStatus.loaded; - final List myShifts = state.myShifts; - final List availableJobs = state.availableShifts; - final bool availableLoading = state.availableLoading; - final bool availableLoaded = state.availableLoaded; - final List pendingAssignments = state.pendingShifts; - final List cancelledShifts = state.cancelledShifts; - final List historyShifts = state.historyShifts; - final bool historyLoading = state.historyLoading; - final bool historyLoaded = state.historyLoaded; - final bool myShiftsLoaded = state.myShiftsLoaded; - final bool blockTabsForFind = _prioritizeFind && !availableLoaded; + child: BlocConsumer( + listener: (BuildContext context, ShiftsState state) { + if (state.status == ShiftsStatus.error && + state.errorMessage != null) { + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, + ); + } + }, + builder: (BuildContext context, ShiftsState state) { + if (_pendingAvailableRefresh && + state.status == ShiftsStatus.loaded) { + _pendingAvailableRefresh = false; + _ordersBloc.add(const LoadAvailableOrdersEvent()); + } + final bool baseLoaded = state.status == ShiftsStatus.loaded; + final List myShifts = state.myShifts; + final List pendingAssignments = + state.pendingShifts; + final List cancelledShifts = state.cancelledShifts; + final List historyShifts = state.historyShifts; + final bool historyLoading = state.historyLoading; + final bool historyLoaded = state.historyLoaded; + final bool myShiftsLoaded = state.myShiftsLoaded; - // Note: "filteredJobs" logic moved to FindShiftsTab - // Note: Calendar logic moved to MyShiftsTab - - return Scaffold( - body: Column( - children: [ - // Header (Blue) - Container( - color: UiColors.primary, - padding: EdgeInsets.fromLTRB( - UiConstants.space5, - MediaQuery.of(context).padding.top + UiConstants.space2, - UiConstants.space5, - UiConstants.space5, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: UiConstants.space4, - children: [ - Text( - t.staff_shifts.title, - style: UiTypography.display1b.white, - ), - - // Tabs - Row( - children: [ - if (state.profileComplete != false) - Expanded( - child: _buildTab( - ShiftTabType.myShifts, - t.staff_shifts.tabs.my_shifts, - UiIcons.calendar, - myShifts.length, - showCount: myShiftsLoaded, - enabled: - !blockTabsForFind && - (state.profileComplete ?? false), - ), - ) - else - const SizedBox.shrink(), - if (state.profileComplete != false) - const SizedBox(width: UiConstants.space2) - else - const SizedBox.shrink(), - _buildTab( - ShiftTabType.find, - t.staff_shifts.tabs.find_work, - UiIcons.search, - availableJobs.length, - showCount: availableLoaded, - enabled: baseLoaded, - ), - if (state.profileComplete != false) - const SizedBox(width: UiConstants.space2) - else - const SizedBox.shrink(), - if (state.profileComplete != false) - Expanded( - child: _buildTab( - ShiftTabType.history, - t.staff_shifts.tabs.history, - UiIcons.clock, - historyShifts.length, - showCount: historyLoaded, - enabled: - !blockTabsForFind && - baseLoaded && - (state.profileComplete ?? false), - ), - ) - else - const SizedBox.shrink(), - ], - ), - ], - ), - ), - - // Body Content - Expanded( - child: state.status == ShiftsStatus.loading - ? const ShiftsPageSkeleton() - : state.status == ShiftsStatus.error - ? Center( - child: Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - translateErrorKey(state.errorMessage ?? ''), - style: UiTypography.body2r.textSecondary, - textAlign: TextAlign.center, - ), - ], - ), - ), - ) - : _buildTabContent( - state, - myShifts, - pendingAssignments, - cancelledShifts, - availableJobs, - historyShifts, - availableLoading, - historyLoading, + return Scaffold( + body: Column( + children: [ + // Header (Blue) + Container( + color: UiColors.primary, + padding: EdgeInsets.fromLTRB( + UiConstants.space5, + MediaQuery.of(context).padding.top + UiConstants.space2, + UiConstants.space5, + UiConstants.space5, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space4, + children: [ + Text( + t.staff_shifts.title, + style: UiTypography.display1b.white, ), - ), - ], - ), - ); - }, + + // Tabs -- use BlocBuilder on orders bloc for count + BlocBuilder( + builder: (BuildContext context, + AvailableOrdersState ordersState) { + final bool ordersLoaded = ordersState.status == + AvailableOrdersStatus.loaded; + final int ordersCount = ordersState.orders.length; + final bool blockTabsForFind = + _prioritizeFind && !ordersLoaded; + + return Row( + children: [ + if (state.profileComplete != false) + Expanded( + child: _buildTab( + ShiftTabType.myShifts, + t.staff_shifts.tabs.my_shifts, + UiIcons.calendar, + myShifts.length, + showCount: myShiftsLoaded, + enabled: !blockTabsForFind && + (state.profileComplete ?? false), + ), + ) + else + const SizedBox.shrink(), + if (state.profileComplete != false) + const SizedBox(width: UiConstants.space2) + else + const SizedBox.shrink(), + _buildTab( + ShiftTabType.find, + t.staff_shifts.tabs.find_work, + UiIcons.search, + ordersCount, + showCount: ordersLoaded, + enabled: baseLoaded, + ), + if (state.profileComplete != false) + const SizedBox(width: UiConstants.space2) + else + const SizedBox.shrink(), + if (state.profileComplete != false) + Expanded( + child: _buildTab( + ShiftTabType.history, + t.staff_shifts.tabs.history, + UiIcons.clock, + historyShifts.length, + showCount: historyLoaded, + enabled: !blockTabsForFind && + baseLoaded && + (state.profileComplete ?? false), + ), + ) + else + const SizedBox.shrink(), + ], + ); + }, + ), + ], + ), + ), + + // Body Content + Expanded( + child: state.status == ShiftsStatus.loading + ? const ShiftsPageSkeleton() + : state.status == ShiftsStatus.error + ? Center( + child: Padding( + padding: + const EdgeInsets.all(UiConstants.space5), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + translateErrorKey( + state.errorMessage ?? ''), + style: + UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], + ), + ), + ) + : _buildTabContent( + state, + myShifts, + pendingAssignments, + cancelledShifts, + historyShifts, + historyLoading, + ), + ), + ], + ), + ); + }, + ), ), ); } @@ -253,9 +288,7 @@ class _ShiftsPageState extends State { List myShifts, List pendingAssignments, List cancelledShifts, - List availableJobs, List historyShifts, - bool availableLoading, bool historyLoading, ) { switch (_activeTab) { @@ -269,12 +302,23 @@ class _ShiftsPageState extends State { submittingShiftId: state.submittingShiftId, ); case ShiftTabType.find: - if (availableLoading) { - return const ShiftsPageSkeleton(); - } - return FindShiftsTab( - availableJobs: availableJobs, - profileComplete: state.profileComplete ?? true, + return BlocBuilder( + builder: + (BuildContext context, AvailableOrdersState ordersState) { + if (ordersState.status == AvailableOrdersStatus.loading) { + return const ShiftsPageSkeleton(); + } + return FindShiftsTab( + availableOrders: ordersState.orders, + profileComplete: state.profileComplete ?? true, + onBook: (String orderId, String roleId) { + _ordersBloc.add( + BookOrderEvent(orderId: orderId, roleId: roleId), + ); + }, + bookingInProgress: ordersState.bookingInProgress, + ); + }, ); case ShiftTabType.history: if (historyLoading) { @@ -296,7 +340,7 @@ class _ShiftsPageState extends State { bool showCount = true, bool enabled = true, }) { - final isActive = _activeTab == type; + final bool isActive = _activeTab == type; return Expanded( child: GestureDetector( onTap: !enabled @@ -307,7 +351,7 @@ class _ShiftsPageState extends State { _bloc.add(LoadHistoryShiftsEvent()); } if (type == ShiftTabType.find) { - _bloc.add(LoadAvailableShiftsEvent()); + _ordersBloc.add(const LoadAvailableOrdersEvent()); } }, child: Container( @@ -324,35 +368,33 @@ class _ShiftsPageState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, - children: [ + children: [ Icon( icon, size: 14, color: !enabled ? UiColors.white.withValues(alpha: 0.5) : isActive - ? UiColors.primary - : UiColors.white, + ? UiColors.primary + : UiColors.white, ), const SizedBox(width: UiConstants.space1), Flexible( child: Text( label, - style: - (isActive - ? UiTypography.body3m.copyWith( - color: UiColors.primary, - ) - : UiTypography.body3m.white) - .copyWith( - color: !enabled - ? UiColors.white.withValues(alpha: 0.5) - : null, - ), + style: (isActive + ? UiTypography.body3m + .copyWith(color: UiColors.primary) + : UiTypography.body3m.white) + .copyWith( + color: !enabled + ? UiColors.white.withValues(alpha: 0.5) + : null, + ), overflow: TextOverflow.ellipsis, ), ), - if (showCount) ...[ + if (showCount) ...[ const SizedBox(width: UiConstants.space1), Container( padding: const EdgeInsets.symmetric( @@ -368,7 +410,7 @@ class _ShiftsPageState extends State { ), child: Center( child: Text( - "$count", + '$count', style: UiTypography.footnote1b.copyWith( color: isActive ? UiColors.primary : UiColors.white, ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart new file mode 100644 index 00000000..63b6f498 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart @@ -0,0 +1,415 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Card displaying an [AvailableOrder] from the staff marketplace. +/// +/// Shows role, location, schedule, pay rate, and a booking/apply action. +class AvailableOrderCard extends StatelessWidget { + /// Creates an [AvailableOrderCard]. + const AvailableOrderCard({ + super.key, + required this.order, + required this.onBook, + this.bookingInProgress = false, + }); + + /// The available order to display. + final AvailableOrder order; + + /// Callback when the user taps book/apply, providing orderId and roleId. + final void Function(String orderId, String roleId) onBook; + + /// Whether a booking request is currently in progress. + final bool bookingInProgress; + + /// Formats a date-only string (e.g. "2026-03-24") to "Mar 24". + String _formatDateShort(String dateStr) { + if (dateStr.isEmpty) return ''; + try { + final DateTime date = DateTime.parse(dateStr); + return DateFormat('MMM d').format(date); + } catch (_) { + return dateStr; + } + } + + /// Returns a human-readable label for the order type. + String _orderTypeLabel(OrderType type) { + switch (type) { + case OrderType.oneTime: + return t.staff_shifts.filter.one_day; + case OrderType.recurring: + return t.staff_shifts.filter.multi_day; + case OrderType.permanent: + return t.staff_shifts.filter.long_term; + case OrderType.rapid: + return 'Rapid'; + case OrderType.unknown: + return ''; + } + } + + /// Returns a capitalised short label for a dispatch team value. + String _dispatchTeamLabel(String team) { + switch (team.toUpperCase()) { + case 'CORE': + return 'Core'; + case 'CERTIFIED_LOCATION': + return 'Certified'; + case 'MARKETPLACE': + return 'Marketplace'; + default: + return team; + } + } + + @override + Widget build(BuildContext context) { + final AvailableOrderSchedule schedule = order.schedule; + final int spotsLeft = order.requiredWorkerCount - order.filledCount; + final String hourlyDisplay = + '\$${order.hourlyRate.toStringAsFixed(order.hourlyRate.truncateToDouble() == order.hourlyRate ? 0 : 2)}'; + final String dateRange = + '${_formatDateShort(schedule.startDate)} - ${_formatDateShort(schedule.endDate)}'; + final String timeRange = '${schedule.startTime} - ${schedule.endTime}'; + + return Container( + margin: const EdgeInsets.only(bottom: UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // -- Badge row: order type, instant book, dispatch team -- + _buildBadgeRow(), + const SizedBox(height: UiConstants.space3), + + // -- Main content row: icon + details + pay -- + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Role icon + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + UiColors.primary.withValues(alpha: 0.09), + UiColors.primary.withValues(alpha: 0.03), + ], + ), + borderRadius: + BorderRadius.circular(UiConstants.radiusBase), + ), + child: const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: UiConstants.iconMd, + ), + ), + ), + const SizedBox(width: UiConstants.space3), + + // Details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Role name + hourly rate + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + order.roleName, + style: UiTypography.body2m.textPrimary, + overflow: TextOverflow.ellipsis, + ), + if (order.clientName.isNotEmpty) + Text( + order.clientName, + style: UiTypography.body3r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: UiConstants.space2), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '$hourlyDisplay${t.available_orders.per_hour}', + style: UiTypography.title1m.textPrimary, + ), + Text( + '${order.filledCount}/${order.requiredWorkerCount} ${t.available_orders.spots_left(count: spotsLeft)}', + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + ], + ), + const SizedBox(height: UiConstants.space2), + + // Location + if (order.location.isNotEmpty) + Padding( + padding: + const EdgeInsets.only(bottom: UiConstants.space1), + child: Row( + children: [ + const Icon( + UiIcons.mapPin, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text( + order.location, + style: UiTypography.footnote1r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + + // Address + if (order.locationAddress.isNotEmpty) + Padding( + padding: + const EdgeInsets.only(bottom: UiConstants.space2), + child: Padding( + padding: const EdgeInsets.only( + left: UiConstants.iconXs + UiConstants.space1, + ), + child: Text( + order.locationAddress, + style: UiTypography.footnote2r.textSecondary, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ), + + // Schedule: days of week chips + if (schedule.daysOfWeek.isNotEmpty) + Padding( + padding: + const EdgeInsets.only(bottom: UiConstants.space2), + child: Wrap( + spacing: UiConstants.space1, + runSpacing: UiConstants.space1, + children: schedule.daysOfWeek + .map( + (DayOfWeek day) => _buildDayChip(day), + ) + .toList(), + ), + ), + + // Date range + time + shifts count + Row( + children: [ + const Icon( + UiIcons.calendar, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + dateRange, + style: UiTypography.footnote1r.textSecondary, + ), + const SizedBox(width: UiConstants.space3), + const Icon( + UiIcons.clock, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + timeRange, + style: UiTypography.footnote1r.textSecondary, + ), + ], + ), + const SizedBox(height: UiConstants.space1), + + // Total shifts + timezone + Row( + children: [ + Text( + t.available_orders.shifts_count( + count: schedule.totalShifts, + ), + style: UiTypography.footnote2r.textSecondary, + ), + if (schedule.timezone.isNotEmpty) ...[ + const SizedBox(width: UiConstants.space2), + Text( + schedule.timezone, + style: UiTypography.footnote2r.textSecondary, + ), + ], + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: UiConstants.space3), + + // -- Action button -- + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: bookingInProgress + ? null + : () => onBook(order.orderId, order.roleId), + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.primary, + foregroundColor: UiColors.white, + disabledBackgroundColor: + UiColors.primary.withValues(alpha: 0.5), + disabledForegroundColor: UiColors.white, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(UiConstants.radiusMdValue), + ), + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space3, + ), + ), + child: bookingInProgress + ? const SizedBox( + width: UiConstants.iconMd, + height: UiConstants.iconMd, + child: CircularProgressIndicator( + strokeWidth: 2, + color: UiColors.white, + ), + ) + : Text( + order.instantBook + ? t.available_orders.book_order + : t.available_orders.apply, + style: UiTypography.body2m.white, + ), + ), + ), + ], + ), + ), + ); + } + + /// Builds the horizontal row of badge chips at the top of the card. + Widget _buildBadgeRow() { + return Wrap( + spacing: UiConstants.space2, + runSpacing: UiConstants.space1, + children: [ + // Order type badge + _buildBadge( + label: _orderTypeLabel(order.orderType), + backgroundColor: UiColors.background, + textColor: UiColors.textSecondary, + borderColor: UiColors.border, + ), + + // Instant book badge + if (order.instantBook) + _buildBadge( + label: t.available_orders.instant_book, + backgroundColor: UiColors.success.withValues(alpha: 0.1), + textColor: UiColors.success, + borderColor: UiColors.success.withValues(alpha: 0.3), + icon: UiIcons.zap, + ), + + // Dispatch team badge + if (order.dispatchTeam.isNotEmpty) + _buildBadge( + label: _dispatchTeamLabel(order.dispatchTeam), + backgroundColor: UiColors.primary.withValues(alpha: 0.08), + textColor: UiColors.primary, + borderColor: UiColors.primary.withValues(alpha: 0.2), + ), + ], + ); + } + + /// Builds a single badge chip with optional leading icon. + Widget _buildBadge({ + required String label, + required Color backgroundColor, + required Color textColor, + required Color borderColor, + IconData? icon, + }) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: UiConstants.radiusSm, + border: Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 10, color: textColor), + const SizedBox(width: 2), + ], + Text( + label, + style: UiTypography.footnote2m.copyWith(color: textColor), + ), + ], + ), + ); + } + + /// Builds a small chip showing a day-of-week abbreviation. + Widget _buildDayChip(DayOfWeek day) { + // Display as 3-letter capitalised abbreviation (e.g. "MON" -> "Mon"). + final String label = day.value.isNotEmpty + ? '${day.value[0]}${day.value.substring(1).toLowerCase()}' + : ''; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.08), + borderRadius: UiConstants.radiusSm, + ), + child: Text( + label, + style: UiTypography.footnote2m.copyWith(color: UiColors.primary), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart index 134fe35b..014b561d 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart @@ -2,26 +2,36 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_shifts/src/presentation/widgets/available_order_card.dart'; import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; -/// Tab showing open shifts available for the worker to browse and apply. +/// Tab showing available orders for the worker to browse and book. +/// +/// Replaces the former open-shift listing with order-level marketplace cards. class FindShiftsTab extends StatefulWidget { /// Creates a [FindShiftsTab]. const FindShiftsTab({ super.key, - required this.availableJobs, + required this.availableOrders, this.profileComplete = true, + required this.onBook, + this.bookingInProgress = false, }); - /// Open shifts loaded from the V2 API. - final List availableJobs; + /// Available orders loaded from the V2 API. + final List availableOrders; /// Whether the worker's profile is complete. final bool profileComplete; + /// Callback when the worker taps book/apply on an order card. + final void Function(String orderId, String roleId) onBook; + + /// Whether a booking request is currently in flight. + final bool bookingInProgress; + @override State createState() => _FindShiftsTabState(); } @@ -30,18 +40,7 @@ class _FindShiftsTabState extends State { String _searchQuery = ''; String _jobType = 'all'; - String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt); - - String _formatDate(DateTime date) { - final DateTime now = DateTime.now(); - final DateTime today = DateTime(now.year, now.month, now.day); - final DateTime tomorrow = today.add(const Duration(days: 1)); - final DateTime d = DateTime(date.year, date.month, date.day); - if (d == today) return 'Today'; - if (d == tomorrow) return 'Tomorrow'; - return DateFormat('EEE, MMM d').format(date); - } - + /// Builds a filter tab chip. Widget _buildFilterTab(String id, String label) { final bool isSelected = _jobType == id; return GestureDetector( @@ -69,178 +68,27 @@ class _FindShiftsTabState extends State { ); } - List _filterByType(List shifts) { - if (_jobType == 'all') return shifts; - return shifts.where((OpenShift s) { - if (_jobType == 'one-day') return s.orderType == OrderType.oneTime; - if (_jobType == 'multi-day') return s.orderType == OrderType.recurring; - if (_jobType == 'long-term') return s.orderType == OrderType.permanent; + /// Filters orders by the selected order type tab. + List _filterByType(List orders) { + if (_jobType == 'all') return orders; + return orders.where((AvailableOrder o) { + if (_jobType == 'one-day') return o.orderType == OrderType.oneTime; + if (_jobType == 'multi-day') return o.orderType == OrderType.recurring; + if (_jobType == 'long-term') return o.orderType == OrderType.permanent; return true; }).toList(); } - /// Builds an open shift card. - Widget _buildOpenShiftCard(BuildContext context, OpenShift shift) { - final double hourlyRate = shift.hourlyRateCents / 100; - final int minutes = shift.endTime.difference(shift.startTime).inMinutes; - final double duration = minutes / 60; - final double estimatedTotal = hourlyRate * duration; - - String typeLabel; - switch (shift.orderType) { - case OrderType.permanent: - typeLabel = t.staff_shifts.filter.long_term; - case OrderType.recurring: - typeLabel = t.staff_shifts.filter.multi_day; - case OrderType.oneTime: - default: - typeLabel = t.staff_shifts.filter.one_day; - } - - return GestureDetector( - onTap: () => Modular.to.toShiftDetailsById(shift.shiftId), - child: Container( - margin: const EdgeInsets.only(bottom: UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), - ), - child: Padding( - padding: const EdgeInsets.all(UiConstants.space4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Type badge - Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space2), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: 2, - ), - decoration: BoxDecoration( - color: UiColors.background, - borderRadius: UiConstants.radiusSm, - border: Border.all(color: UiColors.border), - ), - child: Text( - typeLabel, - style: UiTypography.footnote2m - .copyWith(color: UiColors.textSecondary), - ), - ), - ), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - UiColors.primary.withValues(alpha: 0.09), - UiColors.primary.withValues(alpha: 0.03), - ], - ), - borderRadius: - BorderRadius.circular(UiConstants.radiusBase), - ), - child: const Center( - child: Icon(UiIcons.briefcase, - color: UiColors.primary, size: UiConstants.iconMd), - ), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(shift.roleName, - style: UiTypography.body2m.textPrimary, - overflow: TextOverflow.ellipsis), - Text(shift.location, - style: UiTypography.body3r.textSecondary, - overflow: TextOverflow.ellipsis), - ], - ), - ), - const SizedBox(width: UiConstants.space2), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text('\$${estimatedTotal.toStringAsFixed(0)}', - style: UiTypography.title1m.textPrimary), - Text( - '\$${hourlyRate.toInt()}/hr \u00b7 ${duration.toInt()}h', - style: - UiTypography.footnote2r.textSecondary), - ], - ), - ], - ), - const SizedBox(height: UiConstants.space2), - Row( - children: [ - const Icon(UiIcons.calendar, - size: UiConstants.iconXs, - color: UiColors.iconSecondary), - const SizedBox(width: UiConstants.space1), - Text(_formatDate(shift.date), - style: UiTypography.footnote1r.textSecondary), - const SizedBox(width: UiConstants.space3), - const Icon(UiIcons.clock, - size: UiConstants.iconXs, - color: UiColors.iconSecondary), - const SizedBox(width: UiConstants.space1), - Text( - '${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}', - style: UiTypography.footnote1r.textSecondary), - ], - ), - const SizedBox(height: UiConstants.space1), - Row( - children: [ - const Icon(UiIcons.mapPin, - size: UiConstants.iconXs, - color: UiColors.iconSecondary), - const SizedBox(width: UiConstants.space1), - Expanded( - child: Text(shift.location, - style: UiTypography.footnote1r.textSecondary, - overflow: TextOverflow.ellipsis), - ), - ], - ), - ], - ), - ), - ], - ), - ], - ), - ), - ), - ); - } - @override Widget build(BuildContext context) { - // Client-side filter by order type - final List filteredJobs = - _filterByType(widget.availableJobs).where((OpenShift s) { + // Client-side filter by order type and search query + final List filteredOrders = + _filterByType(widget.availableOrders).where((AvailableOrder o) { if (_searchQuery.isEmpty) return true; final String q = _searchQuery.toLowerCase(); - return s.roleName.toLowerCase().contains(q) || - s.location.toLowerCase().contains(q); + return o.roleName.toLowerCase().contains(q) || + o.clientName.toLowerCase().contains(q) || + o.location.toLowerCase().contains(q); }).toList(); return Column( @@ -322,7 +170,7 @@ class _FindShiftsTabState extends State { ), ), Expanded( - child: filteredJobs.isEmpty + child: filteredOrders.isEmpty ? EmptyStateView( icon: UiIcons.search, title: context.t.staff_shifts.find_shifts.no_jobs_title, @@ -335,9 +183,12 @@ class _FindShiftsTabState extends State { child: Column( children: [ const SizedBox(height: UiConstants.space5), - ...filteredJobs.map( - (OpenShift shift) => - _buildOpenShiftCard(context, shift), + ...filteredOrders.map( + (AvailableOrder order) => AvailableOrderCard( + order: order, + onBook: widget.onBook, + bookingInProgress: widget.bookingInProgress, + ), ), const SizedBox(height: UiConstants.space32), ], diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart index 98a51de7..8bb5f36d 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart @@ -14,7 +14,10 @@ import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase. import 'package:staff_shifts/src/domain/usecases/get_shift_details_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/book_order_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_available_orders_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/submit_for_approval_usecase.dart'; +import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_bloc.dart'; import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_bloc.dart'; import 'package:staff_shifts/src/presentation/utils/shift_tab_type.dart'; @@ -49,6 +52,8 @@ class StaffShiftsModule extends Module { i.addLazySingleton( () => SubmitForApprovalUseCase(i.get()), ); + i.addLazySingleton(GetAvailableOrdersUseCase.new); + i.addLazySingleton(BookOrderUseCase.new); // BLoC i.add( @@ -72,6 +77,12 @@ class StaffShiftsModule extends Module { getProfileCompletion: i.get(), ), ); + i.add( + () => AvailableOrdersBloc( + getAvailableOrders: i.get(), + bookOrder: i.get(), + ), + ); } @override From 833cb99f6bef835beb56c92aba98d1520c009323 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 13:43:39 -0400 Subject: [PATCH 08/29] feat: Enhance AvailableOrderCard layout and time formatting --- .../widgets/available_order_card.dart | 80 +++++++++---------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart index 63b6f498..fa64c9af 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart @@ -74,14 +74,15 @@ class AvailableOrderCard extends StatelessWidget { '\$${order.hourlyRate.toStringAsFixed(order.hourlyRate.truncateToDouble() == order.hourlyRate ? 0 : 2)}'; final String dateRange = '${_formatDateShort(schedule.startDate)} - ${_formatDateShort(schedule.endDate)}'; - final String timeRange = '${schedule.startTime} - ${schedule.endTime}'; + final String timeRange = + '${DateFormat('h:mm a').format(schedule.firstShiftStartsAt)} - ${DateFormat('h:mm a').format(schedule.lastShiftEndsAt)}'; return Container( margin: const EdgeInsets.only(bottom: UiConstants.space3), decoration: BoxDecoration( color: UiColors.white, borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), + border: Border.all(color: UiColors.border, width: 0.5), ), child: Padding( padding: const EdgeInsets.all(UiConstants.space4), @@ -194,7 +195,7 @@ class AvailableOrderCard extends StatelessWidget { if (order.locationAddress.isNotEmpty) Padding( padding: - const EdgeInsets.only(bottom: UiConstants.space2), + const EdgeInsets.only(bottom: UiConstants.space4), child: Padding( padding: const EdgeInsets.only( left: UiConstants.iconXs + UiConstants.space1, @@ -208,6 +209,7 @@ class AvailableOrderCard extends StatelessWidget { ), ), + // Schedule: days of week chips if (schedule.daysOfWeek.isNotEmpty) Padding( @@ -225,50 +227,48 @@ class AvailableOrderCard extends StatelessWidget { ), // Date range + time + shifts count - Row( + Column( children: [ - const Icon( - UiIcons.calendar, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, + const SizedBox(height: UiConstants.space2), + Row( + children: [ + const Icon( + UiIcons.calendar, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + dateRange, + style: UiTypography.footnote1r.textSecondary, + ), + ], ), - const SizedBox(width: UiConstants.space1), - Text( - dateRange, - style: UiTypography.footnote1r.textSecondary, - ), - const SizedBox(width: UiConstants.space3), - const Icon( - UiIcons.clock, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - timeRange, - style: UiTypography.footnote1r.textSecondary, + const SizedBox(width: UiConstants.space2), + Row( + children: [ + const Icon( + UiIcons.clock, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + timeRange, + style: UiTypography.footnote1r.textSecondary, + ), + ], ), ], ), const SizedBox(height: UiConstants.space1), - // Total shifts + timezone - Row( - children: [ - Text( - t.available_orders.shifts_count( - count: schedule.totalShifts, - ), - style: UiTypography.footnote2r.textSecondary, - ), - if (schedule.timezone.isNotEmpty) ...[ - const SizedBox(width: UiConstants.space2), - Text( - schedule.timezone, - style: UiTypography.footnote2r.textSecondary, - ), - ], - ], + // Total shifts count + Text( + t.available_orders.shifts_count( + count: schedule.totalShifts, + ), + style: UiTypography.footnote2r.textSecondary, ), ], ), From 24ff8816d2baf903fe68d4cdc2e67984cb3cfaf8 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 13:46:22 -0400 Subject: [PATCH 09/29] feat: Update AvailableOrderCard to include client name, address, and estimated total pay --- .../widgets/available_order_card.dart | 293 +++++++++--------- 1 file changed, 140 insertions(+), 153 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart index fa64c9af..81bc8cf9 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart @@ -6,7 +6,8 @@ import 'package:krow_domain/krow_domain.dart'; /// Card displaying an [AvailableOrder] from the staff marketplace. /// -/// Shows role, location, schedule, pay rate, and a booking/apply action. +/// Shows role, pay (total + hourly), time, date, client, location, +/// schedule chips, and a booking/apply action. class AvailableOrderCard extends StatelessWidget { /// Creates an [AvailableOrderCard]. const AvailableOrderCard({ @@ -25,6 +26,10 @@ class AvailableOrderCard extends StatelessWidget { /// Whether a booking request is currently in progress. final bool bookingInProgress; + String _formatTime(DateTime time) { + return DateFormat('h:mma').format(time).toLowerCase(); + } + /// Formats a date-only string (e.g. "2026-03-24") to "Mar 24". String _formatDateShort(String dateStr) { if (dateStr.isEmpty) return ''; @@ -36,6 +41,16 @@ class AvailableOrderCard extends StatelessWidget { } } + /// Computes the duration in hours from the first shift start to end. + double _durationHours() { + final int minutes = order.schedule.lastShiftEndsAt + .difference(order.schedule.firstShiftStartsAt) + .inMinutes; + double hours = minutes / 60; + if (hours < 0) hours += 24; + return hours.roundToDouble(); + } + /// Returns a human-readable label for the order type. String _orderTypeLabel(OrderType type) { switch (type) { @@ -70,12 +85,12 @@ class AvailableOrderCard extends StatelessWidget { Widget build(BuildContext context) { final AvailableOrderSchedule schedule = order.schedule; final int spotsLeft = order.requiredWorkerCount - order.filledCount; - final String hourlyDisplay = - '\$${order.hourlyRate.toStringAsFixed(order.hourlyRate.truncateToDouble() == order.hourlyRate ? 0 : 2)}'; + final double durationHours = _durationHours(); + final double estimatedTotal = order.hourlyRate * durationHours; final String dateRange = '${_formatDateShort(schedule.startDate)} - ${_formatDateShort(schedule.endDate)}'; final String timeRange = - '${DateFormat('h:mm a').format(schedule.firstShiftStartsAt)} - ${DateFormat('h:mm a').format(schedule.lastShiftEndsAt)}'; + '${_formatTime(schedule.firstShiftStartsAt)} - ${_formatTime(schedule.lastShiftEndsAt)}'; return Container( margin: const EdgeInsets.only(bottom: UiConstants.space3), @@ -89,8 +104,8 @@ class AvailableOrderCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // -- Badge row: order type, instant book, dispatch team -- - _buildBadgeRow(), + // -- Badge row -- + _buildBadgeRow(spotsLeft), const SizedBox(height: UiConstants.space3), // -- Main content row: icon + details + pay -- @@ -99,176 +114,59 @@ class AvailableOrderCard extends StatelessWidget { children: [ // Role icon Container( - width: 44, - height: 44, + width: UiConstants.space10, + height: UiConstants.space10, decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - UiColors.primary.withValues(alpha: 0.09), - UiColors.primary.withValues(alpha: 0.03), - ], - ), - borderRadius: - BorderRadius.circular(UiConstants.radiusBase), + color: UiColors.tagInProgress, + borderRadius: UiConstants.radiusLg, ), child: const Center( child: Icon( UiIcons.briefcase, color: UiColors.primary, - size: UiConstants.iconMd, + size: UiConstants.space5, ), ), ), const SizedBox(width: UiConstants.space3), - // Details + // Details + pay Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Role name + hourly rate + // Role name + estimated total Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space1, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - order.roleName, - style: UiTypography.body2m.textPrimary, - overflow: TextOverflow.ellipsis, - ), - if (order.clientName.isNotEmpty) - Text( - order.clientName, - style: UiTypography.body3r.textSecondary, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - const SizedBox(width: UiConstants.space2), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '$hourlyDisplay${t.available_orders.per_hour}', - style: UiTypography.title1m.textPrimary, - ), - Text( - '${order.filledCount}/${order.requiredWorkerCount} ${t.available_orders.spots_left(count: spotsLeft)}', - style: UiTypography.footnote2r.textSecondary, - ), - ], - ), - ], - ), - const SizedBox(height: UiConstants.space2), - - // Location - if (order.location.isNotEmpty) - Padding( - padding: - const EdgeInsets.only(bottom: UiConstants.space1), - child: Row( - children: [ - const Icon( - UiIcons.mapPin, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Expanded( - child: Text( - order.location, - style: UiTypography.footnote1r.textSecondary, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - - // Address - if (order.locationAddress.isNotEmpty) - Padding( - padding: - const EdgeInsets.only(bottom: UiConstants.space4), - child: Padding( - padding: const EdgeInsets.only( - left: UiConstants.iconXs + UiConstants.space1, - ), + Flexible( child: Text( - order.locationAddress, - style: UiTypography.footnote2r.textSecondary, + order.roleName, + style: UiTypography.body1m.textPrimary, overflow: TextOverflow.ellipsis, - maxLines: 1, ), ), - ), - - - // Schedule: days of week chips - if (schedule.daysOfWeek.isNotEmpty) - Padding( - padding: - const EdgeInsets.only(bottom: UiConstants.space2), - child: Wrap( - spacing: UiConstants.space1, - runSpacing: UiConstants.space1, - children: schedule.daysOfWeek - .map( - (DayOfWeek day) => _buildDayChip(day), - ) - .toList(), - ), - ), - - // Date range + time + shifts count - Column( - children: [ - const SizedBox(height: UiConstants.space2), - Row( - children: [ - const Icon( - UiIcons.calendar, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - dateRange, - style: UiTypography.footnote1r.textSecondary, - ), - ], - ), - const SizedBox(width: UiConstants.space2), - Row( - children: [ - const Icon( - UiIcons.clock, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - timeRange, - style: UiTypography.footnote1r.textSecondary, - ), - ], + Text( + '\$${estimatedTotal.toStringAsFixed(0)}', + style: UiTypography.title1m.textPrimary, ), ], ), - const SizedBox(height: UiConstants.space1), - - // Total shifts count - Text( - t.available_orders.shifts_count( - count: schedule.totalShifts, - ), - style: UiTypography.footnote2r.textSecondary, + // Time subtitle + hourly rate + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: UiConstants.space1, + children: [ + Text( + timeRange, + style: UiTypography.body3r.textSecondary, + ), + Text( + '\$${order.hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h', + style: UiTypography.footnote2r.textSecondary, + ), + ], ), ], ), @@ -277,6 +175,87 @@ class AvailableOrderCard extends StatelessWidget { ), const SizedBox(height: UiConstants.space3), + // -- Date -- + Row( + children: [ + const Icon( + UiIcons.calendar, + size: UiConstants.space3, + color: UiColors.mutedForeground, + ), + const SizedBox(width: UiConstants.space1), + Text( + dateRange, + style: UiTypography.body3r.textSecondary, + ), + ], + ), + const SizedBox(height: UiConstants.space1), + + // -- Client name -- + if (order.clientName.isNotEmpty) + Row( + children: [ + const Icon( + UiIcons.building, + size: UiConstants.space3, + color: UiColors.mutedForeground, + ), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text( + order.clientName, + style: UiTypography.body3r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + + // -- Address -- + if (order.locationAddress.isNotEmpty) ...[ + const SizedBox(height: UiConstants.space1), + Row( + children: [ + const Icon( + UiIcons.mapPin, + size: UiConstants.space3, + color: UiColors.mutedForeground, + ), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text( + order.locationAddress, + style: UiTypography.body3r.textSecondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + + // -- Schedule: days of week chips -- + if (schedule.daysOfWeek.isNotEmpty) ...[ + const SizedBox(height: UiConstants.space3), + Wrap( + spacing: UiConstants.space1, + runSpacing: UiConstants.space1, + children: schedule.daysOfWeek + .map((DayOfWeek day) => _buildDayChip(day)) + .toList(), + ), + const SizedBox(height: UiConstants.space1), + Text( + t.available_orders.shifts_count( + count: schedule.totalShifts, + ), + style: UiTypography.footnote2r.textSecondary, + ), + ], + + const SizedBox(height: UiConstants.space3), + // -- Action button -- SizedBox( width: double.infinity, @@ -322,7 +301,7 @@ class AvailableOrderCard extends StatelessWidget { } /// Builds the horizontal row of badge chips at the top of the card. - Widget _buildBadgeRow() { + Widget _buildBadgeRow(int spotsLeft) { return Wrap( spacing: UiConstants.space2, runSpacing: UiConstants.space1, @@ -335,6 +314,15 @@ class AvailableOrderCard extends StatelessWidget { borderColor: UiColors.border, ), + // Spots left badge + if (spotsLeft > 0) + _buildBadge( + label: t.available_orders.spots_left(count: spotsLeft), + backgroundColor: UiColors.tagPending, + textColor: UiColors.textWarning, + borderColor: UiColors.textWarning.withValues(alpha: 0.3), + ), + // Instant book badge if (order.instantBook) _buildBadge( @@ -393,7 +381,6 @@ class AvailableOrderCard extends StatelessWidget { /// Builds a small chip showing a day-of-week abbreviation. Widget _buildDayChip(DayOfWeek day) { - // Display as 3-letter capitalised abbreviation (e.g. "MON" -> "Mon"). final String label = day.value.isNotEmpty ? '${day.value[0]}${day.value.substring(1).toLowerCase()}' : ''; From 742c8c75c5bce091a2f2b9fbdec1ff367f4011f1 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 13:49:22 -0400 Subject: [PATCH 10/29] feat: Update AvailableOrderCard to display pay details for long-term orders --- .../widgets/available_order_card.dart | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart index 81bc8cf9..8bc220e3 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart @@ -85,6 +85,7 @@ class AvailableOrderCard extends StatelessWidget { Widget build(BuildContext context) { final AvailableOrderSchedule schedule = order.schedule; final int spotsLeft = order.requiredWorkerCount - order.filledCount; + final bool isLongTerm = order.orderType == OrderType.permanent; final double durationHours = _durationHours(); final double estimatedTotal = order.hourlyRate * durationHours; final String dateRange = @@ -135,7 +136,7 @@ class AvailableOrderCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Role name + estimated total + // Role name + pay headline Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: UiConstants.space1, @@ -148,12 +149,14 @@ class AvailableOrderCard extends StatelessWidget { ), ), Text( - '\$${estimatedTotal.toStringAsFixed(0)}', + isLongTerm + ? '\$${order.hourlyRate.toInt()}/hr' + : '\$${estimatedTotal.toStringAsFixed(0)}', style: UiTypography.title1m.textPrimary, ), ], ), - // Time subtitle + hourly rate + // Time subtitle + pay detail Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: UiConstants.space1, @@ -162,10 +165,11 @@ class AvailableOrderCard extends StatelessWidget { timeRange, style: UiTypography.body3r.textSecondary, ), - Text( - '\$${order.hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h', - style: UiTypography.footnote2r.textSecondary, - ), + if (!isLongTerm) + Text( + '\$${order.hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h', + style: UiTypography.footnote2r.textSecondary, + ), ], ), ], From 9c71acb96a2a0a7dab2f5600becb1b0fbbb19f29 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 14:08:34 -0400 Subject: [PATCH 11/29] feat: Implement order details page and navigation for available orders --- .../core/lib/src/routing/staff/navigator.dart | 7 + .../lib/src/routing/staff/route_paths.dart | 5 + .../lib/src/l10n/en.i18n.json | 14 +- .../lib/src/l10n/es.i18n.json | 14 +- .../shifts/lib/src/order_details_module.dart | 50 ++++ .../pages/order_details_page.dart | 253 ++++++++++++++++++ .../src/presentation/pages/shifts_page.dart | 35 +-- .../widgets/available_order_card.dart | 74 ++--- .../order_details_bottom_bar.dart | 130 +++++++++ .../order_details/order_details_header.dart | 185 +++++++++++++ .../order_details/order_schedule_section.dart | 131 +++++++++ .../widgets/tabs/find_shifts_tab.dart | 12 +- .../staff/shifts/lib/staff_shifts.dart | 1 + .../staff_main/lib/src/staff_main_module.dart | 4 + 14 files changed, 813 insertions(+), 102 deletions(-) create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/order_details_module.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart diff --git a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart index 9a536a65..fbab15a2 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -112,6 +112,13 @@ extension StaffNavigator on IModularNavigator { safeNavigate(StaffPaths.shiftDetails(shift.id), arguments: shift); } + /// Navigates to the order details page for a given [AvailableOrder]. + /// + /// The order is passed as a data argument to the route. + void toOrderDetails(AvailableOrder order) { + safePush(StaffPaths.orderDetailsRoute, arguments: order); + } + /// Navigates to shift details by ID only (no pre-fetched [Shift] object). /// /// Used when only the shift ID is available (e.g. from dashboard list items). diff --git a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart index c3ebff23..a6146f8b 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart @@ -107,6 +107,11 @@ class StaffPaths { /// View detailed information for a specific shift. static const String shiftDetailsRoute = '/worker-main/shift-details'; + /// Order details route. + /// + /// View detailed information for an available order and book/apply. + static const String orderDetailsRoute = '/worker-main/order-details'; + /// Shift details page (dynamic). /// /// View detailed information for a specific shift. diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index cd8d5e29..423ea826 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -1881,14 +1881,26 @@ "available_orders": { "book_order": "Book Order", "apply": "Apply", + "fully_staffed": "Fully Staffed", "spots_left": "${count} spot(s) left", "shifts_count": "${count} shift(s)", + "schedule_label": "SCHEDULE", "booking_success": "Order booked successfully!", "booking_pending": "Your booking is pending approval", "booking_confirmed": "Your booking has been confirmed!", "no_orders": "No orders available", "no_orders_subtitle": "Check back later for new opportunities", "instant_book": "Instant Book", - "per_hour": "/hr" + "per_hour": "/hr", + "book_dialog": { + "title": "Book this order?", + "message": "This will book you for all ${count} shift(s) in this order.", + "confirm": "Confirm Booking" + }, + "booking_dialog": { + "title": "Booking order..." + }, + "order_booked_pending": "Order booking submitted! Awaiting approval.", + "order_booked_confirmed": "Order booked and confirmed!" } } \ No newline at end of file diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index b7f47371..927d701c 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -1881,14 +1881,26 @@ "available_orders": { "book_order": "Reservar Orden", "apply": "Aplicar", + "fully_staffed": "Completamente dotado", "spots_left": "${count} puesto(s) disponible(s)", "shifts_count": "${count} turno(s)", + "schedule_label": "HORARIO", "booking_success": "\u00a1Orden reservada con \u00e9xito!", "booking_pending": "Tu reserva est\u00e1 pendiente de aprobaci\u00f3n", "booking_confirmed": "\u00a1Tu reserva ha sido confirmada!", "no_orders": "No hay \u00f3rdenes disponibles", "no_orders_subtitle": "Vuelve m\u00e1s tarde para nuevas oportunidades", "instant_book": "Reserva Instant\u00e1nea", - "per_hour": "/hr" + "per_hour": "/hr", + "book_dialog": { + "title": "\u00bfReservar esta orden?", + "message": "Esto te reservar\u00e1 para los ${count} turno(s) de esta orden.", + "confirm": "Confirmar Reserva" + }, + "booking_dialog": { + "title": "Reservando orden..." + }, + "order_booked_pending": "\u00a1Reserva de orden enviada! Esperando aprobaci\u00f3n.", + "order_booked_confirmed": "\u00a1Orden reservada y confirmada!" } } \ No newline at end of file diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/order_details_module.dart b/apps/mobile/packages/features/staff/shifts/lib/src/order_details_module.dart new file mode 100644 index 00000000..16935eb3 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/order_details_module.dart @@ -0,0 +1,50 @@ +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_shifts/src/data/repositories_impl/shifts_repository_impl.dart'; +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; +import 'package:staff_shifts/src/domain/usecases/book_order_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_available_orders_usecase.dart'; +import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_bloc.dart'; +import 'package:staff_shifts/src/presentation/pages/order_details_page.dart'; + +/// DI module for the order details page. +/// +/// Registers the repository, use cases, and BLoC needed to display +/// and book an [AvailableOrder] via the V2 API. +class OrderDetailsModule extends Module { + @override + List get imports => [CoreModule()]; + + @override + void binds(Injector i) { + // Repository + i.add( + () => ShiftsRepositoryImpl(apiService: i.get()), + ); + + // Use cases + i.addLazySingleton(GetAvailableOrdersUseCase.new); + i.addLazySingleton(BookOrderUseCase.new); + + // BLoC + i.add( + () => AvailableOrdersBloc( + getAvailableOrders: i.get(), + bookOrder: i.get(), + ), + ); + } + + @override + void routes(RouteManager r) { + r.child( + '/', + child: (_) { + final AvailableOrder order = r.args.data as AvailableOrder; + return OrderDetailsPage(order: order); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart new file mode 100644 index 00000000..ffc0debd --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart @@ -0,0 +1,253 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_bloc.dart'; +import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_event.dart'; +import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_state.dart'; +import 'package:staff_shifts/src/presentation/widgets/order_details/order_details_bottom_bar.dart'; +import 'package:staff_shifts/src/presentation/widgets/order_details/order_details_header.dart'; +import 'package:staff_shifts/src/presentation/widgets/order_details/order_schedule_section.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_location_section.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_stats_row.dart'; + +/// Page displaying full details for an available order. +/// +/// Allows the staff member to review order details and book/apply. +/// Uses [AvailableOrdersBloc] for the booking flow. +class OrderDetailsPage extends StatefulWidget { + /// Creates an [OrderDetailsPage]. + const OrderDetailsPage({super.key, required this.order}); + + /// The available order to display. + final AvailableOrder order; + + @override + State createState() => _OrderDetailsPageState(); +} + +class _OrderDetailsPageState extends State { + /// Whether the action (booking) dialog is currently showing. + bool _actionDialogOpen = false; + + /// Whether a booking request has been initiated. + bool _isBooking = false; + + /// Formats a date-only string (e.g. "2026-03-24") to "Mar 24". + String _formatDateShort(String dateStr) { + if (dateStr.isEmpty) return ''; + try { + final DateTime date = DateTime.parse(dateStr); + return DateFormat('MMM d').format(date); + } catch (_) { + return dateStr; + } + } + + /// Computes the duration in hours from the first shift start to end. + double _durationHours() { + final int minutes = widget.order.schedule.lastShiftEndsAt + .difference(widget.order.schedule.firstShiftStartsAt) + .inMinutes; + double hours = minutes / 60; + if (hours < 0) hours += 24; + return hours.roundToDouble(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => Modular.get(), + child: BlocConsumer( + listener: _onStateChanged, + builder: (BuildContext context, AvailableOrdersState state) { + return _buildScaffold(context, state); + }, + ), + ); + } + + void _onStateChanged(BuildContext context, AvailableOrdersState state) { + // Booking succeeded + if (state.lastBooking != null) { + _closeActionDialog(context); + final bool isPending = state.lastBooking!.status == 'PENDING'; + UiSnackbar.show( + context, + message: isPending + ? t.available_orders.order_booked_pending + : t.available_orders.order_booked_confirmed, + type: UiSnackbarType.success, + ); + Modular.to.toShifts(initialTab: 'find', refreshAvailable: true); + } + + // Booking failed + if (state.errorMessage != null && _isBooking) { + _closeActionDialog(context); + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, + ); + setState(() { + _isBooking = false; + }); + } + } + + Widget _buildScaffold(BuildContext context, AvailableOrdersState state) { + final AvailableOrder order = widget.order; + final bool isLongTerm = order.orderType == OrderType.permanent; + final double durationHours = _durationHours(); + final double estimatedTotal = order.hourlyRate * durationHours; + final int spotsLeft = order.requiredWorkerCount - order.filledCount; + + return Scaffold( + appBar: UiAppBar( + centerTitle: false, + onLeadingPressed: () => Modular.to.toShifts(), + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OrderDetailsHeader(order: order), + const Divider(height: 1, thickness: 0.5), + ShiftStatsRow( + estimatedTotal: + isLongTerm ? order.hourlyRate : estimatedTotal, + hourlyRate: order.hourlyRate, + duration: isLongTerm ? 0 : durationHours, + totalLabel: isLongTerm + ? context.t.staff_shifts.shift_details.hourly_rate + : context.t.staff_shifts.shift_details.est_total, + hourlyRateLabel: + context.t.staff_shifts.shift_details.hourly_rate, + hoursLabel: context.t.staff_shifts.shift_details.hours, + ), + const Divider(height: 1, thickness: 0.5), + OrderScheduleSection( + schedule: order.schedule, + scheduleLabel: + context.t.available_orders.schedule_label, + shiftsCountLabel: t.available_orders.shifts_count( + count: order.schedule.totalShifts, + ), + ), + const Divider(height: 1, thickness: 0.5), + ShiftLocationSection( + location: order.location, + address: order.locationAddress, + locationLabel: + context.t.staff_shifts.shift_details.location, + tbdLabel: context.t.staff_shifts.shift_details.tbd, + getDirectionLabel: + context.t.staff_shifts.shift_details.get_direction, + ), + ], + ), + ), + ), + OrderDetailsBottomBar( + instantBook: order.instantBook, + spotsLeft: spotsLeft, + bookingInProgress: state.bookingInProgress, + onBook: () => _bookOrder(context), + ), + ], + ), + ); + } + + /// Shows the confirmation dialog before booking. + void _bookOrder(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext ctx) => AlertDialog( + title: Text(t.available_orders.book_dialog.title), + content: Text( + t.available_orders.book_dialog.message( + count: widget.order.schedule.totalShifts, + ), + ), + actions: [ + TextButton( + onPressed: () => Modular.to.popSafe(), + child: Text(Translations.of(context).common.cancel), + ), + TextButton( + onPressed: () { + Modular.to.popSafe(); + _showBookingDialog(context); + BlocProvider.of(context).add( + BookOrderEvent( + orderId: widget.order.orderId, + roleId: widget.order.roleId, + ), + ); + }, + style: TextButton.styleFrom(foregroundColor: UiColors.success), + child: Text(t.available_orders.book_dialog.confirm), + ), + ], + ), + ); + } + + /// Shows a non-dismissible dialog while the booking is in progress. + void _showBookingDialog(BuildContext context) { + if (_actionDialogOpen) return; + _actionDialogOpen = true; + _isBooking = true; + showDialog( + context: context, + useRootNavigator: true, + barrierDismissible: false, + builder: (BuildContext ctx) => AlertDialog( + title: Text(t.available_orders.booking_dialog.title), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + height: 36, + width: 36, + child: CircularProgressIndicator(), + ), + const SizedBox(height: UiConstants.space4), + Text( + widget.order.roleName, + style: UiTypography.body2b.textPrimary, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space1), + Text( + '${_formatDateShort(widget.order.schedule.startDate)} - ' + '${_formatDateShort(widget.order.schedule.endDate)} ' + '\u2022 ${widget.order.schedule.totalShifts} shifts', + style: UiTypography.body3r.textSecondary, + textAlign: TextAlign.center, + ), + ], + ), + ), + ).then((_) { + _actionDialogOpen = false; + }); + } + + /// Closes the action dialog if it is open. + void _closeActionDialog(BuildContext context) { + if (!_actionDialogOpen) return; + Navigator.of(context, rootNavigator: true).pop(); + _actionDialogOpen = false; + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart index 7f08bbaa..7ae32917 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart @@ -104,31 +104,7 @@ class _ShiftsPageState extends State { BlocProvider.value(value: _bloc), BlocProvider.value(value: _ordersBloc), ], - child: BlocListener( - listener: (BuildContext context, AvailableOrdersState ordersState) { - // Show booking success / error snackbar. - if (ordersState.lastBooking != null) { - final OrderBooking booking = ordersState.lastBooking!; - final String message = - booking.status.toUpperCase() == 'CONFIRMED' - ? t.available_orders.booking_confirmed - : t.available_orders.booking_pending; - UiSnackbar.show( - context, - message: message, - type: UiSnackbarType.success, - ); - _ordersBloc.add(const ClearBookingResultEvent()); - } - if (ordersState.errorMessage != null) { - UiSnackbar.show( - context, - message: translateErrorKey(ordersState.errorMessage!), - type: UiSnackbarType.error, - ); - } - }, - child: BlocConsumer( + child: BlocConsumer( listener: (BuildContext context, ShiftsState state) { if (state.status == ShiftsStatus.error && state.errorMessage != null) { @@ -279,8 +255,7 @@ class _ShiftsPageState extends State { ); }, ), - ), - ); + ); } Widget _buildTabContent( @@ -311,12 +286,6 @@ class _ShiftsPageState extends State { return FindShiftsTab( availableOrders: ordersState.orders, profileComplete: state.profileComplete ?? true, - onBook: (String orderId, String roleId) { - _ordersBloc.add( - BookOrderEvent(orderId: orderId, roleId: roleId), - ); - }, - bookingInProgress: ordersState.bookingInProgress, ); }, ); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart index 8bc220e3..351f99e1 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart @@ -7,25 +7,22 @@ import 'package:krow_domain/krow_domain.dart'; /// Card displaying an [AvailableOrder] from the staff marketplace. /// /// Shows role, pay (total + hourly), time, date, client, location, -/// schedule chips, and a booking/apply action. +/// and schedule chips. Tapping the card navigates to the order details page. class AvailableOrderCard extends StatelessWidget { /// Creates an [AvailableOrderCard]. const AvailableOrderCard({ super.key, required this.order, - required this.onBook, - this.bookingInProgress = false, + required this.onTap, }); /// The available order to display. final AvailableOrder order; - /// Callback when the user taps book/apply, providing orderId and roleId. - final void Function(String orderId, String roleId) onBook; - - /// Whether a booking request is currently in progress. - final bool bookingInProgress; + /// Callback when the user taps the card. + final VoidCallback onTap; + /// Formats a DateTime to a time string like "3:30pm". String _formatTime(DateTime time) { return DateFormat('h:mma').format(time).toLowerCase(); } @@ -93,14 +90,16 @@ class AvailableOrderCard extends StatelessWidget { final String timeRange = '${_formatTime(schedule.firstShiftStartsAt)} - ${_formatTime(schedule.lastShiftEndsAt)}'; - return Container( - margin: const EdgeInsets.only(bottom: UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border, width: 0.5), - ), - child: Padding( + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border, width: 0.5), + ), + child: Padding( padding: const EdgeInsets.all(UiConstants.space4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -136,7 +135,7 @@ class AvailableOrderCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Role name + pay headline + // Role name + pay headline + chevron Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: UiConstants.space1, @@ -258,49 +257,10 @@ class AvailableOrderCard extends StatelessWidget { ), ], - const SizedBox(height: UiConstants.space3), - - // -- Action button -- - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: bookingInProgress - ? null - : () => onBook(order.orderId, order.roleId), - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.primary, - foregroundColor: UiColors.white, - disabledBackgroundColor: - UiColors.primary.withValues(alpha: 0.5), - disabledForegroundColor: UiColors.white, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(UiConstants.radiusMdValue), - ), - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space3, - ), - ), - child: bookingInProgress - ? const SizedBox( - width: UiConstants.iconMd, - height: UiConstants.iconMd, - child: CircularProgressIndicator( - strokeWidth: 2, - color: UiColors.white, - ), - ) - : Text( - order.instantBook - ? t.available_orders.book_order - : t.available_orders.apply, - style: UiTypography.body2m.white, - ), - ), - ), ], ), ), + ), ); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart new file mode 100644 index 00000000..3f98df84 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart @@ -0,0 +1,130 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A bottom action bar for the order details page. +/// +/// Displays a contextual CTA button based on order booking state: +/// fully staffed, instant book, or standard apply. +class OrderDetailsBottomBar extends StatelessWidget { + /// Creates an [OrderDetailsBottomBar]. + const OrderDetailsBottomBar({ + super.key, + required this.instantBook, + required this.spotsLeft, + required this.bookingInProgress, + required this.onBook, + }); + + /// Whether the order supports instant booking (no approval needed). + final bool instantBook; + + /// Number of spots still available. + final int spotsLeft; + + /// Whether a booking request is currently in flight. + final bool bookingInProgress; + + /// Callback when the user taps the book/apply button. + final VoidCallback onBook; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space4, + UiConstants.space5, + MediaQuery.of(context).padding.bottom + UiConstants.space4, + ), + decoration: const BoxDecoration( + color: UiColors.white, + border: Border(top: BorderSide(color: UiColors.border)), + ), + child: _buildButton(context), + ); + } + + Widget _buildButton(BuildContext context) { + // Loading state + if (bookingInProgress) { + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: null, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.primary.withValues(alpha: 0.5), + disabledBackgroundColor: UiColors.primary.withValues(alpha: 0.5), + disabledForegroundColor: UiColors.white, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(UiConstants.radiusMdValue), + ), + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space3, + ), + ), + child: const SizedBox( + width: UiConstants.iconMd, + height: UiConstants.iconMd, + child: CircularProgressIndicator( + strokeWidth: 2, + color: UiColors.white, + ), + ), + ), + ); + } + + // Fully staffed + if (spotsLeft <= 0) { + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: null, + style: ElevatedButton.styleFrom( + disabledBackgroundColor: UiColors.bgThird, + disabledForegroundColor: UiColors.textSecondary, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(UiConstants.radiusMdValue), + ), + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space3, + ), + ), + child: Text( + t.available_orders.fully_staffed, + style: UiTypography.body2m.textSecondary, + ), + ), + ); + } + + // Instant book or standard apply + final bool isInstant = instantBook; + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: onBook, + style: ElevatedButton.styleFrom( + backgroundColor: isInstant ? UiColors.success : UiColors.primary, + foregroundColor: UiColors.white, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(UiConstants.radiusMdValue), + ), + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space3, + ), + ), + child: Text( + isInstant + ? t.available_orders.book_order + : t.available_orders.apply, + style: UiTypography.body2m.white, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart new file mode 100644 index 00000000..0b8e8022 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart @@ -0,0 +1,185 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Size of the role icon container in the order details header. +const double _kIconContainerSize = 68.0; + +/// A header widget for the order details page. +/// +/// Displays the role icon, role name, client name, and a row of status badges +/// (order type, spots left, instant book, dispatch team). +class OrderDetailsHeader extends StatelessWidget { + /// Creates an [OrderDetailsHeader]. + const OrderDetailsHeader({super.key, required this.order}); + + /// The available order entity. + final AvailableOrder order; + + /// Returns a human-readable label for the order type. + String _orderTypeLabel(OrderType type) { + switch (type) { + case OrderType.oneTime: + return t.staff_shifts.filter.one_day; + case OrderType.recurring: + return t.staff_shifts.filter.multi_day; + case OrderType.permanent: + return t.staff_shifts.filter.long_term; + case OrderType.rapid: + return 'Rapid'; + case OrderType.unknown: + return ''; + } + } + + /// Returns a capitalised short label for a dispatch team value. + String _dispatchTeamLabel(String team) { + switch (team.toUpperCase()) { + case 'CORE': + return 'Core'; + case 'CERTIFIED_LOCATION': + return 'Certified'; + case 'MARKETPLACE': + return 'Marketplace'; + default: + return team; + } + } + + @override + Widget build(BuildContext context) { + final int spotsLeft = order.requiredWorkerCount - order.filledCount; + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space6, + vertical: UiConstants.space4, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space4, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + spacing: UiConstants.space4, + children: [ + Container( + width: _kIconContainerSize, + height: _kIconContainerSize, + decoration: BoxDecoration( + color: UiColors.tagInProgress, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.primary, width: 0.5), + ), + child: const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: 20, + ), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + order.roleName, + style: UiTypography.body1m.textPrimary, + ), + if (order.clientName.isNotEmpty) + Text( + order.clientName, + style: UiTypography.body3r.textSecondary, + ), + ], + ), + ), + ], + ), + _buildBadgeRow(spotsLeft), + ], + ), + ); + } + + /// Builds the horizontal row of badge chips below the header. + Widget _buildBadgeRow(int spotsLeft) { + return Wrap( + spacing: UiConstants.space2, + runSpacing: UiConstants.space1, + children: [ + // Order type badge + _buildBadge( + label: _orderTypeLabel(order.orderType), + backgroundColor: UiColors.background, + textColor: UiColors.textSecondary, + borderColor: UiColors.border, + ), + + // Spots left badge + if (spotsLeft > 0) + _buildBadge( + label: t.available_orders.spots_left(count: spotsLeft), + backgroundColor: UiColors.tagPending, + textColor: UiColors.textWarning, + borderColor: UiColors.textWarning.withValues(alpha: 0.3), + ), + + // Instant book badge + if (order.instantBook) + _buildBadge( + label: t.available_orders.instant_book, + backgroundColor: UiColors.success.withValues(alpha: 0.1), + textColor: UiColors.success, + borderColor: UiColors.success.withValues(alpha: 0.3), + icon: UiIcons.zap, + ), + + // Dispatch team badge + if (order.dispatchTeam.isNotEmpty) + _buildBadge( + label: _dispatchTeamLabel(order.dispatchTeam), + backgroundColor: UiColors.primary.withValues(alpha: 0.08), + textColor: UiColors.primary, + borderColor: UiColors.primary.withValues(alpha: 0.2), + ), + ], + ); + } + + /// Builds a single badge chip with optional leading icon. + Widget _buildBadge({ + required String label, + required Color backgroundColor, + required Color textColor, + required Color borderColor, + IconData? icon, + }) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: UiConstants.radiusSm, + border: Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 10, color: textColor), + const SizedBox(width: 2), + ], + Text( + label, + style: UiTypography.footnote2m.copyWith(color: textColor), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart new file mode 100644 index 00000000..a7cbdfda --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart @@ -0,0 +1,131 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// A section displaying the schedule for an available order. +/// +/// Shows the days-of-week chips, date range, time range, and total shift count. +class OrderScheduleSection extends StatelessWidget { + /// Creates an [OrderScheduleSection]. + const OrderScheduleSection({ + super.key, + required this.schedule, + required this.scheduleLabel, + required this.shiftsCountLabel, + }); + + /// The order schedule data. + final AvailableOrderSchedule schedule; + + /// Localised section title (e.g. "SCHEDULE"). + final String scheduleLabel; + + /// Localised shifts count text (e.g. "3 shift(s)"). + final String shiftsCountLabel; + + /// Formats a date-only string (e.g. "2026-03-24") to "Mar 24". + String _formatDateShort(String dateStr) { + if (dateStr.isEmpty) return ''; + try { + final DateTime date = DateTime.parse(dateStr); + return DateFormat('MMM d').format(date); + } catch (_) { + return dateStr; + } + } + + /// Formats a DateTime to a time string (e.g. "9:00am"). + String _formatTime(DateTime dt) { + return DateFormat('h:mma').format(dt).toLowerCase(); + } + + @override + Widget build(BuildContext context) { + final String dateRange = + '${_formatDateShort(schedule.startDate)} - ${_formatDateShort(schedule.endDate)}'; + final String timeRange = + '${_formatTime(schedule.firstShiftStartsAt)} - ${_formatTime(schedule.lastShiftEndsAt)}'; + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space6, + vertical: UiConstants.space4, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + scheduleLabel, + style: UiTypography.titleUppercase4b.textSecondary, + ), + const SizedBox(height: UiConstants.space3), + + // Days of week chips + if (schedule.daysOfWeek.isNotEmpty) ...[ + Wrap( + spacing: UiConstants.space1, + runSpacing: UiConstants.space1, + children: schedule.daysOfWeek + .map((DayOfWeek day) => _buildDayChip(day)) + .toList(), + ), + const SizedBox(height: UiConstants.space3), + ], + + // Date range row + Row( + children: [ + const Icon( + UiIcons.calendar, + size: 20, + color: UiColors.primary, + ), + const SizedBox(width: UiConstants.space2), + Text(dateRange, style: UiTypography.headline5m.textPrimary), + ], + ), + const SizedBox(height: UiConstants.space2), + + // Time range row + Row( + children: [ + const Icon( + UiIcons.clock, + size: 20, + color: UiColors.primary, + ), + const SizedBox(width: UiConstants.space2), + Text(timeRange, style: UiTypography.headline5m.textPrimary), + ], + ), + const SizedBox(height: UiConstants.space2), + + // Shifts count + Text(shiftsCountLabel, style: UiTypography.footnote2r.textSecondary), + ], + ), + ); + } + + /// Builds a small chip showing a day-of-week abbreviation. + Widget _buildDayChip(DayOfWeek day) { + final String label = day.value.isNotEmpty + ? '${day.value[0]}${day.value.substring(1).toLowerCase()}' + : ''; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.08), + borderRadius: UiConstants.radiusSm, + ), + child: Text( + label, + style: UiTypography.footnote2m.copyWith(color: UiColors.primary), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart index 014b561d..ef13605e 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart @@ -10,14 +10,13 @@ import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.da /// Tab showing available orders for the worker to browse and book. /// /// Replaces the former open-shift listing with order-level marketplace cards. +/// Tapping a card navigates to the order details page. class FindShiftsTab extends StatefulWidget { /// Creates a [FindShiftsTab]. const FindShiftsTab({ super.key, required this.availableOrders, this.profileComplete = true, - required this.onBook, - this.bookingInProgress = false, }); /// Available orders loaded from the V2 API. @@ -26,12 +25,6 @@ class FindShiftsTab extends StatefulWidget { /// Whether the worker's profile is complete. final bool profileComplete; - /// Callback when the worker taps book/apply on an order card. - final void Function(String orderId, String roleId) onBook; - - /// Whether a booking request is currently in flight. - final bool bookingInProgress; - @override State createState() => _FindShiftsTabState(); } @@ -186,8 +179,7 @@ class _FindShiftsTabState extends State { ...filteredOrders.map( (AvailableOrder order) => AvailableOrderCard( order: order, - onBook: widget.onBook, - bookingInProgress: widget.bookingInProgress, + onTap: () => Modular.to.toOrderDetails(order), ), ), const SizedBox(height: UiConstants.space32), diff --git a/apps/mobile/packages/features/staff/shifts/lib/staff_shifts.dart b/apps/mobile/packages/features/staff/shifts/lib/staff_shifts.dart index fd3484ea..f9e4a32e 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/staff_shifts.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/staff_shifts.dart @@ -2,4 +2,5 @@ library; export 'src/staff_shifts_module.dart'; export 'src/shift_details_module.dart'; +export 'src/order_details_module.dart'; diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart index 32aa3711..13d1b7ba 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart @@ -133,6 +133,10 @@ class StaffMainModule extends Module { StaffPaths.childRoute(StaffPaths.main, StaffPaths.shiftDetailsRoute), module: ShiftDetailsModule(), ); + r.module( + StaffPaths.childRoute(StaffPaths.main, StaffPaths.orderDetailsRoute), + module: OrderDetailsModule(), + ); r.module( StaffPaths.childRoute(StaffPaths.main, StaffPaths.faqs), module: FaqsModule(), From 8121a718bb00a35a1c395172a103ec053c0aab7b Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 14:22:57 -0400 Subject: [PATCH 12/29] feat: Refactor OrderDetailsBottomBar to use UiButton for improved styling and consistency --- .../order_details_bottom_bar.dart | 54 ++----------------- 1 file changed, 5 insertions(+), 49 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart index 3f98df84..e97de4c2 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart @@ -50,20 +50,8 @@ class OrderDetailsBottomBar extends StatelessWidget { if (bookingInProgress) { return SizedBox( width: double.infinity, - child: ElevatedButton( + child: UiButton.primary( onPressed: null, - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.primary.withValues(alpha: 0.5), - disabledBackgroundColor: UiColors.primary.withValues(alpha: 0.5), - disabledForegroundColor: UiColors.white, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(UiConstants.radiusMdValue), - ), - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space3, - ), - ), child: const SizedBox( width: UiConstants.iconMd, height: UiConstants.iconMd, @@ -80,50 +68,18 @@ class OrderDetailsBottomBar extends StatelessWidget { if (spotsLeft <= 0) { return SizedBox( width: double.infinity, - child: ElevatedButton( + child: UiButton.primary( onPressed: null, - style: ElevatedButton.styleFrom( - disabledBackgroundColor: UiColors.bgThird, - disabledForegroundColor: UiColors.textSecondary, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(UiConstants.radiusMdValue), - ), - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space3, - ), - ), - child: Text( - t.available_orders.fully_staffed, - style: UiTypography.body2m.textSecondary, - ), + text: t.available_orders.fully_staffed, ), ); } - // Instant book or standard apply - final bool isInstant = instantBook; return SizedBox( width: double.infinity, - child: ElevatedButton( + child: UiButton.primary( onPressed: onBook, - style: ElevatedButton.styleFrom( - backgroundColor: isInstant ? UiColors.success : UiColors.primary, - foregroundColor: UiColors.white, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(UiConstants.radiusMdValue), - ), - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space3, - ), - ), - child: Text( - isInstant - ? t.available_orders.book_order - : t.available_orders.apply, - style: UiTypography.body2m.white, - ), + text: t.available_orders.book_order, ), ); } From 2a99587d2ff573ee42fc64af901f9ce9922736f0 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 15:11:15 -0400 Subject: [PATCH 13/29] feat: Implement Google Maps integration for shift details and enhance UI components --- .../plugins/GeneratedPluginRegistrant.java | 5 ++ .../ios/Runner/GeneratedPluginRegistrant.m | 7 ++ .../pages/shift_details_page.dart | 2 + .../shift_date_time_section.dart | 2 +- .../shift_description_section.dart | 2 +- .../shift_details/shift_details_header.dart | 4 +- .../shift_details/shift_location_section.dart | 53 +++++++++++++- .../features/staff/shifts/pubspec.yaml | 1 + apps/mobile/pubspec.lock | 72 +++++++++++++++++++ 9 files changed, 141 insertions(+), 7 deletions(-) diff --git a/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index 2b40a2eb..05f7bbac 100644 --- a/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -45,6 +45,11 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e); } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.googlemaps.GoogleMapsPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin google_maps_flutter_android, io.flutter.plugins.googlemaps.GoogleMapsPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin()); } catch (Exception e) { diff --git a/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m b/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m index 0285454c..89cde2bb 100644 --- a/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m +++ b/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m @@ -36,6 +36,12 @@ @import geolocator_apple; #endif +#if __has_include() +#import +#else +@import google_maps_flutter_ios; +#endif + #if __has_include() #import #else @@ -80,6 +86,7 @@ [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]]; + [FGMGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FGMGoogleMapsPlugin"]]; [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; [RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]]; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index cb238376..f0f9a27a 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -140,6 +140,8 @@ class _ShiftDetailsPageState extends State { ShiftLocationSection( location: detail.location, address: detail.address ?? '', + latitude: detail.latitude, + longitude: detail.longitude, locationLabel: context.t.staff_shifts.shift_details.location, tbdLabel: context.t.staff_shifts.shift_details.tbd, getDirectionLabel: context.t.staff_shifts.shift_details.get_direction, diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart index 3e38f151..08ce36e2 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart @@ -57,7 +57,7 @@ class ShiftDateTimeSection extends StatelessWidget { const Icon( UiIcons.calendar, size: 20, - color: UiColors.primary, + color: UiColors.textPrimary, ), const SizedBox(width: UiConstants.space2), Text( diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_description_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_description_section.dart index 770fc3f9..41731764 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_description_section.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_description_section.dart @@ -32,7 +32,7 @@ class ShiftDescriptionSection extends StatelessWidget { const SizedBox(height: UiConstants.space2), Text( description, - style: UiTypography.body2r.textSecondary, + style: UiTypography.body2r, ), ], ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart index c822d5e2..a225243e 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart @@ -45,8 +45,8 @@ class ShiftDetailsHeader extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(detail.title, style: UiTypography.headline1b.textPrimary), - Text(detail.roleName, style: UiTypography.body1m.textSecondary), + Text(detail.roleName, style: UiTypography.headline1b.textPrimary), + Text(detail.clientName, style: UiTypography.body1m.textSecondary), ], ), ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart index e85910b6..18e3786d 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart @@ -1,6 +1,7 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; /// A section displaying the shift's location, address, and "Get direction" action. @@ -10,6 +11,8 @@ class ShiftLocationSection extends StatelessWidget { super.key, required this.location, required this.address, + this.latitude, + this.longitude, required this.locationLabel, required this.tbdLabel, required this.getDirectionLabel, @@ -21,6 +24,12 @@ class ShiftLocationSection extends StatelessWidget { /// Street address. final String address; + /// Latitude coordinate for map preview. + final double? latitude; + + /// Longitude coordinate for map preview. + final double? longitude; + /// Localization string for location section title. final String locationLabel; @@ -97,15 +106,53 @@ class ShiftLocationSection extends StatelessWidget { ), ], ), + + if (latitude != null && longitude != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + child: SizedBox( + height: 180, + width: double.infinity, + child: GoogleMap( + initialCameraPosition: CameraPosition( + target: LatLng(latitude!, longitude!), + zoom: 15, + ), + markers: { + Marker( + markerId: const MarkerId('shift_location'), + position: LatLng(latitude!, longitude!), + ), + }, + liteModeEnabled: true, + myLocationButtonEnabled: false, + myLocationEnabled: false, + zoomControlsEnabled: false, + mapToolbarEnabled: false, + compassEnabled: false, + rotateGesturesEnabled: false, + scrollGesturesEnabled: false, + tiltGesturesEnabled: false, + zoomGesturesEnabled: false, + ), + ), + ), + const SizedBox(height: UiConstants.space3), + ], ], ), ); } Future _openDirections(BuildContext context) async { - final String destination = Uri.encodeComponent( - address.isNotEmpty ? address : location, - ); + String destination; + if (latitude != null && longitude != null) { + destination = '$latitude,$longitude'; + } else { + destination = Uri.encodeComponent( + address.isNotEmpty ? address : location, + ); + } final String url = 'https://www.google.com/maps/dir/?api=1&destination=$destination'; diff --git a/apps/mobile/packages/features/staff/shifts/pubspec.yaml b/apps/mobile/packages/features/staff/shifts/pubspec.yaml index a05c568e..d478f4f9 100644 --- a/apps/mobile/packages/features/staff/shifts/pubspec.yaml +++ b/apps/mobile/packages/features/staff/shifts/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: url_launcher: ^6.3.1 bloc: ^8.1.4 meta: ^1.17.0 + google_maps_flutter: ^2.10.0 dev_dependencies: flutter_test: diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 8a97848c..53a17dd0 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -257,6 +257,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" csv: dependency: transitive description: @@ -637,6 +645,54 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + google_maps: + dependency: transitive + description: + name: google_maps + sha256: "5d410c32112d7c6eb7858d359275b2aa04778eed3e36c745aeae905fb2fa6468" + url: "https://pub.dev" + source: hosted + version: "8.2.0" + google_maps_flutter: + dependency: transitive + description: + name: google_maps_flutter + sha256: "0114a31e177f650f0972347d93122c42661a75b869561ff6a374cc42ff3af886" + url: "https://pub.dev" + source: hosted + version: "2.16.0" + google_maps_flutter_android: + dependency: transitive + description: + name: google_maps_flutter_android + sha256: "68a3907c90dc37caffbcfc1093541ef2c18d6ebb53296fdb9f04822d16269353" + url: "https://pub.dev" + source: hosted + version: "2.19.3" + google_maps_flutter_ios: + dependency: transitive + description: + name: google_maps_flutter_ios + sha256: c855600dce17e77e8af96edcf85cb68501675bb77a72f85009d08c17a8805ace + url: "https://pub.dev" + source: hosted + version: "2.18.0" + google_maps_flutter_platform_interface: + dependency: transitive + description: + name: google_maps_flutter_platform_interface + sha256: ddbe34435dfb34e83fca295c6a8dcc53c3b51487e9eec3c737ce4ae605574347 + url: "https://pub.dev" + source: hosted + version: "2.15.0" + google_maps_flutter_web: + dependency: transitive + description: + name: google_maps_flutter_web + sha256: "6cefe4ef4cc61dc0dfba4c413dec4bd105cb6b9461bfbe1465ddd09f80af377d" + url: "https://pub.dev" + source: hosted + version: "0.6.2" google_places_flutter: dependency: transitive description: @@ -669,6 +725,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.5" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: transitive description: @@ -1189,6 +1253,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + sanitize_html: + dependency: transitive + description: + name: sanitize_html + sha256: "12669c4a913688a26555323fb9cec373d8f9fbe091f2d01c40c723b33caa8989" + url: "https://pub.dev" + source: hosted + version: "2.1.0" shared_preferences: dependency: transitive description: From faf27b03f27e42d55c8b3404ccde430e64a46587 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 15:14:13 -0400 Subject: [PATCH 14/29] feat: Update OrderDetailsHeader and ShiftDetailsHeader layout for improved UI consistency --- .../order_details/order_details_header.dart | 15 ++-- .../shift_details/shift_details_header.dart | 70 +++++++------------ 2 files changed, 31 insertions(+), 54 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart index 0b8e8022..705323ff 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart @@ -58,7 +58,7 @@ class OrderDetailsHeader extends StatelessWidget { ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - spacing: UiConstants.space4, + spacing: UiConstants.space6, children: [ Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -68,7 +68,7 @@ class OrderDetailsHeader extends StatelessWidget { width: _kIconContainerSize, height: _kIconContainerSize, decoration: BoxDecoration( - color: UiColors.tagInProgress, + color: UiColors.primary.withAlpha(20), borderRadius: UiConstants.radiusLg, border: Border.all(color: UiColors.primary, width: 0.5), ), @@ -86,13 +86,12 @@ class OrderDetailsHeader extends StatelessWidget { children: [ Text( order.roleName, - style: UiTypography.body1m.textPrimary, + style: UiTypography.headline1b.textPrimary, + ), + Text( + order.clientName, + style: UiTypography.body1m.textSecondary, ), - if (order.clientName.isNotEmpty) - Text( - order.clientName, - style: UiTypography.body3r.textSecondary, - ), ], ), ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart index a225243e..d20226a2 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart @@ -17,56 +17,34 @@ class ShiftDetailsHeader extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, spacing: UiConstants.space4, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - spacing: UiConstants.space4, - children: [ - Container( - width: _kIconContainerSize, - height: _kIconContainerSize, - decoration: BoxDecoration( - color: UiColors.primary.withAlpha(20), - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.primary, width: 0.5), - ), - child: const Center( - child: Icon( - UiIcons.briefcase, - color: UiColors.primary, - size: 20, - ), - ), + Container( + width: _kIconContainerSize, + height: _kIconContainerSize, + decoration: BoxDecoration( + color: UiColors.primary.withAlpha(20), + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.primary, width: 0.5), + ), + child: const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: 20, ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(detail.roleName, style: UiTypography.headline1b.textPrimary), - Text(detail.clientName, style: UiTypography.body1m.textSecondary), - ], - ), - ), - ], + ), ), - Row( - spacing: UiConstants.space1, - children: [ - const Icon( - UiIcons.mapPin, - size: 16, - color: UiColors.textSecondary, - ), - Expanded( - child: Text( - detail.address ?? detail.location, - style: UiTypography.body2r.textSecondary, - ), - ), - ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(detail.roleName, style: UiTypography.headline1b.textPrimary), + Text(detail.clientName, style: UiTypography.body1m.textSecondary), + ], + ), ), ], ), From e1b9ad532bfd148061b437f44c0bd4aa1d3dadd1 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 15:17:26 -0400 Subject: [PATCH 15/29] feat: Update client name text style in OrderDetailsHeader and ShiftDetailsHeader for consistency --- .../widgets/order_details/order_details_header.dart | 2 +- .../widgets/shift_details/shift_details_header.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart index 705323ff..cb8145f9 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart @@ -90,7 +90,7 @@ class OrderDetailsHeader extends StatelessWidget { ), Text( order.clientName, - style: UiTypography.body1m.textSecondary, + style: UiTypography.body2r.textSecondary, ), ], ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart index d20226a2..a64ef1a1 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart @@ -42,7 +42,7 @@ class ShiftDetailsHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(detail.roleName, style: UiTypography.headline1b.textPrimary), - Text(detail.clientName, style: UiTypography.body1m.textSecondary), + Text(detail.clientName, style: UiTypography.body2r.textSecondary), ], ), ), From d2bcb9f3ba8962873dd464e6948f81556a82119b Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:17:48 +0100 Subject: [PATCH 16/29] feat(api): add staff order detail and compliance eligibility --- .../src/lib/staff-order-eligibility.js | 39 +++ .../src/services/mobile-command-service.js | 62 ++++ .../test/staff-order-eligibility.test.js | 14 + .../src/contracts/core/create-verification.js | 2 +- .../core-api/src/services/mobile-upload.js | 80 +++++ .../src/services/verification-jobs.js | 99 +++++- backend/core-api/test/app.test.js | 22 ++ .../src/lib/staff-order-eligibility.js | 39 +++ backend/query-api/src/routes/mobile.js | 22 ++ .../src/services/mobile-query-service.js | 312 ++++++++++++++++++ backend/query-api/test/mobile-routes.test.js | 22 ++ .../query-api/test/staff-order-detail.test.js | 117 +++++++ .../scripts/live-smoke-v2-unified.mjs | 202 ++++++++++-- docs/BACKEND/API_GUIDES/V2/README.md | 1 + .../API_GUIDES/V2/mobile-coding-agent-spec.md | 8 +- .../V2/mobile-frontend-implementation-spec.md | 6 +- docs/BACKEND/API_GUIDES/V2/staff-shifts.md | 40 ++- docs/BACKEND/API_GUIDES/V2/unified-api.md | 6 +- 18 files changed, 1051 insertions(+), 42 deletions(-) create mode 100644 backend/command-api/src/lib/staff-order-eligibility.js create mode 100644 backend/command-api/test/staff-order-eligibility.test.js create mode 100644 backend/query-api/src/lib/staff-order-eligibility.js create mode 100644 backend/query-api/test/staff-order-detail.test.js diff --git a/backend/command-api/src/lib/staff-order-eligibility.js b/backend/command-api/src/lib/staff-order-eligibility.js new file mode 100644 index 00000000..a8f63897 --- /dev/null +++ b/backend/command-api/src/lib/staff-order-eligibility.js @@ -0,0 +1,39 @@ +function dedupeStrings(values = []) { + return [...new Set( + values + .filter((value) => typeof value === 'string') + .map((value) => value.trim()) + .filter(Boolean) + )]; +} + +export function dedupeDocumentNames(values = []) { + return dedupeStrings(values); +} + +export function buildStaffOrderEligibilityBlockers({ + hasActiveWorkforce = true, + businessBlockReason = null, + hasExistingParticipation = false, + missingDocumentNames = [], +} = {}) { + const blockers = []; + + if (!hasActiveWorkforce) { + blockers.push('Workforce profile is not active'); + } + + if (businessBlockReason !== null && businessBlockReason !== undefined) { + blockers.push(businessBlockReason + ? `You are blocked from working for this client: ${businessBlockReason}` + : 'You are blocked from working for this client'); + } + + if (hasExistingParticipation) { + blockers.push('You already applied to or booked this order'); + } + + blockers.push(...dedupeDocumentNames(missingDocumentNames).map((name) => `Missing required document: ${name}`)); + + return dedupeStrings(blockers); +} diff --git a/backend/command-api/src/services/mobile-command-service.js b/backend/command-api/src/services/mobile-command-service.js index def1d189..0448733b 100644 --- a/backend/command-api/src/services/mobile-command-service.js +++ b/backend/command-api/src/services/mobile-command-service.js @@ -1,5 +1,6 @@ import crypto from 'node:crypto'; import { AppError } from '../lib/errors.js'; +import { buildStaffOrderEligibilityBlockers, dedupeDocumentNames } from '../lib/staff-order-eligibility.js'; import { query, withTransaction } from './db.js'; import { loadActorContext, requireClientContext, requireStaffContext } from './actor-context.js'; import { recordGeofenceIncident } from './attendance-monitoring.js'; @@ -89,6 +90,53 @@ async function ensureStaffNotBlockedByBusiness(client, { tenantId, businessId, s } } +async function loadMissingRequiredDocuments(client, { tenantId, roleCode, staffId }) { + if (!roleCode) return []; + + const result = await client.query( + ` + SELECT d.name + FROM documents d + WHERE d.tenant_id = $1 + AND d.required_for_role_code = $2 + AND d.document_type <> 'ATTIRE' + AND NOT EXISTS ( + SELECT 1 + FROM staff_documents sd + WHERE sd.tenant_id = d.tenant_id + AND sd.staff_id = $3 + AND sd.document_id = d.id + AND sd.status = 'VERIFIED' + ) + ORDER BY d.name ASC + `, + [tenantId, roleCode, staffId] + ); + + return dedupeDocumentNames(result.rows.map((row) => row.name)); +} + +function buildMissingDocumentErrorDetails({ + roleCode, + orderId = null, + shiftId = null, + roleId = null, + missingDocumentNames = [], +}) { + const blockers = buildStaffOrderEligibilityBlockers({ + missingDocumentNames, + }); + + return { + orderId, + shiftId, + roleId, + roleCode: roleCode || null, + blockers, + missingDocuments: dedupeDocumentNames(missingDocumentNames), + }; +} + function buildAssignmentReferencePayload(assignment) { return { assignmentId: assignment.id, @@ -3024,6 +3072,20 @@ export async function bookOrder(actor, payload) { staffId: staff.id, }); + const missingRequiredDocuments = await loadMissingRequiredDocuments(client, { + tenantId: context.tenant.tenantId, + roleCode: selectedRole.code, + staffId: staff.id, + }); + if (missingRequiredDocuments.length > 0) { + throw new AppError('UNPROCESSABLE_ENTITY', 'Staff is missing required documents for this role', 422, buildMissingDocumentErrorDetails({ + orderId: payload.orderId, + roleId: payload.roleId, + roleCode: selectedRole.code, + missingDocumentNames: missingRequiredDocuments, + })); + } + const bookingId = crypto.randomUUID(); const assignedShifts = []; diff --git a/backend/command-api/test/staff-order-eligibility.test.js b/backend/command-api/test/staff-order-eligibility.test.js new file mode 100644 index 00000000..245b8c71 --- /dev/null +++ b/backend/command-api/test/staff-order-eligibility.test.js @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildStaffOrderEligibilityBlockers } from '../src/lib/staff-order-eligibility.js'; + +test('buildStaffOrderEligibilityBlockers formats missing document blockers for command flows', () => { + const blockers = buildStaffOrderEligibilityBlockers({ + missingDocumentNames: ['Food Handler Card', 'Food Handler Card', ' Responsible Beverage Service '], + }); + + assert.deepEqual(blockers, [ + 'Missing required document: Food Handler Card', + 'Missing required document: Responsible Beverage Service', + ]); +}); diff --git a/backend/core-api/src/contracts/core/create-verification.js b/backend/core-api/src/contracts/core/create-verification.js index ee03d8ec..970b016e 100644 --- a/backend/core-api/src/contracts/core/create-verification.js +++ b/backend/core-api/src/contracts/core/create-verification.js @@ -1,7 +1,7 @@ import { z } from 'zod'; export const createVerificationSchema = z.object({ - type: z.enum(['attire', 'government_id', 'certification']), + type: z.enum(['attire', 'government_id', 'certification', 'tax_form']), subjectType: z.string().min(1).max(80).optional(), subjectId: z.string().min(1).max(120).optional(), fileUri: z.string().startsWith('gs://', 'fileUri must start with gs://'), diff --git a/backend/core-api/src/services/mobile-upload.js b/backend/core-api/src/services/mobile-upload.js index 07ad0420..e386bc6d 100644 --- a/backend/core-api/src/services/mobile-upload.js +++ b/backend/core-api/src/services/mobile-upload.js @@ -87,6 +87,70 @@ async function resolveVerificationBackedUpload({ }; } +async function bindVerificationToStaffDocument(client, { + verificationId, + tenantId, + staffId, + document, + routeType, +}) { + await client.query( + ` + UPDATE verification_jobs + SET staff_id = $2, + document_id = $3, + subject_type = $4, + subject_id = $5, + metadata = COALESCE(metadata, '{}'::jsonb) || $6::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [ + verificationId, + staffId, + document.id, + routeType === 'attire' ? 'attire_item' : 'staff_document', + document.id, + JSON.stringify({ + routeType, + documentType: document.document_type, + boundFromFinalize: true, + }), + ] + ); +} + +async function bindVerificationToCertificate(client, { + verificationId, + staffId, + certificateType, + certificateName, + certificateIssuer, +}) { + await client.query( + ` + UPDATE verification_jobs + SET staff_id = $2, + subject_type = 'certificate', + subject_id = $3, + metadata = COALESCE(metadata, '{}'::jsonb) || $4::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [ + verificationId, + staffId, + certificateType, + JSON.stringify({ + certificateType, + name: certificateName || null, + issuer: certificateIssuer || null, + boundFromFinalize: true, + }), + ] + ); +} + export async function uploadProfilePhoto({ actorUid, file }) { const context = await requireStaffContext(actorUid); const uploaded = await uploadActorFile({ @@ -166,6 +230,14 @@ export async function uploadStaffDocument({ actorUid, documentId, file, routeTyp }); await withTransaction(async (client) => { + await bindVerificationToStaffDocument(client, { + verificationId: finalized.verification.verificationId, + tenantId: context.tenant.tenantId, + staffId: context.staff.staffId, + document, + routeType, + }); + await client.query( ` INSERT INTO staff_documents ( @@ -363,6 +435,14 @@ export async function finalizeCertificateUpload({ actorUid, payload }) { }); const certificateResult = await withTransaction(async (client) => { + await bindVerificationToCertificate(client, { + verificationId: finalized.verification.verificationId, + staffId: context.staff.staffId, + certificateType: payload.certificateType, + certificateName: payload.name, + certificateIssuer: payload.issuer, + }); + const existing = await client.query( ` SELECT id diff --git a/backend/core-api/src/services/verification-jobs.js b/backend/core-api/src/services/verification-jobs.js index ac70aab8..61265295 100644 --- a/backend/core-api/src/services/verification-jobs.js +++ b/backend/core-api/src/services/verification-jobs.js @@ -225,6 +225,78 @@ async function appendVerificationEvent(client, { ); } +function normalizeArtifactStatus(status) { + switch (`${status || ''}`.toUpperCase()) { + case VerificationStatus.AUTO_PASS: + case VerificationStatus.APPROVED: + return 'VERIFIED'; + case VerificationStatus.AUTO_FAIL: + case VerificationStatus.REJECTED: + return 'REJECTED'; + case VerificationStatus.PENDING: + case VerificationStatus.PROCESSING: + case VerificationStatus.NEEDS_REVIEW: + case VerificationStatus.ERROR: + default: + return 'PENDING'; + } +} + +function looksLikeUuid(value) { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(`${value || ''}`); +} + +async function syncVerificationSubjectStatus(client, job) { + const subjectType = job.subject_type || job.subjectType || null; + const subjectId = job.subject_id || job.subjectId || null; + const tenantId = job.tenant_id || job.tenantId || null; + const staffId = job.staff_id || job.staffId || null; + const verificationId = job.id || job.verificationId || null; + + if (!subjectType || !subjectId || !tenantId || !staffId || !verificationId) { + return; + } + + const nextStatus = normalizeArtifactStatus(job.status); + const metadataPatch = JSON.stringify({ + verificationStatus: job.status, + verificationJobId: verificationId, + syncedFromVerification: true, + }); + const subjectIdIsUuid = looksLikeUuid(subjectId); + + if (subjectType === 'staff_document' || subjectType === 'attire_item' || (subjectType === 'worker' && subjectIdIsUuid)) { + await client.query( + ` + UPDATE staff_documents + SET status = $4, + metadata = COALESCE(metadata, '{}'::jsonb) || $5::jsonb, + updated_at = NOW() + WHERE tenant_id = $1 + AND staff_id = $2 + AND document_id::text = $3 + `, + [tenantId, staffId, subjectId, nextStatus, metadataPatch] + ); + return; + } + + if (subjectType === 'certificate' || (subjectType === 'worker' && !subjectIdIsUuid)) { + await client.query( + ` + UPDATE certificates + SET status = $4, + metadata = COALESCE(metadata, '{}'::jsonb) || $5::jsonb, + updated_at = NOW() + WHERE tenant_id = $1 + AND staff_id = $2 + AND certificate_type = $3 + `, + [tenantId, staffId, subjectId, nextStatus, metadataPatch] + ); + } +} + async function runAttireChecks(job) { if (process.env.VERIFICATION_ATTIRE_AUTOPASS === 'true') { return { @@ -324,6 +396,13 @@ function getProviderConfig(type) { token: process.env.VERIFICATION_GOV_ID_PROVIDER_TOKEN, }; } + if (type === 'tax_form') { + return { + name: 'tax-form-provider', + url: process.env.VERIFICATION_TAX_FORM_PROVIDER_URL, + token: process.env.VERIFICATION_TAX_FORM_PROVIDER_TOKEN, + }; + } return { name: 'certification-provider', url: process.env.VERIFICATION_CERT_PROVIDER_URL, @@ -458,7 +537,7 @@ async function processVerificationJob(verificationId) { : await runThirdPartyChecks(startedJob, startedJob.type); await withTransaction(async (client) => { - await client.query( + const updated = await client.query( ` UPDATE verification_jobs SET status = $2, @@ -469,6 +548,7 @@ async function processVerificationJob(verificationId) { provider_reference = $7, updated_at = NOW() WHERE id = $1 + RETURNING * `, [ verificationId, @@ -481,6 +561,8 @@ async function processVerificationJob(verificationId) { ] ); + await syncVerificationSubjectStatus(client, updated.rows[0]); + await appendVerificationEvent(client, { verificationJobId: verificationId, fromStatus: VerificationStatus.PROCESSING, @@ -494,7 +576,7 @@ async function processVerificationJob(verificationId) { }); } catch (error) { await withTransaction(async (client) => { - await client.query( + const updated = await client.query( ` UPDATE verification_jobs SET status = $2, @@ -503,6 +585,7 @@ async function processVerificationJob(verificationId) { provider_reference = $4, updated_at = NOW() WHERE id = $1 + RETURNING * `, [ verificationId, @@ -512,6 +595,8 @@ async function processVerificationJob(verificationId) { ] ); + await syncVerificationSubjectStatus(client, updated.rows[0]); + await appendVerificationEvent(client, { verificationJobId: verificationId, fromStatus: VerificationStatus.PROCESSING, @@ -703,17 +788,20 @@ export async function reviewVerificationJob(verificationId, actorUid, review) { reasonCode: review.reasonCode || 'MANUAL_REVIEW', }; - await client.query( + const updatedResult = await client.query( ` UPDATE verification_jobs SET status = $2, review = $3::jsonb, updated_at = NOW() WHERE id = $1 + RETURNING * `, [verificationId, review.decision, JSON.stringify(reviewPayload)] ); + await syncVerificationSubjectStatus(client, updatedResult.rows[0]); + await client.query( ` INSERT INTO verification_reviews ( @@ -800,7 +888,7 @@ export async function retryVerificationJob(verificationId, actorUid) { }); } - await client.query( + const updatedResult = await client.query( ` UPDATE verification_jobs SET status = $2, @@ -812,10 +900,13 @@ export async function retryVerificationJob(verificationId, actorUid) { review = '{}'::jsonb, updated_at = NOW() WHERE id = $1 + RETURNING * `, [verificationId, VerificationStatus.PENDING] ); + await syncVerificationSubjectStatus(client, updatedResult.rows[0]); + await appendVerificationEvent(client, { verificationJobId: verificationId, fromStatus: job.status, diff --git a/backend/core-api/test/app.test.js b/backend/core-api/test/app.test.js index fafed45b..646ac494 100644 --- a/backend/core-api/test/app.test.js +++ b/backend/core-api/test/app.test.js @@ -349,6 +349,28 @@ test('POST /core/verifications creates async job and GET returns status', async assert.ok(['NEEDS_REVIEW', 'AUTO_PASS', 'AUTO_FAIL', 'ERROR'].includes(status.body.status)); }); +test('POST /core/verifications accepts tax_form verification jobs', async () => { + const app = createApp(); + const created = await request(app) + .post('/core/verifications') + .set('Authorization', 'Bearer test-token') + .send({ + type: 'tax_form', + subjectType: 'worker', + subjectId: 'document-tax-i9', + fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/i9.pdf', + rules: { formType: 'I-9' }, + }); + + assert.equal(created.status, 202); + assert.equal(created.body.type, 'tax_form'); + + const status = await waitForMachineStatus(app, created.body.verificationId); + assert.equal(status.status, 200); + assert.equal(status.body.type, 'tax_form'); + assert.ok(['NEEDS_REVIEW', 'AUTO_PASS', 'AUTO_FAIL', 'ERROR'].includes(status.body.status)); +}); + test('POST /core/verifications rejects file paths not owned by actor', async () => { const app = createApp(); const res = await request(app) diff --git a/backend/query-api/src/lib/staff-order-eligibility.js b/backend/query-api/src/lib/staff-order-eligibility.js new file mode 100644 index 00000000..a8f63897 --- /dev/null +++ b/backend/query-api/src/lib/staff-order-eligibility.js @@ -0,0 +1,39 @@ +function dedupeStrings(values = []) { + return [...new Set( + values + .filter((value) => typeof value === 'string') + .map((value) => value.trim()) + .filter(Boolean) + )]; +} + +export function dedupeDocumentNames(values = []) { + return dedupeStrings(values); +} + +export function buildStaffOrderEligibilityBlockers({ + hasActiveWorkforce = true, + businessBlockReason = null, + hasExistingParticipation = false, + missingDocumentNames = [], +} = {}) { + const blockers = []; + + if (!hasActiveWorkforce) { + blockers.push('Workforce profile is not active'); + } + + if (businessBlockReason !== null && businessBlockReason !== undefined) { + blockers.push(businessBlockReason + ? `You are blocked from working for this client: ${businessBlockReason}` + : 'You are blocked from working for this client'); + } + + if (hasExistingParticipation) { + blockers.push('You already applied to or booked this order'); + } + + blockers.push(...dedupeDocumentNames(missingDocumentNames).map((name) => `Missing required document: ${name}`)); + + return dedupeStrings(blockers); +} diff --git a/backend/query-api/src/routes/mobile.js b/backend/query-api/src/routes/mobile.js index 31bbd090..a565c3e3 100644 --- a/backend/query-api/src/routes/mobile.js +++ b/backend/query-api/src/routes/mobile.js @@ -17,6 +17,7 @@ import { getForecastReport, getNoShowReport, getOrderReorderPreview, + getStaffOrderDetail, listGeofenceIncidents, getReportSummary, getSavings, @@ -85,6 +86,7 @@ const defaultQueryService = { getForecastReport, getNoShowReport, getOrderReorderPreview, + getStaffOrderDetail, listGeofenceIncidents, getReportSummary, getSavings, @@ -147,6 +149,17 @@ function requireQueryParam(name, value) { return value; } +function requireUuid(value, field) { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) { + const error = new Error(`${field} must be a UUID`); + error.code = 'VALIDATION_ERROR'; + error.status = 400; + error.details = { field }; + throw error; + } + return value; +} + export function createMobileQueryRouter(queryService = defaultQueryService) { const router = Router(); @@ -566,6 +579,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/staff/orders/:orderId', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => { + try { + const data = await queryService.getStaffOrderDetail(req.actor.uid, requireUuid(req.params.orderId, 'orderId')); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + router.get('/staff/shifts/pending', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => { try { const items = await queryService.listPendingAssignments(req.actor.uid); diff --git a/backend/query-api/src/services/mobile-query-service.js b/backend/query-api/src/services/mobile-query-service.js index 9ed9ae15..db22a212 100644 --- a/backend/query-api/src/services/mobile-query-service.js +++ b/backend/query-api/src/services/mobile-query-service.js @@ -1,4 +1,5 @@ import { AppError } from '../lib/errors.js'; +import { buildStaffOrderEligibilityBlockers, dedupeDocumentNames } from '../lib/staff-order-eligibility.js'; import { FAQ_CATEGORIES } from '../data/faqs.js'; import { query } from './db.js'; import { requireClientContext, requireStaffContext } from './actor-context.js'; @@ -98,6 +99,136 @@ function weekdayCodeInTimeZone(value, timeZone = 'UTC') { return label.slice(0, 3).toUpperCase(); } +function formatCurrencyCents(cents) { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format((Number(cents || 0) / 100)); +} + +function managerDisplayRole(manager) { + if (manager?.role) return manager.role; + if (manager?.businessRole === 'owner') return 'Business Owner'; + return 'Hub Manager'; +} + +export function summarizeStaffOrderDetail({ + rows, + managers = [], + blockers = [], +}) { + if (!Array.isArray(rows) || rows.length === 0) { + throw new AppError('NOT_FOUND', 'Order is not available for this staff worker', 404); + } + + const firstRow = rows[0]; + const timeZone = resolveTimeZone(firstRow.timezone); + const orderedRows = [...rows].sort((left, right) => ( + new Date(left.startsAt).getTime() - new Date(right.startsAt).getTime() + )); + const firstShift = orderedRows[0]; + const lastShift = orderedRows[orderedRows.length - 1]; + const daysOfWeek = [...new Set(orderedRows.map((row) => weekdayCodeInTimeZone(row.startsAt, timeZone)))]; + + const requiredWorkerCount = orderedRows.reduce( + (sum, row) => sum + Number(row.requiredWorkerCount || 0), + 0 + ); + const filledCount = orderedRows.reduce( + (sum, row) => sum + Number(row.filledCount || 0), + 0 + ); + const dispatchPriority = orderedRows.reduce( + (min, row) => Math.min(min, Number(row.dispatchPriority || 3)), + 3 + ); + const dispatchTeam = dispatchPriority === 1 + ? 'CORE' + : dispatchPriority === 2 + ? 'CERTIFIED_LOCATION' + : 'MARKETPLACE'; + const hasOpenVacancy = orderedRows.some((row) => ( + row.shiftStatus === 'OPEN' + && Number(row.filledCount || 0) < Number(row.requiredWorkerCount || 0) + )); + const allCancelled = orderedRows.every((row) => row.shiftStatus === 'CANCELLED'); + const allCompleted = orderedRows.every((row) => row.shiftStatus === 'COMPLETED'); + + let status = 'FILLED'; + if (firstRow.orderStatus === 'CANCELLED') status = 'CANCELLED'; + else if (firstRow.orderStatus === 'COMPLETED') status = 'COMPLETED'; + else if (hasOpenVacancy) status = 'OPEN'; + else if (allCancelled) status = 'CANCELLED'; + else if (allCompleted) status = 'COMPLETED'; + + const uniqueManagers = Array.from( + new Map( + managers.map((manager) => { + const key = [ + manager.name || '', + manager.phone || '', + managerDisplayRole(manager), + ].join('|'); + return [key, { + name: manager.name || null, + phone: manager.phone || null, + role: managerDisplayRole(manager), + }]; + }) + ).values() + ); + + const uniqueBlockers = [...new Set(blockers.filter(Boolean))]; + + return { + orderId: firstRow.orderId, + orderType: firstRow.orderType, + roleId: firstRow.roleId, + roleCode: firstRow.roleCode, + roleName: firstRow.roleName, + clientName: firstRow.clientName, + businessId: firstRow.businessId, + instantBook: orderedRows.every((row) => Boolean(row.instantBook)), + dispatchTeam, + dispatchPriority, + jobDescription: firstRow.jobDescription || `${firstRow.roleName} shift at ${firstRow.clientName}`, + instructions: firstRow.instructions || null, + status, + schedule: { + totalShifts: firstRow.orderType === 'PERMANENT' ? null : orderedRows.length, + startDate: formatDateInTimeZone(firstShift.startsAt, timeZone), + endDate: formatDateInTimeZone(lastShift.startsAt, timeZone), + daysOfWeek, + startTime: formatTimeInTimeZone(firstShift.startsAt, timeZone), + endTime: formatTimeInTimeZone(firstShift.endsAt, timeZone), + timezone: timeZone, + firstShiftStartsAt: firstShift.startsAt, + lastShiftEndsAt: lastShift.endsAt, + }, + location: { + name: firstRow.locationName || null, + address: firstRow.locationAddress || null, + latitude: firstRow.latitude == null ? null : Number(firstRow.latitude), + longitude: firstRow.longitude == null ? null : Number(firstRow.longitude), + }, + pay: { + hourlyRateCents: Number(firstRow.hourlyRateCents || 0), + hourlyRate: formatCurrencyCents(firstRow.hourlyRateCents || 0), + }, + staffing: { + requiredWorkerCount, + filledCount, + }, + managers: uniqueManagers, + eligibility: { + isEligible: uniqueBlockers.length === 0 && status === 'OPEN', + blockers: uniqueBlockers, + }, + }; +} + function computeReliabilityScore({ totalShifts, noShowCount, @@ -1232,6 +1363,187 @@ export async function listAvailableOrders(actorUid, { limit, search } = {}) { }); } +export async function getStaffOrderDetail(actorUid, orderId) { + const context = await requireStaffContext(actorUid); + const roleCode = context.staff.primaryRole || 'BARISTA'; + const rowsResult = await query( + ` + SELECT + o.id AS "orderId", + o.business_id AS "businessId", + COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", + o.status AS "orderStatus", + COALESCE(sr.role_id, rc.id) AS "roleId", + COALESCE(sr.role_code, rc.code) AS "roleCode", + COALESCE(sr.role_name, rc.name) AS "roleName", + b.business_name AS "clientName", + COALESCE((sr.metadata->>'instantBook')::boolean, FALSE) AS "instantBook", + COALESCE(dispatch.team_type, 'MARKETPLACE') AS "dispatchTeam", + COALESCE(dispatch.priority, 3) AS "dispatchPriority", + o.description AS "jobDescription", + o.notes AS instructions, + s.id AS "shiftId", + s.status AS "shiftStatus", + s.starts_at AS "startsAt", + s.ends_at AS "endsAt", + COALESCE(s.timezone, 'UTC') AS timezone, + COALESCE(cp.label, s.location_name, o.location_name) AS "locationName", + COALESCE(s.location_address, cp.address, o.location_address) AS "locationAddress", + COALESCE(s.latitude, cp.latitude, o.latitude) AS latitude, + COALESCE(s.longitude, cp.longitude, o.longitude) AS longitude, + COALESCE(sr.pay_rate_cents, 0)::INTEGER AS "hourlyRateCents", + sr.workers_needed::INTEGER AS "requiredWorkerCount", + sr.assigned_count::INTEGER AS "filledCount", + cp.id AS "hubId" + FROM orders o + JOIN shifts s ON s.order_id = o.id + JOIN shift_roles sr ON sr.shift_id = s.id + LEFT JOIN roles_catalog rc + ON rc.tenant_id = o.tenant_id + AND (rc.id = sr.role_id OR (sr.role_id IS NULL AND rc.code = sr.role_code)) + JOIN businesses b ON b.id = o.business_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + LEFT JOIN LATERAL ( + SELECT + dtm.team_type, + CASE dtm.team_type + WHEN 'CORE' THEN 1 + WHEN 'CERTIFIED_LOCATION' THEN 2 + ELSE 3 + END AS priority + FROM dispatch_team_memberships dtm + WHERE dtm.tenant_id = $1 + AND dtm.business_id = s.business_id + AND dtm.staff_id = $4 + AND dtm.status = 'ACTIVE' + AND dtm.effective_at <= NOW() + AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW()) + AND (dtm.hub_id IS NULL OR dtm.hub_id = s.clock_point_id) + ORDER BY + CASE dtm.team_type + WHEN 'CORE' THEN 1 + WHEN 'CERTIFIED_LOCATION' THEN 2 + ELSE 3 + END ASC, + CASE WHEN dtm.hub_id = s.clock_point_id THEN 0 ELSE 1 END ASC, + dtm.created_at ASC + LIMIT 1 + ) dispatch ON TRUE + WHERE o.tenant_id = $1 + AND o.id = $2 + AND s.starts_at > NOW() + AND COALESCE(sr.role_code, rc.code) = $3 + ORDER BY s.starts_at ASC + `, + [context.tenant.tenantId, orderId, roleCode, context.staff.staffId] + ); + + if (rowsResult.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Order is not available for this staff worker', 404, { + orderId, + }); + } + + const firstRow = rowsResult.rows[0]; + const hubIds = [...new Set(rowsResult.rows.map((row) => row.hubId).filter(Boolean))]; + + const [managerResult, blockedResult, participationResult, missingDocumentResult] = await Promise.all([ + hubIds.length === 0 + ? Promise.resolve({ rows: [] }) + : query( + ` + SELECT + COALESCE( + NULLIF(TRIM(CONCAT_WS(' ', bm.metadata->>'firstName', bm.metadata->>'lastName')), ''), + u.display_name, + u.email, + bm.invited_email + ) AS name, + COALESCE(u.phone, bm.metadata->>'phone') AS phone, + bm.business_role AS "businessRole" + FROM hub_managers hm + JOIN business_memberships bm ON bm.id = hm.business_membership_id + LEFT JOIN users u ON u.id = bm.user_id + WHERE hm.tenant_id = $1 + AND hm.hub_id = ANY($2::uuid[]) + ORDER BY name ASC + `, + [context.tenant.tenantId, hubIds] + ), + query( + ` + SELECT reason + FROM staff_blocks + WHERE tenant_id = $1 + AND business_id = $2 + AND staff_id = $3 + LIMIT 1 + `, + [context.tenant.tenantId, firstRow.businessId, context.staff.staffId] + ), + query( + ` + SELECT 1 + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + LEFT JOIN applications a + ON a.shift_role_id = sr.id + AND a.staff_id = $3 + AND a.status IN ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'COMPLETED') + LEFT JOIN assignments ass + ON ass.shift_role_id = sr.id + AND ass.staff_id = $3 + AND ass.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + LEFT JOIN roles_catalog rc + ON rc.tenant_id = s.tenant_id + AND (rc.id = sr.role_id OR (sr.role_id IS NULL AND rc.code = sr.role_code)) + WHERE s.tenant_id = $1 + AND s.order_id = $2 + AND s.starts_at > NOW() + AND COALESCE(sr.role_code, rc.code) = $4 + AND (a.id IS NOT NULL OR ass.id IS NOT NULL) + LIMIT 1 + `, + [context.tenant.tenantId, orderId, context.staff.staffId, roleCode] + ), + query( + ` + SELECT d.name + FROM documents d + WHERE d.tenant_id = $1 + AND d.required_for_role_code = $2 + AND d.document_type <> 'ATTIRE' + AND NOT EXISTS ( + SELECT 1 + FROM staff_documents sd + WHERE sd.tenant_id = d.tenant_id + AND sd.staff_id = $3 + AND sd.document_id = d.id + AND sd.status = 'VERIFIED' + ) + ORDER BY d.name ASC + `, + [context.tenant.tenantId, firstRow.roleCode, context.staff.staffId] + ), + ]); + + const blockers = buildStaffOrderEligibilityBlockers({ + hasActiveWorkforce: Boolean(context.staff.workforceId), + businessBlockReason: blockedResult.rowCount > 0 ? blockedResult.rows[0].reason || null : null, + hasExistingParticipation: participationResult.rowCount > 0, + missingDocumentNames: dedupeDocumentNames(missingDocumentResult.rows.map((row) => row.name)), + }); + + return summarizeStaffOrderDetail({ + rows: rowsResult.rows, + managers: managerResult.rows.map((manager) => ({ + ...manager, + role: managerDisplayRole(manager), + })), + blockers, + }); +} + export async function listOpenShifts(actorUid, { limit, search } = {}) { const context = await requireStaffContext(actorUid); const result = await query( diff --git a/backend/query-api/test/mobile-routes.test.js b/backend/query-api/test/mobile-routes.test.js index 428163de..997e91e7 100644 --- a/backend/query-api/test/mobile-routes.test.js +++ b/backend/query-api/test/mobile-routes.test.js @@ -27,6 +27,7 @@ function createMobileQueryService() { getSpendReport: async () => ({ totals: { amountCents: 2000 } }), getSpendBreakdown: async () => ([{ category: 'Barista', amountCents: 1000 }]), getStaffDashboard: async () => ({ staffName: 'Ana Barista' }), + getStaffOrderDetail: async () => ({ orderId: 'order-available-1', eligibility: { isEligible: true, blockers: [] } }), getStaffReliabilityStats: async () => ({ totalShifts: 12, reliabilityScore: 96.4 }), getStaffProfileCompletion: async () => ({ completed: true }), getStaffSession: async () => ({ staff: { staffId: 's1' } }), @@ -135,6 +136,27 @@ test('GET /query/staff/orders/available returns injected order-level opportuniti assert.equal(res.body.items[0].roleId, 'role-catalog-1'); }); +test('GET /query/staff/orders/:orderId returns injected order detail', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/staff/orders/11111111-1111-4111-8111-111111111111') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.orderId, 'order-available-1'); + assert.equal(res.body.eligibility.isEligible, true); +}); + +test('GET /query/staff/orders/:orderId validates uuid', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/staff/orders/not-a-uuid') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 400); + assert.equal(res.body.code, 'VALIDATION_ERROR'); +}); + test('GET /query/client/shifts/scheduled returns injected shift timeline items', async () => { const app = createApp({ mobileQueryService: createMobileQueryService() }); const res = await request(app) diff --git a/backend/query-api/test/staff-order-detail.test.js b/backend/query-api/test/staff-order-detail.test.js new file mode 100644 index 00000000..b49b8b94 --- /dev/null +++ b/backend/query-api/test/staff-order-detail.test.js @@ -0,0 +1,117 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { summarizeStaffOrderDetail } from '../src/services/mobile-query-service.js'; +import { buildStaffOrderEligibilityBlockers } from '../src/lib/staff-order-eligibility.js'; + +function makeRow(overrides = {}) { + return { + orderId: '11111111-1111-4111-8111-111111111111', + orderType: 'RECURRING', + roleId: '22222222-2222-4222-8222-222222222222', + roleCode: 'BARISTA', + roleName: 'Barista', + clientName: 'Google Mountain View Cafes', + businessId: '33333333-3333-4333-8333-333333333333', + instantBook: false, + dispatchTeam: 'MARKETPLACE', + dispatchPriority: 3, + jobDescription: 'Prepare coffee and support the cafe line.', + instructions: 'Arrive 15 minutes early.', + shiftId: '44444444-4444-4444-8444-444444444444', + shiftStatus: 'OPEN', + startsAt: '2026-03-23T15:00:00.000Z', + endsAt: '2026-03-23T23:00:00.000Z', + timezone: 'America/Los_Angeles', + locationName: 'Google MV Cafe Clock Point', + locationAddress: '1600 Amphitheatre Pkwy, Mountain View, CA', + latitude: 37.4221, + longitude: -122.0841, + hourlyRateCents: 2350, + requiredWorkerCount: 2, + filledCount: 1, + hubId: '55555555-5555-4555-8555-555555555555', + ...overrides, + }; +} + +test('summarizeStaffOrderDetail aggregates recurring order schedule and staffing', () => { + const result = summarizeStaffOrderDetail({ + rows: [ + makeRow(), + makeRow({ + shiftId: '66666666-6666-4666-8666-666666666666', + startsAt: '2026-03-25T15:00:00.000Z', + endsAt: '2026-03-25T23:00:00.000Z', + }), + ], + managers: [ + { name: 'Maria Ops', phone: '+15555550101', role: 'Hub Manager' }, + { name: 'Maria Ops', phone: '+15555550101', role: 'Hub Manager' }, + ], + }); + + assert.equal(result.orderId, '11111111-1111-4111-8111-111111111111'); + assert.equal(result.status, 'OPEN'); + assert.equal(result.schedule.totalShifts, 2); + assert.deepEqual(result.schedule.daysOfWeek, ['MON', 'WED']); + assert.equal(result.staffing.requiredWorkerCount, 4); + assert.equal(result.staffing.filledCount, 2); + assert.equal(result.pay.hourlyRate, '$23.50'); + assert.equal(result.managers.length, 1); + assert.equal(result.eligibility.isEligible, true); +}); + +test('summarizeStaffOrderDetail returns null totalShifts for permanent orders', () => { + const result = summarizeStaffOrderDetail({ + rows: [ + makeRow({ + orderType: 'PERMANENT', + startsAt: '2026-03-24T15:00:00.000Z', + }), + ], + }); + + assert.equal(result.orderType, 'PERMANENT'); + assert.equal(result.schedule.totalShifts, null); +}); + +test('summarizeStaffOrderDetail marks order ineligible when blockers exist', () => { + const result = summarizeStaffOrderDetail({ + rows: [ + makeRow({ + shiftStatus: 'FILLED', + requiredWorkerCount: 1, + filledCount: 1, + }), + ], + blockers: [ + 'You are blocked from working for this client', + 'Missing required document: Food Handler Card', + 'Missing required document: Food Handler Card', + ], + }); + + assert.equal(result.status, 'FILLED'); + assert.equal(result.eligibility.isEligible, false); + assert.deepEqual(result.eligibility.blockers, [ + 'You are blocked from working for this client', + 'Missing required document: Food Handler Card', + ]); +}); + +test('buildStaffOrderEligibilityBlockers normalizes and deduplicates blocker messages', () => { + const blockers = buildStaffOrderEligibilityBlockers({ + hasActiveWorkforce: false, + businessBlockReason: 'Repeated no-show', + hasExistingParticipation: true, + missingDocumentNames: ['Food Handler Card', 'Food Handler Card', ' Responsible Beverage Service '], + }); + + assert.deepEqual(blockers, [ + 'Workforce profile is not active', + 'You are blocked from working for this client: Repeated no-show', + 'You already applied to or booked this order', + 'Missing required document: Food Handler Card', + 'Missing required document: Responsible Beverage Service', + ]); +}); diff --git a/backend/unified-api/scripts/live-smoke-v2-unified.mjs b/backend/unified-api/scripts/live-smoke-v2-unified.mjs index f67e2503..930633e8 100644 --- a/backend/unified-api/scripts/live-smoke-v2-unified.mjs +++ b/backend/unified-api/scripts/live-smoke-v2-unified.mjs @@ -160,6 +160,22 @@ async function finalizeVerifiedUpload({ }; } +async function approveVerification({ + token, + verificationId, + note = 'Smoke approval', +}) { + return apiCall(`/verifications/${verificationId}/review`, { + method: 'POST', + token, + body: { + decision: 'APPROVED', + note, + reasonCode: 'SMOKE_APPROVAL', + }, + }); +} + async function signInClient() { return apiCall('/auth/client/sign-in', { method: 'POST', @@ -794,6 +810,8 @@ async function main() { assert.equal(typeof assignedTodayShift.longitude, 'number'); assert.equal(assignedTodayShift.clockInMode, fixture.shifts.assigned.clockInMode); assert.equal(assignedTodayShift.allowClockInOverride, fixture.shifts.assigned.allowClockInOverride); + const clockableTodayShift = todaysShifts.items.find((shift) => shift.attendanceStatus === 'NOT_CLOCKED_IN') + || assignedTodayShift; logStep('staff.clock-in.shifts-today.ok', { count: todaysShifts.items.length }); const attendanceStatusBefore = await apiCall('/staff/clock-in/status', { @@ -827,30 +845,61 @@ async function main() { const availableOrders = await apiCall('/staff/orders/available?limit=20', { token: staffAuth.idToken, }); - const availableOrder = availableOrders.items.find((item) => item.orderId === createdRecurringOrder.orderId) - || availableOrders.items[0]; - assert.ok(availableOrder); - assert.ok(availableOrder.roleId); - logStep('staff.orders.available.ok', { count: availableOrders.items.length, orderId: availableOrder.orderId }); + assert.ok(availableOrders.items.length > 0); - const bookedOrder = await apiCall(`/staff/orders/${availableOrder.orderId}/book`, { - method: 'POST', + let ineligibleOrder = null; + let ineligibleOrderDetail = null; + for (const item of availableOrders.items) { + const detail = await apiCall(`/staff/orders/${item.orderId}`, { + token: staffAuth.idToken, + }); + + if (!ineligibleOrderDetail && detail.eligibility?.isEligible === false) { + ineligibleOrder = item; + ineligibleOrderDetail = detail; + break; + } + } + + const orderCard = ineligibleOrder || availableOrders.items[0]; + const orderDetail = ineligibleOrderDetail || await apiCall(`/staff/orders/${orderCard.orderId}`, { token: staffAuth.idToken, - idempotencyKey: uniqueKey('staff-order-book'), - body: { - roleId: availableOrder.roleId, - }, }); - assert.equal(bookedOrder.orderId, availableOrder.orderId); - assert.ok(bookedOrder.assignedShiftCount >= 1); - assert.equal(bookedOrder.status, 'PENDING'); - assert.ok(Array.isArray(bookedOrder.assignedShifts)); - logStep('staff.orders.book.ok', { - orderId: bookedOrder.orderId, - assignedShiftCount: bookedOrder.assignedShiftCount, - status: bookedOrder.status, + assert.ok(orderCard.roleId); + logStep('staff.orders.available.ok', { count: availableOrders.items.length, orderId: orderCard.orderId }); + + assert.equal(orderDetail.orderId, orderCard.orderId); + assert.equal(orderDetail.roleId, orderCard.roleId); + assert.ok(orderDetail.clientName); + assert.ok(orderDetail.schedule); + assert.ok(orderDetail.location); + assert.ok(Array.isArray(orderDetail.managers)); + assert.ok(orderDetail.eligibility); + logStep('staff.orders.detail.ok', { + orderId: orderDetail.orderId, + status: orderDetail.status, + isEligible: orderDetail.eligibility.isEligible, }); + if (orderDetail.eligibility?.isEligible === false) { + const rejectedIneligibleBooking = await apiCall(`/staff/orders/${orderCard.orderId}/book`, { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-order-book-ineligible'), + body: { + roleId: orderDetail.roleId, + }, + allowFailure: true, + }); + assert.equal(rejectedIneligibleBooking.statusCode, 422); + assert.equal(rejectedIneligibleBooking.body.code, 'UNPROCESSABLE_ENTITY'); + assert.ok(Array.isArray(rejectedIneligibleBooking.body.details?.blockers)); + logStep('staff.orders.book.ineligible.rejected.ok', { + orderId: orderCard.orderId, + blockers: rejectedIneligibleBooking.body.details.blockers.length, + }); + } + const openShifts = await apiCall('/staff/shifts/open', { token: staffAuth.idToken, }); @@ -864,10 +913,7 @@ async function main() { const pendingShifts = await apiCall('/staff/shifts/pending', { token: staffAuth.idToken, }); - assert.ok( - bookedOrder.assignedShifts.some((shift) => pendingShifts.items.some((item) => item.shiftId === shift.shiftId)) - ); - const pendingShift = pendingShifts.items.find((item) => item.shiftId === fixture.shifts.available.id) + const pendingShift = pendingShifts.items.find((item) => item.shiftId === openShift.shiftId) || pendingShifts.items[0]; assert.ok(pendingShift); logStep('staff.shifts.pending.ok', { count: pendingShifts.items.length }); @@ -1146,12 +1192,12 @@ async function main() { token: staffAuth.idToken, idempotencyKey: uniqueKey('staff-shift-apply'), body: { - roleId: fixture.shiftRoles.availableBarista.id, + roleId: openShift.roleId, }, }); logStep('staff.shifts.apply.ok', appliedShift); - const acceptedShift = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/accept`, { + const acceptedShift = await apiCall(`/staff/shifts/${pendingShift.shiftId}/accept`, { method: 'POST', token: staffAuth.idToken, idempotencyKey: uniqueKey('staff-shift-accept'), @@ -1164,7 +1210,7 @@ async function main() { token: staffAuth.idToken, idempotencyKey: uniqueKey('staff-clock-in'), body: { - shiftId: fixture.shifts.assigned.id, + shiftId: clockableTodayShift.shiftId, sourceType: 'GEO', deviceId: 'smoke-iphone-15-pro', latitude: fixture.clockPoint.latitude + 0.0075, @@ -1177,7 +1223,7 @@ async function main() { }, }); assert.equal(clockIn.validationStatus, 'FLAGGED'); - assert.equal(clockIn.effectiveClockInMode, fixture.shifts.assigned.clockInMode); + assert.equal(clockIn.effectiveClockInMode, clockableTodayShift.clockInMode); assert.equal(clockIn.overrideUsed, true); assert.ok(clockIn.securityProofId); logStep('staff.clock-in.ok', clockIn); @@ -1187,7 +1233,7 @@ async function main() { token: staffAuth.idToken, idempotencyKey: uniqueKey('staff-clock-in-duplicate'), body: { - shiftId: fixture.shifts.assigned.id, + shiftId: clockableTodayShift.shiftId, sourceType: 'GEO', deviceId: 'smoke-iphone-15-pro', latitude: fixture.clockPoint.latitude, @@ -1214,7 +1260,7 @@ async function main() { token: staffAuth.idToken, idempotencyKey: uniqueKey('staff-location-stream'), body: { - shiftId: fixture.shifts.assigned.id, + shiftId: clockableTodayShift.shiftId, sourceType: 'GEO', deviceId: 'smoke-iphone-15-pro', points: [ @@ -1268,7 +1314,7 @@ async function main() { token: staffAuth.idToken, idempotencyKey: uniqueKey('staff-clock-out'), body: { - shiftId: fixture.shifts.assigned.id, + shiftId: clockableTodayShift.shiftId, sourceType: 'GEO', deviceId: 'smoke-iphone-15-pro', latitude: fixture.clockPoint.latitude, @@ -1283,7 +1329,7 @@ async function main() { assert.ok(clockOut.securityProofId); logStep('staff.clock-out.ok', clockOut); - const submittedCompletedShift = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/submit-for-approval`, { + const submittedCompletedShift = await apiCall(`/staff/shifts/${clockableTodayShift.shiftId}/submit-for-approval`, { method: 'POST', token: staffAuth.idToken, idempotencyKey: uniqueKey('staff-shift-submit-approval'), @@ -1430,6 +1476,50 @@ async function main() { assert.equal(uploadedGovId.finalized.documentId, fixture.documents.governmentId.id); logStep('staff.profile.document.upload.ok', uploadedGovId.finalized); + if (!['APPROVED', 'AUTO_PASS'].includes(`${uploadedGovId.finalized.verification?.status || ''}`)) { + const reviewedGovId = await approveVerification({ + token: ownerSession.sessionToken, + verificationId: uploadedGovId.finalized.verification.verificationId, + note: 'Smoke approval for government ID', + }); + assert.equal(reviewedGovId.status, 'APPROVED'); + logStep('staff.profile.document.review.ok', { + verificationId: reviewedGovId.verificationId, + status: reviewedGovId.status, + }); + } + + const uploadedI9 = await finalizeVerifiedUpload({ + token: staffAuth.idToken, + uploadCategory: 'staff-tax-form', + filename: 'i9-completed.pdf', + contentType: 'application/pdf', + content: Buffer.from('fake-i9-tax-form'), + finalizePath: `/staff/profile/documents/${fixture.documents.taxFormI9.id}/upload`, + finalizeMethod: 'PUT', + verificationType: 'tax_form', + subjectId: fixture.documents.taxFormI9.id, + rules: { + documentId: fixture.documents.taxFormI9.id, + formType: 'I-9', + }, + }); + assert.equal(uploadedI9.finalized.documentId, fixture.documents.taxFormI9.id); + logStep('staff.profile.tax-form.upload.ok', uploadedI9.finalized); + + if (!['APPROVED', 'AUTO_PASS'].includes(`${uploadedI9.finalized.verification?.status || ''}`)) { + const reviewedI9 = await approveVerification({ + token: ownerSession.sessionToken, + verificationId: uploadedI9.finalized.verification.verificationId, + note: 'Smoke approval for completed I-9', + }); + assert.equal(reviewedI9.status, 'APPROVED'); + logStep('staff.profile.tax-form.review.ok', { + verificationId: reviewedI9.verificationId, + status: reviewedI9.status, + }); + } + const uploadedAttire = await finalizeVerifiedUpload({ token: staffAuth.idToken, uploadCategory: 'staff-attire', @@ -1474,9 +1564,57 @@ async function main() { const profileDocumentsAfter = await apiCall('/staff/profile/documents', { token: staffAuth.idToken, }); - assert.ok(profileDocumentsAfter.items.some((item) => item.documentId === fixture.documents.governmentId.id)); + const governmentIdAfter = profileDocumentsAfter.items.find((item) => item.documentId === fixture.documents.governmentId.id); + assert.ok(governmentIdAfter); + assert.equal(governmentIdAfter.status, 'VERIFIED'); logStep('staff.profile.documents-after.ok', { count: profileDocumentsAfter.items.length }); + const availableOrdersAfterVerification = await apiCall('/staff/orders/available?limit=20', { + token: staffAuth.idToken, + }); + let eligibleOrder = null; + let eligibleOrderDetail = null; + for (const item of availableOrdersAfterVerification.items) { + const detail = await apiCall(`/staff/orders/${item.orderId}`, { + token: staffAuth.idToken, + }); + if (detail.eligibility?.isEligible === true) { + eligibleOrder = item; + eligibleOrderDetail = detail; + break; + } + } + assert.ok(eligibleOrder, 'Expected at least one eligible available order after document verification'); + + const bookedOrder = await apiCall(`/staff/orders/${eligibleOrder.orderId}/book`, { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-order-book'), + body: { + roleId: eligibleOrderDetail.roleId, + }, + }); + assert.equal(bookedOrder.orderId, eligibleOrder.orderId); + assert.ok(bookedOrder.assignedShiftCount >= 1); + assert.equal(bookedOrder.status, 'PENDING'); + assert.ok(Array.isArray(bookedOrder.assignedShifts)); + logStep('staff.orders.book.ok', { + orderId: bookedOrder.orderId, + assignedShiftCount: bookedOrder.assignedShiftCount, + status: bookedOrder.status, + }); + + const pendingShiftsAfterBooking = await apiCall('/staff/shifts/pending', { + token: staffAuth.idToken, + }); + assert.ok( + bookedOrder.assignedShifts.some((shift) => pendingShiftsAfterBooking.items.some((item) => item.shiftId === shift.shiftId)) + ); + logStep('staff.shifts.pending-after-order-book.ok', { + count: pendingShiftsAfterBooking.items.length, + bookedShiftCount: bookedOrder.assignedShiftCount, + }); + const certificatesAfter = await apiCall('/staff/profile/certificates', { token: staffAuth.idToken, }); diff --git a/docs/BACKEND/API_GUIDES/V2/README.md b/docs/BACKEND/API_GUIDES/V2/README.md index 4d8eb6d6..3a800207 100644 --- a/docs/BACKEND/API_GUIDES/V2/README.md +++ b/docs/BACKEND/API_GUIDES/V2/README.md @@ -120,6 +120,7 @@ For geofence-heavy staff flows, frontend should read the policy from: - `GET /staff/clock-in/shifts/today` - `GET /staff/shifts/:shiftId` +- `GET /staff/orders/:orderId` - `GET /client/hubs` Important operational rules: diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md b/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md index a7a2f940..1a87ac9d 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md @@ -23,7 +23,7 @@ Supporting docs: - Send `Idempotency-Key` on every write route. - Treat `order`, `shift`, `shiftRole`, and `assignment` as different objects. - For staff shift applications, `roleId` must come from the response of `GET /staff/shifts/open`. -- For staff order booking, `roleId` must come from the response of `GET /staff/orders/available`. +- For staff order booking, `roleId` must come from the response of `GET /staff/orders/:orderId`. - Treat API timestamp fields as UTC and convert them to local time in the app. ## 2) What is implemented now @@ -235,14 +235,17 @@ Important: ### Find shifts - `GET /staff/orders/available` +- `GET /staff/orders/:orderId` - `POST /staff/orders/:orderId/book` - `GET /staff/shifts/open` - `POST /staff/shifts/:shiftId/apply` Rule: -- use `roleId` from the order-available response when booking an order +- use `GET /staff/orders/:orderId` as the source of truth for the order details page +- use `roleId` from the order-detail response when booking an order - that `roleId` is the role catalog id for the grouped order booking flow +- if order booking returns `422`, render `details.blockers` and keep the worker on the order details page - use `roleId` from the open-shifts response only for shift-level apply - that `roleId` is the concrete `shift_roles.id` @@ -260,6 +263,7 @@ Rule: Staff shift detail and list rules: +- `GET /staff/orders/:orderId` returns the worker booking detail contract with `schedule`, `location`, `pay`, `staffing`, `managers`, and `eligibility` - assigned shifts include `clientName`, `hourlyRate`, `totalRate`, `startTime`, `endTime` - shift detail includes `clientName`, `latitude`, `longitude`, `hourlyRate`, `totalRate` - completed shifts include `date`, `clientName`, `startTime`, `endTime`, `hourlyRate`, `totalRate` diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md b/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md index a4847d66..1d5c41ca 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md @@ -32,6 +32,7 @@ Important consequences: - `POST /staff/shifts/:shiftId/apply` must send the `roleId` from that response. - `GET /staff/orders/available` returns grouped order opportunities for atomic booking. - `POST /staff/orders/:orderId/book` must send the `roleId` from that response. +- if order booking returns `422`, use `details.blockers` to explain why the worker is not eligible - `GET /client/shifts/scheduled` is the canonical timeline/read model for the client app. - `GET /client/orders/view` is a deprecated compatibility alias. - `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` only affect future shifts. @@ -180,14 +181,17 @@ Rapid-order flow: ### Find shifts - `GET /staff/orders/available` +- `GET /staff/orders/:orderId` - `POST /staff/orders/:orderId/book` - `GET /staff/shifts/open` - `POST /staff/shifts/:shiftId/apply` Rule: -- send the `roleId` from the order-available response when booking an order +- use `GET /staff/orders/:orderId` as the source of truth for the order details page +- send the `roleId` from the order-detail response when booking an order - this `roleId` is the role catalog id for grouped order booking +- if booking fails with `422`, render `details.blockers` and keep the worker on the review screen - send the `roleId` from the open-shifts response only when applying to one shift - that route still uses the concrete `shift_roles.id` diff --git a/docs/BACKEND/API_GUIDES/V2/staff-shifts.md b/docs/BACKEND/API_GUIDES/V2/staff-shifts.md index d881449d..742039f7 100644 --- a/docs/BACKEND/API_GUIDES/V2/staff-shifts.md +++ b/docs/BACKEND/API_GUIDES/V2/staff-shifts.md @@ -9,6 +9,7 @@ Base URL: ## Read routes - `GET /staff/orders/available` +- `GET /staff/orders/:orderId` - `GET /staff/shifts/assigned` - `GET /staff/shifts/open` - `GET /staff/shifts/pending` @@ -80,6 +81,7 @@ Example response: - booking is atomic across the future shifts in that order for the selected role - backend returns `PENDING` when the booking is reserved but not instant-booked - backend returns `CONFIRMED` when every future shift in that booking path is instant-booked +- backend returns `422 UNPROCESSABLE_ENTITY` when the worker is not eligible to book that order Example request: @@ -91,8 +93,44 @@ Example request: Important: -- `roleId` for the order-booking flow is the role catalog id returned by `GET /staff/orders/available` +- `GET /staff/orders/:orderId` is now the source of truth for the order detail screen before booking +- `roleId` for the order-booking flow is the role catalog id returned by `GET /staff/orders/:orderId` - it is not the same thing as the per-shift `shift_roles.id` +- when booking is rejected, use `details.blockers` from the error response to explain why + +### Order detail + +`GET /staff/orders/:orderId` + +Use this as the source of truth for the worker order-review page before calling `POST /staff/orders/:orderId/book`. + +Response shape includes: + +- `orderId` +- `orderType` +- `roleId` +- `roleCode` +- `roleName` +- `clientName` +- `businessId` +- `instantBook` +- `dispatchTeam` +- `dispatchPriority` +- `jobDescription` +- `instructions` +- `status` +- `schedule` +- `location` +- `pay` +- `staffing` +- `managers` +- `eligibility` + +Frontend rules: + +- call this endpoint after a worker taps an order card from `GET /staff/orders/available` +- use the returned `roleId` when calling `POST /staff/orders/:orderId/book` +- if `eligibility.isEligible` is `false`, show the blocker messages and disable booking ### Find shifts diff --git a/docs/BACKEND/API_GUIDES/V2/unified-api.md b/docs/BACKEND/API_GUIDES/V2/unified-api.md index dcbf40c7..dba066f5 100644 --- a/docs/BACKEND/API_GUIDES/V2/unified-api.md +++ b/docs/BACKEND/API_GUIDES/V2/unified-api.md @@ -184,6 +184,7 @@ The manager is created as an invited business membership. If `hubId` is present, - `GET /staff/payments/history` - `GET /staff/payments/chart` - `GET /staff/orders/available` +- `GET /staff/orders/:orderId` - `GET /staff/shifts/assigned` - `GET /staff/shifts/open` - `GET /staff/shifts/pending` @@ -250,9 +251,12 @@ Example `GET /staff/profile/stats` response: Order booking route notes: - `GET /staff/orders/available` is the canonical order-level marketplace feed for recurring and grouped work +- `GET /staff/orders/:orderId` is the canonical staff order-detail route before booking - `GET /staff/shifts/open` remains available for shift-level opportunities and swap coverage - `POST /staff/orders/:orderId/book` books the future shifts of an order atomically for one role -- the `roleId` returned by `GET /staff/orders/available` is the role catalog id for the order booking flow +- if booking is rejected for eligibility reasons, backend returns `422 UNPROCESSABLE_ENTITY` with `details.blockers` +- use the `roleId` returned by `GET /staff/orders/:orderId` when booking +- that `roleId` is the role catalog id for the order booking flow - the `roleId` returned by `GET /staff/shifts/open` is still the concrete `shift_roles.id` for shift-level apply ### Staff writes From fea679b84c325158f8a2c5750630ecbd59547afd Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 15:40:26 -0400 Subject: [PATCH 17/29] feat: Enhance order details page with date range and clock-in/out labels, and improve OrderScheduleSection layout --- .../ui-ux-design/component-patterns.md | 28 +++ .../lib/src/l10n/en.i18n.json | 1 + .../lib/src/l10n/es.i18n.json | 1 + .../pages/order_details_page.dart | 6 + .../order_details_bottom_bar.dart | 2 +- .../order_details/order_schedule_section.dart | 195 +++++++++++++----- 6 files changed, 179 insertions(+), 54 deletions(-) diff --git a/.claude/agent-memory/ui-ux-design/component-patterns.md b/.claude/agent-memory/ui-ux-design/component-patterns.md index 9ce4d7c2..6d50b939 100644 --- a/.claude/agent-memory/ui-ux-design/component-patterns.md +++ b/.claude/agent-memory/ui-ux-design/component-patterns.md @@ -85,3 +85,31 @@ History state is cached in BLoC as `Map> Uses `CustomScrollView` with `SliverList` for header + `SliverPadding` wrapping `SliverList.separated` for content. Bottom padding on content sliver: `EdgeInsets.fromLTRB(16, 16, 16, 120)` to clear bottom nav bar. + +## ShiftDateTimeSection / OrderScheduleSection — Shift Detail Section Pattern + +Both widgets live in `packages/features/staff/shifts/lib/src/presentation/widgets/`: +- `shift_details/shift_date_time_section.dart` — single date, clock-in/clock-out boxes +- `order_details/order_schedule_section.dart` — date range, 7-day circle row, clock-in/clock-out boxes + +**Shared conventions (non-negotiable for section consistency):** +- Outer padding: `EdgeInsets.all(UiConstants.space5)` — 20dp all sides +- Section title: `UiTypography.titleUppercase4b.textSecondary` +- Title → content gap: `UiConstants.space2` (8dp) +- Time boxes: `UiColors.bgThird` background, `UiConstants.radiusBase` (12dp) corners, `UiConstants.space3` (12dp) all padding +- Time box label: `UiTypography.footnote2b.copyWith(color: UiColors.textSecondary, letterSpacing: 0.5)` +- Time box value: `UiTypography.title1m.copyWith(fontWeight: FontWeight.w700).textPrimary` +- Between time boxes: `UiConstants.space4` (16dp) gap +- Date → time boxes gap: `UiConstants.space6` (24dp) +- Time format: `DateFormat('h:mm a')` — uppercase AM/PM with space + +**OrderScheduleSection day-of-week circles:** +- 7 circles always shown (Mon–Sun ISO order) regardless of active days +- Circle size: 32×32dp (fixed, not a token) +- Active: bg=`UiColors.primary`, text=`UiColors.white`, style=`footnote2m` +- Inactive: bg=`UiColors.bgThird`, text=`UiColors.textSecondary`, style=`footnote2m` +- Shape: `UiConstants.radiusFull` +- Single-char labels: M T W T F S S +- Inter-circle gap: `UiConstants.space2` (8dp) +- Accessibility: wrap row with `Semantics(label: "Repeats on ...")`, mark individual circles with `ExcludeSemantics` +- Ordering constant: `[DayOfWeek.mon, .tue, .wed, .thu, .fri, .sat, .sun]` — do NOT derive from API list order diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 423ea826..7088c0e7 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -1885,6 +1885,7 @@ "spots_left": "${count} spot(s) left", "shifts_count": "${count} shift(s)", "schedule_label": "SCHEDULE", + "date_range_label": "Date Range", "booking_success": "Order booked successfully!", "booking_pending": "Your booking is pending approval", "booking_confirmed": "Your booking has been confirmed!", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 927d701c..718eeb72 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -1885,6 +1885,7 @@ "spots_left": "${count} puesto(s) disponible(s)", "shifts_count": "${count} turno(s)", "schedule_label": "HORARIO", + "date_range_label": "Rango de Fechas", "booking_success": "\u00a1Orden reservada con \u00e9xito!", "booking_pending": "Tu reserva est\u00e1 pendiente de aprobaci\u00f3n", "booking_confirmed": "\u00a1Tu reserva ha sido confirmada!", diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart index ffc0debd..3512f336 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart @@ -139,6 +139,12 @@ class _OrderDetailsPageState extends State { schedule: order.schedule, scheduleLabel: context.t.available_orders.schedule_label, + dateRangeLabel: + context.t.available_orders.date_range_label, + clockInLabel: + context.t.staff_shifts.shift_details.start_time, + clockOutLabel: + context.t.staff_shifts.shift_details.end_time, shiftsCountLabel: t.available_orders.shifts_count( count: order.schedule.totalShifts, ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart index e97de4c2..80a5d44b 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart @@ -79,7 +79,7 @@ class OrderDetailsBottomBar extends StatelessWidget { width: double.infinity, child: UiButton.primary( onPressed: onBook, - text: t.available_orders.book_order, + text: 'Book Shift', ), ); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart index a7cbdfda..a961d795 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart @@ -5,13 +5,18 @@ import 'package:krow_domain/krow_domain.dart'; /// A section displaying the schedule for an available order. /// -/// Shows the days-of-week chips, date range, time range, and total shift count. +/// Shows a date range, Google Calendar-style day-of-week circles, +/// clock-in/clock-out time boxes, and total shift count. +/// Follows the same visual structure as [ShiftDateTimeSection]. class OrderScheduleSection extends StatelessWidget { /// Creates an [OrderScheduleSection]. const OrderScheduleSection({ super.key, required this.schedule, required this.scheduleLabel, + required this.dateRangeLabel, + required this.clockInLabel, + required this.clockOutLabel, required this.shiftsCountLabel, }); @@ -21,9 +26,40 @@ class OrderScheduleSection extends StatelessWidget { /// Localised section title (e.g. "SCHEDULE"). final String scheduleLabel; + /// Localised label for the date range row (e.g. "Date Range"). + final String dateRangeLabel; + + /// Localised label for the clock-in time box (e.g. "START TIME"). + final String clockInLabel; + + /// Localised label for the clock-out time box (e.g. "END TIME"). + final String clockOutLabel; + /// Localised shifts count text (e.g. "3 shift(s)"). final String shiftsCountLabel; + /// All seven days in ISO order for the day-of-week row. + static const List _allDays = [ + DayOfWeek.mon, + DayOfWeek.tue, + DayOfWeek.wed, + DayOfWeek.thu, + DayOfWeek.fri, + DayOfWeek.sat, + DayOfWeek.sun, + ]; + + /// Single-letter labels for each day (ISO order). + static const List _dayLabels = [ + 'M', + 'T', + 'W', + 'T', + 'F', + 'S', + 'S', + ]; + /// Formats a date-only string (e.g. "2026-03-24") to "Mar 24". String _formatDateShort(String dateStr) { if (dateStr.isEmpty) return ''; @@ -35,96 +71,149 @@ class OrderScheduleSection extends StatelessWidget { } } - /// Formats a DateTime to a time string (e.g. "9:00am"). - String _formatTime(DateTime dt) { - return DateFormat('h:mma').format(dt).toLowerCase(); + /// Formats [DateTime] to a time string (e.g. "9:00 AM"). + String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt); + + /// Builds the date range display string including the year. + String _buildDateRangeText() { + final String start = _formatDateShort(schedule.startDate); + final String end = _formatDateShort(schedule.endDate); + // Extract year from endDate for display. + String year = ''; + if (schedule.endDate.isNotEmpty) { + try { + final DateTime endDt = DateTime.parse(schedule.endDate); + year = ', ${endDt.year}'; + } catch (_) { + // Ignore parse errors. + } + } + return '$start - $end$year'; } @override Widget build(BuildContext context) { - final String dateRange = - '${_formatDateShort(schedule.startDate)} - ${_formatDateShort(schedule.endDate)}'; - final String timeRange = - '${_formatTime(schedule.firstShiftStartsAt)} - ${_formatTime(schedule.lastShiftEndsAt)}'; - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space6, - vertical: UiConstants.space4, - ), + padding: const EdgeInsets.all(UiConstants.space5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Section title Text( scheduleLabel, style: UiTypography.titleUppercase4b.textSecondary, ), - const SizedBox(height: UiConstants.space3), - - // Days of week chips - if (schedule.daysOfWeek.isNotEmpty) ...[ - Wrap( - spacing: UiConstants.space1, - runSpacing: UiConstants.space1, - children: schedule.daysOfWeek - .map((DayOfWeek day) => _buildDayChip(day)) - .toList(), - ), - const SizedBox(height: UiConstants.space3), - ], + const SizedBox(height: UiConstants.space4), // Date range row Row( children: [ const Icon( UiIcons.calendar, - size: 20, - color: UiColors.primary, + size: UiConstants.space5, + color: UiColors.textPrimary, ), const SizedBox(width: UiConstants.space2), - Text(dateRange, style: UiTypography.headline5m.textPrimary), + Text( + _buildDateRangeText(), + style: UiTypography.title1m.textPrimary, + ), ], ), - const SizedBox(height: UiConstants.space2), + const SizedBox(height: UiConstants.space6), - // Time range row + // Days-of-week circles (Google Calendar style) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + for (int i = 0; i < _allDays.length; i++) + _buildDayCircle( + _allDays[i], + _dayLabels[i], + schedule.daysOfWeek.contains(_allDays[i]), + ), + ], + ), + const SizedBox(height: UiConstants.space6), + + // Clock in / Clock out time boxes Row( children: [ - const Icon( - UiIcons.clock, - size: 20, - color: UiColors.primary, + Expanded( + child: _buildTimeBox(clockInLabel, schedule.firstShiftStartsAt), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: _buildTimeBox(clockOutLabel, schedule.lastShiftEndsAt), ), - const SizedBox(width: UiConstants.space2), - Text(timeRange, style: UiTypography.headline5m.textPrimary), ], ), - const SizedBox(height: UiConstants.space2), + const SizedBox(height: UiConstants.space8), + Text( + 'TOTAL SHIFTS', + style: UiTypography.titleUppercase4b.textSecondary, + ), + const SizedBox(height: UiConstants.space2), // Shifts count - Text(shiftsCountLabel, style: UiTypography.footnote2r.textSecondary), + Text(shiftsCountLabel, style: UiTypography.body1r), ], ), ); } - /// Builds a small chip showing a day-of-week abbreviation. - Widget _buildDayChip(DayOfWeek day) { - final String label = day.value.isNotEmpty - ? '${day.value[0]}${day.value.substring(1).toLowerCase()}' - : ''; + /// Builds a single day-of-week circle. + /// + /// Active days are filled with the primary color and white text. + /// Inactive days use the background color and secondary text. + Widget _buildDayCircle(DayOfWeek day, String label, bool isActive) { return Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: 2, - ), + width: 32, + height: 32, decoration: BoxDecoration( - color: UiColors.primary.withValues(alpha: 0.08), - borderRadius: UiConstants.radiusSm, + border: Border.all( + color: isActive ? UiColors.primary : UiColors.background, + width: 1.5, + ), + color: isActive ? UiColors.primary.withAlpha(40) : UiColors.background, + shape: BoxShape.circle, ), - child: Text( - label, - style: UiTypography.footnote2m.copyWith(color: UiColors.primary), + child: Center( + child: Text( + label, + style: isActive + ? UiTypography.footnote1b.primary + : UiTypography.footnote2m.textSecondary, + ), + ), + ); + } + + /// Builds a time-display box matching the [ShiftDateTimeSection] pattern. + Widget _buildTimeBox(String label, DateTime time) { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.bgThird, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: Column( + children: [ + Text( + label, + style: UiTypography.footnote2b.copyWith( + color: UiColors.textSecondary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: UiConstants.space1), + Text( + _formatTime(time), + style: UiTypography.title1m + .copyWith(fontWeight: FontWeight.w700) + .textPrimary, + ), + ], ), ); } From 3d80e6b7acf7767777f1993cba5c645a13b74487 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 15:55:27 -0400 Subject: [PATCH 18/29] feat: Implement accept shift functionality with localization support for success messages --- .../lib/src/l10n/en.i18n.json | 1 + .../lib/src/l10n/es.i18n.json | 1 + .../shift_details/shift_details_bloc.dart | 25 +++++++++++++++++++ .../shift_details/shift_details_event.dart | 15 +++++++++++ .../pages/shift_details_page.dart | 6 +++-- .../shifts/lib/src/shift_details_module.dart | 3 +++ .../shifts/lib/src/staff_shifts_module.dart | 1 + 7 files changed, 50 insertions(+), 2 deletions(-) diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 7088c0e7..58dc4742 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -1362,6 +1362,7 @@ "go_to_certificates": "Go to Certificates", "shift_booked": "Shift successfully booked!", "shift_not_found": "Shift not found", + "shift_accepted": "Shift accepted successfully!", "shift_declined_success": "Shift declined", "complete_account_title": "Complete Your Account", "complete_account_description": "Complete your account to book this shift and start earning" diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 718eeb72..96fa838f 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -1357,6 +1357,7 @@ "go_to_certificates": "Ir a Certificados", "shift_booked": "¡Turno reservado con éxito!", "shift_not_found": "Turno no encontrado", + "shift_accepted": "¡Turno aceptado con éxito!", "shift_declined_success": "Turno rechazado", "complete_account_title": "Completa Tu Cuenta", "complete_account_description": "Completa tu cuenta para reservar este turno y comenzar a ganar" diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart index 8dc53c57..469dc693 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart @@ -2,6 +2,7 @@ import 'package:bloc/bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/apply_for_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart'; @@ -18,10 +19,12 @@ class ShiftDetailsBloc extends Bloc required this.getShiftDetail, required this.applyForShift, required this.declineShift, + required this.acceptShift, required this.getProfileCompletion, }) : super(ShiftDetailsInitial()) { on(_onLoadDetails); on(_onBookShift); + on(_onAcceptShift); on(_onDeclineShift); } @@ -34,6 +37,9 @@ class ShiftDetailsBloc extends Bloc /// Use case for declining a shift. final DeclineShiftUseCase declineShift; + /// Use case for accepting an assigned shift. + final AcceptShiftUseCase acceptShift; + /// Use case for checking profile completion. final GetProfileCompletionUseCase getProfileCompletion; @@ -83,6 +89,25 @@ class ShiftDetailsBloc extends Bloc ); } + Future _onAcceptShift( + AcceptShiftDetailsEvent event, + Emitter emit, + ) async { + await handleError( + emit: emit.call, + action: () async { + await acceptShift(event.shiftId); + emit( + ShiftActionSuccess( + 'shift_accepted', + shiftDate: event.date, + ), + ); + }, + onError: (String errorKey) => ShiftDetailsError(errorKey), + ); + } + Future _onDeclineShift( DeclineShiftDetailsEvent event, Emitter emit, diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_event.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_event.dart index 48599313..e99ec66d 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_event.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_event.dart @@ -26,6 +26,21 @@ class BookShiftDetailsEvent extends ShiftDetailsEvent { List get props => [shiftId, roleId, date]; } +/// Event dispatched when the worker accepts an already-assigned shift. +class AcceptShiftDetailsEvent extends ShiftDetailsEvent { + /// The shift to accept. + final String shiftId; + + /// Optional date used for post-action navigation. + final DateTime? date; + + /// Creates an [AcceptShiftDetailsEvent]. + const AcceptShiftDetailsEvent(this.shiftId, {this.date}); + + @override + List get props => [shiftId, date]; +} + class DeclineShiftDetailsEvent extends ShiftDetailsEvent { final String shiftId; const DeclineShiftDetailsEvent(this.shiftId); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index f0f9a27a..26f72047 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -166,9 +166,9 @@ class _ShiftDetailsPageState extends State { ).add(DeclineShiftDetailsEvent(detail.shiftId)), onAccept: () => BlocProvider.of(context).add( - BookShiftDetailsEvent( + AcceptShiftDetailsEvent( detail.shiftId, - roleId: detail.roleId, + date: detail.date, ), ), ), @@ -262,6 +262,8 @@ class _ShiftDetailsPageState extends State { switch (key) { case 'shift_booked': return context.t.staff_shifts.shift_details.shift_booked; + case 'shift_accepted': + return context.t.staff_shifts.shift_details.shift_accepted; case 'shift_declined_success': return context.t.staff_shifts.shift_details.shift_declined_success; default: diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart b/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart index 12b958b3..cbce90e4 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart @@ -4,6 +4,7 @@ import 'package:krow_domain/krow_domain.dart'; import 'package:staff_shifts/src/data/repositories_impl/shifts_repository_impl.dart'; import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; +import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/apply_for_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart'; @@ -30,6 +31,7 @@ class ShiftDetailsModule extends Module { i.add(GetShiftDetailUseCase.new); i.add(ApplyForShiftUseCase.new); i.add(DeclineShiftUseCase.new); + i.add(AcceptShiftUseCase.new); i.add(GetProfileCompletionUseCase.new); // BLoC @@ -38,6 +40,7 @@ class ShiftDetailsModule extends Module { getShiftDetail: i.get(), applyForShift: i.get(), declineShift: i.get(), + acceptShift: i.get(), getProfileCompletion: i.get(), ), ); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart index 8bb5f36d..dc0911ba 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart @@ -74,6 +74,7 @@ class StaffShiftsModule extends Module { getShiftDetail: i.get(), applyForShift: i.get(), declineShift: i.get(), + acceptShift: i.get(), getProfileCompletion: i.get(), ), ); From 18a459a453f7cb8166482faddde2144a1637e7e5 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 16:11:16 -0400 Subject: [PATCH 19/29] feat: Implement GetMyShiftsData use case and integrate it into ShiftsBloc for improved shift data handling --- .../lib/src/domain/models/my_shifts_data.dart | 23 ++++++++++ .../usecases/get_my_shifts_data_usecase.dart | 40 ++++++++++++++++++ .../blocs/shifts/shifts_bloc.dart | 42 +++++++++---------- .../shifts/lib/src/staff_shifts_module.dart | 3 ++ 4 files changed, 86 insertions(+), 22 deletions(-) create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/domain/models/my_shifts_data.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_my_shifts_data_usecase.dart diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/models/my_shifts_data.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/models/my_shifts_data.dart new file mode 100644 index 00000000..42669e27 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/models/my_shifts_data.dart @@ -0,0 +1,23 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Combined result from loading all My Shifts tab data sources. +/// +/// Holds assigned shifts, pending assignments, and cancelled shifts +/// fetched in parallel from the V2 API. +class MyShiftsData { + /// Creates a [MyShiftsData] instance. + const MyShiftsData({ + required this.assignedShifts, + required this.pendingAssignments, + required this.cancelledShifts, + }); + + /// Assigned shifts for the requested date range. + final List assignedShifts; + + /// Pending assignments awaiting worker acceptance. + final List pendingAssignments; + + /// Cancelled shift assignments. + final List cancelledShifts; +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_my_shifts_data_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_my_shifts_data_usecase.dart new file mode 100644 index 00000000..f6f6952c --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_my_shifts_data_usecase.dart @@ -0,0 +1,40 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_shifts/src/domain/arguments/get_my_shifts_arguments.dart'; +import 'package:staff_shifts/src/domain/models/my_shifts_data.dart'; +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; + +/// Fetches all data needed for the My Shifts tab in a single call. +/// +/// Calls [ShiftsRepositoryInterface.getAssignedShifts], +/// [ShiftsRepositoryInterface.getPendingAssignments], and +/// [ShiftsRepositoryInterface.getCancelledShifts] in parallel and returns +/// a unified [MyShiftsData]. +class GetMyShiftsDataUseCase + extends UseCase { + /// Creates a [GetMyShiftsDataUseCase]. + GetMyShiftsDataUseCase(this._repository); + + /// The shifts repository. + final ShiftsRepositoryInterface _repository; + + /// Loads assigned, pending, and cancelled shifts for the given date range. + @override + Future call(GetAssignedShiftsArguments arguments) async { + final List results = await Future.wait(>[ + _repository.getAssignedShifts( + start: arguments.start, + end: arguments.end, + ), + _repository.getPendingAssignments(), + _repository.getCancelledShifts(), + ]); + + return MyShiftsData( + assignedShifts: results[0] as List, + pendingAssignments: results[1] as List, + cancelledShifts: results[2] as List, + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart index a63992b3..7c4a6905 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart @@ -8,9 +8,11 @@ import 'package:staff_shifts/src/domain/arguments/get_available_shifts_arguments import 'package:staff_shifts/src/domain/arguments/get_my_shifts_arguments.dart'; import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart'; +import 'package:staff_shifts/src/domain/models/my_shifts_data.dart'; import 'package:staff_shifts/src/domain/usecases/get_available_shifts_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_cancelled_shifts_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_history_shifts_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_my_shifts_data_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_my_shifts_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_pending_assignments_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart'; @@ -34,6 +36,7 @@ class ShiftsBloc extends Bloc required this.acceptShift, required this.declineShift, required this.submitForApproval, + required this.getMyShiftsData, }) : super(const ShiftsState()) { on(_onLoadShifts); on(_onLoadHistoryShifts); @@ -74,6 +77,9 @@ class ShiftsBloc extends Bloc /// Use case for submitting a shift for timesheet approval. final SubmitForApprovalUseCase submitForApproval; + /// Use case that loads assigned, pending, and cancelled shifts in parallel. + final GetMyShiftsDataUseCase getMyShiftsData; + Future _onLoadShifts( LoadShiftsEvent event, Emitter emit, @@ -86,29 +92,16 @@ class ShiftsBloc extends Bloc emit: emit.call, action: () async { final List days = getCalendarDaysForOffset(0); - - // Load assigned, pending, and cancelled shifts in parallel. - final List results = await Future.wait(>[ - getAssignedShifts( - GetAssignedShiftsArguments(start: days.first, end: days.last), - ), - getPendingAssignments(), - getCancelledShifts(), - ]); - - final List myShiftsResult = - results[0] as List; - final List pendingResult = - results[1] as List; - final List cancelledResult = - results[2] as List; + final MyShiftsData data = await getMyShiftsData( + GetAssignedShiftsArguments(start: days.first, end: days.last), + ); emit( state.copyWith( status: ShiftsStatus.loaded, - myShifts: myShiftsResult, - pendingShifts: pendingResult, - cancelledShifts: cancelledResult, + myShifts: data.assignedShifts, + pendingShifts: data.pendingAssignments, + cancelledShifts: data.cancelledShifts, availableShifts: const [], historyShifts: const [], availableLoading: false, @@ -250,18 +243,23 @@ class ShiftsBloc extends Bloc LoadShiftsForRangeEvent event, Emitter emit, ) async { - emit(state.copyWith(myShifts: const [], myShiftsLoaded: false)); + emit(state.copyWith( + myShifts: const [], + myShiftsLoaded: false, + )); await handleError( emit: emit.call, action: () async { - final List myShiftsResult = await getAssignedShifts( + final MyShiftsData data = await getMyShiftsData( GetAssignedShiftsArguments(start: event.start, end: event.end), ); emit( state.copyWith( status: ShiftsStatus.loaded, - myShifts: myShiftsResult, + myShifts: data.assignedShifts, + pendingShifts: data.pendingAssignments, + cancelledShifts: data.cancelledShifts, myShiftsLoaded: true, clearErrorMessage: true, ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart index dc0911ba..f7dee609 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart @@ -8,6 +8,7 @@ import 'package:staff_shifts/src/domain/usecases/apply_for_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_available_shifts_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_cancelled_shifts_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_history_shifts_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_my_shifts_data_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_my_shifts_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_pending_assignments_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart'; @@ -52,6 +53,7 @@ class StaffShiftsModule extends Module { i.addLazySingleton( () => SubmitForApprovalUseCase(i.get()), ); + i.addLazySingleton(GetMyShiftsDataUseCase.new); i.addLazySingleton(GetAvailableOrdersUseCase.new); i.addLazySingleton(BookOrderUseCase.new); @@ -67,6 +69,7 @@ class StaffShiftsModule extends Module { acceptShift: i.get(), declineShift: i.get(), submitForApproval: i.get(), + getMyShiftsData: i.get(), ), ); i.add( From eff8bcce57fa2606ca3f73df0fa833b66ef1eff6 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 16:25:45 -0400 Subject: [PATCH 20/29] feat: Remove shift confirmation and decline dialogs from MyShiftsTab --- .../widgets/tabs/my_shifts_tab.dart | 74 ------------------- 1 file changed, 74 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart index 67063ce3..aeed0436 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart @@ -112,76 +112,6 @@ class _MyShiftsTabState extends State { return a.year == b.year && a.month == b.month && a.day == b.day; } - void _confirmShift(String id) { - showDialog( - context: context, - builder: (BuildContext ctx) => AlertDialog( - title: - Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.title), - content: - Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.message), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: Text(context.t.common.cancel), - ), - TextButton( - onPressed: () { - Navigator.of(ctx).pop(); - ReadContext(context).read().add(AcceptShiftEvent(id)); - UiSnackbar.show( - context, - message: context - .t.staff_shifts.my_shifts_tab.confirm_dialog.success, - type: UiSnackbarType.success, - ); - }, - style: TextButton.styleFrom( - foregroundColor: UiColors.success, - ), - child: - Text(context.t.staff_shifts.shift_details.accept_shift), - ), - ], - ), - ); - } - - void _declineShift(String id) { - showDialog( - context: context, - builder: (BuildContext ctx) => AlertDialog( - title: - Text(context.t.staff_shifts.my_shifts_tab.decline_dialog.title), - content: Text( - context.t.staff_shifts.my_shifts_tab.decline_dialog.message, - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: Text(context.t.common.cancel), - ), - TextButton( - onPressed: () { - Navigator.of(ctx).pop(); - ReadContext(context).read().add(DeclineShiftEvent(id)); - UiSnackbar.show( - context, - message: context - .t.staff_shifts.my_shifts_tab.decline_dialog.success, - type: UiSnackbarType.error, - ); - }, - style: TextButton.styleFrom( - foregroundColor: UiColors.destructive, - ), - child: Text(context.t.staff_shifts.shift_details.decline), - ), - ], - ), - ); - } - @override Widget build(BuildContext context) { final List calendarDays = _getCalendarDays(); @@ -352,10 +282,6 @@ class _MyShiftsTabState extends State { data: ShiftCardData.fromPending(assignment), onTap: () => Modular.to .toShiftDetailsById(assignment.shiftId), - onAccept: () => - _confirmShift(assignment.shiftId), - onDecline: () => - _declineShift(assignment.shiftId), ), ), ), From ac9a0b9c9d7d52b376bbf782e5b37e6b6dec82d1 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 16:28:29 -0400 Subject: [PATCH 21/29] feat: Implement cross-platform NFC clocking interface with calendar and shift sections --- .../tabs/my_shifts/section_header.dart | 44 +++ .../tabs/my_shifts/shift_section_list.dart | 162 ++++++++++ .../my_shifts/week_calendar_selector.dart | 159 ++++++++++ .../widgets/tabs/my_shifts_tab.dart | 293 ++---------------- 4 files changed, 392 insertions(+), 266 deletions(-) create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/section_header.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/week_calendar_selector.dart diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/section_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/section_header.dart new file mode 100644 index 00000000..6bd626bd --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/section_header.dart @@ -0,0 +1,44 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A section header with a colored dot indicator and title text. +class SectionHeader extends StatelessWidget { + /// Creates a [SectionHeader]. + const SectionHeader({ + super.key, + required this.title, + required this.dotColor, + }); + + /// The header title text. + final String title; + + /// The color of the leading dot indicator. + final Color dotColor; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space4), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: UiConstants.space2), + Text( + title, + style: dotColor == UiColors.textSecondary + ? UiTypography.body2b.textSecondary + : UiTypography.body2b.copyWith(color: dotColor), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart new file mode 100644 index 00000000..eb41d04a --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart @@ -0,0 +1,162 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart' show ReadContext; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; +import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_card/index.dart'; +import 'section_header.dart'; + +/// Scrollable list displaying pending, cancelled, and confirmed shift sections. +/// +/// Renders each section with a [SectionHeader] and a list of [ShiftCard] +/// widgets. Shows an [EmptyStateView] when all sections are empty. +class ShiftSectionList extends StatelessWidget { + /// Creates a [ShiftSectionList]. + const ShiftSectionList({ + super.key, + required this.assignedShifts, + required this.pendingAssignments, + required this.cancelledShifts, + this.submittedShiftIds = const {}, + this.submittingShiftId, + }); + + /// Confirmed/assigned shifts visible for the selected day. + final List assignedShifts; + + /// Pending assignments awaiting acceptance. + final List pendingAssignments; + + /// Cancelled shifts visible for the selected week. + final List cancelledShifts; + + /// Set of shift IDs that have been successfully submitted for approval. + final Set submittedShiftIds; + + /// The shift ID currently being submitted (null when idle). + final String? submittingShiftId; + + @override + Widget build(BuildContext context) { + return Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: Column( + children: [ + const SizedBox(height: UiConstants.space5), + + // Pending assignments section + if (pendingAssignments.isNotEmpty) ...[ + SectionHeader( + title: + context.t.staff_shifts.my_shifts_tab.sections.awaiting, + dotColor: UiColors.textWarning, + ), + ...pendingAssignments.map( + (PendingAssignment assignment) => Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space4, + ), + child: ShiftCard( + data: ShiftCardData.fromPending(assignment), + onTap: () => + Modular.to.toShiftDetailsById(assignment.shiftId), + ), + ), + ), + const SizedBox(height: UiConstants.space3), + ], + + // Cancelled shifts section + if (cancelledShifts.isNotEmpty) ...[ + SectionHeader( + title: + context.t.staff_shifts.my_shifts_tab.sections.cancelled, + dotColor: UiColors.textSecondary, + ), + ...cancelledShifts.map( + (CancelledShift cs) => Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space4, + ), + child: ShiftCard( + data: ShiftCardData.fromCancelled(cs), + onTap: () => + Modular.to.toShiftDetailsById(cs.shiftId), + ), + ), + ), + const SizedBox(height: UiConstants.space3), + ], + + // Confirmed shifts section + if (assignedShifts.isNotEmpty) ...[ + SectionHeader( + title: + context.t.staff_shifts.my_shifts_tab.sections.confirmed, + dotColor: UiColors.textSecondary, + ), + ...assignedShifts.map( + (AssignedShift shift) { + final bool isCompleted = + shift.status == AssignmentStatus.completed; + final bool isSubmitted = + submittedShiftIds.contains(shift.shiftId); + final bool isSubmitting = + submittingShiftId == shift.shiftId; + + return Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space3, + ), + child: ShiftCard( + data: ShiftCardData.fromAssigned(shift), + onTap: () => + Modular.to.toShiftDetailsById(shift.shiftId), + showApprovalAction: isCompleted, + isSubmitted: isSubmitted, + isSubmitting: isSubmitting, + onSubmitForApproval: () { + ReadContext(context).read().add( + SubmitForApprovalEvent( + shiftId: shift.shiftId, + ), + ); + UiSnackbar.show( + context, + message: context.t.staff_shifts + .my_shift_card.timesheet_submitted, + type: UiSnackbarType.success, + ); + }, + ), + ); + }, + ), + ], + + // Empty state + if (assignedShifts.isEmpty && + pendingAssignments.isEmpty && + cancelledShifts.isEmpty) + EmptyStateView( + icon: UiIcons.calendar, + title: context.t.staff_shifts.my_shifts_tab.empty.title, + subtitle: + context.t.staff_shifts.my_shifts_tab.empty.subtitle, + ), + + const SizedBox(height: UiConstants.space32), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/week_calendar_selector.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/week_calendar_selector.dart new file mode 100644 index 00000000..7bf42b7a --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/week_calendar_selector.dart @@ -0,0 +1,159 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// A week-view calendar selector showing 7 days with navigation arrows. +/// +/// Displays a month/year header with chevron arrows for week navigation and +/// a row of day cells. Days with assigned shifts show a dot indicator. +class WeekCalendarSelector extends StatelessWidget { + /// Creates a [WeekCalendarSelector]. + const WeekCalendarSelector({ + super.key, + required this.calendarDays, + required this.selectedDate, + required this.shifts, + required this.onDateSelected, + required this.onPreviousWeek, + required this.onNextWeek, + }); + + /// The 7 days to display in the calendar row. + final List calendarDays; + + /// The currently selected date. + final DateTime selectedDate; + + /// Assigned shifts used to show dot indicators on days with shifts. + final List shifts; + + /// Called when a day cell is tapped. + final ValueChanged onDateSelected; + + /// Called when the previous-week chevron is tapped. + final VoidCallback onPreviousWeek; + + /// Called when the next-week chevron is tapped. + final VoidCallback onNextWeek; + + bool _isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; + } + + @override + Widget build(BuildContext context) { + final DateTime weekStartDate = calendarDays.first; + + return Container( + color: UiColors.white, + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space4, + horizontal: UiConstants.space4, + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon( + UiIcons.chevronLeft, + size: 20, + color: UiColors.textPrimary, + ), + onPressed: onPreviousWeek, + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + ), + Text( + DateFormat('MMMM yyyy').format(weekStartDate), + style: UiTypography.title1m.textPrimary, + ), + IconButton( + icon: const Icon( + UiIcons.chevronRight, + size: 20, + color: UiColors.textPrimary, + ), + onPressed: onNextWeek, + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + ), + ], + ), + ), + // Days Grid + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: calendarDays.map((DateTime date) { + final bool isSelected = _isSameDay(date, selectedDate); + final bool hasShifts = shifts.any( + (AssignedShift s) => _isSameDay(s.date, date), + ); + + return GestureDetector( + onTap: () => onDateSelected(date), + child: Column( + children: [ + Container( + width: 44, + height: 60, + decoration: BoxDecoration( + color: isSelected ? UiColors.primary : UiColors.white, + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), + border: Border.all( + color: + isSelected ? UiColors.primary : UiColors.border, + width: 1, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + date.day.toString().padLeft(2, '0'), + style: isSelected + ? UiTypography.body1b.white + : UiTypography.body1b.textPrimary, + ), + Text( + DateFormat('E').format(date), + style: (isSelected + ? UiTypography.footnote2m.white + : UiTypography.footnote2m.textSecondary) + .copyWith( + color: isSelected + ? UiColors.white.withValues(alpha: 0.8) + : null, + ), + ), + if (hasShifts && !isSelected) + Container( + margin: const EdgeInsets.only( + top: UiConstants.space1, + ), + width: 4, + height: 4, + decoration: const BoxDecoration( + color: UiColors.primary, + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ], + ), + ); + }).toList(), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart index aeed0436..8476744a 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart @@ -1,18 +1,17 @@ -import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart' show ReadContext; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:intl/intl.dart'; -import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_shifts/src/domain/utils/shift_date_utils.dart'; import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; -import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; -import 'package:staff_shifts/src/presentation/widgets/shift_card/index.dart'; +import 'my_shifts/shift_section_list.dart'; +import 'my_shifts/week_calendar_selector.dart'; /// Tab displaying the worker's assigned, pending, and cancelled shifts. +/// +/// Manages the calendar selection state and delegates rendering to +/// [WeekCalendarSelector] and [ShiftSectionList]. class MyShiftsTab extends StatefulWidget { /// Creates a [MyShiftsTab]. const MyShiftsTab({ @@ -133,270 +132,32 @@ class _MyShiftsTabState extends State { return Column( children: [ - // Calendar Selector - Container( - color: UiColors.white, - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space4, - horizontal: UiConstants.space4, - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space3), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - icon: const Icon( - UiIcons.chevronLeft, - size: 20, - color: UiColors.textPrimary, - ), - onPressed: () => setState(() { - _weekOffset--; - _selectedDate = _getCalendarDays().first; - _loadShiftsForCurrentWeek(); - }), - constraints: const BoxConstraints(), - padding: EdgeInsets.zero, - ), - Text( - DateFormat('MMMM yyyy').format(weekStartDate), - style: UiTypography.title1m.textPrimary, - ), - IconButton( - icon: const Icon( - UiIcons.chevronRight, - size: 20, - color: UiColors.textPrimary, - ), - onPressed: () => setState(() { - _weekOffset++; - _selectedDate = _getCalendarDays().first; - _loadShiftsForCurrentWeek(); - }), - constraints: const BoxConstraints(), - padding: EdgeInsets.zero, - ), - ], - ), - ), - // Days Grid - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: calendarDays.map((DateTime date) { - final bool isSelected = _isSameDay(date, _selectedDate); - final bool hasShifts = widget.myShifts.any( - (AssignedShift s) => _isSameDay(s.date, date), - ); - - return GestureDetector( - onTap: () => setState(() => _selectedDate = date), - child: Column( - children: [ - Container( - width: 44, - height: 60, - decoration: BoxDecoration( - color: isSelected - ? UiColors.primary - : UiColors.white, - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), - border: Border.all( - color: isSelected - ? UiColors.primary - : UiColors.border, - width: 1, - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - date.day.toString().padLeft(2, '0'), - style: isSelected - ? UiTypography.body1b.white - : UiTypography.body1b.textPrimary, - ), - Text( - DateFormat('E').format(date), - style: (isSelected - ? UiTypography.footnote2m.white - : UiTypography - .footnote2m.textSecondary) - .copyWith( - color: isSelected - ? UiColors.white - .withValues(alpha: 0.8) - : null, - ), - ), - if (hasShifts && !isSelected) - Container( - margin: const EdgeInsets.only( - top: UiConstants.space1, - ), - width: 4, - height: 4, - decoration: const BoxDecoration( - color: UiColors.primary, - shape: BoxShape.circle, - ), - ), - ], - ), - ), - ], - ), - ); - }).toList(), - ), - ], - ), + WeekCalendarSelector( + calendarDays: calendarDays, + selectedDate: _selectedDate, + shifts: widget.myShifts, + onDateSelected: (DateTime date) => + setState(() => _selectedDate = date), + onPreviousWeek: () => setState(() { + _weekOffset--; + _selectedDate = _getCalendarDays().first; + _loadShiftsForCurrentWeek(); + }), + onNextWeek: () => setState(() { + _weekOffset++; + _selectedDate = _getCalendarDays().first; + _loadShiftsForCurrentWeek(); + }), ), const Divider(height: 1, color: UiColors.border), - - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space5, - ), - child: Column( - children: [ - const SizedBox(height: UiConstants.space5), - if (widget.pendingAssignments.isNotEmpty) ...[ - _buildSectionHeader( - context - .t.staff_shifts.my_shifts_tab.sections.awaiting, - UiColors.textWarning, - ), - ...widget.pendingAssignments.map( - (PendingAssignment assignment) => Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space4, - ), - child: ShiftCard( - data: ShiftCardData.fromPending(assignment), - onTap: () => Modular.to - .toShiftDetailsById(assignment.shiftId), - ), - ), - ), - const SizedBox(height: UiConstants.space3), - ], - - if (visibleCancelledShifts.isNotEmpty) ...[ - _buildSectionHeader( - context - .t.staff_shifts.my_shifts_tab.sections.cancelled, - UiColors.textSecondary, - ), - ...visibleCancelledShifts.map( - (CancelledShift cs) => Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space4, - ), - child: ShiftCard( - data: ShiftCardData.fromCancelled(cs), - onTap: () => - Modular.to.toShiftDetailsById(cs.shiftId), - ), - ), - ), - const SizedBox(height: UiConstants.space3), - ], - - // Confirmed Shifts - if (visibleMyShifts.isNotEmpty) ...[ - _buildSectionHeader( - context - .t.staff_shifts.my_shifts_tab.sections.confirmed, - UiColors.textSecondary, - ), - ...visibleMyShifts.map( - (AssignedShift shift) { - final bool isCompleted = - shift.status == AssignmentStatus.completed; - final bool isSubmitted = - widget.submittedShiftIds.contains(shift.shiftId); - final bool isSubmitting = - widget.submittingShiftId == shift.shiftId; - - return Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space3, - ), - child: ShiftCard( - data: ShiftCardData.fromAssigned(shift), - onTap: () => Modular.to - .toShiftDetailsById(shift.shiftId), - showApprovalAction: isCompleted, - isSubmitted: isSubmitted, - isSubmitting: isSubmitting, - onSubmitForApproval: () { - ReadContext(context).read().add( - SubmitForApprovalEvent( - shiftId: shift.shiftId, - ), - ); - UiSnackbar.show( - context, - message: context.t.staff_shifts - .my_shift_card.timesheet_submitted, - type: UiSnackbarType.success, - ); - }, - ), - ); - }, - ), - ], - - if (visibleMyShifts.isEmpty && - widget.pendingAssignments.isEmpty && - widget.cancelledShifts.isEmpty) - EmptyStateView( - icon: UiIcons.calendar, - title: - context.t.staff_shifts.my_shifts_tab.empty.title, - subtitle: context - .t.staff_shifts.my_shifts_tab.empty.subtitle, - ), - - const SizedBox(height: UiConstants.space32), - ], - ), - ), + ShiftSectionList( + assignedShifts: visibleMyShifts, + pendingAssignments: widget.pendingAssignments, + cancelledShifts: visibleCancelledShifts, + submittedShiftIds: widget.submittedShiftIds, + submittingShiftId: widget.submittingShiftId, ), ], ); } - - Widget _buildSectionHeader(String title, Color dotColor) { - return Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space4), - child: Row( - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: dotColor, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: UiConstants.space2), - Text( - title, - style: (dotColor == UiColors.textSecondary - ? UiTypography.body2b.textSecondary - : UiTypography.body2b.copyWith(color: dotColor)), - ), - ], - ), - ); - } } From bd2d5610b3ab73939ce646348c5af0e6c35619a2 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 16:36:35 -0400 Subject: [PATCH 22/29] feat: Add cancellation reason handling and display in shift details --- .../lib/src/l10n/en.i18n.json | 3 +- .../lib/src/l10n/es.i18n.json | 3 +- .../lib/src/entities/shifts/shift_detail.dart | 7 ++ .../pages/shift_details_page.dart | 10 +++ .../cancellation_reason_banner.dart | 70 +++++++++++++++++++ 5 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/cancellation_reason_banner.dart diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 58dc4742..54a98264 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -1365,7 +1365,8 @@ "shift_accepted": "Shift accepted successfully!", "shift_declined_success": "Shift declined", "complete_account_title": "Complete Your Account", - "complete_account_description": "Complete your account to book this shift and start earning" + "complete_account_description": "Complete your account to book this shift and start earning", + "shift_cancelled": "Shift Cancelled" }, "my_shift_card": { "submit_for_approval": "Submit for Approval", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 96fa838f..199f4baf 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -1360,7 +1360,8 @@ "shift_accepted": "¡Turno aceptado con éxito!", "shift_declined_success": "Turno rechazado", "complete_account_title": "Completa Tu Cuenta", - "complete_account_description": "Completa tu cuenta para reservar este turno y comenzar a ganar" + "complete_account_description": "Completa tu cuenta para reservar este turno y comenzar a ganar", + "shift_cancelled": "Turno Cancelado" }, "my_shift_card": { "submit_for_approval": "Enviar para Aprobación", diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart index 38e2dc23..e8aac075 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart @@ -41,6 +41,7 @@ class ShiftDetail extends Equatable { required this.allowClockInOverride, this.geofenceRadiusMeters, this.nfcTagId, + this.cancellationReason, }); /// Deserialises from the V2 API JSON response. @@ -76,6 +77,7 @@ class ShiftDetail extends Equatable { allowClockInOverride: json['allowClockInOverride'] as bool? ?? false, geofenceRadiusMeters: json['geofenceRadiusMeters'] as int?, nfcTagId: json['nfcTagId'] as String?, + cancellationReason: json['cancellationReason'] as String?, ); } @@ -157,6 +159,9 @@ class ShiftDetail extends Equatable { /// NFC tag identifier for NFC-based clock-in. final String? nfcTagId; + /// Reason the shift was cancelled, if applicable. + final String? cancellationReason; + /// Duration of the shift in hours. double get durationHours { return endTime.difference(startTime).inMinutes / 60; @@ -194,6 +199,7 @@ class ShiftDetail extends Equatable { 'allowClockInOverride': allowClockInOverride, 'geofenceRadiusMeters': geofenceRadiusMeters, 'nfcTagId': nfcTagId, + 'cancellationReason': cancellationReason, }; } @@ -225,5 +231,6 @@ class ShiftDetail extends Equatable { allowClockInOverride, geofenceRadiusMeters, nfcTagId, + cancellationReason, ]; } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index 26f72047..4d3d8d85 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -12,6 +12,7 @@ import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_ import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_state.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_date_time_section.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_description_section.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_details/cancellation_reason_banner.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_details_header.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_details_page_skeleton.dart'; @@ -117,6 +118,15 @@ class _ShiftDetailsPageState extends State { icon: UiIcons.sparkles, ), ), + if (detail.assignmentStatus == + AssignmentStatus.cancelled && + detail.cancellationReason != null && + detail.cancellationReason!.isNotEmpty) + CancellationReasonBanner( + reason: detail.cancellationReason!, + titleLabel: context.t.staff_shifts.shift_details + .shift_cancelled, + ), ShiftDetailsHeader(detail: detail), const Divider(height: 1, thickness: 0.5), ShiftStatsRow( diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/cancellation_reason_banner.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/cancellation_reason_banner.dart new file mode 100644 index 00000000..7550b07e --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/cancellation_reason_banner.dart @@ -0,0 +1,70 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A banner displaying the cancellation reason for a cancelled shift. +/// +/// Uses error styling to draw attention to the cancellation without being +/// overly alarming. Shown at the top of the shift details page when the +/// shift has been cancelled with a reason. +class CancellationReasonBanner extends StatelessWidget { + /// Creates a [CancellationReasonBanner]. + const CancellationReasonBanner({ + super.key, + required this.reason, + required this.titleLabel, + }); + + /// The cancellation reason text. + final String reason; + + /// Localized title label (e.g., "Shift Cancelled"). + final String titleLabel; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + vertical: UiConstants.space4, + ), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.tagError, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all( + color: UiColors.error.withValues(alpha: 0.3), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + UiIcons.error, + color: UiColors.error, + size: UiConstants.iconMd, + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + titleLabel, + style: UiTypography.body2b.copyWith(color: UiColors.error), + ), + const SizedBox(height: UiConstants.space1), + Text( + reason, + style: UiTypography.body3r.textPrimary, + ), + ], + ), + ), + ], + ), + ), + ); + } +} From a544b051cc8a6112f4f748b943f077175d5952c3 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 16:40:07 -0400 Subject: [PATCH 23/29] feat: Update shift card styles and remove cancellation reason display --- .../widgets/shift_card/shift_card_body.dart | 10 ---------- .../widgets/shift_card/shift_card_status_badge.dart | 4 ++-- .../widgets/tabs/my_shifts/shift_section_list.dart | 2 +- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart index 0816d430..afad825c 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart @@ -26,16 +26,6 @@ class ShiftCardBody extends StatelessWidget { ShiftCardTitleRow(data: data), const SizedBox(height: UiConstants.space2), ShiftCardMetadataRows(data: data), - if (data.cancellationReason != null && - data.cancellationReason!.isNotEmpty) ...[ - const SizedBox(height: UiConstants.space1), - Text( - data.cancellationReason!, - style: UiTypography.footnote2r.textSecondary, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], ], ), ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_status_badge.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_status_badge.dart index 85465ea3..0aac92ed 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_status_badge.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_status_badge.dart @@ -70,8 +70,8 @@ class ShiftCardStatusBadge extends StatelessWidget { case ShiftCardVariant.cancelled: return ShiftCardStatusStyle( label: context.t.staff_shifts.my_shifts_tab.card.cancelled, - foreground: UiColors.destructive, - dot: UiColors.destructive, + foreground: UiColors.mutedForeground, + dot: UiColors.mutedForeground, ); case ShiftCardVariant.completed: return ShiftCardStatusStyle( diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart index eb41d04a..f53681ab 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart @@ -101,7 +101,7 @@ class ShiftSectionList extends StatelessWidget { SectionHeader( title: context.t.staff_shifts.my_shifts_tab.sections.confirmed, - dotColor: UiColors.textSecondary, + dotColor: UiColors.textSuccess, ), ...assignedShifts.map( (AssignedShift shift) { From 591b5d7b88710b16fde5f212c6592bd16da25c38 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 16:44:54 -0400 Subject: [PATCH 24/29] feat: Enhance CancelledShift and PendingAssignment models with additional fields for client and pay details --- .../src/entities/shifts/cancelled_shift.dart | 56 +++++++++++++++++++ .../entities/shifts/pending_assignment.dart | 35 ++++++++++++ 2 files changed, 91 insertions(+) diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart index d2cff728..99488dbc 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart @@ -15,6 +15,14 @@ class CancelledShift extends Equatable { required this.location, required this.date, this.cancellationReason, + this.roleName, + this.clientName, + this.startTime, + this.endTime, + this.hourlyRateCents, + this.hourlyRate, + this.totalRateCents, + this.totalRate, }); /// Deserialises from the V2 API JSON response. @@ -26,6 +34,14 @@ class CancelledShift extends Equatable { location: json['location'] as String? ?? '', date: parseUtcToLocal(json['date'] as String), cancellationReason: json['cancellationReason'] as String?, + roleName: json['roleName'] as String?, + clientName: json['clientName'] as String?, + startTime: tryParseUtcToLocal(json['startTime'] as String?), + endTime: tryParseUtcToLocal(json['endTime'] as String?), + hourlyRateCents: json['hourlyRateCents'] as int?, + hourlyRate: (json['hourlyRate'] as num?)?.toDouble(), + totalRateCents: json['totalRateCents'] as int?, + totalRate: (json['totalRate'] as num?)?.toDouble(), ); } @@ -47,6 +63,30 @@ class CancelledShift extends Equatable { /// Reason for cancellation, from assignment metadata. final String? cancellationReason; + /// Display name of the role. + final String? roleName; + + /// Name of the client/business. + final String? clientName; + + /// Scheduled start time. + final DateTime? startTime; + + /// Scheduled end time. + final DateTime? endTime; + + /// Pay rate in cents per hour. + final int? hourlyRateCents; + + /// Pay rate in dollars per hour. + final double? hourlyRate; + + /// Total pay for this shift in cents. + final int? totalRateCents; + + /// Total pay for this shift in dollars. + final double? totalRate; + /// Serialises to JSON. Map toJson() { return { @@ -56,6 +96,14 @@ class CancelledShift extends Equatable { 'location': location, 'date': date.toIso8601String(), 'cancellationReason': cancellationReason, + 'roleName': roleName, + 'clientName': clientName, + 'startTime': startTime?.toIso8601String(), + 'endTime': endTime?.toIso8601String(), + 'hourlyRateCents': hourlyRateCents, + 'hourlyRate': hourlyRate, + 'totalRateCents': totalRateCents, + 'totalRate': totalRate, }; } @@ -67,5 +115,13 @@ class CancelledShift extends Equatable { location, date, cancellationReason, + roleName, + clientName, + startTime, + endTime, + hourlyRateCents, + hourlyRate, + totalRateCents, + totalRate, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart index c96c5810..bf89944f 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart @@ -17,6 +17,11 @@ class PendingAssignment extends Equatable { required this.endTime, required this.location, required this.responseDeadline, + this.clientName, + this.hourlyRateCents, + this.hourlyRate, + this.totalRateCents, + this.totalRate, }); /// Deserialises from the V2 API JSON response. @@ -30,6 +35,11 @@ class PendingAssignment extends Equatable { endTime: parseUtcToLocal(json['endTime'] as String), location: json['location'] as String? ?? '', responseDeadline: parseUtcToLocal(json['responseDeadline'] as String), + clientName: json['clientName'] as String?, + hourlyRateCents: json['hourlyRateCents'] as int?, + hourlyRate: (json['hourlyRate'] as num?)?.toDouble(), + totalRateCents: json['totalRateCents'] as int?, + totalRate: (json['totalRate'] as num?)?.toDouble(), ); } @@ -57,6 +67,21 @@ class PendingAssignment extends Equatable { /// Deadline by which the worker must respond. final DateTime responseDeadline; + /// Name of the client/business. + final String? clientName; + + /// Pay rate in cents per hour. + final int? hourlyRateCents; + + /// Pay rate in dollars per hour. + final double? hourlyRate; + + /// Total pay for this shift in cents. + final int? totalRateCents; + + /// Total pay for this shift in dollars. + final double? totalRate; + /// Serialises to JSON. Map toJson() { return { @@ -68,6 +93,11 @@ class PendingAssignment extends Equatable { 'endTime': endTime.toIso8601String(), 'location': location, 'responseDeadline': responseDeadline.toIso8601String(), + 'clientName': clientName, + 'hourlyRateCents': hourlyRateCents, + 'hourlyRate': hourlyRate, + 'totalRateCents': totalRateCents, + 'totalRate': totalRate, }; } @@ -81,5 +111,10 @@ class PendingAssignment extends Equatable { endTime, location, responseDeadline, + clientName, + hourlyRateCents, + hourlyRate, + totalRateCents, + totalRate, ]; } From 0ff2949c1e24f26db82a1ac2bb1b7d88b083a0b6 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 21:13:35 -0400 Subject: [PATCH 25/29] feat: Refactor ShiftCard components to include client name and improve layout consistency --- .../widgets/available_order_card.dart | 2 +- .../widgets/shift_card/shift_card_body.dart | 47 +++---- .../widgets/shift_card/shift_card_data.dart | 10 ++ .../shift_card/shift_card_metadata_rows.dart | 66 ++++++---- .../shift_card/shift_card_title_row.dart | 115 +++++++++--------- 5 files changed, 125 insertions(+), 115 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart index 351f99e1..42fc4b60 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart @@ -135,7 +135,7 @@ class AvailableOrderCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Role name + pay headline + chevron + // Role name + pay headline Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: UiConstants.space1, diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart index afad825c..7573f53c 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart @@ -14,27 +14,23 @@ class ShiftCardBody extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ShiftCardIcon(variant: data.variant), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShiftCardTitleRow(data: data), - const SizedBox(height: UiConstants.space2), - ShiftCardMetadataRows(data: data), - ], - ), + Row( + children: [ + ShiftCardIcon(variant: data.variant), + ShiftCardTitleRow(data: data), + ], ), + const SizedBox(height: UiConstants.space2), + ShiftCardMetadataRows(data: data), ], ); } } -/// The 44x44 icon box with a gradient background. +/// The icon box matching the AvailableOrderCard style. class ShiftCardIcon extends StatelessWidget { /// Creates a [ShiftCardIcon]. const ShiftCardIcon({super.key, required this.variant}); @@ -47,30 +43,19 @@ class ShiftCardIcon extends StatelessWidget { final bool isCancelled = variant == ShiftCardVariant.cancelled; return Container( - width: 44, - height: 44, + width: UiConstants.space10, + height: UiConstants.space10, decoration: BoxDecoration( - gradient: isCancelled - ? null - : LinearGradient( - colors: [ - UiColors.primary.withValues(alpha: 0.09), - UiColors.primary.withValues(alpha: 0.03), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - color: isCancelled ? UiColors.primary.withValues(alpha: 0.05) : null, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: isCancelled - ? null - : Border.all(color: UiColors.primary.withValues(alpha: 0.09)), + color: isCancelled + ? UiColors.primary.withValues(alpha: 0.05) + : UiColors.tagInProgress, + borderRadius: UiConstants.radiusLg, ), child: const Center( child: Icon( UiIcons.briefcase, color: UiColors.primary, - size: UiConstants.iconMd, + size: UiConstants.space5, ), ), ); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_data.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_data.dart index 626ff583..2e029ffa 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_data.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_data.dart @@ -38,6 +38,7 @@ class ShiftCardData { required this.date, required this.variant, this.subtitle, + this.clientName, this.startTime, this.endTime, this.hourlyRateCents, @@ -57,9 +58,12 @@ class ShiftCardData { subtitle: shift.location, location: shift.location, date: shift.date, + clientName: shift.clientName, startTime: shift.startTime, endTime: shift.endTime, hourlyRateCents: shift.hourlyRateCents, + hourlyRate: shift.hourlyRate, + totalRate: shift.totalRate, orderType: shift.orderType, variant: _variantFromAssignmentStatus(shift.status), ); @@ -73,6 +77,7 @@ class ShiftCardData { subtitle: shift.title.isNotEmpty ? shift.title : null, location: shift.location, date: shift.date, + clientName: shift.clientName, startTime: shift.startTime, endTime: shift.endTime, hourlyRateCents: shift.hourlyRateCents, @@ -91,6 +96,7 @@ class ShiftCardData { title: shift.title, location: shift.location, date: shift.date, + clientName: shift.clientName, cancellationReason: shift.cancellationReason, variant: ShiftCardVariant.cancelled, ); @@ -104,6 +110,7 @@ class ShiftCardData { subtitle: assignment.title.isNotEmpty ? assignment.title : null, location: assignment.location, date: assignment.startTime, + clientName: assignment.clientName, startTime: assignment.startTime, endTime: assignment.endTime, variant: ShiftCardVariant.pending, @@ -119,6 +126,9 @@ class ShiftCardData { /// Optional secondary text (e.g. location under the role name). final String? subtitle; + /// Client/business name. + final String? clientName; + /// Human-readable location label. final String location; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_metadata_rows.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_metadata_rows.dart index df0ce572..c7416145 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_metadata_rows.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_metadata_rows.dart @@ -4,7 +4,10 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart'; -/// Date, time, location, and worked-hours rows. +/// Date, client name, location, and worked-hours metadata rows. +/// +/// Follows the AvailableOrderCard element ordering: +/// date -> client name -> location. class ShiftCardMetadataRows extends StatelessWidget { /// Creates a [ShiftCardMetadataRows]. const ShiftCardMetadataRows({super.key, required this.data}); @@ -15,62 +18,71 @@ class ShiftCardMetadataRows extends StatelessWidget { @override Widget build(BuildContext context) { return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Date and time row + // Date row (with optional worked duration for completed shifts). Row( children: [ const Icon( UiIcons.calendar, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, + size: UiConstants.space3, + color: UiColors.mutedForeground, ), const SizedBox(width: UiConstants.space1), Text( _formatDate(context, data.date), - style: UiTypography.footnote1r.textSecondary, + style: UiTypography.body3r.textSecondary, ), - if (data.startTime != null && data.endTime != null) ...[ - const SizedBox(width: UiConstants.space3), - const Icon( - UiIcons.clock, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - '${_formatTime(data.startTime!)} - ${_formatTime(data.endTime!)}', - style: UiTypography.footnote1r.textSecondary, - ), - ], if (data.minutesWorked != null) ...[ const SizedBox(width: UiConstants.space3), const Icon( UiIcons.clock, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, + size: UiConstants.space3, + color: UiColors.mutedForeground, ), const SizedBox(width: UiConstants.space1), Text( _formatWorkedDuration(data.minutesWorked!), - style: UiTypography.footnote1r.textSecondary, + style: UiTypography.body3r.textSecondary, ), ], ], ), + // Client name row. + if (data.clientName != null && data.clientName!.isNotEmpty) ...[ + const SizedBox(height: UiConstants.space1), + Row( + children: [ + const Icon( + UiIcons.building, + size: UiConstants.space3, + color: UiColors.mutedForeground, + ), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text( + data.clientName!, + style: UiTypography.body3r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + // Location row. const SizedBox(height: UiConstants.space1), - // Location row Row( children: [ const Icon( UiIcons.mapPin, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, + size: UiConstants.space3, + color: UiColors.mutedForeground, ), const SizedBox(width: UiConstants.space1), Expanded( child: Text( data.location, - style: UiTypography.footnote1r.textSecondary, + style: UiTypography.body3r.textSecondary, overflow: TextOverflow.ellipsis, ), ), @@ -80,6 +92,7 @@ class ShiftCardMetadataRows extends StatelessWidget { ); } + /// Formats [date] relative to today/tomorrow, or as "EEE, MMM d". String _formatDate(BuildContext context, DateTime date) { final DateTime now = DateTime.now(); final DateTime today = DateTime(now.year, now.month, now.day); @@ -92,8 +105,7 @@ class ShiftCardMetadataRows extends StatelessWidget { return DateFormat('EEE, MMM d').format(date); } - String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt); - + /// Formats total minutes worked into a "Xh Ym" string. String _formatWorkedDuration(int totalMinutes) { final int hours = totalMinutes ~/ 60; final int mins = totalMinutes % 60; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_title_row.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_title_row.dart index f6b18b07..77c2ac4c 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_title_row.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_title_row.dart @@ -1,8 +1,10 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart'; -/// Title row with optional pay summary on the right. +/// Title row showing role name + pay headline, with a time subtitle + pay detail +/// row below. Matches the AvailableOrderCard layout. class ShiftCardTitleRow extends StatelessWidget { /// Creates a [ShiftCardTitleRow]. const ShiftCardTitleRow({super.key, required this.data}); @@ -12,77 +14,78 @@ class ShiftCardTitleRow extends StatelessWidget { @override Widget build(BuildContext context) { + // Determine if we have enough data to show pay information. final bool hasDirectRate = data.hourlyRate != null && data.hourlyRate! > 0; final bool hasComputedRate = data.hourlyRateCents != null && data.startTime != null && data.endTime != null; + final bool hasPay = hasDirectRate || hasComputedRate; - if (!hasDirectRate && !hasComputedRate) { - return Text( - data.title, - style: UiTypography.body2m.textPrimary, - overflow: TextOverflow.ellipsis, - ); + // Compute pay values when available. + double hourlyRate = 0; + double estimatedTotal = 0; + double durationHours = 0; + + if (hasPay) { + if (hasDirectRate && data.totalRate != null && data.totalRate! > 0) { + hourlyRate = data.hourlyRate!; + estimatedTotal = data.totalRate!; + durationHours = hourlyRate > 0 ? (estimatedTotal / hourlyRate) : 0; + } else if (hasComputedRate) { + hourlyRate = data.hourlyRateCents! / 100; + final int durationMinutes = + data.endTime!.difference(data.startTime!).inMinutes; + double hours = durationMinutes / 60; + if (hours < 0) hours += 24; + durationHours = hours.roundToDouble(); + estimatedTotal = hourlyRate * durationHours; + } } - // Prefer pre-computed values from the API when available. - final double hourlyRate; - final double estimatedTotal; - final double durationHours; - - if (hasDirectRate && data.totalRate != null && data.totalRate! > 0) { - hourlyRate = data.hourlyRate!; - estimatedTotal = data.totalRate!; - durationHours = hourlyRate > 0 ? (estimatedTotal / hourlyRate) : 0; - } else { - hourlyRate = data.hourlyRateCents! / 100; - final int durationMinutes = data.endTime! - .difference(data.startTime!) - .inMinutes; - double hours = durationMinutes / 60; - if (hours < 0) hours += 24; - durationHours = hours.roundToDouble(); - estimatedTotal = hourlyRate * durationHours; - } - - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( + // Row 1: Title + Pay headline + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: UiConstants.space1, + children: [ + Flexible( + child: Text( data.title, - style: UiTypography.body2m.textPrimary, + style: UiTypography.body1m.textPrimary, overflow: TextOverflow.ellipsis, ), - if (data.subtitle != null) ...[ - Text( - data.subtitle!, - style: UiTypography.body3r.textSecondary, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - const SizedBox(width: UiConstants.space3), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '\$${estimatedTotal.toStringAsFixed(0)}', - style: UiTypography.title1m.textPrimary, - ), - Text( - '\$${hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h', - style: UiTypography.footnote2r.textSecondary, ), + if (hasPay) + Text( + '\$${estimatedTotal.toStringAsFixed(0)}', + style: UiTypography.title1m.textPrimary, + ), ], ), + // Row 2: Time subtitle + pay detail + if (data.startTime != null && data.endTime != null) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: UiConstants.space1, + children: [ + Text( + '${_formatTime(data.startTime!)} - ${_formatTime(data.endTime!)}', + style: UiTypography.body3r.textSecondary, + ), + if (hasPay) + Text( + '\$${hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h', + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), ], ); } + + /// Formats a [DateTime] to a compact time string like "3:30pm". + String _formatTime(DateTime dt) => DateFormat('h:mma').format(dt).toLowerCase(); } From 207831eb3e76075631b7d13b7d5c1e45301050a6 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 21:15:52 -0400 Subject: [PATCH 26/29] feat: Adjust layout in ShiftCardBody to improve icon and title alignment --- .../presentation/widgets/shift_card/shift_card_body.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart index 7573f53c..21abde9e 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart @@ -18,9 +18,11 @@ class ShiftCardBody extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - children: [ + crossAxisAlignment: CrossAxisAlignment.start, + children: [ ShiftCardIcon(variant: data.variant), - ShiftCardTitleRow(data: data), + const SizedBox(width: UiConstants.space3), + Expanded(child: ShiftCardTitleRow(data: data)), ], ), const SizedBox(height: UiConstants.space2), From 4cd83a92811cf57bcde0f0468d69fa9097ceeb95 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 23:34:29 -0400 Subject: [PATCH 27/29] feat: Implement UTC conversion for order date and time serialization in order use cases --- .../core/lib/src/utils/time_utils.dart | 42 +++++++++++++++ .../arguments/one_time_order_arguments.dart | 33 ++++++++++++ .../arguments/permanent_order_arguments.dart | 45 ++++++++++++++++ .../arguments/recurring_order_arguments.dart | 47 ++++++++++++++++ .../create_one_time_order_usecase.dart | 40 ++------------ .../create_permanent_order_usecase.dart | 52 ++---------------- .../create_recurring_order_usecase.dart | 54 ++----------------- .../view_orders_repository_impl.dart | 4 +- .../shifts_repository_impl.dart | 4 +- 9 files changed, 186 insertions(+), 135 deletions(-) diff --git a/apps/mobile/packages/core/lib/src/utils/time_utils.dart b/apps/mobile/packages/core/lib/src/utils/time_utils.dart index 0f5b7d8c..f8e25eb2 100644 --- a/apps/mobile/packages/core/lib/src/utils/time_utils.dart +++ b/apps/mobile/packages/core/lib/src/utils/time_utils.dart @@ -68,3 +68,45 @@ String formatTime(String timeStr) { } } } + +/// Converts a local date + local HH:MM time string to a UTC HH:MM string. +/// +/// Combines [localDate] with the hours and minutes from [localTime] (e.g. +/// "09:00") to create a full local [DateTime], converts it to UTC, then +/// extracts the HH:MM portion. +/// +/// Example: March 19, "21:00" in UTC-5 → "02:00" (next day UTC). +String toUtcTimeHHmm(DateTime localDate, String localTime) { + final List parts = localTime.split(':'); + final DateTime localDt = DateTime( + localDate.year, + localDate.month, + localDate.day, + int.parse(parts[0]), + int.parse(parts[1]), + ); + final DateTime utcDt = localDt.toUtc(); + return '${utcDt.hour.toString().padLeft(2, '0')}:' + '${utcDt.minute.toString().padLeft(2, '0')}'; +} + +/// Converts a local date + local HH:MM time string to a UTC YYYY-MM-DD string. +/// +/// This accounts for date-boundary crossings: a shift at 11 PM on March 19 +/// in UTC-5 is actually March 20 in UTC. +/// +/// Example: March 19, "23:00" in UTC-5 → "2026-03-20". +String toUtcDateIso(DateTime localDate, String localTime) { + final List parts = localTime.split(':'); + final DateTime localDt = DateTime( + localDate.year, + localDate.month, + localDate.day, + int.parse(parts[0]), + int.parse(parts[1]), + ); + final DateTime utcDt = localDt.toUtc(); + return '${utcDt.year.toString().padLeft(4, '0')}-' + '${utcDt.month.toString().padLeft(2, '0')}-' + '${utcDt.day.toString().padLeft(2, '0')}'; +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart index 890fbeaf..308e74b1 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart @@ -63,6 +63,39 @@ class OneTimeOrderArguments extends UseCaseArgument { /// The selected vendor ID, if applicable. final String? vendorId; + /// Serialises these arguments into the V2 API payload shape. + /// + /// Times and dates are converted to UTC so the backend's + /// `combineDateAndTime` helper receives the correct values. + Map toJson() { + final String firstStartTime = + positions.isNotEmpty ? positions.first.startTime : '00:00'; + final String utcOrderDate = toUtcDateIso(orderDate, firstStartTime); + + final List> positionsList = + positions.map((OneTimeOrderPositionArgument p) { + return { + if (p.roleName != null) 'roleName': p.roleName, + if (p.roleId.isNotEmpty) 'roleId': p.roleId, + 'workerCount': p.workerCount, + 'startTime': toUtcTimeHHmm(orderDate, p.startTime), + 'endTime': toUtcTimeHHmm(orderDate, p.endTime), + if (p.lunchBreak != null && + p.lunchBreak != 'NO_BREAK' && + p.lunchBreak!.isNotEmpty) + 'lunchBreakMinutes': breakMinutesFromLabel(p.lunchBreak!), + }; + }).toList(); + + return { + 'hubId': hubId, + 'eventName': eventName, + 'orderDate': utcOrderDate, + 'positions': positionsList, + if (vendorId != null) 'vendorId': vendorId, + }; + } + @override List get props => [hubId, eventName, orderDate, positions, vendorId]; diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart index fb19864e..859097fd 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart @@ -63,6 +63,51 @@ class PermanentOrderArguments extends UseCaseArgument { /// The selected vendor ID, if applicable. final String? vendorId; + /// Day-of-week labels in Sunday-first order, matching the V2 API convention. + static const List _dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + + /// Serialises these arguments into the V2 API payload shape. + /// + /// Times and dates are converted to UTC so the backend's + /// `combineDateAndTime` helper receives the correct values. + Map toJson() { + final String firstStartTime = + positions.isNotEmpty ? positions.first.startTime : '00:00'; + final String utcStartDate = toUtcDateIso(startDate, firstStartTime); + + final List daysOfWeekList = daysOfWeek + .map((String day) => _dayLabels.indexOf(day) % 7) + .toList(); + + final List> positionsList = + positions.map((PermanentOrderPositionArgument p) { + return { + if (p.roleName != null) 'roleName': p.roleName, + if (p.roleId.isNotEmpty) 'roleId': p.roleId, + 'workerCount': p.workerCount, + 'startTime': toUtcTimeHHmm(startDate, p.startTime), + 'endTime': toUtcTimeHHmm(startDate, p.endTime), + }; + }).toList(); + + return { + 'hubId': hubId, + 'eventName': eventName, + 'startDate': utcStartDate, + 'daysOfWeek': daysOfWeekList, + 'positions': positionsList, + if (vendorId != null) 'vendorId': vendorId, + }; + } + @override List get props => [ hubId, diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart index 01999078..ef219e07 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart @@ -67,6 +67,53 @@ class RecurringOrderArguments extends UseCaseArgument { /// The selected vendor ID, if applicable. final String? vendorId; + /// Day-of-week labels in Sunday-first order, matching the V2 API convention. + static const List _dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + + /// Serialises these arguments into the V2 API payload shape. + /// + /// Times and dates are converted to UTC so the backend's + /// `combineDateAndTime` helper receives the correct values. + Map toJson() { + final String firstStartTime = + positions.isNotEmpty ? positions.first.startTime : '00:00'; + final String utcStartDate = toUtcDateIso(startDate, firstStartTime); + final String utcEndDate = toUtcDateIso(endDate, firstStartTime); + + final List recurrenceDaysList = recurringDays + .map((String day) => _dayLabels.indexOf(day) % 7) + .toList(); + + final List> positionsList = + positions.map((RecurringOrderPositionArgument p) { + return { + if (p.roleName != null) 'roleName': p.roleName, + if (p.roleId.isNotEmpty) 'roleId': p.roleId, + 'workerCount': p.workerCount, + 'startTime': toUtcTimeHHmm(startDate, p.startTime), + 'endTime': toUtcTimeHHmm(startDate, p.endTime), + }; + }).toList(); + + return { + 'hubId': hubId, + 'eventName': eventName, + 'startDate': utcStartDate, + 'endDate': utcEndDate, + 'recurrenceDays': recurrenceDaysList, + 'positions': positionsList, + if (vendorId != null) 'vendorId': vendorId, + }; + } + @override List get props => [ hubId, diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart index f74c4b63..eea3fdbc 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart @@ -1,49 +1,19 @@ -import 'package:krow_core/core.dart'; - import '../arguments/one_time_order_arguments.dart'; import '../repositories/client_create_order_repository_interface.dart'; /// Use case for creating a one-time staffing order. /// -/// Builds the V2 API payload from typed [OneTimeOrderArguments] and -/// delegates submission to the repository. Payload construction (date -/// formatting, position mapping, break-minutes conversion) is business -/// logic that belongs here, not in the BLoC. -class CreateOneTimeOrderUseCase - implements UseCase { +/// Delegates payload construction to [OneTimeOrderArguments.toJson] and +/// submission to the repository. +class CreateOneTimeOrderUseCase { /// Creates a [CreateOneTimeOrderUseCase]. const CreateOneTimeOrderUseCase(this._repository); /// The create-order repository. final ClientCreateOrderRepositoryInterface _repository; - @override + /// Creates a one-time order from the given arguments. Future call(OneTimeOrderArguments input) { - final String orderDate = formatDateToIso(input.orderDate); - - final List> positions = - input.positions.map((OneTimeOrderPositionArgument p) { - return { - if (p.roleName != null) 'roleName': p.roleName, - if (p.roleId.isNotEmpty) 'roleId': p.roleId, - 'workerCount': p.workerCount, - 'startTime': p.startTime, - 'endTime': p.endTime, - if (p.lunchBreak != null && - p.lunchBreak != 'NO_BREAK' && - p.lunchBreak!.isNotEmpty) - 'lunchBreakMinutes': breakMinutesFromLabel(p.lunchBreak!), - }; - }).toList(); - - final Map payload = { - 'hubId': input.hubId, - 'eventName': input.eventName, - 'orderDate': orderDate, - 'positions': positions, - if (input.vendorId != null) 'vendorId': input.vendorId, - }; - - return _repository.createOneTimeOrder(payload); + return _repository.createOneTimeOrder(input.toJson()); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart index e33163d9..970ea149 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart @@ -1,61 +1,19 @@ -import 'package:krow_core/core.dart'; - import '../arguments/permanent_order_arguments.dart'; import '../repositories/client_create_order_repository_interface.dart'; -/// Day-of-week labels in Sunday-first order, matching the V2 API convention. -const List _dayLabels = [ - 'SUN', - 'MON', - 'TUE', - 'WED', - 'THU', - 'FRI', - 'SAT', -]; - /// Use case for creating a permanent staffing order. /// -/// Builds the V2 API payload from typed [PermanentOrderArguments] and -/// delegates submission to the repository. Payload construction (date -/// formatting, day-of-week mapping, position mapping) is business -/// logic that belongs here, not in the BLoC. -class CreatePermanentOrderUseCase - implements UseCase { +/// Delegates payload construction to [PermanentOrderArguments.toJson] and +/// submission to the repository. +class CreatePermanentOrderUseCase { /// Creates a [CreatePermanentOrderUseCase]. const CreatePermanentOrderUseCase(this._repository); /// The create-order repository. final ClientCreateOrderRepositoryInterface _repository; - @override + /// Creates a permanent order from the given arguments. Future call(PermanentOrderArguments input) { - final String startDate = formatDateToIso(input.startDate); - - final List daysOfWeek = input.daysOfWeek - .map((String day) => _dayLabels.indexOf(day) % 7) - .toList(); - - final List> positions = - input.positions.map((PermanentOrderPositionArgument p) { - return { - if (p.roleName != null) 'roleName': p.roleName, - if (p.roleId.isNotEmpty) 'roleId': p.roleId, - 'workerCount': p.workerCount, - 'startTime': p.startTime, - 'endTime': p.endTime, - }; - }).toList(); - - final Map payload = { - 'hubId': input.hubId, - 'eventName': input.eventName, - 'startDate': startDate, - 'daysOfWeek': daysOfWeek, - 'positions': positions, - if (input.vendorId != null) 'vendorId': input.vendorId, - }; - - return _repository.createPermanentOrder(payload); + return _repository.createPermanentOrder(input.toJson()); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart index 7bd1232f..48d26c78 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart @@ -1,63 +1,19 @@ -import 'package:krow_core/core.dart'; - import '../arguments/recurring_order_arguments.dart'; import '../repositories/client_create_order_repository_interface.dart'; -/// Day-of-week labels in Sunday-first order, matching the V2 API convention. -const List _dayLabels = [ - 'SUN', - 'MON', - 'TUE', - 'WED', - 'THU', - 'FRI', - 'SAT', -]; - /// Use case for creating a recurring staffing order. /// -/// Builds the V2 API payload from typed [RecurringOrderArguments] and -/// delegates submission to the repository. Payload construction (date -/// formatting, recurrence-day mapping, position mapping) is business -/// logic that belongs here, not in the BLoC. -class CreateRecurringOrderUseCase - implements UseCase { +/// Delegates payload construction to [RecurringOrderArguments.toJson] and +/// submission to the repository. +class CreateRecurringOrderUseCase { /// Creates a [CreateRecurringOrderUseCase]. const CreateRecurringOrderUseCase(this._repository); /// The create-order repository. final ClientCreateOrderRepositoryInterface _repository; - @override + /// Creates a recurring order from the given arguments. Future call(RecurringOrderArguments input) { - final String startDate = formatDateToIso(input.startDate); - final String endDate = formatDateToIso(input.endDate); - - final List recurrenceDays = input.recurringDays - .map((String day) => _dayLabels.indexOf(day) % 7) - .toList(); - - final List> positions = - input.positions.map((RecurringOrderPositionArgument p) { - return { - if (p.roleName != null) 'roleName': p.roleName, - if (p.roleId.isNotEmpty) 'roleId': p.roleId, - 'workerCount': p.workerCount, - 'startTime': p.startTime, - 'endTime': p.endTime, - }; - }).toList(); - - final Map payload = { - 'hubId': input.hubId, - 'eventName': input.eventName, - 'startDate': startDate, - 'endDate': endDate, - 'recurrenceDays': recurrenceDays, - 'positions': positions, - if (input.vendorId != null) 'vendorId': input.vendorId, - }; - - return _repository.createRecurringOrder(payload); + return _repository.createRecurringOrder(input.toJson()); } } diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart index 192b4384..91967d92 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart @@ -22,8 +22,8 @@ class ViewOrdersRepositoryImpl implements ViewOrdersRepositoryInterface { final ApiResponse response = await _api.get( ClientEndpoints.ordersView, params: { - 'startDate': start.toIso8601String(), - 'endDate': end.toIso8601String(), + 'startDate': start.toUtc().toIso8601String(), + 'endDate': end.toUtc().toIso8601String(), }, ); final Map data = response.data as Map; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart index 2ade65ba..e5a118af 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart @@ -36,8 +36,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { final ApiResponse response = await _apiService.get( StaffEndpoints.shiftsAssigned, params: { - 'startDate': start.toIso8601String(), - 'endDate': end.toIso8601String(), + 'startDate': start.toUtc().toIso8601String(), + 'endDate': end.toUtc().toIso8601String(), }, ); final List items = _extractItems(response.data); From 1e4c8982a504befdd0334c32f18f73033f21f3f7 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 23:55:57 -0400 Subject: [PATCH 28/29] feat: Add additional fields to OrderItem and update cost calculation in ViewOrderCard --- .../lib/src/entities/orders/order_item.dart | 70 +++++++++++++++++++ .../presentation/widgets/view_order_card.dart | 4 +- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart b/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart index dfcd6072..b064f083 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart @@ -26,6 +26,16 @@ class OrderItem extends Equatable { this.locationName, required this.status, this.workers = const [], + this.eventName = '', + this.clientName = '', + this.hourlyRate = 0.0, + this.hours = 0.0, + this.totalValue = 0.0, + this.locationAddress, + this.startTime, + this.endTime, + this.hubManagerId, + this.hubManagerName, }); /// Deserialises an [OrderItem] from a V2 API JSON map. @@ -53,6 +63,16 @@ class OrderItem extends Equatable { locationName: json['locationName'] as String?, status: ShiftStatus.fromJson(json['status'] as String?), workers: workersList, + eventName: json['eventName'] as String? ?? '', + clientName: json['clientName'] as String? ?? '', + hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0, + hours: (json['hours'] as num?)?.toDouble() ?? 0.0, + totalValue: (json['totalValue'] as num?)?.toDouble() ?? 0.0, + locationAddress: json['locationAddress'] as String?, + startTime: json['startTime'] as String?, + endTime: json['endTime'] as String?, + hubManagerId: json['hubManagerId'] as String?, + hubManagerName: json['hubManagerName'] as String?, ); } @@ -98,6 +118,36 @@ class OrderItem extends Equatable { /// Assigned workers for this line item. final List workers; + /// Event/order name. + final String eventName; + + /// Client/business name. + final String clientName; + + /// Billing rate in dollars per hour. + final double hourlyRate; + + /// Duration of the shift in fractional hours. + final double hours; + + /// Total cost in dollars (rate x workers x hours). + final double totalValue; + + /// Full street address of the location. + final String? locationAddress; + + /// Display start time string (HH:MM UTC). + final String? startTime; + + /// Display end time string (HH:MM UTC). + final String? endTime; + + /// Hub manager's business membership ID. + final String? hubManagerId; + + /// Hub manager's display name. + final String? hubManagerName; + /// Serialises this [OrderItem] to a JSON map. Map toJson() { return { @@ -115,6 +165,16 @@ class OrderItem extends Equatable { 'locationName': locationName, 'status': status.toJson(), 'workers': workers.map((AssignedWorkerSummary w) => w.toJson()).toList(), + 'eventName': eventName, + 'clientName': clientName, + 'hourlyRate': hourlyRate, + 'hours': hours, + 'totalValue': totalValue, + 'locationAddress': locationAddress, + 'startTime': startTime, + 'endTime': endTime, + 'hubManagerId': hubManagerId, + 'hubManagerName': hubManagerName, }; } @@ -134,5 +194,15 @@ class OrderItem extends Equatable { locationName, status, workers, + eventName, + clientName, + hourlyRate, + hours, + totalValue, + locationAddress, + startTime, + endTime, + hubManagerId, + hubManagerName, ]; } diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart index 969aed43..d14a2f94 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -123,7 +123,9 @@ class _ViewOrderCardState extends State { : 0; final double hours = _computeHours(order); - final double cost = order.totalCostCents / 100.0; + final double cost = order.totalValue > 0 + ? order.totalValue + : order.totalCostCents / 100.0; return Container( decoration: BoxDecoration( From b10ef57d378bb50ba61c71e37d6c7336385ff6dd Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 23:59:00 -0400 Subject: [PATCH 29/29] feat: Add hourly rate field to order position arguments and update related blocs --- .../arguments/one_time_order_arguments.dart | 16 ++++++++++++++-- .../arguments/permanent_order_arguments.dart | 15 +++++++++++++-- .../arguments/recurring_order_arguments.dart | 15 +++++++++++++-- .../one_time_order/one_time_order_bloc.dart | 2 ++ .../permanent_order/permanent_order_bloc.dart | 2 ++ .../recurring_order/recurring_order_bloc.dart | 2 ++ 6 files changed, 46 insertions(+), 6 deletions(-) diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart index 308e74b1..a0e2d189 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart @@ -10,6 +10,7 @@ class OneTimeOrderPositionArgument extends UseCaseArgument { required this.endTime, this.roleName, this.lunchBreak, + this.hourlyRateCents, }); /// The role ID for this position. @@ -30,9 +31,19 @@ class OneTimeOrderPositionArgument extends UseCaseArgument { /// Break duration label (e.g. `'MIN_30'`, `'NO_BREAK'`), if set. final String? lunchBreak; + /// Hourly rate in cents for this position, if set. + final int? hourlyRateCents; + @override - List get props => - [roleId, roleName, workerCount, startTime, endTime, lunchBreak]; + List get props => [ + roleId, + roleName, + workerCount, + startTime, + endTime, + lunchBreak, + hourlyRateCents, + ]; } /// Typed arguments for [CreateOneTimeOrderUseCase]. @@ -84,6 +95,7 @@ class OneTimeOrderArguments extends UseCaseArgument { p.lunchBreak != 'NO_BREAK' && p.lunchBreak!.isNotEmpty) 'lunchBreakMinutes': breakMinutesFromLabel(p.lunchBreak!), + if (p.hourlyRateCents != null) 'hourlyRateCents': p.hourlyRateCents, }; }).toList(); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart index 859097fd..47bcb943 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart @@ -9,6 +9,7 @@ class PermanentOrderPositionArgument extends UseCaseArgument { required this.startTime, required this.endTime, this.roleName, + this.hourlyRateCents, }); /// The role ID for this position. @@ -26,9 +27,18 @@ class PermanentOrderPositionArgument extends UseCaseArgument { /// Shift end time in HH:mm format. final String endTime; + /// Hourly rate in cents for this position, if set. + final int? hourlyRateCents; + @override - List get props => - [roleId, roleName, workerCount, startTime, endTime]; + List get props => [ + roleId, + roleName, + workerCount, + startTime, + endTime, + hourlyRateCents, + ]; } /// Typed arguments for [CreatePermanentOrderUseCase]. @@ -95,6 +105,7 @@ class PermanentOrderArguments extends UseCaseArgument { 'workerCount': p.workerCount, 'startTime': toUtcTimeHHmm(startDate, p.startTime), 'endTime': toUtcTimeHHmm(startDate, p.endTime), + if (p.hourlyRateCents != null) 'hourlyRateCents': p.hourlyRateCents, }; }).toList(); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart index ef219e07..7a340df7 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart @@ -9,6 +9,7 @@ class RecurringOrderPositionArgument extends UseCaseArgument { required this.startTime, required this.endTime, this.roleName, + this.hourlyRateCents, }); /// The role ID for this position. @@ -26,9 +27,18 @@ class RecurringOrderPositionArgument extends UseCaseArgument { /// Shift end time in HH:mm format. final String endTime; + /// Hourly rate in cents for this position, if set. + final int? hourlyRateCents; + @override - List get props => - [roleId, roleName, workerCount, startTime, endTime]; + List get props => [ + roleId, + roleName, + workerCount, + startTime, + endTime, + hourlyRateCents, + ]; } /// Typed arguments for [CreateRecurringOrderUseCase]. @@ -100,6 +110,7 @@ class RecurringOrderArguments extends UseCaseArgument { 'workerCount': p.workerCount, 'startTime': toUtcTimeHHmm(startDate, p.startTime), 'endTime': toUtcTimeHHmm(startDate, p.endTime), + if (p.hourlyRateCents != null) 'hourlyRateCents': p.hourlyRateCents, }; }).toList(); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart index e40aa20f..e7f50954 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart @@ -265,6 +265,8 @@ class OneTimeOrderBloc extends Bloc startTime: p.startTime, endTime: p.endTime, lunchBreak: p.lunchBreak, + hourlyRateCents: + role != null ? (role.costPerHour * 100).round() : null, ); }).toList(); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart index fae8ee4d..ed6f2ac3 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart @@ -360,6 +360,8 @@ class PermanentOrderBloc extends Bloc workerCount: p.count, startTime: p.startTime, endTime: p.endTime, + hourlyRateCents: + role != null ? (role.costPerHour * 100).round() : null, ); }).toList(); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart index ce226789..65a48ff4 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart @@ -380,6 +380,8 @@ class RecurringOrderBloc extends Bloc workerCount: p.count, startTime: p.startTime, endTime: p.endTime, + hourlyRateCents: + role != null ? (role.costPerHour * 100).round() : null, ); }).toList();