From 8c6dae4a2f312549561d26b9b8c518023bdc005d Mon Sep 17 00:00:00 2001 From: FlashL3opard <69573060+Flashl3opard@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:36:43 +0530 Subject: [PATCH 1/2] PHEE-400: add token refresh, MSW G2P mock, dual env modes, extensibility and maintainability docs --- .env.example | 12 +- .gitignore | 2 + README.md | 44 +- docs/EXTENSIBILITY.md | 360 ++++++++++++++ docs/MAINTAINABILITY.md | 83 ++++ package-lock.json | 586 +++++++++++++++++++++++ package.json | 10 +- public/mockServiceWorker.js | 361 ++++++++++++++ src/components/shared/ToastProvider.tsx | 70 +++ src/lib/api/client.ts | 11 + src/lib/api/g2pConfig.ts | 23 + src/lib/keycloak/KeycloakProvider.tsx | 39 +- src/main.tsx | 31 +- src/mocks/browser.ts | 4 + src/mocks/handlers/g2p.handlers.ts | 27 ++ src/modules/g2p-config/CreateG2PTab.tsx | 62 ++- src/modules/g2p-config/G2PPaymentTab.tsx | 65 ++- src/modules/g2p-config/types.ts | 16 + src/modules/rbac/UserManagementTab.tsx | 38 +- 19 files changed, 1760 insertions(+), 84 deletions(-) create mode 100644 docs/EXTENSIBILITY.md create mode 100644 docs/MAINTAINABILITY.md create mode 100644 public/mockServiceWorker.js create mode 100644 src/components/shared/ToastProvider.tsx create mode 100644 src/lib/api/g2pConfig.ts create mode 100644 src/mocks/browser.ts create mode 100644 src/mocks/handlers/g2p.handlers.ts diff --git a/.env.example b/.env.example index f6f9406..c7f7399 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ +# Copy to .env.development for normal dev (Gazelle APIs) +# Copy to .env.g2p for G2P mock testing (npm run dev:g2p) VITE_API_BASE_URL= VITE_BULK_CONNECTOR_URL= -VITE_KEYCLOAK_URL= -VITE_KEYCLOAK_REALM= -VITE_KEYCLOAK_CLIENT_ID= -VITE_TENANT_ID= +VITE_TENANT_ID=greenbank +VITE_KEYCLOAK_URL=http://localhost:8180 +VITE_KEYCLOAK_REALM=paymenthub +VITE_KEYCLOAK_CLIENT_ID=opsapp +VITE_G2P_SERVICE_URL=http://localhost:8084 +VITE_ENABLE_MSW=false diff --git a/.gitignore b/.gitignore index 8c17ed9..ebbf9f6 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ lerna-debug.log* node_modules dist .env +.env.development +.env.g2p dist-ssr *.local diff --git a/README.md b/README.md index 232bf0c..b0553c2 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,9 @@ src/ root/ ├── components.json # ShadCN CLI config ├── index.html # App HTML shell + favicon -├── .env.example # Environment variable template +├── .env.example # Environment variable template +├── .env.development # Normal dev — Gazelle real APIs (gitignored) +├── .env.g2p # G2P Config mock testing (gitignored) └── vite.config.ts ``` @@ -92,7 +94,7 @@ cd ph-ee-operations-web-react npm install # 4. Copy and configure environment variables -cp .env.example .env +cp .env.example .env.development # 5. Start the development server npm run dev @@ -102,9 +104,36 @@ Navigate to `http://localhost:5173/` — the app auto-reloads on file changes. --- +## Development Modes + +The app supports two Vite modes, each loading its own env file, so you can switch between hitting the real Gazelle backend and testing the G2P Config module against MSW mocks without editing env vars by hand. + +### Normal dev (Gazelle real APIs) + +Loads `.env.development`. + +```bash +cp .env.example .env.development +npm run dev +``` + +### G2P Config mock testing + +Loads `.env.g2p`. Starts MSW (`VITE_ENABLE_MSW=true`) to serve mocked responses for the G2P Config endpoints (`/g2pPaymentConfig`, `/governmentEntity`, `/program`, `/dfsp`) — useful when the real G2P backend at `VITE_G2P_SERVICE_URL` isn't deployed yet. + +```bash +cp .env.example .env.g2p +# then edit .env.g2p and set VITE_ENABLE_MSW=true +npm run dev:g2p +``` + +Building for either mode works the same way: `npm run build` (normal) or `npm run build:g2p` (G2P mocks) — see [Building for Production](#building-for-production). + +--- + ## Environment Variables -Copy `.env.example` to `.env` and fill in the values. +Copy `.env.example` to `.env.development` (normal dev) or `.env.g2p` (G2P mock testing) and fill in the values — see [Development Modes](#development-modes). | Variable | Description | |---|---| @@ -114,6 +143,8 @@ Copy `.env.example` to `.env` and fill in the values. | `VITE_KEYCLOAK_REALM` | Keycloak realm (e.g. `paymenthub`) | | `VITE_KEYCLOAK_CLIENT_ID` | Keycloak client ID (e.g. `opsapp`) | | `VITE_TENANT_ID` | Default Platform Tenant Identifier used in API calls | +| `VITE_G2P_SERVICE_URL` | Base URL for the G2P Payment Config backend | +| `VITE_ENABLE_MSW` | When `true` (and running in dev), starts MSW to mock the G2P Config endpoints — see [G2P Config mock testing](#g2p-config-mock-testing) | --- @@ -138,7 +169,7 @@ Then configure at `http://localhost:8180`: - **Web origins:** `http://localhost:5173` 3. Create a test user and set credentials -Update `.env`: +Update `.env.development` (or `.env.g2p` if using G2P mock mode): ```env VITE_KEYCLOAK_URL=http://localhost:8180 @@ -159,7 +190,7 @@ Add to your hosts file (`C:\Windows\System32\drivers\etc\hosts` on Windows, `/et bulk-connector.mifos.gazelle.test ``` -Update `.env`: +Update `.env.development` (or `.env.g2p` if using G2P mock mode): ```env VITE_API_BASE_URL=https://ops.mifos.gazelle.test/api/v1 @@ -211,7 +242,8 @@ http://localhost:5173/account-mapper/self-service?beneficiaryId=9876543210 ## Building for Production ```bash -npm run build +npm run build # normal build, loads .env.production if present, else .env +npm run build:g2p # G2P mock-mode build, loads .env.g2p ``` Build artifacts are output to `dist/`. diff --git a/docs/EXTENSIBILITY.md b/docs/EXTENSIBILITY.md new file mode 100644 index 0000000..188ee1e --- /dev/null +++ b/docs/EXTENSIBILITY.md @@ -0,0 +1,360 @@ +# Extensibility Guide — Payment Hub EE React App + +## Overview + +This guide explains how to add new feature modules to the Payment Hub EE Operations App. The app follows a feature-based modular architecture: each domain area (Payment Hub, Vouchers, G2P Config, RBAC, Account Mapper, ...) lives under `src/modules//` as a mostly self-contained unit, with a thin route/page wrapper in `src/pages/` and an entry in the router and sidebar. + +This is a living document describing the *current* codebase conventions, not an aspirational architecture. Where the codebase is inconsistent or a piece of plumbing exists but isn't actually used yet, that's called out explicitly rather than glossed over — copying an unused pattern into a new module is worse than knowing it's unused. + +## Module Structure + +Every existing module (`payment-hub`, `g2p-config`, `vouchers`, `rbac`, `account-mapper`) follows this shape: + +``` +src/modules// +├── types.ts # TypeScript interfaces for this domain +├── mocks/ +│ └── .mock.ts # Mock data, used as a fallback when the API errors +├── .tsx # Main page component — breadcrumb + title + Tabs +└── Tab.tsx # One component per tab (e.g. MainBatchesTab.tsx) +``` + +Notes on what's real vs. aspirational here: + +- **No module has an `index.ts` barrel export.** Every import goes straight to the file, e.g. `import MainBatchesTab from './MainBatchesTab'`. Don't add barrel files unless you're deliberately introducing that convention project-wide — a lone barrel in one module would be inconsistent with everything else. +- The module folder is kebab-case (`g2p-config`, `account-mapper`); the main page component inside is PascalCase without the hyphen (`G2PConfig.tsx`, `AccountMapper.tsx`). +- Not every module is tab-only. `payment-hub` also has `BatchDetail.tsx`, a drill-down page routed separately (`payment-hub/batch/:batchId`) rather than a tab — it's fine for a module to have pages beyond its tab set. +- `src/pages/.tsx` files are thin re-exports, e.g. `src/pages/PaymentHub.tsx` is just `export { default } from '@/modules/payment-hub/PaymentHub'`. The router imports from `src/pages/`, never directly from `src/modules/`. + +## Step-by-Step: Adding a New Module + +### Step 1 — Define Types + +Create `src/modules//types.ts` with the interfaces for your domain's data shape. Match the field names and types your backend actually returns — including nullability. Existing modules model backend nulls explicitly rather than assuming fields are always present, e.g. `MainBatch.status: string | null` in `src/modules/payment-hub/types.ts`. Do the same for your module: if a field can come back `null` from the API, type it as `T | null`, not `T`. + +### Step 2 — Create Mock Data + +Create `src/modules//mocks/.mock.ts` exporting an array typed against your `types.ts` interface. This isn't throwaway scaffolding — it's the fallback the UI shows when the real API call fails (see Step 8), so keep it in sync with the type as the type evolves, and give it enough rows/variety to exercise your empty-state and pagination logic in development. + +### Step 3 — Create API Function + +Add `src/lib/api/.ts`. Decide whether your module talks to the **existing backend** (reuse the shared `apiClient` from `src/lib/api/client.ts`, which already injects the `Platform-TenantId` header and handles 401 → redirect-to-login) or a **different backend service** (create your own `axios.create({...})` instance in this file, following the pattern in `src/lib/api/g2pConfig.ts`). + +There's no shared factory for creating a new client — each `lib/api/.ts` file just calls `axios.create()` directly if it needs its own instance. See the "Environment Variables" section below for naming the base URL. + +### Step 4 — Build the UI Component + +Create `src/modules//.tsx` as the main page: breadcrumb nav, `

