diff --git a/backend/src/sync/eligibility/eligibility.config.ts b/backend/src/sync/eligibility/eligibility.config.ts index ad11e4e5..b4b21957 100644 --- a/backend/src/sync/eligibility/eligibility.config.ts +++ b/backend/src/sync/eligibility/eligibility.config.ts @@ -9,8 +9,6 @@ export const PROTECTED_STATUSES = [ CSA_STATUS.BATCH_SENT_CANCELLATION, CSA_STATUS.APPLICATION_REFUSED_CRA, CSA_STATUS.CANCELLATION_REFUSED_CRA, - CSA_STATUS.ELIGIBLE_TBD, - CSA_STATUS.NOT_ELIGIBLE_IP_TBD, CSA_STATUS.OVER_18, ] as const diff --git a/backend/src/sync/eligibility/rules/rule-runner.spec.ts b/backend/src/sync/eligibility/rules/rule-runner.spec.ts index b5a8cb88..64d19916 100644 --- a/backend/src/sync/eligibility/rules/rule-runner.spec.ts +++ b/backend/src/sync/eligibility/rules/rule-runner.spec.ts @@ -92,6 +92,7 @@ describe('runEligibility integration: step3 → step4 → step6', () => { makePlacement({ status: 'Active', contractNumber: 'C-100', + startDate: new Date('2026-03-01'), }), ], orders: [ diff --git a/backend/src/sync/eligibility/rules/steps/outcomes.spec.ts b/backend/src/sync/eligibility/rules/steps/outcomes.spec.ts index 17cce0c3..8e1ad86b 100644 --- a/backend/src/sync/eligibility/rules/steps/outcomes.spec.ts +++ b/backend/src/sync/eligibility/rules/steps/outcomes.spec.ts @@ -36,6 +36,16 @@ describe('step7_UpdateEligible', () => { }) }) + it('should return eligible when current status is eligible_tbd', () => { + const result = step7_UpdateEligible(CSA_STATUS.ELIGIBLE_TBD) + expect(result.newStatus).toBe(CSA_STATUS.ELIGIBLE) + }) + + it('should return in_pay when current status is not_eligible_ip_tbd', () => { + const result = step7_UpdateEligible(CSA_STATUS.NOT_ELIGIBLE_IP_TBD) + expect(result.newStatus).toBe(CSA_STATUS.IN_PAY) + }) + it('should keep existing status when no transition applies', () => { const result = step7_UpdateEligible(CSA_STATUS.ELIGIBLE) expect(result.step).toBe(7) @@ -80,6 +90,16 @@ describe('step9_UpdateNotEligible', () => { expect(result.careEndDate).toEqual(careEnd) }) + it('should return not_eligible_out_of_pay when current status is eligible_tbd', () => { + const result = step9_UpdateNotEligible(CSA_STATUS.ELIGIBLE_TBD) + expect(result.newStatus).toBe(CSA_STATUS.NOT_ELIGIBLE_OUT_OF_PAY) + }) + + it('should return not_eligible_out_of_pay when current status is not_eligible_ip_tbd', () => { + const result = step9_UpdateNotEligible(CSA_STATUS.NOT_ELIGIBLE_IP_TBD) + expect(result.newStatus).toBe(CSA_STATUS.NOT_ELIGIBLE_OUT_OF_PAY) + }) + it('should keep existing status when no transition applies', () => { const result = step9_UpdateNotEligible(CSA_STATUS.ON_HOLD) expect(result.newStatus).toBe(CSA_STATUS.ON_HOLD) @@ -107,6 +127,21 @@ describe('step10_UpdateOver18', () => { expect(result.newStatus).toBe(CSA_STATUS.OVER_18) }) + it('should return over_18 when current status is eligible_tbd', () => { + const result = step10_UpdateOver18(CSA_STATUS.ELIGIBLE_TBD) + expect(result.newStatus).toBe(CSA_STATUS.OVER_18) + }) + + it('should return over_18 when current status is not_eligible_in_pay', () => { + const result = step10_UpdateOver18(CSA_STATUS.NOT_ELIGIBLE_IN_PAY) + expect(result.newStatus).toBe(CSA_STATUS.OVER_18) + }) + + it('should return over_18 when current status is not_eligible_ip_tbd', () => { + const result = step10_UpdateOver18(CSA_STATUS.NOT_ELIGIBLE_IP_TBD) + expect(result.newStatus).toBe(CSA_STATUS.OVER_18) + }) + it('should keep existing status when no transition applies', () => { const result = step10_UpdateOver18(CSA_STATUS.ON_HOLD) expect(result.newStatus).toBe(CSA_STATUS.ON_HOLD) diff --git a/backend/src/sync/eligibility/rules/steps/step10-update-over18.ts b/backend/src/sync/eligibility/rules/steps/step10-update-over18.ts index 361ed01c..7cbe175f 100644 --- a/backend/src/sync/eligibility/rules/steps/step10-update-over18.ts +++ b/backend/src/sync/eligibility/rules/steps/step10-update-over18.ts @@ -5,8 +5,11 @@ export function step10_UpdateOver18(currentStatus: CsaStatus | null): Eligibilit let newStatus: CsaStatus | null = null if ( currentStatus === CSA_STATUS.ELIGIBLE || + currentStatus === CSA_STATUS.ELIGIBLE_TBD || currentStatus === CSA_STATUS.IN_PAY || currentStatus === CSA_STATUS.NOT_ELIGIBLE_OUT_OF_PAY || + currentStatus === CSA_STATUS.NOT_ELIGIBLE_IN_PAY || + currentStatus === CSA_STATUS.NOT_ELIGIBLE_IP_TBD || currentStatus === null ) { newStatus = CSA_STATUS.OVER_18 diff --git a/backend/src/sync/eligibility/rules/steps/step3-placement-check.spec.ts b/backend/src/sync/eligibility/rules/steps/step3-placement-check.spec.ts index 14e19d52..68af6b44 100644 --- a/backend/src/sync/eligibility/rules/steps/step3-placement-check.spec.ts +++ b/backend/src/sync/eligibility/rules/steps/step3-placement-check.spec.ts @@ -4,107 +4,254 @@ import { makeContact, makePlacement } from '../../test-helpers' import { EligibilityContext } from '../rule.interface' import { step3_PlacementCheck } from './step3-placement-check' +const REF_DATE = new Date('2026-02-10') +const BEFORE_CURRENT_MONTH = new Date('2026-01-15') +const IN_CURRENT_MONTH = new Date('2026-02-05') +const IN_PREV_MONTH = new Date('2026-01-20') +const NOT_IN_PREV_MONTH = new Date('2025-12-15') + const makeCtx = (overrides: Partial = {}): EligibilityContext => ({ contact: makeContact(overrides), - referenceDate: new Date('2026-02-10'), + referenceDate: REF_DATE, }) describe('step3_PlacementCheck', () => { - it('should route to step 8 when no placements at all', () => { - const ctx = makeCtx({ placements: [] }) - const result = step3_PlacementCheck.evaluate(ctx) - expect(result!.step).toBe(8) + describe('no placements', () => { + it('should route to step 8 when no placements at all', () => { + const ctx = makeCtx({ placements: [] }) + const result = step3_PlacementCheck.evaluate(ctx) + expect(result!.step).toBe(8) + }) }) - it('should return null (continue to step 4) when only Active Placement found', () => { - const ctx = makeCtx({ - placements: [makePlacement({ type: 'Placement', status: 'Active' })], + describe('Active/Interrupted Placement (startDate prior to current month)', () => { + it('should continue to step 4 when Active Placement found', () => { + const ctx = makeCtx({ + placements: [ + makePlacement({ type: 'Placement', status: 'Active', startDate: BEFORE_CURRENT_MONTH }), + ], + }) + const result = step3_PlacementCheck.evaluate(ctx) + expect(result).toBeNull() + expect(ctx.hasPlacement).toBe(true) + expect(ctx.hasNonPlacement).toBe(false) }) - const result = step3_PlacementCheck.evaluate(ctx) - expect(result).toBeNull() - expect(ctx.hasPlacement).toBe(true) - expect(ctx.hasNonPlacement).toBe(false) - }) - it('should return null (continue to step 4) when only Interrupted Placement found', () => { - const ctx = makeCtx({ - placements: [makePlacement({ type: 'Placement', status: 'Interrupted' })], + it('should continue to step 4 when Interrupted Placement found', () => { + const ctx = makeCtx({ + placements: [ + makePlacement({ + type: 'Placement', + status: 'Interrupted', + startDate: BEFORE_CURRENT_MONTH, + }), + ], + }) + const result = step3_PlacementCheck.evaluate(ctx) + expect(result).toBeNull() + expect(ctx.hasPlacement).toBe(true) }) - const result = step3_PlacementCheck.evaluate(ctx) - expect(result).toBeNull() - expect(ctx.hasPlacement).toBe(true) - }) - it('should route to step 8 when only Active Non-Placement Location found', () => { - const ctx = makeCtx({ - placements: [makePlacement({ type: 'Non-Placement Location', status: 'Active' })], + it('should ignore Active Placement with startDate in current month', () => { + const ctx = makeCtx({ + placements: [ + makePlacement({ type: 'Placement', status: 'Active', startDate: IN_CURRENT_MONTH }), + ], + }) + const result = step3_PlacementCheck.evaluate(ctx) + expect(result!.step).toBe(8) }) - const result = step3_PlacementCheck.evaluate(ctx) - expect(result!.step).toBe(8) - }) - it('should route to step 8 when only Interrupted Non-Placement Location found', () => { - const ctx = makeCtx({ - placements: [makePlacement({ type: 'Non-Placement Location', status: 'Interrupted' })], + it('should ignore Active Placement with null startDate', () => { + const ctx = makeCtx({ + placements: [makePlacement({ type: 'Placement', status: 'Active', startDate: null })], + }) + const result = step3_PlacementCheck.evaluate(ctx) + expect(result!.step).toBe(8) + }) + + it('should continue to step 4 when both Active & Interrupted Placement exist', () => { + const ctx = makeCtx({ + placements: [ + makePlacement({ type: 'Placement', status: 'Active', startDate: BEFORE_CURRENT_MONTH }), + makePlacement({ + type: 'Placement', + status: 'Interrupted', + startDate: BEFORE_CURRENT_MONTH, + }), + ], + }) + const result = step3_PlacementCheck.evaluate(ctx) + expect(result).toBeNull() + expect(ctx.hasPlacement).toBe(true) + expect(ctx.eligiblePlacements).toHaveLength(2) }) - const result = step3_PlacementCheck.evaluate(ctx) - expect(result!.step).toBe(8) }) - it('should return null when both Active and Interrupted Placement on case', () => { - const ctx = makeCtx({ - placements: [ - makePlacement({ type: 'Placement', status: 'Active' }), - makePlacement({ type: 'Placement', status: 'Interrupted' }), - ], - }) - const result = step3_PlacementCheck.evaluate(ctx) - expect(result).toBeNull() - expect(ctx.hasPlacement).toBe(true) - expect(ctx.eligiblePlacements).toHaveLength(2) + describe('Ended/Closed Placement fallback (endDate in previous month)', () => { + it('should continue to step 4 when Ended Placement found with endDate in prev month', () => { + const ctx = makeCtx({ + placements: [makePlacement({ type: 'Placement', status: 'Ended', endDate: IN_PREV_MONTH })], + }) + const result = step3_PlacementCheck.evaluate(ctx) + expect(result).toBeNull() + expect(ctx.hasPlacement).toBe(true) + expect(ctx.eligiblePlacements).toHaveLength(1) + }) + + it('should continue to step 4 when Closed (MIS) Placement found with endDate in prev month', () => { + const ctx = makeCtx({ + placements: [ + makePlacement({ + type: 'Placement', + status: 'Closed', + source: 'MIS', + endDate: IN_PREV_MONTH, + }), + ], + }) + const result = step3_PlacementCheck.evaluate(ctx) + expect(result).toBeNull() + expect(ctx.hasPlacement).toBe(true) + }) + + it('should ignore Ended Placement with endDate not in previous month', () => { + const ctx = makeCtx({ + placements: [ + makePlacement({ type: 'Placement', status: 'Ended', endDate: NOT_IN_PREV_MONTH }), + ], + }) + const result = step3_PlacementCheck.evaluate(ctx) + expect(result!.step).toBe(8) + }) + + it('should prefer Active Placement over Ended Placement', () => { + const ctx = makeCtx({ + placements: [ + makePlacement({ + type: 'Placement', + status: 'Active', + startDate: BEFORE_CURRENT_MONTH, + placementNumber: 'ACTIVE', + }), + makePlacement({ + type: 'Placement', + status: 'Ended', + endDate: IN_PREV_MONTH, + placementNumber: 'ENDED', + }), + ], + }) + const result = step3_PlacementCheck.evaluate(ctx) + expect(result).toBeNull() + expect(ctx.eligiblePlacements).toHaveLength(1) + expect(ctx.eligiblePlacements![0].placementNumber).toBe('ACTIVE') + }) }) - it('should route to step 8 when both Active and Interrupted Non-Placement on case', () => { - const ctx = makeCtx({ - placements: [ - makePlacement({ type: 'Non-Placement Location', status: 'Active' }), - makePlacement({ type: 'Non-Placement Location', status: 'Interrupted' }), - ], + describe('Active/Interrupted Non-Placement → Step 8', () => { + it('should route to step 8 when only Active Non-Placement found', () => { + const ctx = makeCtx({ + placements: [ + makePlacement({ + type: 'Non-Placement Location', + status: 'Active', + startDate: BEFORE_CURRENT_MONTH, + }), + ], + }) + const result = step3_PlacementCheck.evaluate(ctx) + expect(result!.step).toBe(8) + expect(ctx.hasNonPlacement).toBe(true) + }) + + it('should route to step 8 when both Active & Interrupted Non-Placement on case', () => { + const ctx = makeCtx({ + placements: [ + makePlacement({ + type: 'Non-Placement Location', + status: 'Active', + startDate: BEFORE_CURRENT_MONTH, + }), + makePlacement({ + type: 'Non-Placement Location', + status: 'Interrupted', + startDate: BEFORE_CURRENT_MONTH, + }), + ], + }) + const result = step3_PlacementCheck.evaluate(ctx) + expect(result!.step).toBe(8) }) - const result = step3_PlacementCheck.evaluate(ctx) - expect(result!.step).toBe(8) }) - it('should return null with hasNonPlacement=true when both Placement and Non-Placement found', () => { - const ctx = makeCtx({ - placements: [ - makePlacement({ type: 'Placement', status: 'Active' }), - makePlacement({ type: 'Non-Placement Location', status: 'Interrupted' }), - ], - }) - const result = step3_PlacementCheck.evaluate(ctx) - expect(result).toBeNull() - expect(ctx.hasPlacement).toBe(true) - expect(ctx.hasNonPlacement).toBe(true) - // Only placements (not non-placements) in eligiblePlacements - expect(ctx.eligiblePlacements).toHaveLength(1) - expect(ctx.eligiblePlacements![0].type).toBe('Placement') + describe('Ended/Closed Non-Placement fallback → Step 4', () => { + it('should continue to step 4 when Ended Non-Placement found with endDate in prev month', () => { + const ctx = makeCtx({ + placements: [ + makePlacement({ + type: 'Non-Placement Location', + status: 'Ended', + endDate: IN_PREV_MONTH, + }), + ], + }) + const result = step3_PlacementCheck.evaluate(ctx) + expect(result).toBeNull() + expect(ctx.hasNonPlacement).toBe(true) + expect(ctx.eligiblePlacements).toHaveLength(1) + }) + + it('should ignore Ended Non-Placement with endDate not in previous month', () => { + const ctx = makeCtx({ + placements: [ + makePlacement({ + type: 'Non-Placement Location', + status: 'Ended', + endDate: NOT_IN_PREV_MONTH, + }), + ], + }) + const result = step3_PlacementCheck.evaluate(ctx) + expect(result!.step).toBe(8) + }) }) - it('should handle variant casing and whitespace in type and status', () => { - const ctx = makeCtx({ - placements: [makePlacement({ type: ' placement ', status: ' active ' })], + describe('Placement precedence over Non-Placement', () => { + it('should continue to step 4 with hasNonPlacement=true when both Placement and Non-Placement found', () => { + const ctx = makeCtx({ + placements: [ + makePlacement({ type: 'Placement', status: 'Active', startDate: BEFORE_CURRENT_MONTH }), + makePlacement({ + type: 'Non-Placement Location', + status: 'Interrupted', + startDate: BEFORE_CURRENT_MONTH, + }), + ], + }) + const result = step3_PlacementCheck.evaluate(ctx) + expect(result).toBeNull() + expect(ctx.hasPlacement).toBe(true) + expect(ctx.hasNonPlacement).toBe(true) + expect(ctx.eligiblePlacements).toHaveLength(1) + expect(ctx.eligiblePlacements![0].type).toBe('Placement') }) - const result = step3_PlacementCheck.evaluate(ctx) - expect(result).toBeNull() - expect(ctx.hasPlacement).toBe(true) }) - it('should only consider Active/Interrupted placements, ignoring other statuses', () => { - const ctx = makeCtx({ - placements: [makePlacement({ type: 'Placement', status: 'Ended' })], + describe('case-insensitive matching', () => { + it('should handle variant casing and whitespace in type and status', () => { + const ctx = makeCtx({ + placements: [ + makePlacement({ + type: ' placement ', + status: ' active ', + startDate: BEFORE_CURRENT_MONTH, + }), + ], + }) + const result = step3_PlacementCheck.evaluate(ctx) + expect(result).toBeNull() + expect(ctx.hasPlacement).toBe(true) }) - const result = step3_PlacementCheck.evaluate(ctx) - expect(result!.step).toBe(8) // no active/interrupted->step 8 }) }) diff --git a/backend/src/sync/eligibility/rules/steps/step3-placement-check.ts b/backend/src/sync/eligibility/rules/steps/step3-placement-check.ts index b0ebc370..66a767bd 100644 --- a/backend/src/sync/eligibility/rules/steps/step3-placement-check.ts +++ b/backend/src/sync/eligibility/rules/steps/step3-placement-check.ts @@ -4,45 +4,109 @@ import { EligibilityContext, EligibilityRule } from '../rule.interface' import { step8_UpdateEligibleTbd } from './step8-update-eligible-tbd' const ACTIVE_STATUSES = ['ACTIVE', 'INTERRUPTED'] +const ENDED_STATUSES = ['ENDED', 'CLOSED'] /** - * STEP 3: Check Placement / Non-Placement Location - * Analyzes placements and enriches context for downstream rules. + * STEP 3: Check Placement / Non-Placement Location details * - * - Active/Interrupted Placement found->Step 4 (continue chain) - * - Only Non-Placement Location found->Step 8 - * - Both Placement + Non-Placement->Step 4 (flag hasNonPlacement) - * - No placement found->Step 8 + * Checks placements in the previous month (current month - 1): + * + * 1. Active/Interrupted Placement (startDate prior to current month) -> Step 4 + * 2. Fallback: Ended/Closed Placement (endDate in previous month) -> Step 4 + * 3. Active/Interrupted Non-Placement (startDate prior to current month) -> Step 8 + * 4. Fallback: Ended/Closed Non-Placement (endDate in previous month) -> Step 4 + * 5. Both Placement + Non-Placement -> Step 4 (placement precedence) + * 6. Nothing found -> Step 8 */ export const step3_PlacementCheck: EligibilityRule = { name: 'step3_PlacementCheck', evaluate(ctx: EligibilityContext): EligibilityResult | null { const { placements } = ctx.contact + const currentMonthStart = getFirstDayOfMonth(ctx.referenceDate) + const prevMonth = getPreviousMonth(ctx.referenceDate) + + const activeRecords = placements.filter( + (placement) => + ACTIVE_STATUSES.includes(normalize(placement.status)) && + isBeforeDate(placement.startDate, currentMonthStart), + ) - const activePlacements = placements.filter((placement) => - ACTIVE_STATUSES.includes(normalize(placement.status)), + const endedRecords = placements.filter( + (placement) => + ENDED_STATUSES.includes(normalize(placement.status)) && + isInMonth(placement.endDate, prevMonth), ) - const placementRecords = activePlacements.filter( + const activePlacements = activeRecords.filter( (placement) => normalize(placement.type) === 'PLACEMENT', ) - const nonPlacementRecords = activePlacements.filter( + const activeNonPlacements = activeRecords.filter( (placement) => normalize(placement.type) === 'NON-PLACEMENT LOCATION', ) + const endedPlacements = endedRecords.filter((p) => normalize(p.type) === 'PLACEMENT') + const endedNonPlacements = endedRecords.filter( + (p) => normalize(p.type) === 'NON-PLACEMENT LOCATION', + ) - const hasPlacement = placementRecords.length > 0 - const hasNonPlacement = nonPlacementRecords.length > 0 + const hasActivePlacement = activePlacements.length > 0 + const hasActiveNonPlacement = activeNonPlacements.length > 0 + const hasEndedPlacement = endedPlacements.length > 0 + const hasEndedNonPlacement = endedNonPlacements.length > 0 - // Enrich context for downstream rules - ctx.hasPlacement = hasPlacement - ctx.hasNonPlacement = hasNonPlacement - ctx.eligiblePlacements = placementRecords + if (hasActivePlacement) { + ctx.hasPlacement = true + ctx.hasNonPlacement = hasActiveNonPlacement + ctx.eligiblePlacements = activePlacements + return null + } - if (hasPlacement) { + if (hasEndedPlacement) { + ctx.hasPlacement = true + ctx.hasNonPlacement = hasActiveNonPlacement || hasEndedNonPlacement + ctx.eligiblePlacements = endedPlacements return null } + if (hasActiveNonPlacement) { + ctx.hasPlacement = false + ctx.hasNonPlacement = true + ctx.eligiblePlacements = [] + return step8_UpdateEligibleTbd(ctx.contact.csaStatus) + } + + if (hasEndedNonPlacement) { + ctx.hasPlacement = false + ctx.hasNonPlacement = true + ctx.eligiblePlacements = endedNonPlacements + return null + } + + ctx.hasPlacement = false + ctx.hasNonPlacement = false + ctx.eligiblePlacements = [] return step8_UpdateEligibleTbd(ctx.contact.csaStatus) }, } + +function getFirstDayOfMonth(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1)) +} + +function getPreviousMonth(date: Date): { year: number; month: number } { + const month = date.getUTCMonth() - 1 + if (month < 0) { + return { year: date.getUTCFullYear() - 1, month: 11 } + } + return { year: date.getUTCFullYear(), month } +} + +function isBeforeDate(date: Date | null, threshold: Date): boolean { + if (!date) return false + return date < threshold +} + +function isInMonth(date: Date | null, month: { year: number; month: number }): boolean { + if (!date) return false + return date.getUTCFullYear() === month.year && date.getUTCMonth() === month.month +} diff --git a/backend/src/sync/eligibility/rules/steps/step4-fetch-agreement-contract.ts b/backend/src/sync/eligibility/rules/steps/step4-fetch-agreement-contract.ts index ad211244..2f709b9a 100644 --- a/backend/src/sync/eligibility/rules/steps/step4-fetch-agreement-contract.ts +++ b/backend/src/sync/eligibility/rules/steps/step4-fetch-agreement-contract.ts @@ -2,7 +2,7 @@ import { EligibilityResult } from '../../eligibility.types' import { EligibilityContext, EligibilityRule } from '../rule.interface' /** - * STEP 4: Fetch Agreement/Contract# from Active and/or Interrupted Placement + * STEP 4: Fetch Agreement/Contract# from Active/Interrupted/Ended Placement * Extracts contract numbers and enriches context for Step 6. * Always continues to the next rule (Step 6). */ diff --git a/backend/src/sync/eligibility/rules/steps/step7-update-eligible.ts b/backend/src/sync/eligibility/rules/steps/step7-update-eligible.ts index daca2775..6ebb8ae2 100644 --- a/backend/src/sync/eligibility/rules/steps/step7-update-eligible.ts +++ b/backend/src/sync/eligibility/rules/steps/step7-update-eligible.ts @@ -3,9 +3,16 @@ import { EligibilityResult } from '../../eligibility.types' export function step7_UpdateEligible(currentStatus: CsaStatus | null): EligibilityResult { let newStatus: CsaStatus | null = null - if (currentStatus === CSA_STATUS.NOT_ELIGIBLE_OUT_OF_PAY || currentStatus === null) { + if ( + currentStatus === CSA_STATUS.NOT_ELIGIBLE_OUT_OF_PAY || + currentStatus === CSA_STATUS.ELIGIBLE_TBD || + currentStatus === null + ) { newStatus = CSA_STATUS.ELIGIBLE - } else if (currentStatus === CSA_STATUS.NOT_ELIGIBLE_IN_PAY) { + } else if ( + currentStatus === CSA_STATUS.NOT_ELIGIBLE_IN_PAY || + currentStatus === CSA_STATUS.NOT_ELIGIBLE_IP_TBD + ) { newStatus = CSA_STATUS.IN_PAY } else { newStatus = currentStatus diff --git a/backend/src/sync/eligibility/rules/steps/step9-update-not-eligible.ts b/backend/src/sync/eligibility/rules/steps/step9-update-not-eligible.ts index f78409ca..5df5acaa 100644 --- a/backend/src/sync/eligibility/rules/steps/step9-update-not-eligible.ts +++ b/backend/src/sync/eligibility/rules/steps/step9-update-not-eligible.ts @@ -13,7 +13,12 @@ export function step9_UpdateNotEligible( let reasonCode: string | null = null let endDate: Date | null = null - if (currentStatus === CSA_STATUS.ELIGIBLE || currentStatus === null) { + if ( + currentStatus === CSA_STATUS.ELIGIBLE || + currentStatus === CSA_STATUS.ELIGIBLE_TBD || + currentStatus === CSA_STATUS.NOT_ELIGIBLE_IP_TBD || + currentStatus === null + ) { newStatus = CSA_STATUS.NOT_ELIGIBLE_OUT_OF_PAY } else if (currentStatus === CSA_STATUS.IN_PAY) { newStatus = CSA_STATUS.NOT_ELIGIBLE_IN_PAY diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cfee2092..bbece67a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -150,7 +150,7 @@ const COLUMN_LABELS: Record = { const DATE_FORMAT: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: '2-digit' } const toYMD = (date: Date, timeZone: string): string => { - const parts = new Intl.DateTimeFormat('en-CA', { ...DATE_FORMAT, timeZone }).formatToParts(date) + const parts = new Intl.DateTimeFormat('en-US', { ...DATE_FORMAT, timeZone }).formatToParts(date) const get = (type: string) => parts.find((p) => p.type === type)?.value || '' return `${get('year')}-${get('month')}-${get('day')}` } @@ -165,7 +165,7 @@ const formatDateTimeYMD = (dateString: string): string => { const formatDateTimeYMDHMS = (dateString: string): string => { const date = new Date(dateString) - const parts = new Intl.DateTimeFormat('en-CA', { + const parts = new Intl.DateTimeFormat('en-US', { ...DATE_FORMAT, timeZone: 'America/Vancouver', hour: '2-digit', @@ -195,13 +195,6 @@ function App() { clearCsaAccessAlert, } = useAuth() - // Log Keycloak authentication token (for testing in deployed version) - console.log('=== KEYCLOAK AUTH TOKEN ===') - console.log('Keycloak Authenticated:', keycloakAuthenticated) - console.log('Has CSA Access:', hasCSAAccess) - console.log('User Info:', user) - console.log('==========================') - // Local authentication state for IDIR mock login const [isLoggedIn, setIsLoggedIn] = useState(() => { const saved = localStorage.getItem('isLoggedIn') @@ -244,6 +237,8 @@ function App() { const [selectedTab, setSelectedTab] = useState(0) const [selected, setSelected] = useState([]) + // Cache of selected records' csaStatusRaw values for cross-page validation + const [selectedRecordsCache, setSelectedRecordsCache] = useState>(new Map()) const [selectedBatchDetails, setSelectedBatchDetails] = useState([]) const [searchTerm, setSearchTerm] = useState('') const [filterSearchTerm, setFilterSearchTerm] = useState('') @@ -296,8 +291,6 @@ function App() { // Only verify if there's an existing login session with a token (IDIR login - not Keycloak SSO) // Keycloak SSO is handled by AuthContext if (savedLoginState === 'true' && savedToken) { - console.log('Re-verifying CSA access for existing login session...') - try { const csaAccessResponse = await verifyCSAAccess() @@ -332,8 +325,6 @@ function App() { message: 'User not authorised to access CSA', severity: 'error', }) - } else { - console.log('CSA access verified successfully') } } catch (error) { console.error('Failed to re-verify CSA access:', error) @@ -463,6 +454,11 @@ function App() { }) const [batchDetailsFilterSearchTerm, setBatchDetailsFilterSearchTerm] = useState('') + // Pagination states for batch tables + const [batchRequestsPage, setBatchRequestsPage] = useState(1) + const [batchDetailsPage, setBatchDetailsPage] = useState(1) + const BATCH_PAGE_SIZE = 10 + const [columnFilters, setColumnFilters] = useState>({ firstName: [], middleName: [], @@ -609,10 +605,6 @@ function App() { setContacts(response.data) setTotalPages(response.totalPages) setTotalRecords(response.total) - console.log('Fetched contacts:', response.data) - console.log('Total records:', response.total) - console.log('Applied filter:', filter) - console.log('Applied sort:', sort) } catch (error) { console.error('Failed to fetch contacts:', error) setContactsError('Failed to load contacts. Please try again.') @@ -682,10 +674,6 @@ function App() { setContacts(response.data) setTotalPages(response.totalPages) setTotalRecords(response.total) - console.log('Column filter search results:', response.data) - console.log('Total column filter records:', response.total) - console.log('Active filters:', filters) - console.log('Applied filter:', combinedFilter) } catch (error) { console.error('Failed to search column:', error) setContactsError('Failed to search. Please try again.') @@ -708,9 +696,6 @@ function App() { setContacts(response.data) setTotalPages(response.totalPages) setTotalRecords(response.total) - console.log('Search results:', response.data) - console.log('Total search records:', response.total) - console.log('Search query:', query) } catch (error) { console.error('Failed to search contacts:', error) setContactsError('Failed to search contacts. Please try again.') @@ -923,6 +908,7 @@ function App() { setFilterSearchTerm('') // Clear selected records when changing PDQ filter setSelected([]) + setSelectedRecordsCache(new Map()) } const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { @@ -931,14 +917,9 @@ function App() { // Mock IDIR login handler const handleIdirLogin = async () => { - // Simple validation - just check if fields are not empty - // if (username.trim() && password.trim()) { const mockToken = `mock-token-${Date.now()}` localStorage.setItem('authToken', mockToken) localStorage.setItem('username', username) - console.log('=== MOCK LOGIN - AUTH TOKEN SET ===') - console.log('Mock Token:', mockToken) - console.log('===================================') // Verify CSA access before granting login try { @@ -992,15 +973,10 @@ function App() { }) setShowIdirLogin(false) } - // } } // Mock logout handler const handleLogout = () => { - console.log('=== LOGOUT - CLEARING AUTH TOKEN ===') - console.log('Token before logout:', localStorage.getItem('authToken')) - console.log('====================================') - if (keycloakAuthenticated) { // Logout from Keycloak logout() @@ -1083,6 +1059,7 @@ function App() { // Clear selection setSelected([]) + setSelectedRecordsCache(new Map()) // Reload contacts to reflect the changes if at least one record was updated if (totalSuccess > 0) { @@ -1147,6 +1124,7 @@ function App() { // Clear selection setSelected([]) + setSelectedRecordsCache(new Map()) // Reload contacts to reflect the changes if (response.success.length > 0) { @@ -1211,6 +1189,7 @@ function App() { // Clear selection setSelected([]) + setSelectedRecordsCache(new Map()) // Reload contacts to reflect the changes if (response.success.length > 0) { @@ -1277,6 +1256,7 @@ function App() { // Clear selection setSelected([]) + setSelectedRecordsCache(new Map()) // Reload contacts to reflect the changes if (response.success.length > 0) { @@ -1348,6 +1328,7 @@ function App() { // Clear selection after successful operation setSelected([]) + setSelectedRecordsCache(new Map()) // Reload contacts to reflect the updated CSA status if (successCount > 0) { @@ -1787,7 +1768,6 @@ function App() { serviceProviderName: contact.serviceProviderName || '', providerId: contact.providerId || '', placeOfServiceName: contact.placeOfServiceName || '', - sourceAgreement: '', // Placeholder - backend field not yet available agreementType: contact.agreementType || '', agreementStatus: contact.agreementStatus || '', agreementStartDate: contact.agreementStartDate @@ -1811,20 +1791,20 @@ function App() { if (selected.length === 0) return false return selected.every((id) => { - const record = filteredData.find((row) => row.id === id) - return record && VALID_CSA_STATUSES.includes(record.csaStatusRaw) + const cachedStatus = selectedRecordsCache.get(id) + return cachedStatus && VALID_CSA_STATUSES.includes(cachedStatus) }) - }, [selected, filteredData]) + }, [selected, selectedRecordsCache]) // Check if all selected records have valid CSA status for Add to Batch const canAddToBatch = useMemo(() => { if (selected.length === 0) return false return selected.every((id) => { - const record = filteredData.find((row) => row.id === id) - return record && VALID_BATCH_STATUSES.includes(record.csaStatusRaw) + const cachedStatus = selectedRecordsCache.get(id) + return cachedStatus && VALID_BATCH_STATUSES.includes(cachedStatus) }) - }, [selected, filteredData]) + }, [selected, selectedRecordsCache]) // Check if Remove from Batch button should be enabled const canRemoveFromBatch = useMemo(() => { @@ -2016,6 +1996,35 @@ function App() { return data }, [currentBatchDetails, batchDetailsSearchTerm, batchDetailsColumnFilters]) + // Paginated batch requests + const paginatedBatchRequests = useMemo(() => { + const startIndex = (batchRequestsPage - 1) * BATCH_PAGE_SIZE + return filteredBatchRequests.slice(startIndex, startIndex + BATCH_PAGE_SIZE) + }, [filteredBatchRequests, batchRequestsPage]) + + const batchRequestsTotalPages = useMemo(() => { + return Math.ceil(filteredBatchRequests.length / BATCH_PAGE_SIZE) + }, [filteredBatchRequests.length]) + + // Paginated batch details + const paginatedBatchDetails = useMemo(() => { + const startIndex = (batchDetailsPage - 1) * BATCH_PAGE_SIZE + return filteredBatchDetails.slice(startIndex, startIndex + BATCH_PAGE_SIZE) + }, [filteredBatchDetails, batchDetailsPage]) + + const batchDetailsTotalPages = useMemo(() => { + return Math.ceil(filteredBatchDetails.length / BATCH_PAGE_SIZE) + }, [filteredBatchDetails.length]) + + // Reset pagination when filters/search change + useEffect(() => { + setBatchRequestsPage(1) + }, [batchRequestsSearchTerm, batchRequestsColumnFilters]) + + useEffect(() => { + setBatchDetailsPage(1) + }, [batchDetailsSearchTerm, batchDetailsColumnFilters, selectedBatch]) + return ( handlePreDefinedFilterChange(e.target.value)} displayEmpty > - All Records + All Children in CSA Master Table Pending User review/action @@ -2490,12 +2499,28 @@ function App() { }) return newSelected }) + // Update cache with current page records + setSelectedRecordsCache((prev) => { + const newCache = new Map(prev) + filteredData.forEach((row) => { + newCache.set(row.id, row.csaStatusRaw) + }) + return newCache + }) } else { // Deselect all rows on current page setSelected((prev) => { const currentPageIds = filteredData.map((row) => row.id) return prev.filter((id) => !currentPageIds.includes(id)) }) + // Remove current page records from cache + setSelectedRecordsCache((prev) => { + const newCache = new Map(prev) + filteredData.forEach((row) => { + newCache.delete(row.id) + }) + return newCache + }) } }} /> @@ -2530,7 +2555,7 @@ function App() { onClick={(e) => handleSortClick(e, 'firstName')} style={{ cursor: 'pointer', userSelect: 'none' }} > - First Name + Given Name handleSortClick(e, 'middleName')} style={{ cursor: 'pointer', userSelect: 'none' }} > - Middle Name + Middle Name(s) { e.stopPropagation() - setSelected((prev) => - prev.includes(row.id) - ? prev.filter((id) => id !== row.id) - : [...prev, row.id], - ) + if (selected.includes(row.id)) { + setSelected((prev) => prev.filter((id) => id !== row.id)) + setSelectedRecordsCache((prev) => { + const newCache = new Map(prev) + newCache.delete(row.id) + return newCache + }) + } else { + setSelected((prev) => [...prev, row.id]) + setSelectedRecordsCache((prev) => { + const newCache = new Map(prev) + newCache.set(row.id, row.csaStatusRaw) + return newCache + }) + } }} /> @@ -4101,7 +4136,23 @@ function App() { wordBreak: 'break-word', }} > - {childData.sourceAgreement || '-'} + {childData.sourcePlacement ? ( + + {childData.sourcePlacement} + + ) : ( + '-' + )} @@ -4664,7 +4715,7 @@ function App() { ) : ( - filteredBatchRequests.map((row) => ( + paginatedBatchRequests.map((row) => ( + {/* Batch Requests Pagination */} + {filteredBatchRequests.length > 0 && ( + + + Showing {paginatedBatchRequests.length} of {filteredBatchRequests.length}{' '} + records + + setBatchRequestsPage(page)} + color="primary" + showFirstButton + showLastButton + /> + + )} + {/* Batch Details Section */} {/* Batch Details Header */} @@ -4795,7 +4872,7 @@ function App() { - Middle Name + Middle Name(s) handleBatchDetailsFilterClick(e, 'middleName')} @@ -4905,7 +4982,7 @@ function App() { ) : ( - filteredBatchDetails.map((row) => ( + paginatedBatchDetails.map((row) => ( + + {/* Batch Details Pagination */} + {filteredBatchDetails.length > 0 && ( + + + Showing {paginatedBatchDetails.length} of {filteredBatchDetails.length}{' '} + records + + setBatchDetailsPage(page)} + color="primary" + showFirstButton + showLastButton + /> + + )} )} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 8a20134c..0de5ec75 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -62,9 +62,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children // Store token in localStorage if (keycloakInstance.token) { - localStorage.setItem('authToken', keycloakInstance.token) - - console.log('Token stored in localStorage:', keycloakInstance.token) console.log('Calling admin api to verify CSA access...') // Verify CSA access via admin API diff --git a/frontend/src/service/api-service.ts b/frontend/src/service/api-service.ts index 803ee191..653b9f0f 100644 --- a/frontend/src/service/api-service.ts +++ b/frontend/src/service/api-service.ts @@ -35,7 +35,6 @@ class APIService { this.client.interceptors.response.use( (config) => { - console.info(`received response status: ${config.status} , data: ${config.data}`) return config }, (error) => {