- supabase/config.toml + supabase/.gitignore from supabase init (needed for supabase db push / local dev) - phase 2 and phase 4 implementation plans referenced by the SDD progress ledger but never committed - ignore local tooling dirs (node_modules.nosync, graphify-out, .claude) and iCloud "name 2.ext" sync-conflict copies Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ
1842 lines
60 KiB
Markdown
1842 lines
60 KiB
Markdown
# Phase 4 — Scale & Polish Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add WhatsApp Business API notifications, CAPA effectiveness re-check automation (30/60/90-day), multi-language UI (EN/MS/ZH), and PWA offline incident capture.
|
||
|
||
**Architecture:** WhatsApp is a thin fetch wrapper over Meta Graph API used fire-and-forget alongside existing Resend email. i18n uses a lightweight cookie-based custom context (no extra library) to avoid restructuring App Router routes. Offline capture uses `idb` (IndexedDB wrapper) to queue reports when offline and auto-sync on reconnect via a client-side listener. All cron routes share the existing `Bearer $CRON_SECRET` auth pattern.
|
||
|
||
**Tech Stack:** Next.js 15 App Router · Supabase · Meta WhatsApp Business Cloud API v19.0 · idb · Vitest 4
|
||
|
||
## Global Constraints
|
||
|
||
- `export const dynamic = 'force-dynamic'` on every route.ts and protected page.tsx.
|
||
- Auth pattern for hse/admin routes: `auth.getUser()` → `users.role` check → 401/403.
|
||
- Every DB mutation calls `supabase.rpc('write_audit_log', { p_table_name, p_record_id, p_action, p_new_value })`.
|
||
- Supabase join type casts: `(row.relation as unknown as { field: string } | null)?.field`.
|
||
- `getApiKey(supabase, 'KEY_NAME')` from `lib/settings.ts` for all API credentials — DB first, env fallback.
|
||
- `basePath: '/ims'` — hard-code `/ims/` prefix in manifest `start_url`, SW registration path, and WhatsApp notification URLs. Client-side `fetch()` calls use `/api/...` paths (consistent with existing codebase).
|
||
- WhatsApp sends are always fire-and-forget (non-blocking, `catch(err => console.error(...))`).
|
||
- No service-role key in app code. No `.env` secrets committed.
|
||
- Cron routes: `GET` protected by `Authorization: Bearer $CRON_SECRET` header.
|
||
- `next.config.js` uses CommonJS (`module.exports`). No new build plugins introduced.
|
||
- Meta WhatsApp template names: `ims_incident_alert`, `ims_capa_overdue`, `ims_effectiveness_recheck`. These must be created and approved in Meta Business Manager before production use. The implementation is complete regardless; see Task 1 Step 1 for exact variable specs.
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
**New files:**
|
||
- `lib/notifications/whatsapp.ts`
|
||
- `lib/notifications/effectiveness-recheck.ts`
|
||
- `lib/i18n/locales.ts`
|
||
- `lib/i18n/context.tsx`
|
||
- `lib/i18n/server.ts`
|
||
- `messages/en.json`
|
||
- `messages/ms.json`
|
||
- `messages/zh.json`
|
||
- `components/language-switcher.tsx`
|
||
- `components/incidents/offline-sync.tsx`
|
||
- `lib/offline/db.ts`
|
||
- `public/manifest.json`
|
||
- `public/icons/icon.svg`
|
||
- `public/sw.js`
|
||
- `app/api/cron/effectiveness-recheck/route.ts`
|
||
- `supabase/migrations/20260711000015_phase4.sql`
|
||
- `tests/lib/notifications/whatsapp.test.ts`
|
||
- `tests/lib/notifications/effectiveness-recheck.test.ts`
|
||
- `tests/lib/i18n/translations.test.ts`
|
||
- `tests/lib/offline/db.test.ts`
|
||
|
||
**Modified files:**
|
||
- `app/api/settings/route.ts` — expand ALLOWED_KEYS with WhatsApp credentials
|
||
- `app/api/incidents/route.ts` — fire WhatsApp alongside email
|
||
- `lib/notifications/capa-escalation.ts` — add WhatsApp for urgent thresholds
|
||
- `app/api/capa/[id]/verify/route.ts` — set effectiveness_recheck_date + round on verify
|
||
- `app/layout.tsx` — I18nProvider wrap + manifest link + SW registration script
|
||
- `app/report/page.tsx` — add LanguageSwitcher
|
||
- `components/incidents/report-form.tsx` — offline mode + i18n translations
|
||
|
||
---
|
||
|
||
## Task 1: WhatsApp notification helper + migration
|
||
|
||
**Files:**
|
||
- Create: `lib/notifications/whatsapp.ts`
|
||
- Create: `supabase/migrations/20260711000015_phase4.sql`
|
||
- Modify: `app/api/settings/route.ts`
|
||
- Test: `tests/lib/notifications/whatsapp.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `sendWhatsAppMessage(phoneNumber, templateName, parameters, phoneNumberId, accessToken): Promise<void>` — pure, testable, used by Tasks 2 and 3.
|
||
|
||
**WhatsApp template variable specs (create these in Meta Business Manager):**
|
||
```
|
||
Template: ims_incident_alert
|
||
Body: "IMS Alert: New incident {{1}} ({{2}}) reported at {{3}}. Please review immediately."
|
||
Variables: {{1}}=reference_no, {{2}}=incident_type, {{3}}=site_name
|
||
|
||
Template: ims_capa_overdue
|
||
Body: "IMS CAPA Overdue: Action for incident {{1}} is overdue. Description: {{2}}. Due: {{3}}."
|
||
Variables: {{1}}=incident_ref, {{2}}=capa_description, {{3}}=due_date
|
||
|
||
Template: ims_effectiveness_recheck
|
||
Body: "IMS Effectiveness Check ({{1}}): Please verify that the corrective action for incident {{2}} is still holding. Action: {{3}}"
|
||
Variables: {{1}}=round_label (e.g. "30-day"), {{2}}=incident_ref, {{3}}=capa_description
|
||
```
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```typescript
|
||
// tests/lib/notifications/whatsapp.test.ts
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
||
|
||
const fetchMock = vi.fn()
|
||
vi.stubGlobal('fetch', fetchMock)
|
||
|
||
describe('sendWhatsAppMessage', () => {
|
||
beforeEach(() => fetchMock.mockClear())
|
||
|
||
it('posts to Meta Graph API with correct template structure', async () => {
|
||
fetchMock.mockResolvedValueOnce(
|
||
new Response('{"messages":[{"id":"wamid.abc"}]}', { status: 200 })
|
||
)
|
||
await sendWhatsAppMessage(
|
||
'60123456789',
|
||
'ims_incident_alert',
|
||
['SETIA-202407-0001', 'injury', 'Warehouse A'],
|
||
'test-phone-id',
|
||
'test-token'
|
||
)
|
||
expect(fetchMock).toHaveBeenCalledOnce()
|
||
const [url, opts] = fetchMock.mock.calls[0]
|
||
expect(url).toBe('https://graph.facebook.com/v19.0/test-phone-id/messages')
|
||
expect(opts.method).toBe('POST')
|
||
expect(opts.headers['Authorization']).toBe('Bearer test-token')
|
||
const body = JSON.parse(opts.body)
|
||
expect(body.messaging_product).toBe('whatsapp')
|
||
expect(body.to).toBe('60123456789')
|
||
expect(body.type).toBe('template')
|
||
expect(body.template.name).toBe('ims_incident_alert')
|
||
expect(body.template.language.code).toBe('en_US')
|
||
expect(body.template.components[0].parameters).toHaveLength(3)
|
||
expect(body.template.components[0].parameters[0]).toEqual({ type: 'text', text: 'SETIA-202407-0001' })
|
||
})
|
||
|
||
it('throws on non-2xx response', async () => {
|
||
fetchMock.mockResolvedValueOnce(
|
||
new Response('{"error":{"message":"Invalid token"}}', { status: 400 })
|
||
)
|
||
await expect(
|
||
sendWhatsAppMessage('60123456789', 'ims_incident_alert', ['a'], 'pid', 'tok')
|
||
).rejects.toThrow('WhatsApp API error: 400')
|
||
})
|
||
|
||
it('returns immediately without calling fetch when phoneNumber is empty', async () => {
|
||
await sendWhatsAppMessage('', 'ims_incident_alert', ['a'], 'pid', 'tok')
|
||
expect(fetchMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('sanitizes phone number — strips spaces, dashes, and plus sign', async () => {
|
||
fetchMock.mockResolvedValueOnce(
|
||
new Response('{"messages":[{"id":"x"}]}', { status: 200 })
|
||
)
|
||
await sendWhatsAppMessage('+60 12-345 6789', 'ims_incident_alert', ['a'], 'pid', 'tok')
|
||
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
|
||
expect(body.to).toBe('60123456789')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to confirm it fails**
|
||
|
||
```bash
|
||
npx vitest run tests/lib/notifications/whatsapp.test.ts
|
||
```
|
||
Expected: FAIL — "Cannot find module '@/lib/notifications/whatsapp'"
|
||
|
||
- [ ] **Step 3: Create `lib/notifications/whatsapp.ts`**
|
||
|
||
```typescript
|
||
export async function sendWhatsAppMessage(
|
||
phoneNumber: string,
|
||
templateName: string,
|
||
parameters: string[],
|
||
phoneNumberId: string,
|
||
accessToken: string,
|
||
): Promise<void> {
|
||
const sanitized = phoneNumber.replace(/[^0-9]/g, '')
|
||
if (!sanitized) return
|
||
|
||
const body = {
|
||
messaging_product: 'whatsapp',
|
||
to: sanitized,
|
||
type: 'template',
|
||
template: {
|
||
name: templateName,
|
||
language: { code: 'en_US' },
|
||
components: [
|
||
{
|
||
type: 'body',
|
||
parameters: parameters.map(text => ({ type: 'text', text })),
|
||
},
|
||
],
|
||
},
|
||
}
|
||
|
||
const res = await fetch(
|
||
`https://graph.facebook.com/v19.0/${phoneNumberId}/messages`,
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${accessToken}`,
|
||
},
|
||
body: JSON.stringify(body),
|
||
}
|
||
)
|
||
|
||
if (!res.ok) {
|
||
throw new Error(`WhatsApp API error: ${res.status}`)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to confirm it passes**
|
||
|
||
```bash
|
||
npx vitest run tests/lib/notifications/whatsapp.test.ts
|
||
```
|
||
Expected: 4/4 PASS
|
||
|
||
- [ ] **Step 5: Create migration `supabase/migrations/20260711000015_phase4.sql`**
|
||
|
||
```sql
|
||
-- Add effectiveness recheck round tracking to capa_actions
|
||
ALTER TABLE capa_actions
|
||
ADD COLUMN IF NOT EXISTS effectiveness_recheck_round INT NOT NULL DEFAULT 0;
|
||
|
||
-- WhatsApp and effectiveness recheck credentials for Settings UI
|
||
INSERT INTO app_settings (key, value, updated_at, updated_by)
|
||
VALUES
|
||
('META_WHATSAPP_PHONE_NUMBER_ID', '', now(), NULL),
|
||
('META_WHATSAPP_ACCESS_TOKEN', '', now(), NULL)
|
||
ON CONFLICT (key) DO NOTHING;
|
||
```
|
||
|
||
- [ ] **Step 6: Expand ALLOWED_KEYS in `app/api/settings/route.ts`**
|
||
|
||
Change line 6 from:
|
||
```typescript
|
||
const ALLOWED_KEYS = ['ANTHROPIC_API_KEY', 'VOYAGE_API_KEY'] as const
|
||
type SettingKey = typeof ALLOWED_KEYS[number]
|
||
```
|
||
To:
|
||
```typescript
|
||
const ALLOWED_KEYS = [
|
||
'ANTHROPIC_API_KEY',
|
||
'VOYAGE_API_KEY',
|
||
'META_WHATSAPP_PHONE_NUMBER_ID',
|
||
'META_WHATSAPP_ACCESS_TOKEN',
|
||
] as const
|
||
type SettingKey = typeof ALLOWED_KEYS[number]
|
||
```
|
||
|
||
- [ ] **Step 7: Build check**
|
||
|
||
```bash
|
||
npm run build 2>&1 | tail -20
|
||
```
|
||
Expected: zero TypeScript/ESLint errors
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add lib/notifications/whatsapp.ts \
|
||
supabase/migrations/20260711000015_phase4.sql \
|
||
app/api/settings/route.ts \
|
||
tests/lib/notifications/whatsapp.test.ts
|
||
git commit -m "feat: WhatsApp notification helper + phase 4 migration (recheck_round, WhatsApp settings)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: WhatsApp integration — new incident alert + CAPA escalation
|
||
|
||
**Files:**
|
||
- Modify: `app/api/incidents/route.ts`
|
||
- Modify: `lib/notifications/capa-escalation.ts`
|
||
- Test: extend `tests/lib/notifications/whatsapp.test.ts` (no, this is in capa-escalation.test.ts)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `sendWhatsAppMessage` from `lib/notifications/whatsapp.ts`
|
||
- Consumes: `getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID')` from `lib/settings.ts`
|
||
|
||
**Note:** The `users` table has a `phone TEXT` column. WhatsApp sends only go to users with a non-empty phone. Entire WhatsApp block is fire-and-forget — never blocks the main request.
|
||
|
||
- [ ] **Step 1: Write the failing test for WhatsApp in capa escalation**
|
||
|
||
Read `tests/lib/notifications/capa-escalation.test.ts` first to understand the existing mock structure. Then:
|
||
|
||
1. Add `vi.mock('@/lib/notifications/whatsapp', () => ({ sendWhatsAppMessage: vi.fn().mockResolvedValue(undefined) }))` at the top of the file (alongside the existing vi.mock calls).
|
||
2. Import `sendWhatsAppMessage` from the mock: `import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'`
|
||
3. Add the following two test cases inside the existing `describe` block, after existing tests:
|
||
|
||
```typescript
|
||
// In tests/lib/notifications/capa-escalation.test.ts
|
||
// (add vi.mock and import above, then these test cases inside the describe block)
|
||
|
||
it('sends WhatsApp when threshold is overdue_7d and owner has phone', async () => {
|
||
// Use the same mock pattern as existing tests for supabase + resend.
|
||
// Key difference: mock the owner to have a phone number and set today 7 days after due_date.
|
||
// Then call escalateOverdueCapa(supabase) and assert:
|
||
const { sendWhatsAppMessage: mockWA } = await import('@/lib/notifications/whatsapp')
|
||
// Set up getApiKey to return dummy credentials (mock lib/settings.ts if not already mocked):
|
||
// vi.mock('@/lib/settings', () => ({ getApiKey: vi.fn().mockResolvedValue('test-cred') }))
|
||
// The supabase mock must return a capa with due_date = 7 days ago and owner.phone set.
|
||
// After calling escalateOverdueCapa(supabase), expect:
|
||
expect(mockWA).toHaveBeenCalledWith(
|
||
expect.stringMatching(/^\d+$/), // sanitized phone digits
|
||
'ims_capa_overdue',
|
||
expect.arrayContaining([expect.any(String)]),
|
||
'test-cred',
|
||
'test-cred',
|
||
)
|
||
})
|
||
|
||
it('does NOT send WhatsApp when threshold is warning_3d', async () => {
|
||
const { sendWhatsAppMessage: mockWA } = await import('@/lib/notifications/whatsapp')
|
||
vi.clearAllMocks()
|
||
// Set up supabase mock to return a capa with due_date = 3 days from today (warning_3d threshold).
|
||
// After calling escalateOverdueCapa(supabase):
|
||
expect(mockWA).not.toHaveBeenCalled()
|
||
})
|
||
```
|
||
|
||
**Implementation note for the test setup:** The existing tests in this file already mock `supabase` as a chainable query builder and `Resend`. Mirror that exact pattern — do not create new mock infrastructure. Only add the `whatsapp` mock and `settings` mock on top of what exists. If `lib/settings` is already mocked in the file, reuse that mock; if not, add `vi.mock('@/lib/settings', () => ({ getApiKey: vi.fn().mockResolvedValue('test-cred') }))` at the top.
|
||
|
||
- [ ] **Step 2: Modify `lib/notifications/capa-escalation.ts`**
|
||
|
||
Add imports at the top (after existing imports):
|
||
```typescript
|
||
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
||
import { getApiKey } from '@/lib/settings'
|
||
```
|
||
|
||
Inside `escalateOverdueCapa`, after the `resend.emails.send()` call and before the `notifications_log.insert()` call, add the WhatsApp block. Locate this section (around line 88):
|
||
|
||
```typescript
|
||
const { error } = await resend.emails.send({ from, to: [...new Set(to)], subject, html, text })
|
||
if (error) {
|
||
console.error('Escalation email error:', error)
|
||
continue
|
||
}
|
||
```
|
||
|
||
Change to:
|
||
```typescript
|
||
const { error } = await resend.emails.send({ from, to: [...new Set(to)], subject, html, text })
|
||
if (error) {
|
||
console.error('Escalation email error:', error)
|
||
continue
|
||
}
|
||
|
||
// WhatsApp for urgent thresholds only (owner must have a phone number)
|
||
if (['overdue_3d', 'overdue_7d'].includes(threshold)) {
|
||
const ownerPhone = (capa.owner as unknown as { phone: string | null } | null)?.phone ?? ''
|
||
if (ownerPhone) {
|
||
try {
|
||
const phoneNumberId = await getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID')
|
||
const accessToken = await getApiKey(supabase, 'META_WHATSAPP_ACCESS_TOKEN')
|
||
await sendWhatsAppMessage(
|
||
ownerPhone,
|
||
'ims_capa_overdue',
|
||
[incidentRef, (capa as { description: string }).description, (capa as { due_date: string }).due_date],
|
||
phoneNumberId,
|
||
accessToken,
|
||
)
|
||
} catch (waErr) {
|
||
console.error('WhatsApp escalation error:', waErr)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Also update the `.select()` query near the top of `escalateOverdueCapa` to include `phone` in the owner join. Change:
|
||
```typescript
|
||
owner:users!owner_user_id (email, name)
|
||
```
|
||
To:
|
||
```typescript
|
||
owner:users!owner_user_id (email, name, phone)
|
||
```
|
||
|
||
- [ ] **Step 3: Add WhatsApp fire-and-forget to `app/api/incidents/route.ts`**
|
||
|
||
Add import at the top (after the existing `sendNewIncidentEmail` import):
|
||
```typescript
|
||
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
||
import { getApiKey } from '@/lib/settings'
|
||
```
|
||
|
||
Find the fire-and-forget email call (around line 109):
|
||
```typescript
|
||
sendNewIncidentEmail(incident.id, zone.site_id, incident.reference_no ?? '', input.incident_type)
|
||
.catch(err => console.error('email notification failed:', err))
|
||
```
|
||
|
||
Add immediately after it:
|
||
```typescript
|
||
// WhatsApp alert — fire-and-forget alongside email
|
||
;(async () => {
|
||
try {
|
||
const phoneNumberId = await getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID')
|
||
const accessToken = await getApiKey(supabase, 'META_WHATSAPP_ACCESS_TOKEN')
|
||
const { data: siteData } = await supabase
|
||
.from('sites').select('name').eq('id', zone.site_id).single()
|
||
const siteName = (siteData as { name: string } | null)?.name ?? 'Unknown'
|
||
const { data: recipients } = await supabase
|
||
.from('users')
|
||
.select('phone')
|
||
.in('role', ['supervisor', 'hse'])
|
||
.eq('site_id', zone.site_id)
|
||
for (const r of recipients ?? []) {
|
||
const phone = (r as { phone: string | null }).phone ?? ''
|
||
if (!phone) continue
|
||
await sendWhatsAppMessage(
|
||
phone,
|
||
'ims_incident_alert',
|
||
[incident.reference_no ?? '', input.incident_type, siteName],
|
||
phoneNumberId,
|
||
accessToken,
|
||
)
|
||
}
|
||
} catch (err) {
|
||
console.error('WhatsApp incident alert error:', err)
|
||
}
|
||
})()
|
||
```
|
||
|
||
- [ ] **Step 4: Build check**
|
||
|
||
```bash
|
||
npm run build 2>&1 | tail -20
|
||
```
|
||
Expected: zero errors
|
||
|
||
- [ ] **Step 5: Run tests**
|
||
|
||
```bash
|
||
npx vitest run tests/lib/notifications/
|
||
```
|
||
Expected: all pass (previous whatsapp.test.ts 4/4 + capa-escalation additions pass)
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add app/api/incidents/route.ts lib/notifications/capa-escalation.ts
|
||
git commit -m "feat: WhatsApp notifications — new incident alert and CAPA overdue escalation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: CAPA effectiveness re-check automation (30/60/90-day)
|
||
|
||
**Files:**
|
||
- Create: `lib/notifications/effectiveness-recheck.ts`
|
||
- Create: `app/api/cron/effectiveness-recheck/route.ts`
|
||
- Modify: `app/api/capa/[id]/verify/route.ts`
|
||
- Test: `tests/lib/notifications/effectiveness-recheck.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `sendWhatsAppMessage` from `lib/notifications/whatsapp.ts`
|
||
- Produces: `getNextRecheckDate(verifiedAt, currentRound): string | null` — pure, tested
|
||
- Produces: `shouldSendRecheck(recheckDate, round, today?): boolean` — pure, tested
|
||
- Produces: `sendEffectivenessRecheckNotifications(supabase): Promise<{notified: number}>`
|
||
|
||
**Re-check round semantics:**
|
||
```
|
||
effectiveness_recheck_round = 0 + recheck_date = verified_at + 30d → cron fires → send 30-day check → set round=1, date=verified_at+60d
|
||
effectiveness_recheck_round = 1 + recheck_date = verified_at + 60d → cron fires → send 60-day check → set round=2, date=verified_at+90d
|
||
effectiveness_recheck_round = 2 + recheck_date = verified_at + 90d → cron fires → send 90-day check → set round=3, date=null (done)
|
||
effectiveness_recheck_round = 3 → skip (done)
|
||
```
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```typescript
|
||
// tests/lib/notifications/effectiveness-recheck.test.ts
|
||
import { describe, it, expect } from 'vitest'
|
||
import {
|
||
getNextRecheckDate,
|
||
shouldSendRecheck,
|
||
getRoundLabel,
|
||
} from '@/lib/notifications/effectiveness-recheck'
|
||
|
||
describe('getRoundLabel', () => {
|
||
it('returns "30-day" for round 0', () => {
|
||
expect(getRoundLabel(0)).toBe('30-day')
|
||
})
|
||
it('returns "60-day" for round 1', () => {
|
||
expect(getRoundLabel(1)).toBe('60-day')
|
||
})
|
||
it('returns "90-day" for round 2', () => {
|
||
expect(getRoundLabel(2)).toBe('90-day')
|
||
})
|
||
})
|
||
|
||
describe('getNextRecheckDate', () => {
|
||
// verifiedAt 2026-07-01. July has 31 days.
|
||
// +60d → July 1 + 60 = Aug 30
|
||
// +90d → July 1 + 90 = Sep 29
|
||
it('returns 60d from verifiedAt when round 0 just sent', () => {
|
||
expect(getNextRecheckDate('2026-07-01T00:00:00Z', 0)).toBe('2026-08-30')
|
||
})
|
||
it('returns 90d from verifiedAt when round 1 just sent', () => {
|
||
expect(getNextRecheckDate('2026-07-01T00:00:00Z', 1)).toBe('2026-09-29')
|
||
})
|
||
it('returns null when round 2 just sent (all done)', () => {
|
||
expect(getNextRecheckDate('2026-07-01T00:00:00Z', 2)).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('shouldSendRecheck', () => {
|
||
it('true when date equals today and round < 3', () => {
|
||
expect(shouldSendRecheck('2026-07-11', 0, '2026-07-11')).toBe(true)
|
||
})
|
||
it('true when date is in the past and round < 3', () => {
|
||
expect(shouldSendRecheck('2026-07-10', 2, '2026-07-11')).toBe(true)
|
||
})
|
||
it('false when round is 3 (all done)', () => {
|
||
expect(shouldSendRecheck('2026-07-10', 3, '2026-07-11')).toBe(false)
|
||
})
|
||
it('false when recheckDate is null', () => {
|
||
expect(shouldSendRecheck(null, 0, '2026-07-11')).toBe(false)
|
||
})
|
||
it('false when date is in the future', () => {
|
||
expect(shouldSendRecheck('2026-07-20', 0, '2026-07-11')).toBe(false)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to confirm failure**
|
||
|
||
```bash
|
||
npx vitest run tests/lib/notifications/effectiveness-recheck.test.ts
|
||
```
|
||
Expected: FAIL — module not found
|
||
|
||
- [ ] **Step 3: Create `lib/notifications/effectiveness-recheck.ts`**
|
||
|
||
```typescript
|
||
import { Resend } from 'resend'
|
||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
||
import { getApiKey } from '@/lib/settings'
|
||
|
||
const ROUND_LABELS = ['30-day', '60-day', '90-day'] as const
|
||
const NEXT_INTERVAL_DAYS: (number | null)[] = [60, 90, null]
|
||
|
||
export function getRoundLabel(round: number): string {
|
||
return ROUND_LABELS[round] ?? '90-day'
|
||
}
|
||
|
||
export function getNextRecheckDate(verifiedAt: string, currentRound: number): string | null {
|
||
const days = NEXT_INTERVAL_DAYS[currentRound]
|
||
if (days === null || days === undefined) return null
|
||
const base = new Date(verifiedAt)
|
||
base.setDate(base.getDate() + days)
|
||
return base.toISOString().split('T')[0]
|
||
}
|
||
|
||
export function shouldSendRecheck(
|
||
recheckDate: string | null,
|
||
round: number,
|
||
today: string = new Date().toISOString().split('T')[0],
|
||
): boolean {
|
||
if (!recheckDate || round >= 3) return false
|
||
return recheckDate <= today
|
||
}
|
||
|
||
export async function sendEffectivenessRecheckNotifications(
|
||
supabase: SupabaseClient,
|
||
): Promise<{ notified: number }> {
|
||
const today = new Date().toISOString().split('T')[0]
|
||
|
||
const { data: capas } = await supabase
|
||
.from('capa_actions')
|
||
.select(`
|
||
id, description, effectiveness_recheck_date, effectiveness_recheck_round,
|
||
verified_at, incident_id,
|
||
incidents (reference_no),
|
||
verifier:users!verified_by (email, name, phone)
|
||
`)
|
||
.in('status', ['verified', 'closed'])
|
||
.not('effectiveness_recheck_date', 'is', null)
|
||
.lt('effectiveness_recheck_round', 3)
|
||
|
||
if (!capas || capas.length === 0) return { notified: 0 }
|
||
|
||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||
const from = process.env.RESEND_FROM_EMAIL ?? 'onboarding@resend.dev'
|
||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
|
||
|
||
let whatsappPhoneId: string | null = null
|
||
let whatsappToken: string | null = null
|
||
try {
|
||
whatsappPhoneId = await getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID')
|
||
whatsappToken = await getApiKey(supabase, 'META_WHATSAPP_ACCESS_TOKEN')
|
||
} catch {
|
||
// WhatsApp not configured — email only
|
||
}
|
||
|
||
let notified = 0
|
||
|
||
for (const capa of capas) {
|
||
const recheckDate = (capa as { effectiveness_recheck_date: string | null }).effectiveness_recheck_date
|
||
const round = (capa as { effectiveness_recheck_round: number }).effectiveness_recheck_round
|
||
|
||
if (!shouldSendRecheck(recheckDate, round, today)) continue
|
||
|
||
const verifierEmail = (capa.verifier as unknown as { email: string } | null)?.email
|
||
const verifierName = (capa.verifier as unknown as { name: string } | null)?.name ?? 'HSE Officer'
|
||
const verifierPhone = (capa.verifier as unknown as { phone: string | null } | null)?.phone ?? ''
|
||
if (!verifierEmail) continue
|
||
|
||
const incidentRef = (capa.incidents as unknown as { reference_no: string | null } | null)?.reference_no
|
||
?? (capa as { incident_id: string }).incident_id
|
||
const roundLabel = getRoundLabel(round)
|
||
const capaDesc = (capa as { description: string }).description
|
||
const capaUrl = `${siteUrl}/ims/hse/capa/${(capa as { id: string }).id}`
|
||
|
||
const subject = `[IMS] ${roundLabel} effectiveness check — ${incidentRef}`
|
||
const html = `
|
||
<p>Hi ${verifierName},</p>
|
||
<p>This is the <strong>${roundLabel} effectiveness check</strong> for a corrective action you verified on incident <strong>${incidentRef}</strong>.</p>
|
||
<p><strong>Action:</strong> ${capaDesc}</p>
|
||
<p>Please confirm the corrective action is still in place and effective.</p>
|
||
<p><a href="${capaUrl}">View CAPA</a></p>
|
||
`
|
||
const text = `${roundLabel} effectiveness check for ${incidentRef}\nAction: ${capaDesc}\n${capaUrl}`
|
||
|
||
const { error } = await resend.emails.send({ from, to: [verifierEmail], subject, html, text })
|
||
if (error) {
|
||
console.error('Effectiveness recheck email error:', error)
|
||
continue
|
||
}
|
||
|
||
// WhatsApp (non-blocking, best-effort)
|
||
if (whatsappPhoneId && whatsappToken && verifierPhone) {
|
||
sendWhatsAppMessage(
|
||
verifierPhone,
|
||
'ims_effectiveness_recheck',
|
||
[roundLabel, incidentRef, capaDesc],
|
||
whatsappPhoneId,
|
||
whatsappToken,
|
||
).catch(err => console.error('WhatsApp recheck error:', err))
|
||
}
|
||
|
||
// Advance round
|
||
const verifiedAt = (capa as { verified_at: string }).verified_at
|
||
const nextDate = getNextRecheckDate(verifiedAt, round)
|
||
await supabase
|
||
.from('capa_actions')
|
||
.update({
|
||
effectiveness_recheck_round: round + 1,
|
||
effectiveness_recheck_date: nextDate ?? null,
|
||
})
|
||
.eq('id', (capa as { id: string }).id)
|
||
|
||
notified++
|
||
}
|
||
|
||
return { notified }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run pure-function tests**
|
||
|
||
```bash
|
||
npx vitest run tests/lib/notifications/effectiveness-recheck.test.ts
|
||
```
|
||
Expected: 8/8 PASS
|
||
|
||
- [ ] **Step 5: Create `app/api/cron/effectiveness-recheck/route.ts`**
|
||
|
||
```typescript
|
||
export const dynamic = 'force-dynamic'
|
||
|
||
import { NextRequest, NextResponse } from 'next/server'
|
||
import { createClient } from '@/lib/supabase/server'
|
||
import { sendEffectivenessRecheckNotifications } from '@/lib/notifications/effectiveness-recheck'
|
||
|
||
export async function GET(request: NextRequest) {
|
||
const auth = request.headers.get('authorization')
|
||
const expected = `Bearer ${process.env.CRON_SECRET}`
|
||
if (!auth || auth !== expected) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
}
|
||
|
||
const supabase = await createClient()
|
||
const { notified } = await sendEffectivenessRecheckNotifications(supabase)
|
||
return NextResponse.json({ ok: true, notified })
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Modify `app/api/capa/[id]/verify/route.ts` — set recheck fields on verify**
|
||
|
||
Find the `update` object (around line 37):
|
||
```typescript
|
||
const update: Record<string, unknown> = {
|
||
status: body.verdict,
|
||
verified_by: user.id,
|
||
verified_at: new Date().toISOString(),
|
||
}
|
||
```
|
||
|
||
Change to:
|
||
```typescript
|
||
const verifiedAt = new Date()
|
||
const recheckDate = new Date(verifiedAt)
|
||
recheckDate.setDate(recheckDate.getDate() + 30)
|
||
|
||
const update: Record<string, unknown> = {
|
||
status: body.verdict,
|
||
verified_by: user.id,
|
||
verified_at: verifiedAt.toISOString(),
|
||
...(body.verdict === 'verified'
|
||
? {
|
||
effectiveness_recheck_date: recheckDate.toISOString().split('T')[0],
|
||
effectiveness_recheck_round: 0,
|
||
}
|
||
: {}),
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Build check**
|
||
|
||
```bash
|
||
npm run build 2>&1 | tail -20
|
||
```
|
||
Expected: zero errors
|
||
|
||
- [ ] **Step 8: Run all tests**
|
||
|
||
```bash
|
||
npx vitest run
|
||
```
|
||
Expected: all existing + new tests pass
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add lib/notifications/effectiveness-recheck.ts \
|
||
app/api/cron/effectiveness-recheck/route.ts \
|
||
app/api/capa/\[id\]/verify/route.ts \
|
||
tests/lib/notifications/effectiveness-recheck.test.ts
|
||
git commit -m "feat: CAPA effectiveness re-check cron — 30/60/90-day email + WhatsApp notifications"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: i18n infrastructure + report form translations (EN/MS/ZH)
|
||
|
||
**Files:**
|
||
- Create: `lib/i18n/locales.ts`
|
||
- Create: `lib/i18n/context.tsx`
|
||
- Create: `lib/i18n/server.ts`
|
||
- Create: `messages/en.json`
|
||
- Create: `messages/ms.json`
|
||
- Create: `messages/zh.json`
|
||
- Create: `components/language-switcher.tsx`
|
||
- Modify: `app/layout.tsx`
|
||
- Modify: `app/report/page.tsx`
|
||
- Modify: `components/incidents/report-form.tsx`
|
||
- Test: `tests/lib/i18n/translations.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `I18nProvider` (client component) — wraps layout, provides translations to tree
|
||
- Produces: `useTranslations<K>(namespace: K): Messages[K]` — client hook
|
||
- Produces: `getLocale(): Promise<Locale>` — server helper, reads `locale` cookie
|
||
- Produces: `loadMessages(locale: Locale): Promise<Messages>` — server helper
|
||
|
||
**Design decision:** No external i18n library. Cookie-based locale (no URL restructuring). `app/layout.tsx` becomes async server component to load messages. Locale switches trigger `router.refresh()` which re-renders server components with the new cookie.
|
||
|
||
- [ ] **Step 1: Write the failing translations test**
|
||
|
||
```typescript
|
||
// tests/lib/i18n/translations.test.ts
|
||
import { describe, it, expect } from 'vitest'
|
||
import en from '@/messages/en.json'
|
||
import ms from '@/messages/ms.json'
|
||
import zh from '@/messages/zh.json'
|
||
|
||
const NAMESPACES = ['ReportForm', 'IncidentType', 'MedicalStatus'] as const
|
||
|
||
NAMESPACES.forEach(ns => {
|
||
describe(`${ns} namespace`, () => {
|
||
const enKeys = Object.keys(en[ns])
|
||
|
||
it(`ms.${ns} has all keys present in en.${ns}`, () => {
|
||
const msKeys = Object.keys(ms[ns])
|
||
enKeys.forEach(key => expect(msKeys, `missing key: ${key}`).toContain(key))
|
||
})
|
||
|
||
it(`zh.${ns} has all keys present in en.${ns}`, () => {
|
||
const zhKeys = Object.keys(zh[ns])
|
||
enKeys.forEach(key => expect(zhKeys, `missing key: ${key}`).toContain(key))
|
||
})
|
||
|
||
it(`en.${ns} values are non-empty strings`, () => {
|
||
enKeys.forEach(key => {
|
||
const val = (en[ns] as Record<string, string>)[key]
|
||
expect(typeof val).toBe('string')
|
||
expect(val.length).toBeGreaterThan(0)
|
||
})
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to confirm failure**
|
||
|
||
```bash
|
||
npx vitest run tests/lib/i18n/translations.test.ts
|
||
```
|
||
Expected: FAIL — modules not found
|
||
|
||
- [ ] **Step 3: Create `lib/i18n/locales.ts`**
|
||
|
||
```typescript
|
||
export const SUPPORTED_LOCALES = ['en', 'ms', 'zh'] as const
|
||
export type Locale = typeof SUPPORTED_LOCALES[number]
|
||
```
|
||
|
||
- [ ] **Step 4: Create `messages/en.json`**
|
||
|
||
```json
|
||
{
|
||
"ReportForm": {
|
||
"title": "Report an Incident",
|
||
"incidentTypeLabel": "Incident type",
|
||
"incidentTypePlaceholder": "Select type…",
|
||
"descriptionLabel": "What happened?",
|
||
"descriptionPlaceholder": "Describe what happened, where, and any immediate actions taken…",
|
||
"injuryInvolved": "Person was injured",
|
||
"treatmentLevel": "Treatment level",
|
||
"treatmentPlaceholder": "Select treatment…",
|
||
"assetInvolved": "Equipment / asset was damaged",
|
||
"filesLabel": "Photos / Videos / Documents",
|
||
"submitButton": "Submit Incident Report",
|
||
"submitting": "Submitting…",
|
||
"submitAnyway": "Submit anyway",
|
||
"qualityScoreLabel": "Report quality — {score}/10",
|
||
"errorGeneric": "Something went wrong. Please try again.",
|
||
"savedOffline": "Report saved. It will be submitted automatically when you’re back online.",
|
||
"offlineBanner": "You’re offline. Your report will be saved and submitted when you reconnect."
|
||
},
|
||
"IncidentType": {
|
||
"injury": "Injury / Medical",
|
||
"near_miss": "Near Miss",
|
||
"hazard": "Hazard / Unsafe Condition",
|
||
"asset_damage": "Asset / Equipment Damage",
|
||
"environmental": "Environmental Incident",
|
||
"security": "Security Incident",
|
||
"fire": "Fire / Emergency"
|
||
},
|
||
"MedicalStatus": {
|
||
"none": "No treatment needed",
|
||
"first_aid": "First aid only",
|
||
"medical_treatment": "Medical treatment (non-LTI)",
|
||
"lti": "Lost Time Injury (LTI)"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Create `messages/ms.json`**
|
||
|
||
```json
|
||
{
|
||
"ReportForm": {
|
||
"title": "Laporkan Insiden",
|
||
"incidentTypeLabel": "Jenis insiden",
|
||
"incidentTypePlaceholder": "Pilih jenis…",
|
||
"descriptionLabel": "Apa yang berlaku?",
|
||
"descriptionPlaceholder": "Terangkan apa yang berlaku, di mana, dan tindakan segera yang diambil…",
|
||
"injuryInvolved": "Seseorang telah cedera",
|
||
"treatmentLevel": "Tahap rawatan",
|
||
"treatmentPlaceholder": "Pilih rawatan…",
|
||
"assetInvolved": "Peralatan / aset rosak",
|
||
"filesLabel": "Foto / Video / Dokumen",
|
||
"submitButton": "Hantar Laporan Insiden",
|
||
"submitting": "Menghantar…",
|
||
"submitAnyway": "Hantar juga",
|
||
"qualityScoreLabel": "Kualiti laporan — {score}/10",
|
||
"errorGeneric": "Berlaku ralat. Sila cuba lagi.",
|
||
"savedOffline": "Laporan disimpan. Ia akan dihantar secara automatik apabila anda dalam talian semula.",
|
||
"offlineBanner": "Anda tiada sambungan. Laporan anda akan disimpan dan dihantar apabila disambungkan semula."
|
||
},
|
||
"IncidentType": {
|
||
"injury": "Kecederaan / Perubatan",
|
||
"near_miss": "Hampir Berlaku",
|
||
"hazard": "Bahaya / Keadaan Tidak Selamat",
|
||
"asset_damage": "Kerosakan Aset / Peralatan",
|
||
"environmental": "Insiden Alam Sekitar",
|
||
"security": "Insiden Keselamatan",
|
||
"fire": "Kebakaran / Kecemasan"
|
||
},
|
||
"MedicalStatus": {
|
||
"none": "Tiada rawatan diperlukan",
|
||
"first_aid": "Pertolongan cemas sahaja",
|
||
"medical_treatment": "Rawatan perubatan (bukan LTI)",
|
||
"lti": "Kecederaan Masa Hilang (LTI)"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Create `messages/zh.json`**
|
||
|
||
```json
|
||
{
|
||
"ReportForm": {
|
||
"title": "事故报告",
|
||
"incidentTypeLabel": "事故类型",
|
||
"incidentTypePlaceholder": "选择类型…",
|
||
"descriptionLabel": "发生了什么?",
|
||
"descriptionPlaceholder": "描述发生了什么、在哪里,以及采取的即时行动…",
|
||
"injuryInvolved": "有人受伤",
|
||
"treatmentLevel": "治疗级别",
|
||
"treatmentPlaceholder": "选择治疗方式…",
|
||
"assetInvolved": "设备/资产受损",
|
||
"filesLabel": "照片/视频/文件",
|
||
"submitButton": "提交事故报告",
|
||
"submitting": "提交中…",
|
||
"submitAnyway": "仍然提交",
|
||
"qualityScoreLabel": "报告质量 — {score}/10",
|
||
"errorGeneric": "出现错误,请重试。",
|
||
"savedOffline": "报告已保存。当您重新联网时将自动提交。",
|
||
"offlineBanner": "您处于离线状态。您的报告将在重新联网时自动提交。"
|
||
},
|
||
"IncidentType": {
|
||
"injury": "受伤/医疗",
|
||
"near_miss": "未遂事故",
|
||
"hazard": "危险/不安全状况",
|
||
"asset_damage": "资产/设备损坏",
|
||
"environmental": "环境事故",
|
||
"security": "安全事故",
|
||
"fire": "火灾/紧急情况"
|
||
},
|
||
"MedicalStatus": {
|
||
"none": "无需治疗",
|
||
"first_aid": "仅急救",
|
||
"medical_treatment": "医疗治疗(非 LTI)",
|
||
"lti": "工伤失时(LTI)"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Run translations test to confirm it passes**
|
||
|
||
```bash
|
||
npx vitest run tests/lib/i18n/translations.test.ts
|
||
```
|
||
Expected: 9/9 PASS (3 namespaces × 3 assertions each)
|
||
|
||
- [ ] **Step 8: Create `lib/i18n/context.tsx`**
|
||
|
||
```typescript
|
||
'use client'
|
||
|
||
import { createContext, useContext } from 'react'
|
||
import type en from '../../messages/en.json'
|
||
|
||
export type Messages = typeof en
|
||
|
||
const I18nContext = createContext<Messages | null>(null)
|
||
|
||
export function I18nProvider({
|
||
messages,
|
||
children,
|
||
}: {
|
||
messages: Messages
|
||
children: React.ReactNode
|
||
}) {
|
||
return <I18nContext.Provider value={messages}>{children}</I18nContext.Provider>
|
||
}
|
||
|
||
export function useTranslations<K extends keyof Messages>(namespace: K): Messages[K] {
|
||
const ctx = useContext(I18nContext)
|
||
if (!ctx) throw new Error('useTranslations must be used inside I18nProvider')
|
||
return ctx[namespace]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 9: Create `lib/i18n/server.ts`**
|
||
|
||
```typescript
|
||
import { cookies } from 'next/headers'
|
||
import { SUPPORTED_LOCALES, type Locale } from './locales'
|
||
import type { Messages } from './context'
|
||
|
||
export async function getLocale(): Promise<Locale> {
|
||
const cookieStore = await cookies()
|
||
const lang = cookieStore.get('locale')?.value
|
||
if (lang && (SUPPORTED_LOCALES as readonly string[]).includes(lang)) {
|
||
return lang as Locale
|
||
}
|
||
return 'en'
|
||
}
|
||
|
||
export async function loadMessages(locale: Locale): Promise<Messages> {
|
||
switch (locale) {
|
||
case 'ms': return (await import('../../messages/ms.json')).default as Messages
|
||
case 'zh': return (await import('../../messages/zh.json')).default as Messages
|
||
default: return (await import('../../messages/en.json')).default as Messages
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 10: Create `components/language-switcher.tsx`**
|
||
|
||
```typescript
|
||
'use client'
|
||
|
||
import { useRouter } from 'next/navigation'
|
||
import { useEffect, useState } from 'react'
|
||
|
||
const LOCALES: Record<string, string> = {
|
||
en: 'English',
|
||
ms: 'Bahasa Malaysia',
|
||
zh: '中文',
|
||
}
|
||
|
||
export function LanguageSwitcher() {
|
||
const router = useRouter()
|
||
const [current, setCurrent] = useState('en')
|
||
|
||
useEffect(() => {
|
||
const match = document.cookie
|
||
.split('; ')
|
||
.find(c => c.startsWith('locale='))
|
||
?.split('=')[1]
|
||
if (match && match in LOCALES) setCurrent(match)
|
||
}, [])
|
||
|
||
function handleChange(locale: string) {
|
||
document.cookie = `locale=${locale}; path=/; max-age=31536000; SameSite=Lax`
|
||
setCurrent(locale)
|
||
router.refresh()
|
||
}
|
||
|
||
return (
|
||
<select
|
||
value={current}
|
||
onChange={e => handleChange(e.target.value)}
|
||
aria-label="Select language"
|
||
className="text-xs border border-gray-300 rounded px-2 py-1 bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||
>
|
||
{Object.entries(LOCALES).map(([code, label]) => (
|
||
<option key={code} value={code}>{label}</option>
|
||
))}
|
||
</select>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 11: Modify `app/layout.tsx` — make async, add I18nProvider**
|
||
|
||
Replace the entire file with:
|
||
```typescript
|
||
import type { Metadata } from 'next'
|
||
import { Geist, Geist_Mono } from 'next/font/google'
|
||
import './globals.css'
|
||
import { I18nProvider } from '@/lib/i18n/context'
|
||
import { getLocale, loadMessages } from '@/lib/i18n/server'
|
||
|
||
const geistSans = Geist({
|
||
variable: '--font-geist-sans',
|
||
subsets: ['latin'],
|
||
})
|
||
|
||
const geistMono = Geist_Mono({
|
||
variable: '--font-geist-mono',
|
||
subsets: ['latin'],
|
||
})
|
||
|
||
export const metadata: Metadata = {
|
||
title: 'IMS — HSE Incident Management',
|
||
description: 'Setia Corporation HSE Incident Management System',
|
||
}
|
||
|
||
export default async function RootLayout({
|
||
children,
|
||
}: Readonly<{
|
||
children: React.ReactNode
|
||
}>) {
|
||
const locale = await getLocale()
|
||
const messages = await loadMessages(locale)
|
||
|
||
return (
|
||
<html
|
||
lang={locale}
|
||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||
>
|
||
<head>
|
||
<link rel="manifest" href="/ims/manifest.json" />
|
||
</head>
|
||
<body className="min-h-full flex flex-col">
|
||
<I18nProvider messages={messages}>
|
||
{children}
|
||
</I18nProvider>
|
||
<script
|
||
dangerouslySetInnerHTML={{
|
||
__html: `if('serviceWorker'in navigator){navigator.serviceWorker.register('/ims/sw.js',{scope:'/ims/'}).catch(console.error)}`,
|
||
}}
|
||
/>
|
||
</body>
|
||
</html>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 12: Add LanguageSwitcher to `app/report/page.tsx`**
|
||
|
||
Read the current `app/report/page.tsx`. Find the `<h1>` heading block. Add the LanguageSwitcher import and render it next to the heading. The heading section currently looks like:
|
||
|
||
```typescript
|
||
<div className="mb-6">
|
||
<h1 className="text-2xl font-bold text-gray-900">Report an Incident</h1>
|
||
```
|
||
|
||
Change to:
|
||
```typescript
|
||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||
|
||
// inside the return:
|
||
<div className="mb-6">
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-2xl font-bold text-gray-900">{/* will be translated via ReportForm */}Report an Incident</h1>
|
||
<LanguageSwitcher />
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 13: Modify `components/incidents/report-form.tsx` to use translations**
|
||
|
||
Replace the entire file with (preserves all existing logic, replaces hardcoded strings):
|
||
|
||
```typescript
|
||
'use client'
|
||
|
||
import { useState, useEffect } from 'react'
|
||
import { useRouter } from 'next/navigation'
|
||
import { FileUpload } from '@/components/incidents/file-upload'
|
||
import type { IncidentType, MedicalStatus } from '@/lib/incidents/validate'
|
||
import { useTranslations } from '@/lib/i18n/context'
|
||
|
||
interface Props {
|
||
zoneToken: string | null
|
||
zoneName: string | null
|
||
siteName: string | null
|
||
}
|
||
|
||
export function ReportForm({ zoneToken }: Props) {
|
||
const router = useRouter()
|
||
const t = useTranslations('ReportForm')
|
||
const itLabels = useTranslations('IncidentType')
|
||
const msLabels = useTranslations('MedicalStatus')
|
||
|
||
const INCIDENT_TYPE_OPTIONS: [IncidentType, string][] = [
|
||
['injury', itLabels.injury],
|
||
['near_miss', itLabels.near_miss],
|
||
['hazard', itLabels.hazard],
|
||
['asset_damage', itLabels.asset_damage],
|
||
['environmental', itLabels.environmental],
|
||
['security', itLabels.security],
|
||
['fire', itLabels.fire],
|
||
]
|
||
|
||
const MEDICAL_STATUS_OPTIONS: [MedicalStatus, string][] = [
|
||
['none', msLabels.none],
|
||
['first_aid', msLabels.first_aid],
|
||
['medical_treatment', msLabels.medical_treatment],
|
||
['lti', msLabels.lti],
|
||
]
|
||
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [files, setFiles] = useState<File[]>([])
|
||
const [isOnline, setIsOnline] = useState(true)
|
||
const [savedOffline, setSavedOffline] = useState(false)
|
||
const [qualityCheck, setQualityCheck] = useState<{
|
||
score: number
|
||
passes: boolean
|
||
feedback: string
|
||
suggestions: string[]
|
||
} | null>(null)
|
||
const [overrideQuality, setOverrideQuality] = useState(false)
|
||
|
||
const [form, setForm] = useState({
|
||
incident_type: '' as IncidentType | '',
|
||
description: '',
|
||
injury_involved: false,
|
||
medical_status: '' as MedicalStatus | '',
|
||
asset_involved: false,
|
||
})
|
||
|
||
useEffect(() => {
|
||
setIsOnline(navigator.onLine)
|
||
const onOnline = () => setIsOnline(true)
|
||
const onOffline = () => setIsOnline(false)
|
||
window.addEventListener('online', onOnline)
|
||
window.addEventListener('offline', onOffline)
|
||
return () => {
|
||
window.removeEventListener('online', onOnline)
|
||
window.removeEventListener('offline', onOffline)
|
||
}
|
||
}, [])
|
||
|
||
async function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
setError(null)
|
||
setSubmitting(true)
|
||
|
||
// Offline path — save to IndexedDB
|
||
if (!isOnline) {
|
||
try {
|
||
const { addPendingReport } = await import('@/lib/offline/db')
|
||
await addPendingReport({
|
||
zone_token: zoneToken ?? '',
|
||
incident_type: form.incident_type as IncidentType,
|
||
description: form.description,
|
||
injury_involved: form.injury_involved,
|
||
asset_involved: form.asset_involved,
|
||
medical_status: form.medical_status || undefined,
|
||
created_at: new Date().toISOString(),
|
||
})
|
||
setSavedOffline(true)
|
||
} catch (err) {
|
||
console.error('Offline save error:', err)
|
||
setError(t.errorGeneric)
|
||
}
|
||
setSubmitting(false)
|
||
return
|
||
}
|
||
|
||
if (!overrideQuality) {
|
||
try {
|
||
const qcRes = await fetch('/api/incidents/ai/quality-check', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
description: form.description,
|
||
incident_type: form.incident_type,
|
||
}),
|
||
})
|
||
if (qcRes.ok) {
|
||
const qc = await qcRes.json() as {
|
||
score: number
|
||
passes: boolean
|
||
feedback: string
|
||
suggestions: string[]
|
||
}
|
||
setQualityCheck(qc)
|
||
if (!qc.passes) {
|
||
setSubmitting(false)
|
||
return
|
||
}
|
||
}
|
||
} catch {
|
||
// Quality check failure is non-blocking
|
||
}
|
||
}
|
||
|
||
try {
|
||
const fd = new FormData()
|
||
if (zoneToken) fd.append('zone_token', zoneToken)
|
||
fd.append('incident_type', form.incident_type)
|
||
fd.append('description', form.description)
|
||
fd.append('injury_involved', String(form.injury_involved))
|
||
fd.append('asset_involved', String(form.asset_involved))
|
||
if (form.injury_involved && form.medical_status) {
|
||
fd.append('medical_status', form.medical_status)
|
||
}
|
||
files.forEach(f => fd.append('files', f))
|
||
|
||
const res = await fetch('/api/incidents', { method: 'POST', body: fd })
|
||
const data = await res.json()
|
||
|
||
if (!res.ok) {
|
||
setError(data.details ? data.details.join('. ') : data.error)
|
||
return
|
||
}
|
||
|
||
router.push(`/report/success?ref=${data.reference_no}`)
|
||
} catch {
|
||
setError(t.errorGeneric)
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
if (savedOffline) {
|
||
return (
|
||
<div className="bg-green-50 border border-green-200 rounded-xl p-5 text-sm text-green-700">
|
||
{t.savedOffline}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} className="space-y-5 bg-white rounded-xl shadow-sm p-5">
|
||
{!isOnline && (
|
||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 text-sm text-yellow-700">
|
||
{t.offlineBanner}
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="bg-red-50 border border-red-200 rounded p-3 text-sm text-red-700">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
{t.incidentTypeLabel} <span className="text-red-500">*</span>
|
||
</label>
|
||
<select
|
||
required
|
||
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
value={form.incident_type}
|
||
onChange={e => setForm(f => ({ ...f, incident_type: e.target.value as IncidentType }))}
|
||
>
|
||
<option value="">{t.incidentTypePlaceholder}</option>
|
||
{INCIDENT_TYPE_OPTIONS.map(([v, l]) => (
|
||
<option key={v} value={v}>{l}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
{t.descriptionLabel} <span className="text-red-500">*</span>
|
||
</label>
|
||
<textarea
|
||
required
|
||
minLength={10}
|
||
rows={4}
|
||
placeholder={t.descriptionPlaceholder}
|
||
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||
value={form.description}
|
||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||
/>
|
||
</div>
|
||
|
||
{qualityCheck && !qualityCheck.passes && (
|
||
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3 space-y-2">
|
||
<p className="text-xs font-semibold text-amber-700 uppercase tracking-wide">
|
||
{t.qualityScoreLabel.replace('{score}', String(qualityCheck.score))}
|
||
</p>
|
||
<p className="text-sm text-amber-800">{qualityCheck.feedback}</p>
|
||
{qualityCheck.suggestions.length > 0 && (
|
||
<ul className="list-disc list-inside space-y-1">
|
||
{qualityCheck.suggestions.map((s, i) => (
|
||
<li key={i} className="text-xs text-amber-700">{s}</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<label className="flex items-center gap-2 text-xs text-amber-700 cursor-pointer mt-1">
|
||
<input
|
||
type="checkbox"
|
||
checked={overrideQuality}
|
||
onChange={e => setOverrideQuality(e.target.checked)}
|
||
className="rounded border-amber-300 text-amber-600"
|
||
/>
|
||
{t.submitAnyway}
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-3">
|
||
<label className="flex items-center gap-3 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
className="w-4 h-4 text-blue-600"
|
||
checked={form.injury_involved}
|
||
onChange={e => setForm(f => ({ ...f, injury_involved: e.target.checked, medical_status: '' }))}
|
||
/>
|
||
<span className="text-sm font-medium text-gray-700">{t.injuryInvolved}</span>
|
||
</label>
|
||
|
||
{form.injury_involved && (
|
||
<div className="ml-7">
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
{t.treatmentLevel} <span className="text-red-500">*</span>
|
||
</label>
|
||
<select
|
||
required={form.injury_involved}
|
||
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
value={form.medical_status}
|
||
onChange={e => setForm(f => ({ ...f, medical_status: e.target.value as MedicalStatus }))}
|
||
>
|
||
<option value="">{t.treatmentPlaceholder}</option>
|
||
{MEDICAL_STATUS_OPTIONS.map(([v, l]) => (
|
||
<option key={v} value={v}>{l}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
<label className="flex items-center gap-3 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
className="w-4 h-4 text-blue-600"
|
||
checked={form.asset_involved}
|
||
onChange={e => setForm(f => ({ ...f, asset_involved: e.target.checked }))}
|
||
/>
|
||
<span className="text-sm font-medium text-gray-700">{t.assetInvolved}</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
{t.filesLabel}
|
||
</label>
|
||
<FileUpload onFilesChange={setFiles} disabled={submitting} />
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={submitting || !form.incident_type}
|
||
className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium text-sm
|
||
hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
{submitting ? t.submitting : t.submitButton}
|
||
</button>
|
||
</form>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 14: Build check**
|
||
|
||
```bash
|
||
npm run build 2>&1 | tail -20
|
||
```
|
||
Expected: zero errors
|
||
|
||
- [ ] **Step 15: Run all tests**
|
||
|
||
```bash
|
||
npx vitest run
|
||
```
|
||
Expected: all previous tests + new translations tests pass
|
||
|
||
- [ ] **Step 16: Commit**
|
||
|
||
```bash
|
||
git add lib/i18n/ messages/ components/language-switcher.tsx \
|
||
app/layout.tsx app/report/page.tsx \
|
||
components/incidents/report-form.tsx \
|
||
tests/lib/i18n/
|
||
git commit -m "feat: i18n — EN/MS/ZH translations with cookie-based locale switching, report form translated"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: PWA offline capture
|
||
|
||
**Files:**
|
||
- Create: `public/manifest.json`
|
||
- Create: `public/icons/icon.svg`
|
||
- Create: `public/sw.js`
|
||
- Create: `lib/offline/db.ts`
|
||
- Create: `components/incidents/offline-sync.tsx`
|
||
- Modify: `app/report/page.tsx`
|
||
- Test: `tests/lib/offline/db.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `addPendingReport(report): Promise<number>` — saves to IndexedDB
|
||
- Produces: `getPendingReports(): Promise<PendingReport[]>`
|
||
- Produces: `removePendingReport(id: number): Promise<void>`
|
||
- Produces: `getPendingCount(): Promise<number>`
|
||
- Produces: `OfflineSync` (client component) — shows pending badge, auto-syncs on reconnect
|
||
|
||
**Note:** `idb` does not work in jsdom (no IndexedDB). Tests mock the `idb` module entirely — they verify the integration logic, not the browser API.
|
||
|
||
**Note on service worker scope:** `public/sw.js` is served at `/ims/sw.js` with `basePath: '/ims'`. Register with `{ scope: '/ims/' }`. Static assets at `/ims/_next/static/` are cache-first; pages are network-first with cache fallback.
|
||
|
||
- [ ] **Step 1: Install `idb`**
|
||
|
||
```bash
|
||
npm install idb
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing tests**
|
||
|
||
```typescript
|
||
// tests/lib/offline/db.test.ts
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||
|
||
const mockDb = {
|
||
add: vi.fn(),
|
||
getAll: vi.fn(),
|
||
delete: vi.fn(),
|
||
count: vi.fn(),
|
||
}
|
||
|
||
vi.mock('idb', () => ({
|
||
openDB: vi.fn().mockResolvedValue(mockDb),
|
||
}))
|
||
|
||
// Import AFTER mock is set up
|
||
const { addPendingReport, getPendingReports, removePendingReport, getPendingCount } =
|
||
await import('@/lib/offline/db')
|
||
|
||
beforeEach(() => vi.clearAllMocks())
|
||
|
||
describe('addPendingReport', () => {
|
||
it('calls db.add on pending_reports store and returns the id', async () => {
|
||
mockDb.add.mockResolvedValueOnce(42)
|
||
const report = {
|
||
zone_token: 'abc',
|
||
incident_type: 'near_miss' as const,
|
||
description: 'Slippery floor',
|
||
injury_involved: false,
|
||
asset_involved: false,
|
||
created_at: '2026-07-11T00:00:00Z',
|
||
}
|
||
const id = await addPendingReport(report)
|
||
expect(mockDb.add).toHaveBeenCalledWith('pending_reports', report)
|
||
expect(id).toBe(42)
|
||
})
|
||
})
|
||
|
||
describe('getPendingReports', () => {
|
||
it('calls db.getAll on pending_reports store', async () => {
|
||
const reports = [
|
||
{ id: 1, zone_token: 'abc', incident_type: 'near_miss', description: 'test', injury_involved: false, asset_involved: false, created_at: '2026-07-11T00:00:00Z' },
|
||
]
|
||
mockDb.getAll.mockResolvedValueOnce(reports)
|
||
const result = await getPendingReports()
|
||
expect(mockDb.getAll).toHaveBeenCalledWith('pending_reports')
|
||
expect(result).toEqual(reports)
|
||
})
|
||
})
|
||
|
||
describe('removePendingReport', () => {
|
||
it('calls db.delete on pending_reports store with the id', async () => {
|
||
mockDb.delete.mockResolvedValueOnce(undefined)
|
||
await removePendingReport(1)
|
||
expect(mockDb.delete).toHaveBeenCalledWith('pending_reports', 1)
|
||
})
|
||
})
|
||
|
||
describe('getPendingCount', () => {
|
||
it('calls db.count and returns the result', async () => {
|
||
mockDb.count.mockResolvedValueOnce(3)
|
||
const count = await getPendingCount()
|
||
expect(mockDb.count).toHaveBeenCalledWith('pending_reports')
|
||
expect(count).toBe(3)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Run test to confirm failure**
|
||
|
||
```bash
|
||
npx vitest run tests/lib/offline/db.test.ts
|
||
```
|
||
Expected: FAIL — module not found
|
||
|
||
- [ ] **Step 4: Create `lib/offline/db.ts`**
|
||
|
||
```typescript
|
||
import { openDB, type IDBPDatabase } from 'idb'
|
||
|
||
export interface PendingReport {
|
||
id?: number
|
||
zone_token: string
|
||
incident_type: string
|
||
description: string
|
||
injury_involved: boolean
|
||
asset_involved: boolean
|
||
medical_status?: string
|
||
created_at: string
|
||
}
|
||
|
||
let dbPromise: Promise<IDBPDatabase> | null = null
|
||
|
||
function getDb(): Promise<IDBPDatabase> {
|
||
if (!dbPromise) {
|
||
dbPromise = openDB('ims-offline', 1, {
|
||
upgrade(db) {
|
||
db.createObjectStore('pending_reports', { keyPath: 'id', autoIncrement: true })
|
||
},
|
||
})
|
||
}
|
||
return dbPromise
|
||
}
|
||
|
||
export async function addPendingReport(report: Omit<PendingReport, 'id'>): Promise<number> {
|
||
const db = await getDb()
|
||
return db.add('pending_reports', report) as Promise<number>
|
||
}
|
||
|
||
export async function getPendingReports(): Promise<PendingReport[]> {
|
||
const db = await getDb()
|
||
return db.getAll('pending_reports')
|
||
}
|
||
|
||
export async function removePendingReport(id: number): Promise<void> {
|
||
const db = await getDb()
|
||
return db.delete('pending_reports', id)
|
||
}
|
||
|
||
export async function getPendingCount(): Promise<number> {
|
||
const db = await getDb()
|
||
return db.count('pending_reports')
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run offline db tests**
|
||
|
||
```bash
|
||
npx vitest run tests/lib/offline/db.test.ts
|
||
```
|
||
Expected: 4/4 PASS
|
||
|
||
- [ ] **Step 6: Create `public/icons/icon.svg`**
|
||
|
||
```bash
|
||
mkdir -p /Users/yapweeihan/Desktop/Projects/IMS.nosync/public/icons
|
||
```
|
||
|
||
Create `public/icons/icon.svg`:
|
||
```xml
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
|
||
<rect width="192" height="192" fill="#2563eb" rx="24"/>
|
||
<text x="96" y="80" font-family="sans-serif" font-size="36" font-weight="bold" fill="white" text-anchor="middle">IMS</text>
|
||
<text x="96" y="126" font-family="sans-serif" font-size="22" fill="#bfdbfe" text-anchor="middle">HSE Report</text>
|
||
</svg>
|
||
```
|
||
|
||
- [ ] **Step 7: Create `public/manifest.json`**
|
||
|
||
```json
|
||
{
|
||
"name": "IMS — Incident Management",
|
||
"short_name": "IMS",
|
||
"description": "HSE Incident Management System — Setia Corporation",
|
||
"start_url": "/ims/report",
|
||
"scope": "/ims/",
|
||
"display": "standalone",
|
||
"background_color": "#ffffff",
|
||
"theme_color": "#2563eb",
|
||
"icons": [
|
||
{
|
||
"src": "/ims/icons/icon.svg",
|
||
"sizes": "any",
|
||
"type": "image/svg+xml",
|
||
"purpose": "any maskable"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Create `public/sw.js`**
|
||
|
||
```javascript
|
||
const CACHE_NAME = 'ims-v1'
|
||
|
||
self.addEventListener('install', () => {
|
||
self.skipWaiting()
|
||
})
|
||
|
||
self.addEventListener('activate', event => {
|
||
event.waitUntil(
|
||
caches.keys().then(keys =>
|
||
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||
)
|
||
)
|
||
self.clients.claim()
|
||
})
|
||
|
||
self.addEventListener('fetch', event => {
|
||
const url = new URL(event.request.url)
|
||
|
||
// Only handle same-origin GET requests under /ims/
|
||
if (
|
||
event.request.method !== 'GET' ||
|
||
url.origin !== self.location.origin ||
|
||
!url.pathname.startsWith('/ims/')
|
||
) {
|
||
return
|
||
}
|
||
|
||
// Skip API routes — always go to network
|
||
if (url.pathname.startsWith('/ims/api/')) return
|
||
|
||
// Cache-first for immutable Next.js static assets
|
||
if (url.pathname.startsWith('/ims/_next/static/')) {
|
||
event.respondWith(
|
||
caches.match(event.request).then(cached => {
|
||
if (cached) return cached
|
||
return fetch(event.request).then(res => {
|
||
if (res.ok) {
|
||
const clone = res.clone()
|
||
caches.open(CACHE_NAME).then(c => c.put(event.request, clone))
|
||
}
|
||
return res
|
||
})
|
||
})
|
||
)
|
||
return
|
||
}
|
||
|
||
// Network-first for pages — fall back to cache when offline
|
||
event.respondWith(
|
||
fetch(event.request)
|
||
.then(res => {
|
||
if (res.ok) {
|
||
const clone = res.clone()
|
||
caches.open(CACHE_NAME).then(c => c.put(event.request, clone))
|
||
}
|
||
return res
|
||
})
|
||
.catch(() => caches.match(event.request))
|
||
)
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 9: Create `components/incidents/offline-sync.tsx`**
|
||
|
||
```typescript
|
||
'use client'
|
||
|
||
import { useEffect, useState, useCallback } from 'react'
|
||
|
||
export function OfflineSync() {
|
||
const [pendingCount, setPendingCount] = useState(0)
|
||
const [syncing, setSyncing] = useState(false)
|
||
|
||
async function checkPending() {
|
||
const { getPendingCount } = await import('@/lib/offline/db')
|
||
setPendingCount(await getPendingCount())
|
||
}
|
||
|
||
const syncNow = useCallback(async () => {
|
||
if (syncing) return
|
||
setSyncing(true)
|
||
try {
|
||
const { getPendingReports, removePendingReport } = await import('@/lib/offline/db')
|
||
const reports = await getPendingReports()
|
||
for (const report of reports) {
|
||
const fd = new FormData()
|
||
fd.append('zone_token', report.zone_token)
|
||
fd.append('incident_type', report.incident_type)
|
||
fd.append('description', report.description)
|
||
fd.append('injury_involved', String(report.injury_involved))
|
||
fd.append('asset_involved', String(report.asset_involved))
|
||
if (report.medical_status) fd.append('medical_status', report.medical_status)
|
||
|
||
try {
|
||
const res = await fetch('/api/incidents', { method: 'POST', body: fd })
|
||
if (res.ok && report.id != null) {
|
||
await removePendingReport(report.id)
|
||
}
|
||
} catch {
|
||
// Network still unavailable — will retry on next online event
|
||
}
|
||
}
|
||
} finally {
|
||
await checkPending()
|
||
setSyncing(false)
|
||
}
|
||
}, [syncing])
|
||
|
||
useEffect(() => {
|
||
checkPending()
|
||
const handleOnline = () => { syncNow() }
|
||
window.addEventListener('online', handleOnline)
|
||
return () => window.removeEventListener('online', handleOnline)
|
||
}, [syncNow])
|
||
|
||
if (pendingCount === 0) return null
|
||
|
||
return (
|
||
<div className="fixed bottom-4 left-4 right-4 bg-yellow-50 border border-yellow-300 rounded-lg p-3 flex items-center justify-between shadow-md z-50">
|
||
<span className="text-sm text-yellow-800 font-medium">
|
||
{pendingCount} report{pendingCount > 1 ? 's' : ''} saved offline
|
||
</span>
|
||
<button
|
||
onClick={syncNow}
|
||
disabled={syncing || !navigator.onLine}
|
||
className="text-xs bg-yellow-600 text-white px-3 py-1.5 rounded font-medium
|
||
hover:bg-yellow-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
{syncing ? 'Syncing…' : 'Sync now'}
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 10: Add OfflineSync to `app/report/page.tsx`**
|
||
|
||
Add import:
|
||
```typescript
|
||
import { OfflineSync } from '@/components/incidents/offline-sync'
|
||
```
|
||
|
||
Add `<OfflineSync />` at the bottom of the `<main>` element, just before its closing tag:
|
||
```typescript
|
||
<OfflineSync />
|
||
</main>
|
||
```
|
||
|
||
- [ ] **Step 11: Build check**
|
||
|
||
```bash
|
||
npm run build 2>&1 | tail -20
|
||
```
|
||
Expected: zero errors
|
||
|
||
- [ ] **Step 12: Run all tests**
|
||
|
||
```bash
|
||
npx vitest run
|
||
```
|
||
Expected: all tests pass (previous 61 + new whatsapp 4 + effectiveness-recheck 8 + translations 9 + offline db 4 = ≥86 total)
|
||
|
||
- [ ] **Step 13: Commit**
|
||
|
||
```bash
|
||
git add public/manifest.json public/icons/icon.svg public/sw.js \
|
||
lib/offline/db.ts \
|
||
components/incidents/offline-sync.tsx \
|
||
app/report/page.tsx \
|
||
tests/lib/offline/
|
||
git commit -m "feat: PWA offline capture — IndexedDB queue, service worker cache, auto-sync on reconnect"
|
||
```
|
||
|
||
---
|
||
|
||
## Post-Implementation: Cron Registration
|
||
|
||
Add effectiveness recheck to the cron schedule alongside CAPA escalation. See `docs/vps-cron.md` for the cron format. The command mirrors the existing escalation cron — daily at a different time:
|
||
|
||
```cron
|
||
# CAPA escalation (already registered)
|
||
0 8 * * * curl -s -H "Authorization: Bearer $CRON_SECRET" https://your-domain/ims/api/cron/capa-escalation
|
||
|
||
# CAPA effectiveness re-check (new)
|
||
30 8 * * * curl -s -H "Authorization: Bearer $CRON_SECRET" https://your-domain/ims/api/cron/effectiveness-recheck
|
||
```
|
||
|
||
Apply migration `20260711000015_phase4.sql` to Supabase cloud:
|
||
```bash
|
||
supabase db push
|
||
```
|