diff --git a/README.md b/README.md index 3c29b1a..232bf0c 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,85 @@ -# Payment Hub EE - Operations Web App (React) +# Payment Hub EE — Operations Web App (React) -This repository contains the React-based rewrite of the Payment Hub EE Operations Web App. The goal is to achieve feature parity with the existing Angular Operations App through an MVP release targeted for version 2.1.0 (October 2026). +A modern React-based rebuild of the Payment Hub EE Operations App, replacing the legacy Angular frontend. Built as part of **C4GT DMP 2026** under the [Mifos Initiative](https://mifos.org), targeting release **2.1.0 (October 2026)**. -It is currently work in progress and not recommended for production deployment. +> The `main` branch contains release code. All PRs should target the `dev` branch first and will be merged to `main` at release. -The `main` branch contains release code. All PRs should be submitted to the `dev` branch first, and will be merged to `main` at a release. +--- -This project is part of **C4GT DMP 2026** under [The Mifos Initiative](https://mifos.org). +## Tech Stack -## Tech Stack - -- **React + TypeScript** – Frontend architecture -- **Vite** – Build tool -- **Tailwind CSS v4 + ShadCN UI (Radix Nova)** – UI and styling -- **React Router v6** – Routing -- **TanStack Query** – Server state management -- **Axios** – HTTP client -- **Keycloak** – Authentication and RBAC (OAuth2) - -## Project Structure +| Layer | Technology | +|---|---| +| Framework | React 19 + TypeScript 6 (Vite 8) | +| UI | ShadCN UI (Radix Nova) + TailwindCSS v4 | +| Routing | React Router v7 | +| Server state | TanStack Query v5 | +| HTTP client | Axios | +| Auth | Keycloak JS — ROPC (Direct Grant) flow | +| Charts | Recharts | +| PDF export | jsPDF + jspdf-autotable | + +--- + +## Features + +- **Dashboard** — operational overview with KPI stats, recent batches, recent vouchers, quick actions +- **Payment Hub** — Main Batches, Sub Batches, Transfers with status filters, CSV/PDF export +- **Voucher Management** — voucher list with status filters, bulk CSV upload +- **Account Mapper** — beneficiary management, create beneficiary form, self-service portal +- **G2P Payment Config** — configuration list and create form +- **RBAC** — role configuration cards and user management with lock/unlock +- **Reporting Dashboard** — Recharts line/bar/pie charts, KPI metrics, failed batch table, date/tenant filters +- **Settings** — app configuration with accordion sections +- **In-app Keycloak authentication** — ROPC flow, token stored in `localStorage`, JWT expiry validation +- **Animated splash screen** — shimmer progress bar, auto-navigates to login + +--- + +## Project Structure ``` src/ -├── modules/ # Feature modules, one per domain area -│ ├── auth/ # Keycloak integration, login, protected routes -│ ├── dashboard/ # Dashboard module cards -│ ├── payment-hub/ # Main Batches, Sub Batches, Transfers -│ ├── vouchers/ # Voucher Management (G2P) -│ ├── account-mapper/ # Beneficiary account mapping +├── modules/ # Feature modules, one per domain area +│ ├── auth/ # Login page, Splash screen +│ ├── payment-hub/ # Main Batches, Sub Batches, Transfers +│ ├── vouchers/ # Voucher Management (G2P) +│ ├── account-mapper/ # Beneficiary mapping + self-service portal │ ├── g2p-config/ # G2P Payment Configuration -│ ├── settings/ # App settings -│ └── rbac/ # Role configuration and user management +│ ├── rbac/ # Role config and user management +│ ├── reporting/ # Reporting dashboard with charts +│ └── settings/ # App settings ├── components/ -│ ├── ui/ # ShadCN UI primitives -│ └── shared/ # Shared components (AppLayout, DataTable, StatusBadge) +│ ├── ui/ # ShadCN UI primitives +│ └── shared/ # AppLayout, StatusBadge ├── lib/ -│ ├── api/ # Axios client setup, API interceptors -│ └── keycloak/ # Keycloak auth context and hooks -├── config/ # Environment config, constants -├── types/ # Shared TypeScript types -├── App.tsx # Root component -├── index.css # Tailwind base layer and theme -├── main.tsx # React entry point, router setup -└── vite-env.d.ts # Vite TypeScript definitions +│ ├── api/ # Axios client, API functions (paymentHub, accountMapper) +│ ├── keycloak/ # KeycloakProvider, useAuth hook, singleton +│ ├── exportCsv.ts # CSV download utility +│ └── exportPdf.ts # PDF download utility (jsPDF) +├── pages/ # Thin re-exports wiring modules to routes +├── config/ # Constants (APP_NAME, TENANTS, DEFAULT_TENANT) +├── main.tsx # Router setup, QueryClient, KeycloakProvider +└── index.css # Tailwind base layer and theme root/ -├── components.json # ShadCN CLI config -├── index.html # App HTML shell -├── eslint.config.js # Linting rules -├── .env.example # Environment variable template -└── .gitignore +├── components.json # ShadCN CLI config +├── index.html # App HTML shell + favicon +├── .env.example # Environment variable template +└── vite.config.ts ``` -## Prerequisites +--- -- Node.js and npm installed -- Access to a running Payment Hub EE backend instance (or use mock data during early development) -- A configured Keycloak realm for authentication (once auth integration begins) +## Prerequisites -## Installation & Setup +- **Node.js 18+** and npm +- **Docker Desktop** — for running Keycloak locally +- Access to a **Mifos Gazelle** backend instance (or use built-in mock data) + +--- + +## Installation ```bash # 1. Clone the repository @@ -70,62 +91,147 @@ cd ph-ee-operations-web-react # 3. Install dependencies npm install -# 4. Copy environment variables and configure them +# 4. Copy and configure environment variables cp .env.example .env # 5. Start the development server npm run dev ``` -Navigate to `http://localhost:5173/` to view the app. It will automatically reload if you change any source files. +Navigate to `http://localhost:5173/` — the app auto-reloads on file changes. + +--- -## Environment Variables +## Environment Variables -Copy `.env.example` to `.env` and adjust the values as needed. +Copy `.env.example` to `.env` and fill in the values. | Variable | Description | |---|---| -| `VITE_API_BASE_URL` | Base URL for the Payment Hub Operations backend services | -| `VITE_BULK_CONNECTOR_URL` | URL for Bulk Import Batch creation backend services | -| `VITE_KEYCLOAK_URL` | Keycloak auth server URL | -| `VITE_KEYCLOAK_REALM` | Keycloak realm used for authentication | -| `VITE_KEYCLOAK_CLIENT_ID` | Keycloak client identifier for this app | +| `VITE_API_BASE_URL` | Base URL for the Payment Hub Operations backend (`/api/v1`) | +| `VITE_BULK_CONNECTOR_URL` | URL for Bulk Import / Batch creation backend | +| `VITE_KEYCLOAK_URL` | Keycloak auth server URL (e.g. `http://localhost:8180`) | +| `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 | -## Routes +--- -| Route | Description | -|---|---| -| `/splash` | Animated landing screen | -| `/login` | Login with Keycloak authentication | -| `/` | Dashboard with module cards | -| `/payment-hub` | Main Batches, Sub Batches, Transfers | -| `/vouchers` | Voucher Management (list and create) | -| `/account-mapper` | Beneficiary account mapping (list and create) | -| `/g2p-config` | G2P Payment Configuration (list and create) | -| `/settings` | App settings | -| `/rbac` | Role configuration and user management | -| `/reporting` | Reporting dashboard (in development) | - -## Building for Production +## Keycloak Setup (Local Docker) + +Run Keycloak locally on port 8180: + +```bash +docker run -p 8180:8080 \ + -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \ + -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ + quay.io/keycloak/keycloak:latest start-dev +``` + +Then configure at `http://localhost:8180`: + +1. Create realm: `paymenthub` +2. Create client: `opsapp` + - **Client authentication:** OFF + - **Direct access grants:** ON + - **Valid redirect URIs:** `http://localhost:5173/*` + - **Web origins:** `http://localhost:5173` +3. Create a test user and set credentials + +Update `.env`: + +```env +VITE_KEYCLOAK_URL=http://localhost:8180 +VITE_KEYCLOAK_REALM=paymenthub +VITE_KEYCLOAK_CLIENT_ID=opsapp +``` + +--- + +## Gazelle Backend Setup + +Add to your hosts file (`C:\Windows\System32\drivers\etc\hosts` on Windows, `/etc/hosts` on Linux/macOS): + +``` + ops.mifos.gazelle.test + kibana-phee.mifos.gazelle.test + zeebe-operate.mifos.gazelle.test + bulk-connector.mifos.gazelle.test +``` + +Update `.env`: + +```env +VITE_API_BASE_URL=https://ops.mifos.gazelle.test/api/v1 +VITE_BULK_CONNECTOR_URL=https://bulk-connector.mifos.gazelle.test +VITE_TENANT_ID=greenbank +``` + +--- + +## Routes + +| Route | Auth | Description | +|---|---|---| +| `/splash` | Public | Animated splash screen | +| `/login` | Public | In-app Keycloak login (ROPC) | +| `/` | Protected | Dashboard — KPIs, recent batches, quick actions | +| `/payment-hub` | Protected | Main Batches, Sub Batches, Transfers | +| `/vouchers` | Protected | Voucher list, status filters, CSV upload | +| `/account-mapper` | Protected | Beneficiary list and create form | +| `/g2p-config` | Protected | G2P Payment Config list and create | +| `/rbac` | Protected | Role config and user management | +| `/reporting` | Protected | Charts, KPI metrics, failed batch table | +| `/settings` | Protected | App configuration | +| `/account-mapper/self-service` | **Public** | Beneficiary self-service portal (iframe) | + +--- + +## Account Mapper Self-Service Portal + +The self-service portal is a standalone, iframe-friendly page for beneficiaries to update their own payment details — no Keycloak credentials required. + +**URL format:** +``` +/account-mapper/self-service?beneficiaryId={id} +``` + +**Example:** +``` +http://localhost:5173/account-mapper/self-service?beneficiaryId=9876543210 +``` + +- Looks up the beneficiary via `GET /beneficiary/{id}` (falls back to mock data if the API is unreachable) +- Pre-fills a form with current Financial Institution, Financial Address, and Payment Modality +- On submit calls `PUT /beneficiary/{id}` via TanStack Query `useMutation` +- No sidebar, no header — minimal white card layout, mobile responsive, embeddable in external portals + +--- + +## Building for Production ```bash npm run build ``` -Build artifacts will be stored in the `dist/` directory. +Build artifacts are output to `dist/`. -## Contributing +--- -Contributions are welcome to improve the project. +## Contributing + +Contributions are welcome. - All PRs should target the `dev` branch -- Reference the relevant Jira ticket in your PR title, e.g. `PHEE-XXX: short description` +- Reference the relevant Jira ticket in your PR title: `PHEE-XXX: short description` - For design references, see the Figma file (link to be added) - For backend integration questions or access, reach out to the project mentors -## Related Links +--- + +## Related Links -- [Angular Operations App (reference implementation)](https://github.com/openMF/ph-ee-operations-web) +- [Angular Operations App (reference)](https://github.com/openMF/ph-ee-operations-web) - [Jira Epic — PHEE-363](https://mifosforge.jira.com/browse/PHEE-363) -- [The Mifos Initiative](https://mifos.org) \ No newline at end of file +- [The Mifos Initiative](https://mifos.org) +- [C4GT DMP 2026](https://codeforgovtech.in) diff --git a/package-lock.json b/package-lock.json index 54478df..a06ef26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.17.0", + "recharts": "^3.10.0", "shadcn": "^4.11.0", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.1", @@ -2612,6 +2613,32 @@ "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -2878,6 +2905,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", @@ -3182,6 +3221,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -3253,6 +3355,12 @@ "license": "MIT", "optional": true }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -4185,6 +4293,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -4211,6 +4440,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -4492,6 +4727,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4727,6 +4972,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -5492,6 +5743,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", + "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5524,6 +5785,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/iobuffer": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", @@ -7109,6 +7379,36 @@ "react": "^19.2.7" } }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -7232,6 +7532,51 @@ "node": ">= 4" } }, + "node_modules/recharts": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.0.tgz", + "integrity": "sha512-wulMvfncpIlmu2uFtRU/mE5/+NiVtASXkw2KdwJTdHs3WsASX0WxZlX+rpKgyn5BDbIhkPtCpUKkB9XNK5KE0w==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", @@ -7248,6 +7593,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -8180,6 +8531,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -8214,6 +8574,28 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", diff --git a/package.json b/package.json index c29ca93..5e062b1 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.17.0", + "recharts": "^3.10.0", "shadcn": "^4.11.0", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.1", diff --git a/src/lib/api/accountMapper.ts b/src/lib/api/accountMapper.ts new file mode 100644 index 0000000..61ade35 --- /dev/null +++ b/src/lib/api/accountMapper.ts @@ -0,0 +1,14 @@ +import apiClient from './client' + +export const lookupBeneficiary = async (beneficiaryId: string) => { + const response = await apiClient.get(`/beneficiary/${beneficiaryId}`) + return response.data +} + +export const updateBeneficiary = async ( + beneficiaryId: string, + data: { financialInstitution: string; financialAddress: string; paymentModality: string }, +) => { + const response = await apiClient.put(`/beneficiary/${beneficiaryId}`, data) + return response.data +} diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index c1f2bb9..0f1de0d 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -8,11 +8,6 @@ apiClient.interceptors.request.use((config) => { const tenant = localStorage.getItem('tenant') || 'greenbank' config.headers.set('Platform-TenantId', tenant) - const token = localStorage.getItem('kc_token') - if (token) { - config.headers.set('Authorization', `Bearer ${token}`) - } - return config }) diff --git a/src/lib/api/paymentHub.ts b/src/lib/api/paymentHub.ts new file mode 100644 index 0000000..651d847 --- /dev/null +++ b/src/lib/api/paymentHub.ts @@ -0,0 +1,15 @@ +import apiClient from './client' + +export const fetchMainBatches = async () => { + console.log('Fetching batches...') + const response = await apiClient.get('/batches') + console.log('Batches response:', response.data) + return response.data +} + +export const fetchTransfers = async () => { + const response = await apiClient.get('/transfers', { + params: { page: 0, size: 20 }, + }) + return response.data +} diff --git a/src/lib/keycloak/KeycloakProvider.tsx b/src/lib/keycloak/KeycloakProvider.tsx index 6d46876..fb642e3 100644 --- a/src/lib/keycloak/KeycloakProvider.tsx +++ b/src/lib/keycloak/KeycloakProvider.tsx @@ -23,6 +23,23 @@ export default function KeycloakProvider({ children }: { children: ReactNode }) const [authenticated, setAuthenticated] = useState(false) useEffect(() => { + // Fast path: ROPC token already in localStorage — validate expiry and skip keycloak.init() + const existingToken = localStorage.getItem('kc_token') + if (existingToken) { + try { + const payload = JSON.parse(atob(existingToken.split('.')[1])) + if (payload.exp * 1000 > Date.now()) { + setAuthenticated(true) + setInitialized(true) + return + } else { + localStorage.removeItem('kc_token') + } + } catch { + localStorage.removeItem('kc_token') + } + } + if (keycloak.didInitialize) { setAuthenticated(keycloak.authenticated ?? false) if (keycloak.token) localStorage.setItem('kc_token', keycloak.token) diff --git a/src/modules/account-mapper/SelfServicePortal.tsx b/src/modules/account-mapper/SelfServicePortal.tsx index 11ab645..08bcae5 100644 --- a/src/modules/account-mapper/SelfServicePortal.tsx +++ b/src/modules/account-mapper/SelfServicePortal.tsx @@ -1,16 +1,15 @@ -import React, { useState } from 'react' -import { Link } from 'react-router-dom' +import React, { useEffect, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { useQuery, useMutation } from '@tanstack/react-query' +import { AlertCircle, CheckCircle, Loader2 } from 'lucide-react' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Button } from '@/components/ui/button' import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' -import { CheckCircle, AlertCircle, UserCircle } from 'lucide-react' +import { lookupBeneficiary, updateBeneficiary } from '@/lib/api/accountMapper' +import { beneficiaries as mockBeneficiaries } from './mocks/beneficiaries.mock' const PAYMENT_MODALITIES = [ '(01) Mobile Money', @@ -19,297 +18,231 @@ const PAYMENT_MODALITIES = [ '(04) Bank Account', ] -const FINANCIAL_INSTITUTIONS = [ - 'GreenBank', - 'BlueBank', - 'RedBank', - 'HDFC', - 'SBI', - 'DigiWallet', - 'VoucherFSP', -] - -interface FormState { +interface BeneficiaryData { functionalId: string - fullName: string + governmentEntity: string financialInstitution: string financialAddress: string paymentModality: string - confirmAddress: string } -const empty: FormState = { - functionalId: '', - fullName: '', - financialInstitution: '', - financialAddress: '', - paymentModality: '', - confirmAddress: '', +function findInMock(beneficiaryId: string): BeneficiaryData | null { + const hit = mockBeneficiaries.find( + (b) => + b.functionalId === beneficiaryId || + b.financialAddress.includes(beneficiaryId), + ) + return hit + ? { + functionalId: hit.functionalId, + governmentEntity: hit.governmentEntity, + financialInstitution: hit.financialInstitution, + financialAddress: hit.financialAddress, + paymentModality: hit.paymentModality, + } + : null } -type Step = 'lookup' | 'update' | 'success' - export default function SelfServicePortal() { - const [step, setStep] = useState('lookup') - const [form, setForm] = useState(empty) - const [lookupId, setLookupId] = useState('') - const [lookupError, setLookupError] = useState(false) - - function set(field: keyof FormState, value: string) { - setForm((prev) => ({ ...prev, [field]: value })) - } - - function handleLookup(e: React.FormEvent) { - e.preventDefault() - // Simulate lookup: any non-empty ID that starts with "BEN-" is found - if (lookupId.trim().toUpperCase().startsWith('BEN-')) { - setLookupError(false) - setForm((prev) => ({ ...prev, functionalId: lookupId.trim().toUpperCase() })) - setStep('update') - } else { - setLookupError(true) + const [searchParams] = useSearchParams() + const beneficiaryId = searchParams.get('beneficiaryId') ?? '' + + const [financialInstitution, setFinancialInstitution] = useState('') + const [financialAddress, setFinancialAddress] = useState('') + const [paymentModality, setPaymentModality] = useState('') + const [successMsg, setSuccessMsg] = useState('') + const [errorMsg, setErrorMsg] = useState('') + + // Lookup query — falls back to mock on error + const { data: apiData, isLoading, isError } = useQuery({ + queryKey: ['beneficiary', beneficiaryId], + queryFn: () => lookupBeneficiary(beneficiaryId), + enabled: !!beneficiaryId, + retry: 1, + }) + + // Resolve beneficiary: API first, mock fallback + const beneficiary: BeneficiaryData | null = + apiData ?? (isError ? findInMock(beneficiaryId) : null) + + // Pre-fill form once data resolves + useEffect(() => { + if (beneficiary) { + setFinancialInstitution(beneficiary.financialInstitution) + setFinancialAddress(beneficiary.financialAddress) + setPaymentModality(beneficiary.paymentModality) } - } + }, [beneficiary?.functionalId]) // eslint-disable-line react-hooks/exhaustive-deps + + const mutation = useMutation({ + mutationFn: () => + updateBeneficiary(beneficiaryId, { + financialInstitution, + financialAddress, + paymentModality, + }), + onSuccess: () => { + setSuccessMsg('Your payment details have been updated successfully.') + setErrorMsg('') + }, + onError: () => { + setErrorMsg('Failed to update details. Please try again.') + setSuccessMsg('') + }, + }) function handleSubmit(e: React.FormEvent) { e.preventDefault() - if (form.financialAddress !== form.confirmAddress) return - setStep('success') - } - - function handleReset() { - setStep('lookup') - setLookupId('') - setLookupError(false) - setForm(empty) + setSuccessMsg('') + setErrorMsg('') + mutation.mutate() } - const mismatch = form.confirmAddress.length > 0 && form.financialAddress !== form.confirmAddress - - return ( -
- {/* Top bar */} -
-
- Payment Hub EE + // ── States ───────────────────────────────────────────────────────────── + + const shell = (content: React.ReactNode) => ( +
+
+ {/* Logo */} +
+ Payment Hub EE { e.currentTarget.style.display = 'none' }} + />
- - ← Back to Admin - -
- -
-
- - {/* Step 1 — Lookup */} - {step === 'lookup' && ( -
-
-
-
-
- -
-

Update Your Account

-

- Enter your Functional ID to look up your record and update your financial account details. -

-
+ {/* Card */} +
+
+
+ {content} +
+
+

Powered by Mifos Initiative

+
+
+ ) -
-
- - { setLookupId(e.target.value); setLookupError(false) }} - required - /> - {lookupError && ( -

- - No record found for this ID. Please check and try again. -

- )} -
- -
+ if (!beneficiaryId) { + return shell( +
+ + No beneficiary ID provided. Please use a valid link. +
, + ) + } -