` title, and a shadcn `Tabs` block if the module has more than one view. Create one `Tab.tsx` per tab. Wire data fetching per the TanStack Query pattern in the "API Integration Pattern" section below. + +### Step 5 — Add Route + +In `src/main.tsx`, create `src/pages/.tsx` as a re-export (`export { default } from '@/modules//'`), import it, and add `{ path: '', element: }` to the `children` array under the `AppLayout` route. This gives you the sidebar chrome and Keycloak-protected auth automatically. If the page must be public (no login required), add it to the top-level public route array instead — see how `account-mapper/self-service` is registered outside `AuthRoot`. + +### Step 6 — Add to Sidebar Navigation + +In `src/components/shared/AppLayout.tsx`, add an entry to the `navItems` array: + +```ts +{ label: 'Notifications', path: '/notifications', icon: Bell }, +``` + +Import the icon from `lucide-react`. The `path` must exactly match what you registered in `main.tsx` — the app currently has one stale sidebar entry (`Audit Trails` → `/audit`) whose route was never registered, so it silently 404s. Don't repeat that mistake; add the route in the same change as the nav entry. + +### Step 7 — Add to Dashboard (optional) + +There is **no generic "every module gets a dashboard card" mechanism** — `src/pages/Dashboard.tsx`'s four `StatCard`s are all Payment-Hub-specific numbers computed from one `useQuery(['mainBatches'], fetchMainBatches)` call, not one query per module. If you want your new module represented on the dashboard, you have two real options, both manual: + +- Add a `StatCard` to the existing `grid-cols-4` grid (you'll likely need to widen the grid, e.g. `grid-cols-5`), backed by your own `useQuery` call and a summary number you compute. +- Add a button to the "Quick Actions" card at the bottom of the dashboard that navigates to your module — this is the more common pattern for surfacing a module without inventing a new metric. + +Don't invent a "cards registry" or config-driven dashboard — it doesn't exist today, and hand-rolling one for a single module addition is scope creep. + +### Step 8 — Wire to Real API + +Swap the mock-only data source for the TanStack Query + mock-fallback pattern described below. Do this once you have a real endpoint to hit; until then, tabs can render straight from the mock array as vouchers/rbac currently do for some of their data. + +## Example — Adding a "Notifications" Module + +This walks through a complete, minimal module end to end. + +**1. `src/modules/notifications/types.ts`** + +```ts +export interface Notification { + id: number + title: string + message: string + severity: 'INFO' | 'WARNING' | 'CRITICAL' + createdAt: number | null + readAt: number | null +} +``` + +**2. `src/modules/notifications/mocks/notifications.mock.ts`** + +```ts +import type { Notification } from '../types' + +export const notifications: Notification[] = [ + { + id: 1, + title: 'Batch BATCH-004-2026 failed', + message: '45 transactions failed validation.', + severity: 'CRITICAL', + createdAt: new Date('2026-06-04T14:05:00').getTime(), + readAt: null, + }, + { + id: 2, + title: 'Nightly reconciliation complete', + message: 'All batches reconciled successfully.', + severity: 'INFO', + createdAt: new Date('2026-06-05T02:00:00').getTime(), + readAt: new Date('2026-06-05T08:12:00').getTime(), + }, +] +``` + +**3. `src/lib/api/notifications.ts`** + +```ts +import apiClient from './client' + +export const fetchNotifications = async () => { + const response = await apiClient.get('/notifications') + return response.data +} +``` + +(This assumes notifications live on the same backend as Payment Hub, hence reusing `apiClient`. If it's a separate service, follow the `g2pConfig.ts` pattern instead — see Step 3.) + +**4. `src/modules/notifications/NotificationsTab.tsx`** + +```tsx +import { useQuery } from '@tanstack/react-query' +import { fetchNotifications } from '@/lib/api/notifications' +import { notifications as mockNotifications } from './mocks/notifications.mock' +import type { Notification } from './types' +import StatusBadge from '@/components/shared/StatusBadge' +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table' +import { AlertCircle } from 'lucide-react' + +const SKELETON_ROWS = 5 + +export default function NotificationsTab() { + const { data: apiData, isLoading, isError } = useQuery({ + queryKey: ['notifications'], + queryFn: fetchNotifications, + }) + + const rows: Notification[] = isError ? mockNotifications : (apiData ?? []) + + return ( +
+ {isError && ( +
+ + Could not reach the API — showing cached data. +
+ )} + +
+ + + + Title + Message + Created + Severity + + + + {isLoading + ? Array.from({ length: SKELETON_ROWS }).map((_, i) => ( + + {Array.from({ length: 4 }).map((__, j) => ( + +
+ + ))} + + )) + : rows.map((n) => ( + + {n.title} + {n.message} + {n.createdAt ? new Date(n.createdAt).toLocaleString() : '-'} + + + ))} + +
+
+
+ ) +} +``` + +**5. `src/modules/notifications/Notifications.tsx`** + +```tsx +import { Link } from 'react-router-dom' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import NotificationsTab from './NotificationsTab' + +export default function Notifications() { + return ( +
+ + +

Notifications

+ + + + All Notifications + + + + + +
+ ) +} +``` + +**6. `src/pages/Notifications.tsx`** + +```ts +export { default } from '@/modules/notifications/Notifications' +``` + +**7. `src/main.tsx`** — add the import and route: + +```tsx +import Notifications from '@/pages/Notifications' +// ... +{ path: 'notifications', element: }, +``` + +**8. `src/components/shared/AppLayout.tsx`** — add the sidebar entry: + +```ts +import { Bell } from 'lucide-react' +// ... +{ label: 'Notifications', path: '/notifications', icon: Bell }, +``` + +That's a complete, routable, mock-backed module. Wiring the real endpoint later is just implementing `fetchNotifications` against the live API — the component code doesn't change. + +## API Integration Pattern + +Every data-fetching component in this app follows the same TanStack Query + mock-fallback shape, verbatim in `MainBatchesTab.tsx`, `G2PPaymentTab.tsx`, and `Dashboard.tsx`: + +```ts +const { data: apiData, isLoading, isError } = useQuery({ + queryKey: ['mainBatches'], + queryFn: fetchMainBatches, +}) + +// Fall back to mock data only when the API call fails +const rows: MainBatch[] = isError ? mockBatches : (apiData?.data ?? []) +``` + +Two details matter here and are easy to get wrong by copying an older/incorrect version of this pattern: + +- **Fall back on `isError`, not on an empty result.** An earlier version of this pattern checked `apiData?.data?.length ? apiData.data : mockData`, which silently substituted mock rows whenever the API returned a legitimate empty array — indistinguishable from "API is down" and meaning "No records found" could never actually render. Always gate the fallback on `isError`. +- **Show an inline error banner when `isError` is true**, so the user knows they're looking at cached/mock data rather than assuming it's live: + +```tsx +{isError && ( +
+ + Could not reach the API — showing cached data. +
+)} +``` + +This banner markup is currently copy-pasted per component rather than extracted into a shared component — follow the existing pattern rather than introducing a new shared `` unless you're prepared to also migrate the existing call sites. + +Loading state uses a skeleton, not a spinner — an array of pulsing `
`s matching the table's column count, shown while `isLoading` is true (see the `SKELETON_ROWS` pattern in the example above). + +## StatusBadge Extension + +`src/components/shared/StatusBadge.tsx` renders any status string as a colored pill. To support a new status value, add a key to its flat `statusStyles` map: + +```ts +const statusStyles: Record = { + Completed: 'bg-green-100 text-green-700', + COMPLETED: 'bg-green-100 text-green-700', + // ... + YourNewStatus: 'bg-indigo-100 text-indigo-700', +} +``` + +Things to know before adding a key: + +- **Keys are matched verbatim against whatever string the backend/mock emits — there's no case normalization.** The map already carries both `Completed` and `COMPLETED` as separate entries because two different backends emit different casing for conceptually the same status. If your new status might come back in more than one casing, add both keys rather than assuming the component will normalize it for you. +- Any status not present in the map falls back to gray (`bg-gray-100 text-gray-600`) rather than erroring — a missing key is a silent visual bug (wrong/no color), not a crash, so double-check the exact string your API returns. +- The component takes an optional `label` prop to show different text than the lookup key — useful when you want to look up styling for one string but display another. See `G2PPaymentTab.tsx`, which passes `status={config.status === 'Inactive' ? 'Inactive-G2P' : config.status}` with `label={config.status}` so "Inactive" G2P configs render in red instead of the generic gray "Inactive" color, while still displaying the word "Inactive" to the user. +- `status` accepts `string | null | undefined` — passing `null`/`undefined` renders a plain gray `-` pill rather than crashing, so you don't need to guard against nullable status fields before passing them in. + +## Environment Variables + +New backend service URLs follow the naming convention `VITE__URL` — all-caps snake case, `VITE_` prefix (required by Vite to expose a variable to client-side code), `_URL` suffix. Existing examples: `VITE_API_BASE_URL`, `VITE_G2P_SERVICE_URL`, `VITE_BULK_CONNECTOR_URL`. + +To add one: + +1. Add the key (with a value) to your local `.env`. +2. Add the same key (blank) to `.env.example` so other developers know it's needed. +3. Reference it directly as `import.meta.env.VITE_YOUR_SERVICE_URL` in your `src/lib/api/.ts` file — see `g2pConfig.ts`'s `axios.create({ baseURL: import.meta.env.VITE_G2P_SERVICE_URL || 'http://localhost:8084' })` for the pattern, including a local-dev fallback default. + +There is **no `vite-env.d.ts` with a typed `ImportMetaEnv` interface** in this repo — env var access is untyped (effectively `string | undefined`), and TypeScript won't catch a typo'd variable name or a missing one at compile time. If you're adding several new env vars for a large module, consider adding a `vite-env.d.ts` with a typed `ImportMetaEnv` as a small standalone improvement — but that's a repo-wide change, not something to bolt on silently as part of one module's PR. + +## Keycloak RBAC + +**Be aware before you rely on this: `hasRole` exists but nothing in the app currently uses it to gate anything.** `useAuth()` (`src/lib/keycloak/useAuth.ts`) returns: + +```ts +interface UseAuthReturn { + authenticated: boolean + token: string | undefined + user: AuthUser | null + logout: () => void + hasRole: (role: string) => boolean +} +``` + +`hasRole(role)` checks `keycloak.hasRealmRole(role) || keycloak.hasResourceRole(role)`. Today, the only real call site of `useAuth()` in the whole app is `AppLayout.tsx`, and it only destructures `user`/`logout` — to show the avatar name and wire the sign-out button. No route, page, or tab currently checks `hasRole` for anything; every authenticated user sees every route and every action. + +If your new module needs role-gated UI or routes, you're the first to actually wire this up — do it deliberately rather than assuming there's an established pattern to copy. Two straightforward approaches, consistent with how the rest of the app is structured: + +**Gate a whole route** — wrap the element in `main.tsx`, or add a guard component: + +```tsx +function RequireRole({ role, children }: { role: string; children: React.ReactNode }) { + const { hasRole } = useAuth() + if (!hasRole(role)) return + return <>{children} +} + +// in the route config: +{ path: 'notifications', element: }, +``` + +**Gate a piece of UI** — conditionally render inside a component: + +```tsx +const { hasRole } = useAuth() + +{hasRole('notifications-admin') && ( + +)} +``` + +Whichever you pick, name the Keycloak role clearly and confirm with whoever owns the Keycloak realm config that the role actually exists there — `hasRole` will just silently return `false` for a role that was never created, which looks identical to "user correctly lacks permission" from the UI's perspective. diff --git a/docs/MAINTAINABILITY.md b/docs/MAINTAINABILITY.md new file mode 100644 index 0000000..2eb65fa --- /dev/null +++ b/docs/MAINTAINABILITY.md @@ -0,0 +1,83 @@ +# Maintainability Guide + +This document covers how the codebase is kept consistent day to day: conventions, review process, dependency hygiene, and — honestly — where the project currently has no tooling at all. Where something doesn't exist yet, it says so, rather than describing an aspirational setup that would mislead a new contributor into thinking it's already there. + +## Code Conventions + +- **Language/tooling:** React 19 + TypeScript ~6.0, built with Vite (`tsc -b && vite build`). Routing via `react-router-dom` v7 (`createBrowserRouter`). Server state via `@tanstack/react-query` v5. Styling via Tailwind CSS v4 (Vite plugin form, not a PostCSS config file) plus shadcn/Radix UI primitives under `src/components/ui/`. +- **Module layout:** feature-based, under `src/modules//` — see `docs/EXTENSIBILITY.md` for the full shape and step-by-step for adding a new one. `src/pages/.tsx` files are thin re-exports consumed by the router; don't put real logic there. +- **No barrel exports.** No module has an `index.ts` — import directly from the file (`import MainBatchesTab from './MainBatchesTab'`). Don't introduce one in a single module; it'd be inconsistent with every other module in the repo. +- **Nullability:** model backend nulls explicitly in `types.ts` (`status: string | null`, not `status: string`), and handle `null`/`undefined` at render time rather than assuming a field is always populated. Several past bugs in this codebase were exactly this: treating an optional field as always-present. +- **Status colors:** all status→color mapping goes through the shared `StatusBadge` component (`src/components/shared/StatusBadge.tsx`) and its `statusStyles` map — don't hand-roll a new colored-pill component for a new status set. +- **Data fetching:** TanStack Query + `isError`-gated mock fallback, described in `docs/EXTENSIBILITY.md`'s "API Integration Pattern" section. Don't fall back to mock data on an empty successful response — only on an actual fetch error. +- **Formatting:** there is **no Prettier** in this repo — no `.prettierrc*`, no `prettier` dependency. Formatting is whatever your editor does plus ESLint's opinions; match the surrounding file's style (2-space indent, no semicolons, single quotes) since that's the prevailing convention throughout `src/`, even though nothing enforces it automatically. + +## PR Process + +There is no formal, written PR template or required-reviewer policy encoded in this repo (no `.github/PULL_REQUEST_TEMPLATE.md`, no CODEOWNERS). In practice: + +- Keep PRs scoped to one module or one cross-cutting concern (e.g. "wire Payment Hub to real API" as its own PR, not bundled with an unrelated RBAC change). +- Run `npm run build` locally before opening a PR — it runs `tsc -b` first, so a broken build catches type errors that `npm run dev` (plain `vite`, no type-checking) won't surface. `vite dev` will happily serve a file with type errors; only `build` (or a manual `tsc --noEmit`) actually checks types. +- Run `npm run lint` and fix what it flags before requesting review — see the "Adding Dependencies" section for what's actually configured. +- If you're the reviewer and see feedback with fabricated specifics (a function name, a field that supposedly exists, an "as the PR description says" claim) — verify it against the actual code before accepting it. Several past review cycles on this project caught reviewer suggestions that referenced field names or endpoint shapes that didn't match what the code actually had. + +## Adding Dependencies + +- Lint: ESLint only, flat config at `eslint.config.js` — `@eslint/js` recommended + `typescript-eslint` recommended + `eslint-plugin-react-hooks` (flat recommended) + `eslint-plugin-react-refresh` (Vite variant). Run via `npm run lint`. +- No Prettier, no Stylelint, no commit hooks (no Husky/lint-staged config present) — nothing blocks a commit or push based on lint/format state today. Don't assume a pre-commit hook will catch what you didn't check yourself. +- Before adding a new package, check whether an existing dependency already covers the need — this app already carries `axios`, `@tanstack/react-query`, `recharts` (charts), `jspdf`/`jspdf-autotable` (PDF export), `lucide-react` (icons), `class-variance-authority`/`clsx`/`tailwind-merge` (styling utilities), and the full `radix-ui` primitive set via shadcn. A new charting, icon, or PDF library is very unlikely to be justified. +- When you do add something, prefer a scoped, actively maintained package over a kitchen-sink framework, and add it to the correct `dependencies`/`devDependencies` bucket — build-only tooling (test runners, type stubs) belongs in `devDependencies`; anything imported by app code at runtime belongs in `dependencies`. +- `msw` (Mock Service Worker) is already a devDependency, but currently wired only for **dev-mode API mocking** (`enableMocking()` in `main.tsx`, gated on `import.meta.env.DEV`) — not for tests, since there are no tests yet (see below). If you use MSW handlers for a new module, add them under `src/mocks/handlers/` following the existing `g2p.handlers.ts` pattern and register them in `src/mocks/browser.ts`. + +## Testing Approach + +**There is currently no automated testing in this repository.** No test runner is configured (no Vitest, no Jest — no config file for either, no `test` script in `package.json`), no `*.test.tsx`/`*.spec.tsx` files exist anywhere in `src/`, and no testing-library packages are installed. + +This means: + +- Correctness today is verified by `tsc -b` (type checking), `npm run lint` (ESLint), a manual `npm run build`, and manual/visual verification in the browser. That's a real gap, not a stopgap for something more rigorous — treat "I ran the build and it compiled" as necessary but not sufficient evidence a change works. +- If you're introducing non-trivial logic (a data-transform function, a status/rate calculation, a date-grouping helper), consider whether this is the PR to also introduce Vitest — it's the natural fit given the Vite toolchain already in place, and would need: `vitest` + `@testing-library/react` + `@testing-library/jest-dom` as devDependencies, a `vitest.config.ts` (or a `test` block in `vite.config.ts`), and a `"test": "vitest"` script. Don't do this silently as a side effect of an unrelated feature PR — it's a repo-wide decision worth its own PR and sign-off. +- Until then, manually exercise: the happy path, the loading-skeleton state, the `isError` fallback-to-mock state, and the empty-result state, for any component you touch that fetches data. These four states are exactly where this codebase's past bugs have clustered (see the git history around the `isError`-vs-empty-array fallback fix across several tabs). + +## Environment Configuration + +- Env vars are Vite-standard: `VITE_`-prefixed to be exposed to client code, read via `import.meta.env.VITE_X`. See `.env.example` for the current full list (`VITE_API_BASE_URL`, `VITE_BULK_CONNECTOR_URL`, `VITE_KEYCLOAK_URL`, `VITE_KEYCLOAK_REALM`, `VITE_KEYCLOAK_CLIENT_ID`, `VITE_TENANT_ID`, `VITE_G2P_SERVICE_URL`). +- **No typed `ImportMetaEnv`** — there's no `vite-env.d.ts` augmenting the env var types, so a typo'd or missing `VITE_*` reference won't be caught by TypeScript; it'll just resolve to `undefined` at runtime. Double-check env var names by hand when adding or renaming one. +- Whenever you add a new env var, update **both** `.env` (your local value, gitignored) and `.env.example` (blank placeholder, committed) — a var only in `.env` means every other developer's checkout silently lacks it with no error, just a broken feature. +- Multi-tenancy: the app reads a `tenant` value from `localStorage` (defaulting to `greenbank`), sent as a `Platform-TenantId` header by the shared `apiClient`'s request interceptor (`src/lib/api/client.ts`). If your module's backend is tenant-scoped and you're using the shared `apiClient`, this is handled for you automatically; if you create a separate axios instance (see `g2pConfig.ts`), you'd need to add the same header yourself if that backend also needs it. + +## Mock Data Strategy + +Every module ships hand-written mock data under `src/modules//mocks/`, typed against that module's `types.ts`. This mock data is not disposable scaffolding — it's the fallback rendered whenever a `useQuery` call fails (`isError === true`), so it's part of the shipped UX for "backend is down," not just a development convenience. + +Keep in mind: + +- Mock data must stay in sync with the type it's typed against — a stale mock missing a newly required field is a compile error waiting to be silenced with an `any` cast; fix the mock, don't cast around it. +- Mock data should be realistic enough to exercise pagination, filtering, and empty/edge-case states — several existing mocks (e.g. `mainBatches.mock.ts`) deliberately include a spread of statuses and null fields for exactly this reason. +- When a real endpoint's response shape differs from what was assumed when the mock was written, the mock needs to be corrected to match reality — this has happened repeatedly during the Payment Hub API integration (mock fields renamed from `batchReferenceNumber`/`sourceMinistry`/etc. to the real `batchId`/`payerFsp`/etc.), and a stale mock silently misleads anyone hitting the fallback path. +- MSW handlers (`src/mocks/handlers/`) are a second, separate mock layer used only in dev mode to intercept actual HTTP calls before they leave the browser — currently used for the G2P Config module (`localhost:8084` endpoints not yet deployed). Use this when you need to develop against an endpoint that doesn't exist yet at all, as opposed to the `isError`-fallback mock, which is for when a real endpoint exists but might be unreachable. + +## API Integration Checklist + +When wiring a module to a real backend (Step 8 in `docs/EXTENSIBILITY.md`), work through this list: + +- [ ] Confirm the **actual** response shape against a real payload (or a teammate's sample JSON) — don't assume field names/casing from a spec or a PR description; those have been wrong before in this codebase (e.g. an assumed `size: 50` that turned out to not match what was actually implemented, and several assumed field names that didn't match the real Gazelle API). +- [ ] Update `types.ts` to match the verified real shape, including nullability. +- [ ] Update the mock data to match the same shape, so the `isError` fallback path stays truthful. +- [ ] Confirm the fallback condition is `isError ? mock : (data ?? [])`, not `data?.length ? data : mock` — the latter masks legitimate empty results as errors. +- [ ] Confirm `StatusBadge` has entries for every real status string your endpoint can return, in the exact casing the backend sends (`COMPLETED` vs `Completed` are different keys). +- [ ] If amounts come back as minor-unit integers or as signed strings (seen in the Transfers endpoint), confirm the display formatting (`Math.abs(amount / 100).toLocaleString()`) matches what the backend actually does, rather than assuming major-unit floats. +- [ ] Confirm date/timestamp fields are epoch milliseconds vs. ISO strings vs. epoch seconds before formatting — this codebase has both `startedAt: number` (epoch ms) and `startTime: string` (ISO-ish) fields across different modules; don't assume one format based on a field's name alone. +- [ ] Verify loading skeleton, error banner, and empty state all render correctly by temporarily forcing each condition (e.g. point `VITE_*_URL` at an invalid host to force `isError`). + +## Known Limitations + +Documenting these plainly so they're treated as known debt rather than rediscovered as surprises: + +- **No automated tests.** See "Testing Approach" above. +- **No RBAC enforcement.** `useAuth().hasRole` exists but is not called anywhere except its own definition — every authenticated user can reach every route and action regardless of their Keycloak role. See `docs/EXTENSIBILITY.md`'s "Keycloak RBAC" section if you need to actually gate something. +- **Dead sidebar link.** `AppLayout.tsx`'s `navItems` includes "Audit Trails" pointing at `/audit`, which is not registered in `src/main.tsx` — clicking it renders the catch-all `NotFound` page. Either register the route or remove the nav entry. +- **Duplicate sidebar targets.** "Users" and "Roles & Permissions" both link to `/rbac` — there's a single RBAC page with tabs, but two separate nav entries pointing at the same URL rather than one entry, or two entries deep-linking to specific tabs. +- **No typed environment variables.** See "Environment Configuration" above — a mistyped `VITE_*` name fails silently at runtime, not at compile time. +- **Inconsistent module data conventions across the codebase's history.** Some modules were built against assumed API shapes that didn't match the real backend and needed multiple follow-up corrections once real payloads were available (field renames, status-casing fixes, amount/unit fixes). When extending an existing module, don't assume its current `types.ts` is necessarily final — verify against a live payload if one is available. +- **Large production bundle.** The Vite build currently warns about a >500kB chunk (`recharts`, `jspdf`/`jspdf-autotable`, and the shadcn/Radix set are the likely bulk). No code-splitting has been set up yet; if bundle size becomes a real problem, look at `build.rolldownOptions.output.codeSplitting` or route-level `React.lazy()` before reaching for a different charting/PDF library. diff --git a/package-lock.json b/package-lock.json index a06ef26..f3bad4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "msw": "^2.15.0", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^8.0.12" @@ -911,6 +912,93 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1018,6 +1106,31 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -1110,6 +1223,31 @@ "node": ">= 8" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -3348,6 +3486,23 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -3783,6 +3938,22 @@ "node": ">=8" } }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4109,6 +4280,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4124,6 +4342,26 @@ "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", "license": "MIT" }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -5179,6 +5417,23 @@ "pako": "^2.1.0" } }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -5195,6 +5450,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -5467,6 +5732,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -5597,6 +5872,16 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -5636,6 +5921,24 @@ "node": ">= 0.4" } }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, + "node_modules/headers-polyfill/node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -5848,6 +6151,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -5902,6 +6215,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6649,6 +6969,68 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msw": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.15.0.tgz", + "integrity": "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -6912,6 +7294,13 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7584,6 +7973,16 @@ "license": "MIT", "optional": true }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -7624,6 +8023,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rettime": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -8066,6 +8472,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -8170,6 +8583,19 @@ "node": ">=12.0.0" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", @@ -8231,6 +8657,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8252,6 +8698,19 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -8317,6 +8776,22 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -8448,6 +8923,16 @@ "node": ">= 0.8" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -8707,6 +9192,46 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -8729,12 +9254,73 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 5e062b1..e209425 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "type": "module", "scripts": { "dev": "vite", + "dev:g2p": "vite --mode g2p", "build": "tsc -b && vite build", + "build:g2p": "tsc -b && vite build --mode g2p", "lint": "eslint .", "preview": "vite preview" }, @@ -40,8 +42,14 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "msw": "^2.15.0", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^8.0.12" + }, + "msw": { + "workerDirectory": [ + "public" + ] } -} +} \ No newline at end of file diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 0000000..0c970ef --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,361 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.15.0' +const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Omit the body of server-sent event stream responses. + // Cloning such responses would prevent client-side stream cancelations + // from reaching the original stream (a teed stream only cancels its + // source once both of its branches cancel) and would buffer the + // entire stream into the unconsumed clone indefinitely. + const isEventStreamResponse = response.headers + .get('content-type') + ?.toLowerCase() + .startsWith('text/event-stream') + + // Clone the response so both the client and the library could consume it. + const responseClone = isEventStreamResponse ? null : response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseClone ? responseClone.body : null, + }, + }, + }, + responseClone && responseClone.body + ? [serializedRequest.body, responseClone.body] + : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/src/components/shared/ToastProvider.tsx b/src/components/shared/ToastProvider.tsx new file mode 100644 index 0000000..ff2650c --- /dev/null +++ b/src/components/shared/ToastProvider.tsx @@ -0,0 +1,70 @@ +import { createContext, useCallback, useContext, useState, type ReactNode } from 'react' +import { CheckCircle, AlertTriangle, AlertCircle } from 'lucide-react' +import { cn } from '@/lib/utils' + +type ToastVariant = 'success' | 'warning' | 'error' + +interface Toast { + id: number + message: string + variant: ToastVariant +} + +interface ToastContextValue { + toast: (message: string, variant?: ToastVariant) => void +} + +const ToastContext = createContext(null) + +export function useToast() { + const ctx = useContext(ToastContext) + if (!ctx) throw new Error('useToast must be used inside ToastProvider') + return ctx +} + +const variantStyles: Record = { + success: 'border-green-200 bg-green-50 text-green-700', + warning: 'border-orange-200 bg-orange-50 text-orange-700', + error: 'border-red-200 bg-red-50 text-red-700', +} + +const variantIcons: Record = { + success: CheckCircle, + warning: AlertTriangle, + error: AlertCircle, +} + +let toastId = 0 + +export default function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + + const toast = useCallback((message: string, variant: ToastVariant = 'success') => { + const id = ++toastId + setToasts((prev) => [...prev, { id, message, variant }]) + setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 2500) + }, []) + + return ( + + {children} +
+ {toasts.map((t) => { + const Icon = variantIcons[t.variant] + return ( +
+ + {t.message} +
+ ) + })} +
+
+ ) +} diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 0f1de0d..6fbd51b 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -11,4 +11,15 @@ apiClient.interceptors.request.use((config) => { return config }) +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('kc_token') + window.location.href = '/login' + } + return Promise.reject(error) + } +) + export default apiClient diff --git a/src/lib/api/g2pConfig.ts b/src/lib/api/g2pConfig.ts new file mode 100644 index 0000000..53c094a --- /dev/null +++ b/src/lib/api/g2pConfig.ts @@ -0,0 +1,23 @@ +import axios from 'axios' + +const g2pClient = axios.create({ baseURL: import.meta.env.VITE_G2P_SERVICE_URL || 'http://localhost:8084' }) + +export const fetchG2PConfigs = async () => { + const response = await g2pClient.get('/g2pPaymentConfig') + return response.data +} + +export const fetchGovernmentEntities = async () => { + const response = await g2pClient.get('/governmentEntity') + return response.data +} + +export const fetchPrograms = async () => { + const response = await g2pClient.get('/program') + return response.data +} + +export const fetchDFSPs = async () => { + const response = await g2pClient.get('/dfsp') + return response.data +} diff --git a/src/lib/keycloak/KeycloakProvider.tsx b/src/lib/keycloak/KeycloakProvider.tsx index fb642e3..032067a 100644 --- a/src/lib/keycloak/KeycloakProvider.tsx +++ b/src/lib/keycloak/KeycloakProvider.tsx @@ -1,6 +1,10 @@ -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' +import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react' import { useNavigate } from 'react-router-dom' import keycloak from './keycloak' +import { useToast } from '@/components/shared/ToastProvider' + +const EXPIRY_CHECK_INTERVAL_MS = 60_000 +const EXPIRY_WARNING_THRESHOLD_MS = 120_000 interface KeycloakContextValue { keycloak: typeof keycloak @@ -19,8 +23,10 @@ export function useKeycloak() { export default function KeycloakProvider({ children }: { children: ReactNode }) { const navigate = useNavigate() + const { toast } = useToast() const [initialized, setInitialized] = useState(false) const [authenticated, setAuthenticated] = useState(false) + const warnedForTokenRef = useRef(null) useEffect(() => { // Fast path: ROPC token already in localStorage — validate expiry and skip keycloak.init() @@ -83,6 +89,37 @@ export default function KeycloakProvider({ children }: { children: ReactNode }) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // Poll token expiry — auto-logout when expired, warn shortly before + useEffect(() => { + const interval = setInterval(() => { + const token = localStorage.getItem('kc_token') + if (!token) return + + try { + const payload = JSON.parse(atob(token.split('.')[1])) + const msUntilExpiry = payload.exp * 1000 - Date.now() + + if (msUntilExpiry < 0) { + localStorage.removeItem('kc_token') + setAuthenticated(false) + navigate('/login', { replace: true }) + return + } + + if (msUntilExpiry < EXPIRY_WARNING_THRESHOLD_MS && warnedForTokenRef.current !== token) { + warnedForTokenRef.current = token + toast('Your session will expire in 2 minutes', 'warning') + } + } catch { + localStorage.removeItem('kc_token') + setAuthenticated(false) + navigate('/login', { replace: true }) + } + }, EXPIRY_CHECK_INTERVAL_MS) + + return () => clearInterval(interval) + }, [navigate, toast]) + function logout() { localStorage.removeItem('kc_token') keycloak.logout({ redirectUri: `${window.location.origin}/login` }) diff --git a/src/main.tsx b/src/main.tsx index bace96c..ee20ab8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import { createBrowserRouter, RouterProvider, Outlet } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import './index.css' import KeycloakProvider from '@/lib/keycloak/KeycloakProvider' +import ToastProvider from '@/components/shared/ToastProvider' import AppLayout from '@/components/shared/AppLayout' import SplashScreen from '@/pages/SplashScreen' import LoginPage from '@/pages/LoginPage' @@ -63,10 +64,26 @@ const router = createBrowserRouter([ }, ]) -createRoot(document.getElementById('root')!).render( - - - - - , -) +async function enableMocking() { + if (import.meta.env.DEV && import.meta.env.VITE_ENABLE_MSW === 'true') { + const { worker } = await import('./mocks/browser') + await worker.start({ + onUnhandledRequest: 'bypass', + serviceWorker: { + url: '/mockServiceWorker.js', + }, + }) + } +} + +enableMocking().then(() => { + createRoot(document.getElementById('root')!).render( + + + + + + + , + ) +}) diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts new file mode 100644 index 0000000..f2b767c --- /dev/null +++ b/src/mocks/browser.ts @@ -0,0 +1,4 @@ +import { setupWorker } from 'msw/browser' +import { g2pHandlers } from './handlers/g2p.handlers' + +export const worker = setupWorker(...g2pHandlers) diff --git a/src/mocks/handlers/g2p.handlers.ts b/src/mocks/handlers/g2p.handlers.ts new file mode 100644 index 0000000..f8ced27 --- /dev/null +++ b/src/mocks/handlers/g2p.handlers.ts @@ -0,0 +1,27 @@ +import { http, HttpResponse } from 'msw' + +const g2pConfigs = [ + { id: 1, governmentEntity: 'Ministry of Corporate Affairs', program: 'Scholarship Support', payerDFSP: 'Green Bank', paymentAccount: '123456789012', status: 'Active' }, + { id: 2, governmentEntity: 'Ministry of Education', program: 'School Meals', payerDFSP: 'Blue Bank', paymentAccount: '223344556677', status: 'Inactive' }, + { id: 3, governmentEntity: 'Ministry of Health', program: 'Healthcare Initiative', payerDFSP: 'Red Bank', paymentAccount: '334455667788', status: 'Active' }, +] + +export const g2pHandlers = [ + http.get('http://localhost:8084/g2pPaymentConfig', () => HttpResponse.json(g2pConfigs)), + http.post('http://localhost:8084/g2pPaymentConfig', () => HttpResponse.json({ message: 'Created successfully' }, { status: 201 })), + http.get('http://localhost:8084/governmentEntity', () => HttpResponse.json([ + { id: 1, name: 'Ministry of Corporate Affairs' }, + { id: 2, name: 'Ministry of Education' }, + { id: 3, name: 'Ministry of Health' }, + ])), + http.get('http://localhost:8084/program', () => HttpResponse.json([ + { id: 1, name: 'Scholarship Support' }, + { id: 2, name: 'School Meals' }, + { id: 3, name: 'Healthcare Initiative' }, + ])), + http.get('http://localhost:8084/dfsp', () => HttpResponse.json([ + { id: 1, name: 'Green Bank' }, + { id: 2, name: 'Blue Bank' }, + { id: 3, name: 'Red Bank' }, + ])), +] diff --git a/src/modules/g2p-config/CreateG2PTab.tsx b/src/modules/g2p-config/CreateG2PTab.tsx index a3901af..cbfbabb 100644 --- a/src/modules/g2p-config/CreateG2PTab.tsx +++ b/src/modules/g2p-config/CreateG2PTab.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react' +import { useQuery } from '@tanstack/react-query' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Button } from '@/components/ui/button' @@ -10,9 +11,9 @@ import { SelectValue, } from '@/components/ui/select' import { CheckCircle } from 'lucide-react' -import type { G2PConfig } from './types' +import type { G2PConfig, GovernmentEntity, Program, DFSP } from './types' +import { fetchGovernmentEntities, fetchPrograms, fetchDFSPs } from '@/lib/api/g2pConfig' -const PAYER_DFSPS = ['Green Bank', 'Blue Bank', 'Red Bank', 'SBI', 'HDFC'] const STATUSES: G2PConfig['status'][] = ['Active', 'Inactive'] interface Props { @@ -40,6 +41,19 @@ export default function CreateG2PTab({ onCancel, onSuccess }: Props) { const [form, setForm] = useState(empty) const [success, setSuccess] = useState(false) + const { data: governmentEntities } = useQuery({ + queryKey: ['governmentEntities'], + queryFn: fetchGovernmentEntities, + }) + const { data: programs } = useQuery({ + queryKey: ['programs'], + queryFn: fetchPrograms, + }) + const { data: dfsps } = useQuery({ + queryKey: ['dfsps'], + queryFn: fetchDFSPs, + }) + function set(field: keyof FormState, value: string) { setForm((prev) => ({ ...prev, [field]: value })) } @@ -74,24 +88,42 @@ export default function CreateG2PTab({ onCancel, onSuccess }: Props) {
- set('governmentEntity', e.target.value)} + onValueChange={(v) => set('governmentEntity', v)} required - /> + > + + + + + {(governmentEntities ?? []).map((e: GovernmentEntity) => ( + + {e.name} + + ))} + +
- set('program', e.target.value)} + onValueChange={(v) => set('program', v)} required - /> + > + + + + + {(programs ?? []).map((p: Program) => ( + + {p.name} + + ))} + +
@@ -105,9 +137,9 @@ export default function CreateG2PTab({ onCancel, onSuccess }: Props) { - {PAYER_DFSPS.map((d) => ( - - {d} + {(dfsps ?? []).map((d: DFSP) => ( + + {d.name} ))} diff --git a/src/modules/g2p-config/G2PPaymentTab.tsx b/src/modules/g2p-config/G2PPaymentTab.tsx index 68f2eb7..caf20b1 100644 --- a/src/modules/g2p-config/G2PPaymentTab.tsx +++ b/src/modules/g2p-config/G2PPaymentTab.tsx @@ -1,5 +1,7 @@ import { useState } from 'react' -import { g2pConfigs } from './mocks/g2pConfigs.mock' +import { useQuery } from '@tanstack/react-query' +import { fetchG2PConfigs } from '@/lib/api/g2pConfig' +import { g2pConfigs as mockConfigs } from './mocks/g2pConfigs.mock' import type { G2PConfig } from './types' import StatusBadge from '@/components/shared/StatusBadge' import { @@ -18,7 +20,7 @@ import { SelectValue, } from '@/components/ui/select' import { Button } from '@/components/ui/button' -import { ChevronLeft, ChevronRight } from 'lucide-react' +import { ChevronLeft, ChevronRight, AlertCircle } from 'lucide-react' const filterChips = [ 'Government Entity', @@ -32,13 +34,22 @@ type FilterChip = (typeof filterChips)[number] const statuses: G2PConfig['status'][] = ['Active', 'Inactive'] +const SKELETON_ROWS = 5 + export default function G2PPaymentTab() { const [activeChip, setActiveChip] = useState(null) const [statusFilter, setStatusFilter] = useState('all') const [page, setPage] = useState(1) const [perPage, setPerPage] = useState(10) - const filtered = g2pConfigs.filter( + const { data: apiData, isLoading, isError } = useQuery({ + queryKey: ['g2pConfigs'], + queryFn: fetchG2PConfigs, + }) + + const rows: G2PConfig[] = isError ? mockConfigs : (apiData ?? []) + + const filtered = rows.filter( (c) => statusFilter === 'all' || c.status === statusFilter ) @@ -109,6 +120,14 @@ export default function G2PPaymentTab() { )}
+ {/* Error banner */} + {isError && ( +
+ + Could not reach the API — showing cached data. +
+ )} + {/* Table */}
@@ -122,21 +141,31 @@ export default function G2PPaymentTab() { - {paginated.map((config) => ( - - {config.governmentEntity} - {config.program} - {config.payerDFSP} - {config.paymentAccount} - - - - - ))} - {paginated.length === 0 && ( + {isLoading + ? Array.from({ length: SKELETON_ROWS }).map((_, i) => ( + + {Array.from({ length: 5 }).map((__, j) => ( + +
+ + ))} + + )) + : paginated.map((config) => ( + + {config.governmentEntity} + {config.program} + {config.payerDFSP} + {config.paymentAccount} + + + + + ))} + {!isLoading && paginated.length === 0 && ( No records found. diff --git a/src/modules/g2p-config/types.ts b/src/modules/g2p-config/types.ts index 6b815e8..6e72d46 100644 --- a/src/modules/g2p-config/types.ts +++ b/src/modules/g2p-config/types.ts @@ -1,7 +1,23 @@ export interface G2PConfig { + id?: number governmentEntity: string program: string payerDFSP: string paymentAccount: string status: 'Active' | 'Inactive' } + +export interface GovernmentEntity { + id: number + name: string +} + +export interface Program { + id: number + name: string +} + +export interface DFSP { + id: number + name: string +} diff --git a/src/modules/rbac/UserManagementTab.tsx b/src/modules/rbac/UserManagementTab.tsx index c9825c1..0f9e6ed 100644 --- a/src/modules/rbac/UserManagementTab.tsx +++ b/src/modules/rbac/UserManagementTab.tsx @@ -27,7 +27,7 @@ import { TableHeader, TableRow, } from '@/components/ui/table' -import { CheckCircle } from 'lucide-react' +import { useToast } from '@/components/shared/ToastProvider' const ROLES: Role[] = ['Admin', 'Operator', 'Auditor'] @@ -37,17 +37,10 @@ const roleBadge: Record = { Auditor: 'bg-gray-100 text-gray-600', } -interface Toast { - id: number - message: string -} - -let toastId = 0 - export default function UserManagementTab() { + const { toast } = useToast() const [users, setUsers] = useState(mockUsers) const [open, setOpen] = useState(false) - const [toasts, setToasts] = useState([]) // Add user form state const [fullName, setFullName] = useState('') @@ -55,12 +48,6 @@ export default function UserManagementTab() { const [username, setUsername] = useState('') const [role, setRole] = useState('') - function showToast(message: string) { - const id = ++toastId - setToasts((prev) => [...prev, { id, message }]) - setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 2500) - } - function handleCreateUser(e: React.FormEvent) { e.preventDefault() if (!role) return @@ -77,11 +64,11 @@ export default function UserManagementTab() { setEmail('') setUsername('') setRole('') - showToast('User created successfully') + toast('User created successfully') } function handleAssignRole(id: string) { - showToast('Role assigned successfully') + toast('Role assigned successfully') } function handleToggleLock(id: string) { @@ -92,28 +79,15 @@ export default function UserManagementTab() { : u ) ) - showToast('User status updated') + toast('User status updated') } function handleResetPassword(id: string) { - showToast('Password reset email sent') + toast('Password reset email sent') } return (
- {/* Toast stack */} -
- {toasts.map((t) => ( -
- - {t.message} -
- ))} -
- {/* Header */}
+ + {error && ( +
+ + {error} +
+ )}
) diff --git a/src/modules/g2p-config/G2PPaymentTab.tsx b/src/modules/g2p-config/G2PPaymentTab.tsx index caf20b1..84b6479 100644 --- a/src/modules/g2p-config/G2PPaymentTab.tsx +++ b/src/modules/g2p-config/G2PPaymentTab.tsx @@ -20,7 +20,7 @@ import { SelectValue, } from '@/components/ui/select' import { Button } from '@/components/ui/button' -import { ChevronLeft, ChevronRight, AlertCircle } from 'lucide-react' +import { ChevronLeft, ChevronRight } from 'lucide-react' const filterChips = [ 'Government Entity', @@ -45,8 +45,11 @@ export default function G2PPaymentTab() { const { data: apiData, isLoading, isError } = useQuery({ queryKey: ['g2pConfigs'], queryFn: fetchG2PConfigs, + retry: false, }) + // Silently fall back to mock data if the API is unreachable (e.g. G2P + // service not deployed yet) — no error banner shown to the user. const rows: G2PConfig[] = isError ? mockConfigs : (apiData ?? []) const filtered = rows.filter( @@ -120,14 +123,6 @@ export default function G2PPaymentTab() { )} - {/* Error banner */} - {isError && ( -
- - Could not reach the API — showing cached data. -
- )} - {/* Table */}
diff --git a/src/modules/payment-hub/BatchDetail.tsx b/src/modules/payment-hub/BatchDetail.tsx index 7ec3303..4e7d178 100644 --- a/src/modules/payment-hub/BatchDetail.tsx +++ b/src/modules/payment-hub/BatchDetail.tsx @@ -1,9 +1,10 @@ import { Link, useNavigate, useParams } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { ArrowLeft } from 'lucide-react' -import { fetchMainBatches, fetchSubBatches } from '@/lib/api/paymentHub' +import { fetchMainBatches, fetchBatchTransactions } from '@/lib/api/paymentHub' import { mainBatches as mockBatches } from './mocks/mainBatches.mock' -import { subBatches as mockSubBatches } from './mocks/subBatches.mock' +import { batchTransactionsByBatch } from './mocks/batchTransactions.mock' +import type { BatchTransaction } from './types' import StatusBadge from '@/components/shared/StatusBadge' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { @@ -23,6 +24,10 @@ const formatDate = (ts: number | null) => (ts ? new Date(ts).toLocaleString() : const formatField = (value: string | null | undefined) => !value || value === 'null' ? '-' : value +function toRows(data: Record): BatchTransaction[] { + return Object.entries(data).map(([transactionId, status]) => ({ transactionId, status })) +} + function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { return (
@@ -55,15 +60,15 @@ export default function BatchDetail() { const batches = isBatchesError ? mockBatches : (batchData?.data ?? []) const batch = batches.find((b) => b.batchId === batchId) - const { data: subBatchData, isLoading: isSubBatchesLoading, isError: isSubBatchesError } = useQuery({ - queryKey: ['subBatches', batchId], - queryFn: () => fetchSubBatches(batchId!), + const { data: transactionsData, isLoading: isTransactionsLoading, isError: isTransactionsError } = useQuery({ + queryKey: ['batchTransactions', batchId], + queryFn: () => fetchBatchTransactions(batchId!), enabled: !!batchId, }) - const subBatches = isSubBatchesError - ? mockSubBatches.filter((sb) => sb.batchId === batchId) - : (subBatchData?.content ?? []) + const transactions: BatchTransaction[] = isTransactionsError + ? toRows(batchTransactionsByBatch[batchId ?? ''] ?? {}) + : toRows(transactionsData ?? {}) return (
@@ -97,7 +102,7 @@ export default function BatchDetail() {
- {/* Batch info + Sub batches */} + {/* Batch info + Transactions */}
{/* Batch Info */} @@ -114,13 +119,13 @@ export default function BatchDetail() { - {/* Sub Batches */} + {/* Batch Transactions */} - Sub Batches + Transactions - {isSubBatchesError && ( + {isTransactionsError && (
Could not reach the API — showing cached data.
@@ -128,40 +133,32 @@ export default function BatchDetail() {
- Sub Batch ID - Transactions - Completed - Failed - Amount + Transaction ID Status - {isSubBatchesLoading + {isTransactionsLoading ? Array.from({ length: SKELETON_ROWS }).map((_, i) => ( - {Array.from({ length: 6 }).map((__, j) => ( + {Array.from({ length: 2 }).map((__, j) => (
))} )) - : subBatches.length > 0 - ? subBatches.map((sb) => ( - - {sb.subBatchId ?? '-'} - {sb.totalTransactions} - {sb.completed} - {sb.failed} - {formatAmount(sb.totalAmount)} - + : transactions.length > 0 + ? transactions.map((t) => ( + + {t.transactionId} + )) : ( - - No sub batches found. + + No transactions found. ) diff --git a/src/modules/payment-hub/SubBatchesTab.tsx b/src/modules/payment-hub/SubBatchesTab.tsx index 80f63be..2e50086 100644 --- a/src/modules/payment-hub/SubBatchesTab.tsx +++ b/src/modules/payment-hub/SubBatchesTab.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { fetchMainBatches, fetchSubBatches } from '@/lib/api/paymentHub' +import { fetchMainBatches, fetchBatchTransactions } from '@/lib/api/paymentHub' import { mainBatches as mockBatches } from './mocks/mainBatches.mock' -import { subBatches as mockSubBatches } from './mocks/subBatches.mock' -import type { MainBatch, SubBatch } from './types' +import { batchTransactionsByBatch } from './mocks/batchTransactions.mock' +import type { MainBatch, BatchTransaction } from './types' import StatusBadge from '@/components/shared/StatusBadge' import { Table, @@ -27,9 +27,8 @@ import { exportPdf } from '@/lib/exportPdf' const SKELETON_ROWS = 5 -const formatAmount = (amount: number | null) => { - if (!amount) return '0' - return Math.abs(amount / 100).toLocaleString() +function toRows(data: Record): BatchTransaction[] { + return Object.entries(data).map(([transactionId, status]) => ({ transactionId, status })) } export default function SubBatchesTab() { @@ -51,28 +50,21 @@ export default function SubBatchesTab() { }, [batchId, batchOptions]) const { data: apiData, isLoading, isError } = useQuery({ - queryKey: ['subBatches', batchId], - queryFn: () => fetchSubBatches(batchId), + queryKey: ['batchTransactions', batchId], + queryFn: () => fetchBatchTransactions(batchId), enabled: !!batchId, }) - const rows: SubBatch[] = isError - ? mockSubBatches.filter((sb) => sb.batchId === batchId) - : (apiData?.content ?? []) - const totalCount: number = apiData?.totalElements ?? rows.length + const rows: BatchTransaction[] = isError + ? toRows(batchTransactionsByBatch[batchId] ?? {}) + : toRows(apiData ?? {}) const totalPages = Math.max(1, Math.ceil(rows.length / perPage)) const paginated = rows.slice((page - 1) * perPage, page * perPage) - const exportRows: Record[] = rows.map((b) => ({ - 'Sub Batch ID': b.subBatchId ?? '-', - 'Start Time': b.startedAt ? new Date(b.startedAt).toLocaleString() : '-', - 'Completed Time': b.completedAt ? new Date(b.completedAt).toLocaleString() : '-', - 'Total Transactions': b.totalTransactions, - Completed: b.completed, - Failed: b.failed, - Amount: formatAmount(b.totalAmount), - Status: b.status ?? 'Unknown', + const exportRows: Record[] = rows.map((t) => ({ + 'Transaction ID': t.transactionId, + Status: t.status, })) return ( @@ -98,7 +90,7 @@ export default function SubBatchesTab() { variant="outline" size="sm" className="gap-1.5 text-xs" - onClick={() => exportCsv(exportRows, `sub-batches-${csvDate()}.csv`)} + onClick={() => exportCsv(exportRows, `batch-transactions-${csvDate()}.csv`)} > Export CSV @@ -108,10 +100,10 @@ export default function SubBatchesTab() { size="sm" className="gap-1.5 text-xs" onClick={() => exportPdf( - 'Sub Batches', - ['Sub Batch ID', 'Start Time', 'Completed Time', 'Total Transactions', 'Completed', 'Failed', 'Amount', 'Status'], - rows.map((b) => [b.subBatchId ?? '-', b.startedAt ? new Date(b.startedAt).toLocaleString() : '-', b.completedAt ? new Date(b.completedAt).toLocaleString() : '-', b.totalTransactions, b.completed, b.failed, formatAmount(b.totalAmount), b.status ?? 'Unknown']), - `sub-batches-${csvDate()}.pdf`, + 'Batch Transactions', + ['Transaction ID', 'Status'], + rows.map((t) => [t.transactionId, t.status]), + `batch-transactions-${csvDate()}.pdf`, )} > @@ -133,13 +125,7 @@ export default function SubBatchesTab() {
- Sub Batch ID - Start Time - Completed Time - Total Transactions - Completed - Failed - Amount + Transaction ID Status @@ -147,7 +133,7 @@ export default function SubBatchesTab() { {isLoading ? Array.from({ length: SKELETON_ROWS }).map((_, i) => ( - {Array.from({ length: 8 }).map((__, j) => ( + {Array.from({ length: 2 }).map((__, j) => (
@@ -155,21 +141,15 @@ export default function SubBatchesTab() { )) : paginated.length > 0 - ? paginated.map((batch) => ( - - {batch.subBatchId ?? '-'} - {batch.startedAt ? new Date(batch.startedAt).toLocaleString() : '-'} - {batch.completedAt ? new Date(batch.completedAt).toLocaleString() : '-'} - {batch.totalTransactions} - {batch.completed} - {batch.failed} - {formatAmount(batch.totalAmount)} - + ? paginated.map((t) => ( + + {t.transactionId} + )) : ( - + No records found. @@ -203,7 +183,7 @@ export default function SubBatchesTab() { {rows.length === 0 ? '0–0 of 0' - : `${(page - 1) * perPage + 1}–${Math.min(page * perPage, rows.length)} of ${totalCount}` + : `${(page - 1) * perPage + 1}–${Math.min(page * perPage, rows.length)} of ${rows.length}` }