- Your Functional ID is provided by your government programme administrator. -

-
-
- )} + if (isLoading) { + return shell( +
+ +

Looking up your account…

+
, + ) + } - {/* Step 2 — Update form */} - {step === 'update' && ( -
-
-
-
-

Update Account Details

-

- Record found for {form.functionalId}. Fill in your updated details below. -

-
+ if (!beneficiary) { + return shell( +
+ + No account details found for ID: {beneficiaryId} +
, + ) + } -
-
- - set('fullName', e.target.value)} - required - /> -
+ // ── Main form ─────────────────────────────────────────────────────────── -
- - -
+ return shell( + <> +
+

Update Payment Details

+

Review and update your financial account information below.

+
-
- - -
+ {successMsg && ( +
+ + {successMsg} +
+ )} + {errorMsg && ( +
+ + {errorMsg} +
+ )} + + + {/* Read-only fields */} +
+ + +
-
- - set('financialAddress', e.target.value)} - required - /> -

- Enter your account number, registered mobile number, or wallet identifier. -

-
+
+ + +
-
- - set('confirmAddress', e.target.value)} - required - className={mismatch ? 'border-red-400 focus-visible:ring-red-300' : ''} - /> - {mismatch && ( -

- - Financial addresses do not match. -

- )} -
+ {/* Editable fields */} +
+ + setFinancialInstitution(e.target.value)} + required + /> +
-
- - -
-
-
-
- )} +
+ + setFinancialAddress(e.target.value)} + required + /> +

Your registered account number, mobile number, or wallet identifier.

+
- {/* Step 3 — Success */} - {step === 'success' && ( -
-
-
-
-
- -
-
-

Request Submitted

-

- Your account update request has been received. Changes will be reviewed and applied within 1–2 business days. -

-
-
- Functional ID - {form.functionalId} -
-
- Institution - {form.financialInstitution} -
-
- Modality - {form.paymentModality} -
-
- -
-
- )} +
+ +
-
-
- Powered by Mifos Initiative -
-
+ + + , ) } diff --git a/src/modules/payment-hub/MainBatchesTab.tsx b/src/modules/payment-hub/MainBatchesTab.tsx index 26dfc6f..8f2ed78 100644 --- a/src/modules/payment-hub/MainBatchesTab.tsx +++ b/src/modules/payment-hub/MainBatchesTab.tsx @@ -1,107 +1,103 @@ import { useState } from 'react' -import { mainBatches } from './mocks/mainBatches.mock' +import { useQuery } from '@tanstack/react-query' +import { fetchMainBatches } from '@/lib/api/paymentHub' +import { mainBatches as mockBatches } from './mocks/mainBatches.mock' import type { MainBatch } from './types' import StatusBadge from '@/components/shared/StatusBadge' import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Button } from '@/components/ui/button' -import { ChevronLeft, ChevronRight, Download, FileText } from 'lucide-react' +import { ChevronLeft, ChevronRight, Download, FileText, AlertCircle } from 'lucide-react' import { exportCsv, csvDate } from '@/lib/exportCsv' import { exportPdf } from '@/lib/exportPdf' -const statuses: MainBatch['status'][] = [ - 'Completed', - 'Partially Authorized', - 'Rejected', -] +const STATUSES: MainBatch['status'][] = ['Completed', 'Partially Authorized', 'Rejected'] +const SKELETON_ROWS = 5 export default function MainBatchesTab() { const [statusFilter, setStatusFilter] = useState('all') const [page, setPage] = useState(1) const [perPage, setPerPage] = useState(10) - const filtered = mainBatches.filter( + const { data: apiData, isLoading, isError } = useQuery({ + queryKey: ['mainBatches'], + queryFn: fetchMainBatches, + }) + + // Fall back to mock data when API returns empty or errors + const rows: MainBatch[] = (apiData?.data?.length ? apiData.data : mockBatches) + const totalCount: number = apiData?.totalBatches ?? rows.length + + const filtered = rows.filter( (b) => statusFilter === 'all' || b.status === statusFilter ) - const totalPages = Math.max(1, Math.ceil(filtered.length / perPage)) const paginated = filtered.slice((page - 1) * perPage, page * perPage) + const exportRows = filtered as unknown as Record[] + return (
{/* Filters + Export */}
-
- - {statuses.map((s) => ( +
- ))} -
+ {STATUSES.map((s) => ( + + ))} +
+ {/* Error banner */} + {isError && ( +
+ + Could not reach the API — showing cached data. +
+ )} + {/* Table */}
@@ -119,34 +115,38 @@ export default function MainBatchesTab() { - {paginated.map((batch) => ( - - - {batch.batchReferenceNumber} - - {batch.startTime} - {batch.completedTime} - {batch.registeringInstitutionId} - {batch.sourceMinistry} - - {batch.numberOfInstructions} - - - {batch.amount.toLocaleString()} - - {batch.payerFSP} - - - - - ))} - {paginated.length === 0 && ( - - - No records found. - - - )} + {isLoading + ? Array.from({ length: SKELETON_ROWS }).map((_, i) => ( + + {Array.from({ length: 9 }).map((__, j) => ( + +
+ + ))} + + )) + : paginated.length > 0 + ? paginated.map((batch) => ( + + {batch.batchReferenceNumber} + {batch.startTime} + {batch.completedTime} + {batch.registeringInstitutionId} + {batch.sourceMinistry} + {batch.numberOfInstructions} + {batch.amount.toLocaleString()} + {batch.payerFSP} + + + )) + : ( + + + No records found. + + + ) + }
@@ -154,16 +154,8 @@ export default function MainBatchesTab() {
Items per page - { setPerPage(Number(v)); setPage(1) }}> + 5 10 @@ -173,23 +165,15 @@ export default function MainBatchesTab() {
- {(page - 1) * perPage + 1}– - {Math.min(page * perPage, filtered.length)} of {filtered.length} + {filtered.length === 0 + ? '0–0 of 0' + : `${(page - 1) * perPage + 1}–${Math.min(page * perPage, filtered.length)} of ${totalCount}` + } - -
diff --git a/src/modules/payment-hub/TransfersTab.tsx b/src/modules/payment-hub/TransfersTab.tsx index 76942f4..c333d9f 100644 --- a/src/modules/payment-hub/TransfersTab.tsx +++ b/src/modules/payment-hub/TransfersTab.tsx @@ -1,33 +1,22 @@ import { useState } from 'react' -import { transfers } from './mocks/transfers.mock' +import { useQuery } from '@tanstack/react-query' +import { fetchTransfers } from '@/lib/api/paymentHub' +import { transfers as mockTransfers } from './mocks/transfers.mock' import type { Transfer } from './types' import StatusBadge from '@/components/shared/StatusBadge' import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Button } from '@/components/ui/button' -import { ChevronLeft, ChevronRight, Download, FileText } from 'lucide-react' +import { ChevronLeft, ChevronRight, Download, FileText, AlertCircle } from 'lucide-react' import { exportCsv, csvDate } from '@/lib/exportCsv' import { exportPdf } from '@/lib/exportPdf' -const statuses: Transfer['status'][] = [ - 'Completed', - 'Partially Authorized', - 'Pending', - 'Rejected', -] +const STATUSES: Transfer['status'][] = ['Completed', 'Partially Authorized', 'Pending', 'Rejected'] +const SKELETON_ROWS = 5 export default function TransfersTab() { const [statusFilter, setStatusFilter] = useState('all') @@ -35,101 +24,93 @@ export default function TransfersTab() { const [page, setPage] = useState(1) const [perPage, setPerPage] = useState(10) - const filtered = transfers.filter((t) => { + const { data: apiData, isLoading, isError } = useQuery({ + queryKey: ['transfers'], + queryFn: fetchTransfers, + }) + + const rows: Transfer[] = (apiData?.content?.length ? apiData.content : mockTransfers) + const totalCount: number = apiData?.totalElements ?? rows.length + + const payers = [...new Set(rows.map((t) => t.payerFSP))] + + const filtered = rows.filter((t) => { if (statusFilter !== 'all' && t.status !== statusFilter) return false if (payerFilter !== 'all' && t.payerFSP !== payerFilter) return false return true }) - const totalPages = Math.max(1, Math.ceil(filtered.length / perPage)) const paginated = filtered.slice((page - 1) * perPage, page * perPage) - const payers = [...new Set(transfers.map((t) => t.payerFSP))] + const exportRows = filtered as unknown as Record[] return (
{/* Filters + Export */}
-
-
- - {statuses.map((s) => ( +
+
- ))} -
- - -
+
+ +
+ {/* Error banner */} + {isError && ( +
+ + Could not reach the API — showing cached data. +
+ )} + {/* Table */}
@@ -145,30 +126,36 @@ export default function TransfersTab() { - {paginated.map((transfer) => ( - - - {transfer.transactionId} - - {transfer.startTime} - {transfer.completedTime || '—'} - {transfer.sourceMinistry} - - {transfer.bulkAmount.toLocaleString()} - - {transfer.payerFSP} - - - - - ))} - {paginated.length === 0 && ( - - - No records found. - - - )} + {isLoading + ? Array.from({ length: SKELETON_ROWS }).map((_, i) => ( + + {Array.from({ length: 7 }).map((__, j) => ( + +
+ + ))} + + )) + : paginated.length > 0 + ? paginated.map((transfer) => ( + + {transfer.transactionId} + {transfer.startTime} + {transfer.completedTime || '—'} + {transfer.sourceMinistry} + {transfer.bulkAmount.toLocaleString()} + {transfer.payerFSP} + + + )) + : ( + + + No records found. + + + ) + }
@@ -176,16 +163,8 @@ export default function TransfersTab() {
Items per page - { setPerPage(Number(v)); setPage(1) }}> + 5 10 @@ -195,23 +174,15 @@ export default function TransfersTab() {
- {(page - 1) * perPage + 1}– - {Math.min(page * perPage, filtered.length)} of {filtered.length} + {filtered.length === 0 + ? '0–0 of 0' + : `${(page - 1) * perPage + 1}–${Math.min(page * perPage, filtered.length)} of ${totalCount}` + } - -
diff --git a/src/modules/reporting/Reporting.tsx b/src/modules/reporting/Reporting.tsx new file mode 100644 index 0000000..2f1fdb4 --- /dev/null +++ b/src/modules/reporting/Reporting.tsx @@ -0,0 +1,265 @@ +import { useState } from 'react' +import { + LineChart, Line, + BarChart, Bar, + PieChart, Pie, Cell, + XAxis, YAxis, CartesianGrid, Tooltip, Legend, + ResponsiveContainer, +} from 'recharts' +import { TrendingUp, TrendingDown } from 'lucide-react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select' +import StatusBadge from '@/components/shared/StatusBadge' + +// ── Mock data ────────────────────────────────────────────────────────────── + +const volumeData = [ + { date: 'Jul 17', completed: 142, failed: 18 }, + { date: 'Jul 18', completed: 189, failed: 24 }, + { date: 'Jul 19', completed: 97, failed: 31 }, + { date: 'Jul 20', completed: 213, failed: 15 }, + { date: 'Jul 21', completed: 176, failed: 22 }, + { date: 'Jul 22', completed: 234, failed: 29 }, + { date: 'Jul 23', completed: 196, failed: 20 }, +] + +const pieData = [ + { name: 'Completed', value: 62, color: '#22c55e' }, + { name: 'Partially Authorized', value: 28, color: '#f97316' }, + { name: 'Rejected', value: 10, color: '#ef4444' }, +] + +const ministryData = [ + { ministry: 'Finance', amount: 560 }, + { ministry: 'Health', amount: 320 }, + { ministry: 'Education', amount: 410 }, + { ministry: 'Agriculture',amount: 190 }, + { ministry: 'Transport', amount: 280 }, + { ministry: 'Rural Dev', amount: 230 }, +] + +const dfspData = [ + { dfsp: 'GreenBank', rate: 8.2 }, + { dfsp: 'BlueBank', rate: 14.7 }, + { dfsp: 'RedBank', rate: 19.3 }, + { dfsp: 'HDFC', rate: 6.1 }, + { dfsp: 'SBI', rate: 11.5 }, +] + +const failedBatches = [ + { ref: 'BATCH-004-2026', ministry: 'Ministry of Agriculture', amount: '$780K', reason: 'Insufficient funds', status: 'Rejected' }, + { ref: 'BATCH-008-2026', ministry: 'Ministry of Education', amount: '$4.2M', reason: 'Invalid account numbers', status: 'Rejected' }, + { ref: 'BATCH-011-2026', ministry: 'Ministry of Transport', amount: '$1.1M', reason: 'Compliance check failed', status: 'Rejected' }, + { ref: 'BATCH-015-2026', ministry: 'Ministry of Health', amount: '$650K', reason: 'Duplicate batch detected', status: 'Rejected' }, + { ref: 'BATCH-019-2026', ministry: 'Ministry of Finance', amount: '$2.3M', reason: 'Timeout — payer FSP', status: 'Rejected' }, +] + +const TENANTS = ['greenbank', 'bluebank', 'redbank'] + +// ── KPI Card ─────────────────────────────────────────────────────────────── + +function KpiCard({ label, value, trend }: { label: string; value: string; trend: 'up' | 'down' }) { + const isUp = trend === 'up' + return ( + + +

{label}

+
+ {value} + {isUp + ? + : } +
+
+
+ ) +} + +// ── Page ─────────────────────────────────────────────────────────────────── + +export default function Reporting() { + const [fromDate, setFromDate] = useState('2026-07-17') + const [toDate, setToDate] = useState('2026-07-23') + const [tenant, setTenant] = useState('all') + + return ( +
+

Reporting Dashboard

+ + {/* Filters */} + + +
+
+ + setFromDate(e.target.value)} className="w-40" /> +
+
+ + setToDate(e.target.value)} className="w-40" /> +
+
+ + +
+ +
+
+
+ + {/* Row 1 — KPIs */} +
+ + + + +
+ + {/* Row 2 — Line + Pie */} +
+ {/* Transaction Volume Over Time */} + + + Transaction Volume Over Time + + + + + + + + + + + + + + + + + {/* Batch Status Breakdown */} + + + Batch Status Breakdown + + + + + + {pieData.map((entry) => ( + + ))} + + `${v}%`} /> + + +
+ {pieData.map((d) => ( +
+
+ + {d.name} +
+ {d.value}% +
+ ))} +
+
+
+
+ + {/* Row 3 — Bar charts */} +
+ {/* Amount by Ministry */} + + + Amount Disbursed by Ministry ($K) + + + + + + + + `$${v}K`} /> + + + + + + + {/* Failure Rate by DFSP */} + + + Failure Rate by DFSP (%) + + + + + + + + `${v}%`} /> + + + + + +
+ + {/* Row 4 — Failed batches table */} + + + Recent Failed Batches + + + + + + + + + + + + + + {failedBatches.map((b) => ( + + + + + + + + ))} + +
Batch ReferenceMinistryAmountFailure ReasonStatus
{b.ref}{b.ministry}{b.amount}{b.reason}
+
+
+
+ ) +} diff --git a/src/pages/Reporting.tsx b/src/pages/Reporting.tsx index 69ebc91..9943cdd 100644 --- a/src/pages/Reporting.tsx +++ b/src/pages/Reporting.tsx @@ -1,7 +1 @@ -export default function Reporting() { - return ( -
- Reporting -
- ) -} +export { default } from '@/modules/reporting/Reporting'