commit f430f8520243c65570a595b4b29b0c9256b21b5f Author: LincPay Date: Thu Aug 6 13:30:24 2026 +0530 chore: initial commit — UdyamLinc UI, phases A-E Vite/React 19 console and enterprise PWA. Officer console screens, offline ledger and sync, data feeds and trip-wire rules screens with freshness chip. Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..eeaf4a8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.git +dist +# never let a local API override leak into the deployed build +*.local +.env.local diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9ab25f7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +# ── build stage: compile the Vite bundle inside Docker ── +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +# VITE_UDYAM_API_BASE_URL is unset here, so the compiled-in default +# (http://udyamlinc-backend.lincpay/api/v1/udyam) is baked into the bundle. +RUN npm run build + +# ── serve stage: nginx serving the static SPA on 5173 ── +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 5173 diff --git a/index.html b/index.html new file mode 100644 index 0000000..b275768 --- /dev/null +++ b/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + UdyamLinc · Early Warning Console + + +
+ + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..d988963 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 5173; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/plain text/css application/json application/javascript image/svg+xml; + gzip_min_length 1024; + + # Immutable fingerprinted assets + location /assets/ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # The service worker must never be cached, or the PWA can never update + location = /sw.js { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + # SPA fallback — React Router owns the paths + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d6c5beb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2367 @@ +{ + "name": "udyamlinc-ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "udyamlinc-ui", + "version": "0.0.0", + "dependencies": { + "@tanstack/react-query": "^5.101.2", + "axios": "^1.18.1", + "chart.js": "^4.5.1", + "react": "^19.2.7", + "react-chartjs-2": "^5.3.1", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "tailwindcss": "^4.3.2", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.77.0.tgz", + "integrity": "sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.77.0.tgz", + "integrity": "sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.77.0.tgz", + "integrity": "sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.77.0.tgz", + "integrity": "sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.77.0.tgz", + "integrity": "sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.77.0.tgz", + "integrity": "sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.77.0.tgz", + "integrity": "sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.77.0.tgz", + "integrity": "sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.77.0.tgz", + "integrity": "sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.77.0.tgz", + "integrity": "sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.77.0.tgz", + "integrity": "sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.77.0.tgz", + "integrity": "sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.77.0.tgz", + "integrity": "sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.77.0.tgz", + "integrity": "sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.77.0.tgz", + "integrity": "sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.77.0.tgz", + "integrity": "sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.77.0.tgz", + "integrity": "sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.77.0.tgz", + "integrity": "sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.77.0.tgz", + "integrity": "sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz", + "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz", + "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz", + "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz", + "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz", + "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz", + "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz", + "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz", + "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz", + "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz", + "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz", + "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz", + "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz", + "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz", + "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.77.0.tgz", + "integrity": "sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.77.0", + "@oxlint/binding-android-arm64": "1.77.0", + "@oxlint/binding-darwin-arm64": "1.77.0", + "@oxlint/binding-darwin-x64": "1.77.0", + "@oxlint/binding-freebsd-x64": "1.77.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.77.0", + "@oxlint/binding-linux-arm-musleabihf": "1.77.0", + "@oxlint/binding-linux-arm64-gnu": "1.77.0", + "@oxlint/binding-linux-arm64-musl": "1.77.0", + "@oxlint/binding-linux-ppc64-gnu": "1.77.0", + "@oxlint/binding-linux-riscv64-gnu": "1.77.0", + "@oxlint/binding-linux-riscv64-musl": "1.77.0", + "@oxlint/binding-linux-s390x-gnu": "1.77.0", + "@oxlint/binding-linux-x64-gnu": "1.77.0", + "@oxlint/binding-linux-x64-musl": "1.77.0", + "@oxlint/binding-openharmony-arm64": "1.77.0", + "@oxlint/binding-win32-arm64-msvc": "1.77.0", + "@oxlint/binding-win32-ia32-msvc": "1.77.0", + "@oxlint/binding-win32-x64-msvc": "1.77.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-chartjs-2": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz", + "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz", + "integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.2", + "@rolldown/binding-darwin-arm64": "1.2.2", + "@rolldown/binding-darwin-x64": "1.2.2", + "@rolldown/binding-freebsd-x64": "1.2.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.2", + "@rolldown/binding-linux-arm64-gnu": "1.2.2", + "@rolldown/binding-linux-arm64-musl": "1.2.2", + "@rolldown/binding-linux-ppc64-gnu": "1.2.2", + "@rolldown/binding-linux-s390x-gnu": "1.2.2", + "@rolldown/binding-linux-x64-gnu": "1.2.2", + "@rolldown/binding-linux-x64-musl": "1.2.2", + "@rolldown/binding-openharmony-arm64": "1.2.2", + "@rolldown/binding-win32-arm64-msvc": "1.2.2", + "@rolldown/binding-win32-x64-msvc": "1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b454ade --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "udyamlinc-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.101.2", + "axios": "^1.18.1", + "chart.js": "^4.5.1", + "react": "^19.2.7", + "react-chartjs-2": "^5.3.1", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "tailwindcss": "^4.3.2", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } +} diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..756165e --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "UdyamLinc — उद्यम हिसाब", + "short_name": "UdyamLinc", + "description": "Cash-flow forecast and early warning for microenterprises. Works offline.", + "start_url": "/app", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#06301f", + "theme_color": "#0b5d3b", + "lang": "hi", + "icons": [ + { "src": "/udyamlinc-tile.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..bc8c284 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,54 @@ +/* + * UdyamLinc service worker — app-shell precache + stale-while-revalidate. + * + * Scope of the offline promise (deck feature 7): the enterprise PWA must open, + * render and accept ledger entries with zero connectivity. Ledger writes are + * queued in localStorage by the app itself (see lib/ledgerStore.ts); this worker + * only guarantees the shell and its assets are reachable. + */ +const CACHE = 'udyamlinc-v1' +const SHELL = ['/', '/app', '/index.html', '/manifest.webmanifest', '/udyamlinc-tile.svg'] + +self.addEventListener('install', (event) => { + event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting())) +}) + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()), + ) +}) + +self.addEventListener('fetch', (event) => { + const req = event.request + if (req.method !== 'GET') return + + const url = new URL(req.url) + // API calls are never served stale — the app falls back to its local store instead. + if (url.pathname.includes('/api/')) return + + // Navigations: network first, fall back to the cached shell when offline. + if (req.mode === 'navigate') { + event.respondWith(fetch(req).catch(() => caches.match('/index.html'))) + return + } + + // Assets: cache first, refresh in the background. + event.respondWith( + caches.match(req).then((hit) => { + const network = fetch(req) + .then((res) => { + if (res.ok) { + const copy = res.clone() + caches.open(CACHE).then((c) => c.put(req, copy)) + } + return res + }) + .catch(() => hit) + return hit || network + }), + ) +}) diff --git a/public/udyamlinc-tile.svg b/public/udyamlinc-tile.svg new file mode 100644 index 0000000..349a4d9 --- /dev/null +++ b/public/udyamlinc-tile.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..68a6daf --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,104 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom' +import { LoginPage } from './pages/LoginPage' +import { enterpriseSession, officerSession } from './lib/api' + +// Field officer console +import { OfficerShell } from './features/officer/OfficerShell' +import { OfficerDashboard } from './features/officer/OfficerDashboard' +import { EnterprisesPage } from './features/officer/EnterprisesPage' +import { EnterpriseDetailPage } from './features/officer/EnterpriseDetailPage' +import { AlertsPage } from './features/officer/AlertsPage' +import { ForecastsPage } from './features/officer/ForecastsPage' +import { VisitsPage } from './features/officer/VisitsPage' +import { RulesPage } from './features/officer/RulesPage' +import { DataFeedsPage } from './features/officer/DataFeedsPage' +import { ReportsPage } from './features/officer/ReportsPage' +import { SettingsPage } from './features/officer/SettingsPage' + +// Enterprise PWA +import { EnterpriseShell } from './features/enterprise/EnterpriseShell' +import { AppHomePage } from './features/enterprise/AppHomePage' +import { AppLedgerPage } from './features/enterprise/AppLedgerPage' +import { AppAlertsPage } from './features/enterprise/AppAlertsPage' +import { AppProfilePage } from './features/enterprise/AppProfilePage' + +/** + * Queries fall back to demo data per page, so a failed request should not spin + * or spam retries — one attempt, generous staleness, no refetch on focus. + */ +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: 0, refetchOnWindowFocus: false, staleTime: 60_000 }, + }, +}) + +function RequireOfficer({ children }: { children: React.ReactNode }) { + const location = useLocation() + if (!officerSession()?.token) return + return children +} + +function RequireEnterprise({ children }: { children: React.ReactNode }) { + const location = useLocation() + if (!enterpriseSession()?.token) return + return children +} + +/** Land on whichever surface already has a session. */ +function RootRedirect() { + if (officerSession()?.token) return + if (enterpriseSession()?.token) return + return +} + +export default function App() { + return ( + + + + } /> + } /> + + {/* Field officer console — portfolio, risk detail and recommended action */} + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* Enterprise PWA — offline, Hindi-first */} + + + + } + > + } /> + } /> + } /> + } /> + + + } /> + + + + ) +} diff --git a/src/components/charts/BandMigrationBar.tsx b/src/components/charts/BandMigrationBar.tsx new file mode 100644 index 0000000..d19d588 --- /dev/null +++ b/src/components/charts/BandMigrationBar.tsx @@ -0,0 +1,41 @@ +import { Bar } from 'react-chartjs-2' +import type { ChartOptions } from 'chart.js' +import { CHART_COLOURS } from './chartSetup' +import './chartSetup' + +export interface MigrationRow { + month: string + red: number + watch: number + standard: number +} + +/** Stacked band counts month over month — how the book migrated between watchlist categories. */ +export function BandMigrationBar({ rows, height = 240 }: { rows: MigrationRow[]; height?: number }) { + const data = { + labels: rows.map((r) => r.month), + datasets: [ + { label: 'High risk', data: rows.map((r) => r.red), backgroundColor: CHART_COLOURS.red, borderRadius: 3 }, + { label: 'Watch', data: rows.map((r) => r.watch), backgroundColor: CHART_COLOURS.amber, borderRadius: 3 }, + { label: 'Standard', data: rows.map((r) => r.standard), backgroundColor: CHART_COLOURS.green, borderRadius: 3 }, + ], + } + + const options: ChartOptions<'bar'> = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { position: 'bottom', labels: { boxWidth: 10, boxHeight: 10, padding: 14, usePointStyle: true } }, + }, + scales: { + x: { stacked: true, grid: { display: false } }, + y: { stacked: true, grid: { color: CHART_COLOURS.grid } }, + }, + } + + return ( +
+ +
+ ) +} diff --git a/src/components/charts/ForecastFan.tsx b/src/components/charts/ForecastFan.tsx new file mode 100644 index 0000000..304e9dd --- /dev/null +++ b/src/components/charts/ForecastFan.tsx @@ -0,0 +1,87 @@ +import { Line } from 'react-chartjs-2' +import type { ChartOptions } from 'chart.js' +import type { ForecastPoint } from '../../lib/api' +import { CHART_COLOURS } from './chartSetup' +import './chartSetup' +import { inr } from '../../lib/demo/seed' + +/** + * The P10 / P50 / P90 quantile fan against the scheduled-obligation line. + * Where the P10 band dips under the dashed obligation line, the enterprise + * escalates — that crossing is the whole product in one picture. + */ +export function ForecastFan({ points, height = 240 }: { points: ForecastPoint[]; height?: number }) { + const labels = points.map((p) => p.label) + + const data = { + labels, + datasets: [ + // P90 drawn first, then P10 filled back to it — that pair renders the band. + { + label: 'P90 (upside)', + data: points.map((p) => p.p90), + borderColor: 'rgba(63,184,113,0.45)', + backgroundColor: 'rgba(63,184,113,0.12)', + borderWidth: 1, + pointRadius: 0, + fill: false, + tension: 0.35, + }, + { + label: 'P10 (downside)', + data: points.map((p) => p.p10), + borderColor: 'rgba(192,57,43,0.55)', + backgroundColor: 'rgba(63,184,113,0.14)', + borderWidth: 1, + pointRadius: 0, + fill: '-1', + tension: 0.35, + }, + { + label: 'P50 (expected)', + data: points.map((p) => p.p50), + borderColor: CHART_COLOURS.forest, + backgroundColor: CHART_COLOURS.forest, + borderWidth: 2.4, + pointRadius: 3, + pointHoverRadius: 5, + fill: false, + tension: 0.35, + }, + { + label: 'Obligation due', + data: points.map((p) => p.obligation), + borderColor: CHART_COLOURS.amber, + borderWidth: 1.8, + borderDash: [5, 4], + pointRadius: 0, + fill: false, + }, + ], + } + + const options: ChartOptions<'line'> = { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { position: 'bottom', labels: { boxWidth: 10, boxHeight: 10, padding: 14, usePointStyle: true } }, + tooltip: { + callbacks: { label: (c) => `${c.dataset.label}: ${inr(c.parsed.y ?? 0)}` }, + }, + }, + scales: { + x: { grid: { display: false } }, + y: { + grid: { color: CHART_COLOURS.grid }, + ticks: { callback: (v) => `₹${Math.round(Number(v) / 1000)}k` }, + }, + }, + } + + return ( +
+ +
+ ) +} diff --git a/src/components/charts/ModelComparisonBar.tsx b/src/components/charts/ModelComparisonBar.tsx new file mode 100644 index 0000000..6d68109 --- /dev/null +++ b/src/components/charts/ModelComparisonBar.tsx @@ -0,0 +1,38 @@ +import { Bar } from 'react-chartjs-2' +import type { ChartOptions } from 'chart.js' +import { BENCHMARKS } from '../../lib/demo/ops' +import { CHART_COLOURS } from './chartSetup' +import './chartSetup' + +/** Slide-10 benchmark: 90-day stress detection AUC-ROC against two baselines. */ +export function ModelComparisonBar({ height = 190 }: { height?: number }) { + const data = { + labels: ['UdyamLinc — full signal set', 'Repayment history only', 'Bureau-score baseline'], + datasets: [ + { + label: 'AUC-ROC', + data: [BENCHMARKS.aucRoc, BENCHMARKS.aucRepaymentOnly, BENCHMARKS.aucBureau], + backgroundColor: [CHART_COLOURS.forest, CHART_COLOURS.greenHi, '#94a3b8'], + borderRadius: 4, + barThickness: 22, + }, + ], + } + + const options: ChartOptions<'bar'> = { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + x: { min: 0, max: 1, grid: { color: CHART_COLOURS.grid }, ticks: { stepSize: 0.1 } }, + y: { grid: { display: false }, ticks: { font: { size: 10.5 } } }, + }, + } + + return ( +
+ +
+ ) +} diff --git a/src/components/charts/SubScoreRadar.tsx b/src/components/charts/SubScoreRadar.tsx new file mode 100644 index 0000000..0e3ea43 --- /dev/null +++ b/src/components/charts/SubScoreRadar.tsx @@ -0,0 +1,47 @@ +import { Radar } from 'react-chartjs-2' +import type { ChartOptions } from 'chart.js' +import type { Band, SubScores } from '../../lib/api' +import { DRIVER_META } from '../../lib/demo/enterprises' +import './chartSetup' + +/** The five risk families as a radar — shows the *shape* of the stress, not just its size. */ +export function SubScoreRadar({ sub, band, height = 230 }: { sub: SubScores; band: Band; height?: number }) { + const colour = band === 'RED' ? '#c0392b' : band === 'WATCH' ? '#e8a020' : '#1e7a46' + + const data = { + labels: DRIVER_META.map((d) => d.label), + datasets: [ + { + label: 'Sub-score', + data: DRIVER_META.map((d) => sub[d.key]), + borderColor: colour, + backgroundColor: `${colour}22`, + borderWidth: 2, + pointBackgroundColor: colour, + pointRadius: 3, + }, + ], + } + + const options: ChartOptions<'radar'> = { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + r: { + beginAtZero: true, + suggestedMax: 30, + angleLines: { color: '#e8eeea' }, + grid: { color: '#e8eeea' }, + pointLabels: { font: { size: 10 } }, + ticks: { display: false }, + }, + }, + } + + return ( +
+ +
+ ) +} diff --git a/src/components/charts/chartSetup.ts b/src/components/charts/chartSetup.ts new file mode 100644 index 0000000..e57beeb --- /dev/null +++ b/src/components/charts/chartSetup.ts @@ -0,0 +1,41 @@ +import { + BarElement, + CategoryScale, + Chart as ChartJS, + Filler, + Legend, + LineElement, + LinearScale, + PointElement, + RadialLinearScale, + Tooltip, +} from 'chart.js' + +/* + * chart.js elements are registered exactly once, here. Every chart component + * imports this module for its side effect, so no page re-registers. + */ +ChartJS.register( + CategoryScale, + LinearScale, + RadialLinearScale, + PointElement, + LineElement, + BarElement, + Filler, + Tooltip, + Legend, +) + +ChartJS.defaults.font.family = '"Segoe UI", Roboto, -apple-system, Arial, sans-serif' +ChartJS.defaults.font.size = 11 +ChartJS.defaults.color = '#64748b' + +export const CHART_COLOURS = { + forest: '#0b5d3b', + green: '#1e7a46', + greenHi: '#3fb871', + amber: '#e8a020', + red: '#c0392b', + grid: '#e8eeea', +} diff --git a/src/components/ui/bits.tsx b/src/components/ui/bits.tsx new file mode 100644 index 0000000..ff6d24e --- /dev/null +++ b/src/components/ui/bits.tsx @@ -0,0 +1,310 @@ +import { NavLink } from 'react-router-dom' +import type { Band } from '../../lib/api' +import { BANDS } from '../../lib/demo/enterprises' + +/* ------------------------------------------------------------------ */ +/* Shared widgets. Structure follows BharatLinc's components/ui/bits; */ +/* the palette is UdyamLinc forest/amber, and the risk primitives at */ +/* the bottom (RiskBadge / ScoreDial / Sparkline / DriverBar) are new. */ +/* ------------------------------------------------------------------ */ + +export function StatusPill({ status }: { status: string }) { + const s = status.toUpperCase() + const cls = + s.includes('DONE') || s.includes('CLOSED') || s.includes('SYNCED') || s.includes('ACTIVE') + ? 'pill-ok' + : s.includes('OVERDUE') || s.includes('ESCALATED') || s.includes('OPEN') || s.includes('BREACH') + ? 'pill-bad' + : s.includes('PLANNED') || s.includes('PENDING') || s.includes('SNOOZED') + ? 'pill-pend' + : s.includes('ACK') + ? 'pill-info' + : 'pill-mut' + return {status.replace(/_/g, ' ')} +} + +export function Card({ + title, + badge, + actions, + children, + className = '', +}: { + title?: string + badge?: string + actions?: React.ReactNode + children: React.ReactNode + className?: string +}) { + return ( +
+ {title && ( +
+

{title}

+ {badge && {badge}} + {actions &&
{actions}
} +
+ )} + {children} +
+ ) +} + +export function Modal({ + title, + onClose, + children, + wide, + xwide, +}: { + title: string + onClose: () => void + children: React.ReactNode + wide?: boolean + xwide?: boolean +}) { + return ( +
+
e.stopPropagation()} + > +
+

{title}

+ +
+
{children}
+
+
+ ) +} + +export function SubNav({ items }: { items: { label: string; to: string; end?: boolean }[] }) { + return ( +
+ {items.map((i) => ( + + [ + 'px-3.5 py-2 text-[11.5px] font-semibold whitespace-nowrap border-b-2 -mb-px', + isActive ? 'text-forest border-amber' : 'text-slate-500 border-transparent hover:text-forest', + ].join(' ') + } + > + {i.label} + + ))} +
+ ) +} + +export function Field({ + label, + hint, + children, + className = '', +}: { + label: string + hint?: string + children: React.ReactNode + className?: string +}) { + return ( + + ) +} + +export const inputCls = + 'w-full border border-slate-300 rounded-md px-2.5 py-1.5 text-[12.5px] outline-none focus:border-green focus:ring-2 focus:ring-green/15 bg-white' + +export function PrimaryBtn({ + children, + className = '', + ...rest +}: React.ButtonHTMLAttributes) { + return ( + + ) +} + +export function GhostBtn({ + children, + className = '', + ...rest +}: React.ButtonHTMLAttributes) { + return ( + + ) +} + +export function ActionChip({ + children, + active, + ...rest +}: React.ButtonHTMLAttributes & { active?: boolean }) { + return ( + + ) +} + +/** Empty/loading state used wherever a list can come back with nothing. */ +export function Empty({ text }: { text: string }) { + return
{text}
+} + +// ── UdyamLinc risk primitives ─────────────────────────────────── + +export function RiskBadge({ band, hi = false }: { band: Band; hi?: boolean }) { + const b = BANDS[band] + return {hi ? b.labelHi : b.label} +} + +/** + * The 0–100 Composite Stress Score as an arc — the dial on the deck's risk + * detail panel. Red sweeps clockwise in proportion to the score. + */ +export function ScoreDial({ score, band, size = 96 }: { score: number; band: Band; size?: number }) { + const stroke = size * 0.11 + const r = (size - stroke) / 2 + const circ = 2 * Math.PI * r + const colour = band === 'RED' ? '#c0392b' : band === 'WATCH' ? '#e8a020' : '#1e7a46' + return ( +
+ + + + +
+
+ {score} +
+
+ {BANDS[band].label} +
+
+
+ ) +} + +/** Six-month score trend as an inline SVG — one per table row, so no chart.js. */ +export function Sparkline({ + values, + band, + width = 84, + height = 26, +}: { + values: number[] + band: Band + width?: number + height?: number +}) { + if (!values.length) return null + const min = Math.min(...values) + const max = Math.max(...values) + const span = Math.max(1, max - min) + const colour = band === 'RED' ? '#c0392b' : band === 'WATCH' ? '#e8a020' : '#1e7a46' + const pts = values + .map((v, i) => { + const x = (i / (values.length - 1)) * (width - 4) + 2 + const y = height - 3 - ((v - min) / span) * (height - 6) + return `${x.toFixed(1)},${y.toFixed(1)}` + }) + .join(' ') + const last = values[values.length - 1] + const lastX = width - 2 + const lastY = height - 3 - ((last - min) / span) * (height - 6) + return ( + + + + + ) +} + +/** One SHAP driver as a labelled bar — the "Top drivers" list on the deck. */ +export function DriverBar({ label, weight, max = 30 }: { label: string; weight: number; max?: number }) { + const pct = Math.min(100, (weight / max) * 100) + const colour = weight >= 20 ? 'bg-risk-red' : weight >= 12 ? 'bg-amber' : 'bg-green' + return ( +
+ {label} + + + + {weight} +
+ ) +} + +/** KPI tile used across the console — value, caption and a coloured corner dot. */ +export function KpiTile({ + label, + value, + delta, + tone = 'neutral', +}: { + label: string + value: string | number + delta?: string + tone?: 'neutral' | 'red' | 'watch' | 'good' +}) { + const dot = + tone === 'red' ? 'bg-risk-red' : tone === 'watch' ? 'bg-amber' : tone === 'good' ? 'bg-green' : 'bg-forest' + const deltaCls = tone === 'red' ? 'text-risk-red' : tone === 'watch' ? 'text-amber' : 'text-slate-400' + return ( +
+
+ {label} + +
+
{value}
+ {delta &&
{delta}
} +
+ ) +} + +/** UdyamLinc mark — hexagon + bolt, matching the deck's logo. */ +export function UdyamMark({ size = 26, onDark = false }: { size?: number; onDark?: boolean }) { + return ( + + + + + ) +} diff --git a/src/features/enterprise/AppAlertsPage.tsx b/src/features/enterprise/AppAlertsPage.tsx new file mode 100644 index 0000000..7dd2eb6 --- /dev/null +++ b/src/features/enterprise/AppAlertsPage.tsx @@ -0,0 +1,97 @@ +import { enterpriseSession } from '../../lib/api' +import { Empty } from '../../components/ui/bits' +import { findEnterprise, reasonCodesFor, tripWiresFor } from '../../lib/demo/enterprises' +import { alertForEnterprise } from '../../lib/demo/ops' +import { inr, monthRef } from '../../lib/demo/seed' +import { useLang } from '../../lib/i18n' + +/** + * Alert history for the owner. Same reason codes the officer sees, but only + * ever as plain sentences — no scores, no model vocabulary, nothing that reads + * like a credit rating handed to the borrower. + */ +export function AppAlertsPage() { + const session = enterpriseSession() + const { lang, t } = useLang('hi') + const hi = lang === 'hi' + + const e = session ? findEnterprise(session.enterpriseId) : undefined + if (!session || !e) return + + const alert = alertForEnterprise(e.enterpriseId) + const reasons = reasonCodesFor(e) + const fired = tripWiresFor(e).filter((w) => w.fired) + + if (!alert) { + return ( +
+
+ ✓ +
+
{t('alerts.none')}
+

{t('home.allGoodDetail')}

+
+ ) + } + + const breachRef = alert.breachMonth ? monthRef(new Date(`${alert.breachMonth}-01T00:00:00`)) : null + + return ( +
+
+
+
{hi ? alert.titleHi : alert.title}
+
+ {t('home.estimate')}: {inr(alert.shortfall)} {t('home.shortfall')} + {breachRef && ` · ${hi ? breachRef.fullHi : breachRef.fullEn}`} +
+
+ +
+
+ {hi ? 'ऐसा क्यों लग रहा है' : 'Why this is expected'} +
+
    + {reasons.map((r) => ( +
  • + + {hi ? r.sentenceHi : r.sentenceEn} +
  • + ))} +
+
+ +
+
{t('alerts.whatToDo')}
+

+ {hi + ? 'अपने फ़ील्ड अधिकारी से बात करें — किस्त की तारीख़ कमाई के मौसम से मिलाई जा सकती है।' + : 'Speak to your field officer — the instalment date can be aligned to your earning season.'} +

+
+ +
+ {t('alerts.raisedOn')}: {alert.raisedAt} · {alert.alertId} +
+
+ + {fired.length > 0 && ( +
+
+ {hi ? 'किन बातों पर नज़र है' : 'What is being watched'} +
+
    + {fired.map((w) => ( +
  • + + ! + + {hi ? w.labelHi : w.label} +
  • + ))} +
+
+ )} +
+ ) +} diff --git a/src/features/enterprise/AppHomePage.tsx b/src/features/enterprise/AppHomePage.tsx new file mode 100644 index 0000000..137a587 --- /dev/null +++ b/src/features/enterprise/AppHomePage.tsx @@ -0,0 +1,208 @@ +import { useNavigate } from 'react-router-dom' +import { enterpriseSession } from '../../lib/api' +import type { ForecastPoint } from '../../lib/api' +import { Empty } from '../../components/ui/bits' +import { breachPoint, findEnterprise, forecastFor } from '../../lib/demo/enterprises' +import { alertForEnterprise } from '../../lib/demo/ops' +import { inr, monthRef } from '../../lib/demo/seed' +import { dayTotals, useLedger } from '../../lib/ledgerStore' +import { useLang } from '../../lib/i18n' +import { EntrySheet } from './EntrySheet' +import { useState } from 'react' +import type { LedgerKind } from '../../lib/api' + +/** + * The enterprise home screen, reproducing the deck's phone wireframe: + * an alert card, the six-month forecast strip against the instalment line, + * the three-tap ledger tiles, today's totals and a suggestion. + */ +export function AppHomePage() { + const navigate = useNavigate() + const session = enterpriseSession() + const { lang, t } = useLang('hi') + const [sheet, setSheet] = useState(null) + + const e = session ? findEnterprise(session.enterpriseId) : undefined + const { entries } = useLedger(session?.enterpriseId ?? '') + + if (!session || !e) return + + const points = forecastFor(e) + const alert = alertForEnterprise(e.enterpriseId) + const breach = breachPoint(points) + const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null + const totals = dayTotals(entries) + const hi = lang === 'hi' + + return ( +
+ {/* ── alert card ─────────────────────────────────────── */} + {alert && breachRef ? ( + + ) : ( +
+
{t('home.allGood')}
+
{t('home.allGoodDetail')}
+
+ )} + + {/* ── forecast strip ─────────────────────────────────── */} +
+
+

{t('home.comingMonths')}

+ {t('home.sixMonths')} +
+ +

{t('home.legend')}

+
+ + {/* ── three-tap ledger ───────────────────────────────── */} +
+

{t('home.todayAccount')}

+
+ {( + [ + { kind: 'IN' as LedgerKind, key: 'home.moneyIn', sign: '+', cls: 'bg-green-soft text-green border-green/25' }, + { kind: 'OUT' as LedgerKind, key: 'home.moneyOut', sign: '−', cls: 'bg-risk-soft text-risk-red border-risk-red/20' }, + { kind: 'STOCK' as LedgerKind, key: 'home.stock', sign: '▢', cls: 'bg-amber-soft text-amber border-amber/30' }, + ] + ).map((tile) => ( + + ))} +
+ +
+
+
{t('home.todayIn')}
+
{inr(totals.in)}
+
+
+
{t('home.todayOut')}
+
{inr(totals.out)}
+
+
+
+ + {/* ── suggestion ─────────────────────────────────────── */} +
+ + ✓ + +
+
{t('home.todaySuggestion')}
+

+ {hi ? adviceHi(e.sector, breachRef?.fullHi) : adviceEn(e.sector, breachRef?.fullEn)} +

+
+
+ + {sheet && setSheet(null)} />} +
+ ) +} + +/** + * Six months as bars against the instalment line. A bar is green when even the + * downside clears what is due, amber when only the expected case does, and red + * when the expected case falls short. + */ +function ForecastStrip({ + points, + hi, + instalmentLabel, +}: { + points: ForecastPoint[] + hi: boolean + instalmentLabel: string +}) { + const ceiling = Math.max(...points.map((p) => Math.max(p.p50, p.obligation))) * 1.15 + const obligation = points[0].obligation + const linePct = (obligation / ceiling) * 100 + + return ( +
+
+ {points.map((p) => { + const tone = p.p10 >= p.obligation ? 'bg-green' : p.p50 >= p.obligation ? 'bg-amber' : 'bg-risk-red' + return ( +
+
+
+ ) + })} +
+ + {/* instalment threshold */} +
+ + {instalmentLabel} + +
+ +
+ {points.map((p) => ( + + {hi ? p.labelHi : p.label} + + ))} +
+
+ ) +} + +function adviceHi(sector: string, breachMonth?: string): string { + const base: Record = { + POULTRY: 'अभी दाना ख़रीद लें — रेट 18% बढ़ा है और आगे और बढ़ सकता है।', + DAIRY: 'चारा अभी भर लें और समिति से भुगतान जल्दी माँगें।', + FOOD_PROC: 'कच्चा माल थोक में लें और खरीदार से 15 दिन में भुगतान तय करें।', + HANDICRAFTS: 'तैयार माल की बिक्री के लिए दूसरा खरीदार भी जोड़ें।', + RURAL_RETAIL: 'उधार बिक्री कम करें और रोज़ UPI से वसूली करें।', + } + const tail = breachMonth ? ` ${breachMonth} तक पैसा कम पड़ सकता है।` : '' + return (base[sector] ?? base.RURAL_RETAIL) + tail +} + +function adviceEn(sector: string, breachMonth?: string): string { + const base: Record = { + POULTRY: 'Buy feed now — the rate is up 18% and may rise further.', + DAIRY: 'Stock fodder now and press the society for faster payment.', + FOOD_PROC: 'Buy raw material in bulk and hold buyers to 15-day settlement.', + HANDICRAFTS: 'Add a second buyer so sales do not depend on one account.', + RURAL_RETAIL: 'Reduce credit sales and collect daily over UPI.', + } + const tail = breachMonth ? ` Cash may fall short by ${breachMonth}.` : '' + return (base[sector] ?? base.RURAL_RETAIL) + tail +} diff --git a/src/features/enterprise/AppLedgerPage.tsx b/src/features/enterprise/AppLedgerPage.tsx new file mode 100644 index 0000000..be83586 --- /dev/null +++ b/src/features/enterprise/AppLedgerPage.tsx @@ -0,0 +1,140 @@ +import { useMemo, useState } from 'react' +import { enterpriseSession } from '../../lib/api' +import type { LedgerKind } from '../../lib/api' +import { Empty } from '../../components/ui/bits' +import { CATEGORIES, useLedger } from '../../lib/ledgerStore' +import { inr, todayIso } from '../../lib/demo/seed' +import { useLang } from '../../lib/i18n' +import { EntrySheet } from './EntrySheet' + +type Range = 'DAY' | 'WEEK' | 'MONTH' + +/** The owner's own book: running balance, day/week/month view, and the entry sheet. */ +export function AppLedgerPage() { + const session = enterpriseSession() + const { lang, t } = useLang('hi') + const [range, setRange] = useState('WEEK') + const [sheet, setSheet] = useState(null) + const { entries } = useLedger(session?.enterpriseId ?? '') + const hi = lang === 'hi' + + const cutoff = useMemo(() => { + const d = new Date() + if (range === 'DAY') return todayIso() + d.setDate(d.getDate() - (range === 'WEEK' ? 7 : 30)) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + }, [range]) + + const rows = entries.filter((e) => (range === 'DAY' ? e.entryDate === cutoff : e.entryDate >= cutoff)) + const inTotal = rows.filter((r) => r.kind === 'IN').reduce((a, r) => a + r.amount, 0) + const outTotal = rows.filter((r) => r.kind === 'OUT').reduce((a, r) => a + r.amount, 0) + const balance = inTotal - outTotal + + const byDate = rows.reduce>((acc, r) => { + ;(acc[r.entryDate] ||= []).push(r) + return acc + }, {}) + + const label = (kind: LedgerKind, code: string) => { + const c = CATEGORIES[kind].find((x) => x.code === code) + return c ? (hi ? c.hi : c.en) : code + } + + if (!session) return + + return ( +
+ {/* balance */} +
+
{t('ledger.balance')}
+
{inr(balance)}
+
+ ↑ {inr(inTotal)} + ↓ {inr(outTotal)} +
+
+ + {/* range */} +
+ {( + [ + { key: 'DAY' as Range, hi: 'आज', en: 'Today' }, + { key: 'WEEK' as Range, hi: 'हफ़्ता', en: 'Week' }, + { key: 'MONTH' as Range, hi: 'महीना', en: 'Month' }, + ] + ).map((r) => ( + + ))} +
+ + {/* add */} +
+ {( + [ + { kind: 'IN' as LedgerKind, key: 'home.moneyIn', cls: 'bg-green-soft text-green border-green/25' }, + { kind: 'OUT' as LedgerKind, key: 'home.moneyOut', cls: 'bg-risk-soft text-risk-red border-risk-red/20' }, + { kind: 'STOCK' as LedgerKind, key: 'home.stock', cls: 'bg-amber-soft text-amber border-amber/30' }, + ] + ).map((b) => ( + + ))} +
+ + {/* entries */} + {Object.entries(byDate).map(([date, list]) => ( +
+
+ + {date === todayIso() ? t('ledger.today') : date} + + {list.length} +
+ {list.map((r) => ( +
+ + {r.kind === 'IN' ? '+' : r.kind === 'OUT' ? '−' : '▢'} + + + {label(r.kind, r.category)} + + {r.syncState === 'PENDING' ? t('ledger.pending') : t('ledger.synced')} + + + + {r.kind === 'OUT' ? '−' : ''} + {inr(r.amount)} + +
+ ))} +
+ ))} + + {!rows.length && } + + {sheet && setSheet(null)} />} +
+ ) +} diff --git a/src/features/enterprise/AppProfilePage.tsx b/src/features/enterprise/AppProfilePage.tsx new file mode 100644 index 0000000..b93d33b --- /dev/null +++ b/src/features/enterprise/AppProfilePage.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { clearSessions, enterpriseSession } from '../../lib/api' +import type { Lang } from '../../lib/api' +import { Empty } from '../../components/ui/bits' +import { SECTORS, findEnterprise } from '../../lib/demo/enterprises' +import { lastSyncedAt, pendingCount, syncPending } from '../../lib/ledgerStore' +import { useLang } from '../../lib/i18n' +import { useOffline } from '../../lib/useOffline' + +/** + * Enterprise profile: what the system knows, the consent that permits it, the + * sync state, and the language switch. The "no personal data" promise is stated + * here because this is the only screen where the owner sees their own record. + */ +export function AppProfilePage() { + const navigate = useNavigate() + const session = enterpriseSession() + const { lang, setLang, t } = useLang('hi') + const offline = useOffline() + const [consent, setConsent] = useState(true) + const [justSynced, setJustSynced] = useState(null) + const hi = lang === 'hi' + + const e = session ? findEnterprise(session.enterpriseId) : undefined + if (!session || !e) return + + const pending = pendingCount(e.enterpriseId) + const synced = lastSyncedAt() + + function doSync() { + const n = syncPending(e!.enterpriseId) + setJustSynced(n) + setTimeout(() => setJustSynced(null), 2200) + } + + function signOut() { + clearSessions() + navigate('/login', { replace: true }) + } + + return ( +
+
+
+ {e.name[0]} +
+
{hi ? e.nameHi : e.name}
+
{e.enterpriseId}
+
+ +
+ {[ + [t('profile.sector'), hi ? SECTORS[e.sector].labelHi : SECTORS[e.sector].label], + [t('profile.block'), e.block], + [hi ? 'ज़िला' : 'District', e.district], + ].map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+ + {/* consent */} +
+
+ {t('profile.consent')} + + {consent ? t('profile.consentActive') : hi ? 'वापस ली गई' : 'Revoked'} + +
+

+ {hi + ? 'सिर्फ़ लेन-देन का जोड़ देखा जाता है — किसी का नाम, नंबर या खाता नहीं।' + : 'Only transaction aggregates are read — never a name, number or account.'} +

+ +
+ + {/* sync */} +
+
+ {t('profile.sync')} + + {offline ? (hi ? 'इंटरनेट नहीं' : 'Offline') : hi ? 'जुड़ा है' : 'Online'} + + {pending > 0 && {pending} {t('ledger.pending')}} +
+
+ {t('profile.lastSynced')}: {synced ? new Date(synced).toLocaleString() : t('profile.never')} +
+ + {justSynced !== null && ( +

+ {justSynced} {hi ? 'हिसाब भेज दिए' : 'entries synced'} +

+ )} +
+ + {/* language */} +
+
{t('profile.language')}
+
+ {( + [ + { key: 'hi' as Lang, label: 'हिन्दी' }, + { key: 'en' as Lang, label: 'English' }, + ] + ).map((l) => ( + + ))} +
+
+ +

{t('profile.noPii')}

+ + +
+ ) +} diff --git a/src/features/enterprise/EnterpriseShell.tsx b/src/features/enterprise/EnterpriseShell.tsx new file mode 100644 index 0000000..c2c76c7 --- /dev/null +++ b/src/features/enterprise/EnterpriseShell.tsx @@ -0,0 +1,95 @@ +import { NavLink, Outlet } from 'react-router-dom' +import { enterpriseSession } from '../../lib/api' +import { UdyamMark } from '../../components/ui/bits' +import { useLang } from '../../lib/i18n' +import { useOffline } from '../../lib/useOffline' +import { pendingCount } from '../../lib/ledgerStore' + +/* + * Enterprise PWA shell — a phone-width frame with a forest header and a four-tab + * bottom bar. Hindi-first: the owner's language is the default and English is + * the option, not the other way round. + */ + +const TABS: { to: string; key: string; icon: string; end?: boolean }[] = [ + { to: '/app', key: 'tab.home', icon: '⌂', end: true }, + { to: '/app/ledger', key: 'tab.ledger', icon: '₹' }, + { to: '/app/alerts', key: 'tab.alerts', icon: '!' }, + { to: '/app/profile', key: 'tab.profile', icon: '☺' }, +] + +export function EnterpriseShell() { + const session = enterpriseSession() + const { lang, t } = useLang('hi') + const offline = useOffline() + const pending = session ? pendingCount(session.enterpriseId) : 0 + + return ( +
+
+ {/* ── header ────────────────────────────────────────── */} +
+ +
+
+ UdyamLinc +
+
+ {lang === 'hi' ? (session?.nameHi ?? session?.name) : session?.name} +
+
+
+ {pending > 0 && ( + + {pending} + + )} + + 🔔 + +
+
+ + {offline && ( +
+ {t('common.offline')} +
+ )} + +
+ +
+ + {/* ── bottom tab bar ────────────────────────────────── */} + +
+
+ ) +} diff --git a/src/features/enterprise/EntrySheet.tsx b/src/features/enterprise/EntrySheet.tsx new file mode 100644 index 0000000..9fe11d3 --- /dev/null +++ b/src/features/enterprise/EntrySheet.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react' +import type { LedgerKind } from '../../lib/api' +import { CATEGORIES, addEntry } from '../../lib/ledgerStore' +import { useLang } from '../../lib/i18n' +import { inr } from '../../lib/demo/seed' + +/** + * The three-tap ledger entry: type the amount, tap a category, tap save. + * + * Designed for ~15 seconds of daily effort — big touch targets, a numeric pad + * of quick amounts, no free text required, and it never blocks on the network. + */ + +const QUICK = [100, 200, 500, 1000, 2000, 5000] + +const KIND_META: Record = { + IN: { key: 'home.moneyIn', cls: 'bg-green text-white', ring: 'focus:ring-green/30 focus:border-green' }, + OUT: { key: 'home.moneyOut', cls: 'bg-risk-red text-white', ring: 'focus:ring-risk-red/25 focus:border-risk-red' }, + STOCK: { key: 'home.stock', cls: 'bg-amber text-white', ring: 'focus:ring-amber/30 focus:border-amber' }, +} + +export function EntrySheet({ + kind, + enterpriseId, + onClose, +}: { + kind: LedgerKind + enterpriseId: string + onClose: () => void +}) { + const { lang, t } = useLang('hi') + const [amount, setAmount] = useState('') + const [category, setCategory] = useState(CATEGORIES[kind][0].code) + const [done, setDone] = useState(false) + const hi = lang === 'hi' + const meta = KIND_META[kind] + const value = Number(amount) || 0 + + function save() { + if (value <= 0) return + addEntry({ enterpriseId, kind, amount: value, category }) + setDone(true) + setTimeout(onClose, 900) + } + + return ( +
+
ev.stopPropagation()} + > + {done ? ( +
+
+ ✓ +
+
{t('ledger.saved')}
+
{inr(value)}
+
+ ) : ( + <> +
+ + {kind === 'IN' ? '+' : kind === 'OUT' ? '−' : '▢'} + +

{t(meta.key)}

+ +
+ + {/* 1 — amount */} + + +
+ {QUICK.map((q) => ( + + ))} +
+ + {/* 2 — category */} +
+ {t('ledger.chooseCategory')} +
+ {CATEGORIES[kind].map((c) => ( + + ))} +
+
+ + {/* 3 — save */} + + + )} +
+
+ ) +} diff --git a/src/features/officer/AlertsPage.tsx b/src/features/officer/AlertsPage.tsx new file mode 100644 index 0000000..a78c7d4 --- /dev/null +++ b/src/features/officer/AlertsPage.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { officerSession } from '../../lib/api' +import type { AlertState, RiskAlert } from '../../lib/api' +import { ActionChip, Card, Empty, GhostBtn, RiskBadge, StatusPill } from '../../components/ui/bits' +import { SECTORS } from '../../lib/demo/enterprises' +import { inr, monthRef } from '../../lib/demo/seed' +import { setAlertState, useAlerts } from '../../lib/alertStore' +import { useScope } from './officerScope' + +const FILTERS: { key: AlertState | 'ALL'; label: string }[] = [ + { key: 'ALL', label: 'All' }, + { key: 'OPEN', label: 'Open' }, + { key: 'ACKNOWLEDGED', label: 'Acknowledged' }, + { key: 'ESCALATED', label: 'Escalated' }, + { key: 'SNOOZED', label: 'Snoozed' }, +] + +/** + * The alert queue. Every row carries its origin (model vs trip-wire), the + * plain-language reason, the projected gap, and the three dispositions an + * officer can take — each of which is written to the audit strip below. + */ +export function AlertsPage() { + const navigate = useNavigate() + const { block } = useScope() + const [filter, setFilter] = useState('OPEN') + const { alerts, audit } = useAlerts(block) + const actor = officerSession()?.fullName ?? 'Field Officer' + + const rows = alerts.filter((a) => filter === 'ALL' || a.state === filter) + + function act(alert: RiskAlert, to: AlertState) { + setAlertState(alert, to, actor) + } + + return ( +
+
+
+ {FILTERS.map((f) => ( + setFilter(f.key)}> + {f.label} + {f.key !== 'ALL' && ( + {alerts.filter((a) => a.state === f.key).length} + )} + + ))} + {rows.length} shown +
+ +
+ {rows.map((a) => { + const breachRef = a.breachMonth ? monthRef(new Date(`${a.breachMonth}-01T00:00:00`)) : null + return ( +
+
+ + ! + + +
+
+ + + + {a.origin === 'TRIPWIRE' ? 'Trip-wire' : 'Model'} + + + {a.alertId} +
+ +
+ {a.enterpriseId} · {SECTORS[a.sector].short} · {a.block} · score{' '} + {a.score} · raised {a.raisedAt} +
+ +

{a.title}

+

{a.reason.sentenceEn}

+

{a.reason.sentenceHi}

+ + {a.shortfall > 0 && ( +

+ Projected gap {inr(a.shortfall)} at P10{breachRef && ` in ${breachRef.fullEn}`} +

+ )} + +
+ act(a, 'ACKNOWLEDGED')}> + Acknowledge + + act(a, 'ESCALATED')}> + Escalate + + act(a, 'SNOOZED')}> + Snooze + + navigate(`/officer/enterprises/${a.enterpriseId}`)}> + Open enterprise + +
+
+
+
+ ) + })} +
+ + {!rows.length && } +
+ + {/* ── audit strip ─────────────────────────────────────── */} + +
+ {audit.map((a, i) => ( +
+
{new Date(a.at).toLocaleString()}
+
+ {a.alertId} {a.from} → {a.to} +
+
{a.actor}
+
+ ))} + {!audit.length &&

No actions taken yet.

} +
+
+
+ ) +} diff --git a/src/features/officer/DataFeedsPage.tsx b/src/features/officer/DataFeedsPage.tsx new file mode 100644 index 0000000..74569b9 --- /dev/null +++ b/src/features/officer/DataFeedsPage.tsx @@ -0,0 +1,315 @@ +import { useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { get } from '../../lib/api' +import type { ClimateSignal, Freshness, IngestionRun, MarketSignal } from '../../lib/api' +import { Card, Empty, StatusPill } from '../../components/ui/bits' +import { + demoClimateSignals, + demoFreshness, + demoIngestionRuns, + demoMarketSignals, +} from '../../lib/demo/feeds' + +/** + * Where the numbers came from and how old they are. + * + *

This screen exists because the failure mode that matters is silent: a score + * computed from a three-week-old mandi price looks exactly like a score computed + * this morning. Staleness is shown first and everywhere, before any price. + */ +export function DataFeedsPage() { + const [district, setDistrict] = useState('ALL') + + const freshnessQ = useQuery({ queryKey: ['freshness'], queryFn: () => get('/admin/ingestion/freshness') }) + const marketQ = useQuery({ + queryKey: ['market-signals'], + queryFn: () => get('/admin/ingestion/signals/market', { weeks: 8 }), + }) + const climateQ = useQuery({ + queryKey: ['climate-signals'], + queryFn: () => get('/admin/ingestion/signals/climate', { weeks: 8 }), + }) + const runsQ = useQuery({ + queryKey: ['ingestion-runs'], + queryFn: () => get('/admin/ingestion/runs', { limit: 10 }), + }) + + const demo = freshnessQ.isError || marketQ.isError || climateQ.isError || runsQ.isError + const freshness = freshnessQ.data ?? demoFreshness() + const market = marketQ.data ?? demoMarketSignals() + const climate = climateQ.data ?? demoClimateSignals() + const runs = runsQ.data ?? demoIngestionRuns() + + const districts = useMemo( + () => Array.from(new Set(freshness.map((f) => f.districtName))).sort(), + [freshness], + ) + // Latest week per (district, commodity) — the panel is a current view, not a log. + const latestMarket = useMemo(() => { + const byKey = new Map() + for (const row of market) { + if (district !== 'ALL' && row.districtName !== district) continue + const key = `${row.districtName}|${row.commodityName}` + const held = byKey.get(key) + if (!held || row.weekStart > held.weekStart) byKey.set(key, row) + } + return Array.from(byKey.values()).sort( + (a, b) => + a.districtName.localeCompare(b.districtName) || a.commodityName.localeCompare(b.commodityName), + ) + }, [market, district]) + + const latestClimate = useMemo(() => { + const byDistrict = new Map() + for (const row of climate) { + if (district !== 'ALL' && row.districtName !== district) continue + const held = byDistrict.get(row.districtName) + if (!held || row.weekStart > held.weekStart) byDistrict.set(row.districtName, row) + } + return Array.from(byDistrict.values()).sort((a, b) => a.districtName.localeCompare(b.districtName)) + }, [climate, district]) + + return ( +

+
+
+

Data feeds

+

+ Mandi prices from Agmarknet via data.gov.in; rainfall and temperature from the reanalysis archive. + External feeds are pulled nightly and never called while you wait. +

+
+
+ {demo && Demo data} + +
+
+ + {/* ── freshness ─────────────────────────────────────── */} + +
+

+ A rule refuses to fire on data older than it tolerates — a trip-wire on a three-week-old price is worse + than no trip-wire. These are the ages every rule is measured against. +

+
+ {freshness.map((f) => ( +
+
{f.districtName}
+ + +
+ ))} +
+
+
+ +
+ {/* ── market panel ───────────────────────────────── */} + +
+ {latestMarket.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + + {latestMarket.map((row) => ( + + + + + + + + + + ))} + +
DistrictCommodityModalRangeArrivalsWeekAge
{row.districtName}{row.commodityName} + {row.modalPrice === null ? '—' : `₹${row.modalPrice.toLocaleString('en-IN')}`} + + {row.minPrice === null || row.maxPrice === null + ? '—' + : `${row.minPrice.toLocaleString('en-IN')} – ${row.maxPrice.toLocaleString('en-IN')}`} + {row.arrivalCount ?? '—'}{row.weekStart} + +
+ )} +

+ A price level is not a feature. These rows become feed_cost_change,{' '} + margin_squeeze and{' '} + price_z_score against their trailing means. +

+
+
+ + {/* ── climate panel ──────────────────────────────── */} + +
+ {latestClimate.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + + {latestClimate.map((row) => ( + + + + + + + + + + ))} + +
DistrictRainNormalAnomalyMax tempvs normalWeek
{row.districtName}{row.rainfallMm === null ? '—' : `${row.rainfallMm} mm`} + {row.rainfallNormalMm === null ? '—' : `${row.rainfallNormalMm} mm`} + + + {row.tmaxC === null ? '—' : `${row.tmaxC} °C`} + + {row.weekStart}
+ )} +

+ An absolute rainfall figure means nothing on its own — the normal beside it is what makes a deficit + readable, and it needs decades of history the live endpoints do not serve. +

+
+
+
+ + {/* ── runs ──────────────────────────────────────────── */} + +
+ + + + + + + + + + + + + + + {runs.map((run) => ( + + + + + + + + + + + ))} + +
SourceDateTriggerStatusFetchedWrittenFailed callsNote
{run.source}{run.runDate}{run.trigger} + + {run.rowsFetched ?? '—'}{run.rowsWritten ?? '—'} + {run.callsFailed ?? 0} + {run.message}
+

+ A skipped run is recorded too. "Nothing ran last night" and "it ran and found nothing" must never look + the same here. +

+
+
+
+ ) +} + +// ── small parts ────────────────────────────────────────────────── + +function FreshnessRow({ + label, + days, + validFor, + warnAbove, +}: { + label: string + days: number | null + validFor: string | null + warnAbove: number +}) { + const stale = days !== null && days > warnAbove + return ( +
+ {label} + {days === null ? ( + no data + ) : ( + <> + {days}d + {validFor} + + )} +
+ ) +} + +function AgeChip({ validFor, warnAbove }: { validFor: string; warnAbove: number }) { + const days = Math.max(0, Math.round((Date.now() - new Date(validFor).getTime()) / 86_400_000)) + return ( + warnAbove ? 'pill-bad' : 'pill-ok'}`}>{days}d + ) +} + +function Delta({ + value, + suffix, + goodWhenPositive, +}: { + value: number | null + suffix: string + goodWhenPositive?: boolean +}) { + if (value === null) return — + const bad = goodWhenPositive ? value < -20 : value > 1.5 + return ( + + {value > 0 ? '+' : ''} + {value} + {suffix} + + ) +} diff --git a/src/features/officer/EnterpriseDetailPage.tsx b/src/features/officer/EnterpriseDetailPage.tsx new file mode 100644 index 0000000..29243c2 --- /dev/null +++ b/src/features/officer/EnterpriseDetailPage.tsx @@ -0,0 +1,339 @@ +import { useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { officerSession } from '../../lib/api' +import type { Enterprise } from '../../lib/api' +import { + ActionChip, + Card, + DriverBar, + Empty, + GhostBtn, + PrimaryBtn, + RiskBadge, + ScoreDial, + Sparkline, + StatusPill, +} from '../../components/ui/bits' +import { ForecastFan } from '../../components/charts/ForecastFan' +import { SubScoreRadar } from '../../components/charts/SubScoreRadar' +import { + DRIVER_META, + SECTORS, + breachPoint, + findEnterprise, + forecastFor, + reasonCodesFor, + stressScoreFor, + subScoresFor, + tripWiresFor, +} from '../../lib/demo/enterprises' +import { inr, inrCompact, monthRef } from '../../lib/demo/seed' +import { loadLedger } from '../../lib/ledgerStore' +import { CATEGORIES } from '../../lib/ledgerStore' +import { useVisits } from '../../lib/visitStore' +import { ScheduleVisitModal } from './ScheduleVisitModal' + +type Tab = 'OVERVIEW' | 'FORECAST' | 'DRIVERS' | 'LEDGER' | 'VISITS' + +const TABS: { key: Tab; label: string }[] = [ + { key: 'OVERVIEW', label: 'Overview' }, + { key: 'FORECAST', label: 'Forecast' }, + { key: 'DRIVERS', label: 'Drivers' }, + { key: 'LEDGER', label: 'Ledger' }, + { key: 'VISITS', label: 'Visits' }, +] + +/** Everything the officer needs about one enterprise, in five tabs. */ +export function EnterpriseDetailPage() { + const { enterpriseId = '' } = useParams() + const navigate = useNavigate() + const [tab, setTab] = useState('OVERVIEW') + const [scheduling, setScheduling] = useState(false) + + const e = findEnterprise(enterpriseId) + if (!e) return + + const points = forecastFor(e) + const breach = breachPoint(points) + const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null + + return ( +
+ {/* ── header ─────────────────────────────────────────── */} +
+ +
+
+

{e.name}

+ +
+
+ {e.enterpriseId} · {SECTORS[e.sector].label} · {e.block} block, {e.district} district ·{' '} + {e.vintageMonths} months on book +
+
+
+ setScheduling(true)}>Schedule visit + setTab('FORECAST')}>Full forecast +
+
+ + {breach && ( +
+ + ! + +

+ Downside case fails to cover obligations from {breachRef?.fullEn} — projected gap{' '} + {inr(breach.obligation - breach.p10)} at P10. +

+
+ )} + +
+ {TABS.map((t) => ( + setTab(t.key)}> + {t.label} + + ))} +
+ + {tab === 'OVERVIEW' && } + {tab === 'FORECAST' && } + {tab === 'DRIVERS' && } + {tab === 'LEDGER' && } + {tab === 'VISITS' && } + + {scheduling && setScheduling(false)} />} +
+ ) +} + +function OverviewTab({ e }: { e: Enterprise }) { + const score = stressScoreFor(e) + const reasons = reasonCodesFor(e) + return ( +
+ +
+ +
+ +
6-month score history
+
+
+
+ + +
+ {[ + ['Loan outstanding', inr(e.loanOutstanding)], + ['Monthly instalment', inr(e.monthlyInstalment)], + ['Sector pack', SECTORS[e.sector].label], + ['Block / district', `${e.block} / ${e.district}`], + ['Owners on record', String(e.ownerCount)], + ['Vintage', `${e.vintageMonths} months`], + ].map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+
+ + +
+ {reasons.map((r) => ( +
+
+ {r.driver} · {r.weight} +
+

{r.sentenceEn}

+

{r.sentenceHi}

+
+ ))} +
+
+
+ ) +} + +function ForecastTab({ e }: { e: Enterprise }) { + const points = forecastFor(e) + return ( +
+ +
+ +
+
+ + +
+ + + + + + + + + + + {points.map((p) => { + const short = p.p10 < p.obligation + return ( + + + + + + + ) + })} + +
MonthP10P50Due
{p.label} + {inrCompact(p.p10)} + {inrCompact(p.p50)}{inrCompact(p.obligation)}
+
+

+ Rows in red are months where the downside case does not cover what is due. +

+
+
+ ) +} + +function DriversTab({ e }: { e: Enterprise }) { + const sub = subScoresFor(e) + const wires = tripWiresFor(e) + return ( +
+ +
+ +
+ {DRIVER_META.map((d) => ( + + ))} +
+
+
+ + +
+ + + + + + + + + + + {wires.map((w) => ( + + + + + + + ))} + +
CodeRuleThresholdState
{w.code} + {w.label} + {w.detail} + {w.threshold} + {w.fired ? 'Fired' : 'Clear'} +
+
+

+ Trip-wires fire independently of the model, so every alert stays auditable. +

+
+
+ ) +} + +function LedgerTab({ e }: { e: Enterprise }) { + const entries = loadLedger(e.enterpriseId).slice(0, 40) + const label = (kind: string, code: string) => + CATEGORIES[kind as keyof typeof CATEGORIES]?.find((c) => c.code === code)?.en ?? code + + return ( + +
+ + + + + + + + + + + + {entries.map((l) => ( + + + + + + + + ))} + +
DateTypeCategoryAmountSync
{l.entryDate} + + {l.kind === 'IN' ? 'Money in' : l.kind === 'OUT' ? 'Money out' : 'Stock'} + + {label(l.kind, l.category)}{inr(l.amount)} + +
+
+ {!entries.length && } +
+ ) +} + +function VisitsTab({ e }: { e: Enterprise }) { + const { visits } = useVisits('ALL', officerSession()?.fullName ?? 'Field Officer') + const mine = visits.filter((v) => v.enterpriseId === e.enterpriseId) + + return ( + +
+ + + + + + + + + + + + {mine.map((v) => ( + + + + + + + + ))} + +
VisitActionDueStateOutcome
{v.visitId}{v.action}{v.dueDate} + + {v.outcome ?? '—'}
+
+ {!mine.length && } +
+ ) +} diff --git a/src/features/officer/EnterprisesPage.tsx b/src/features/officer/EnterprisesPage.tsx new file mode 100644 index 0000000..972ae16 --- /dev/null +++ b/src/features/officer/EnterprisesPage.tsx @@ -0,0 +1,152 @@ +import { useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import { get } from '../../lib/api' +import type { Band, Enterprise, Sector } from '../../lib/api' +import { ActionChip, Empty, GhostBtn, RiskBadge, Sparkline, inputCls } from '../../components/ui/bits' +import { SECTORS, SECTOR_LIST } from '../../lib/demo/enterprises' +import { scopedEnterprises } from '../../lib/demo/ops' +import { inrCompact } from '../../lib/demo/seed' +import { exportCsv } from '../../lib/csv' +import { useScope } from './officerScope' + +const BAND_FILTERS: { key: Band | 'ALL'; label: string }[] = [ + { key: 'ALL', label: 'All bands' }, + { key: 'RED', label: 'High risk' }, + { key: 'WATCH', label: 'Watch' }, + { key: 'STANDARD', label: 'Standard' }, +] + +/** The full enterprise register — searchable and filterable, feeding the detail page. */ +export function EnterprisesPage() { + const navigate = useNavigate() + const { block } = useScope() + const [q, setQ] = useState('') + const [band, setBand] = useState('ALL') + const [sector, setSector] = useState('ALL') + + const { data } = useQuery({ + queryKey: ['enterprises', block], + queryFn: () => get('/officer/enterprises', { block }), + }) + + const all = data ?? scopedEnterprises(block) + + const rows = useMemo(() => { + const needle = q.trim().toLowerCase() + return all.filter( + (e) => + (band === 'ALL' || e.band === band) && + (sector === 'ALL' || e.sector === sector) && + (!needle || e.name.toLowerCase().includes(needle) || e.enterpriseId.toLowerCase().includes(needle)), + ) + }, [all, band, sector, q]) + + function handleExport() { + exportCsv( + `udyamlinc-enterprises-${block}.csv`, + ['Enterprise ID', 'Name', 'Sector', 'Block', 'District', 'Score', 'Band', 'Vintage (months)', 'Outstanding', 'Instalment'], + rows.map((e) => [ + e.enterpriseId, + e.name, + SECTORS[e.sector].label, + e.block, + e.district, + e.score, + e.band, + e.vintageMonths, + e.loanOutstanding, + e.monthlyInstalment, + ]), + ) + } + + return ( +
+
+ setQ(e.target.value)} + /> + +
+ {BAND_FILTERS.map((b) => ( + setBand(b.key)}> + {b.label} + + ))} +
+ {rows.length} enterprises + + Export CSV + +
+ +
+
+ + + + + + + + + + + + + + + + {rows.map((e) => ( + navigate(`/officer/enterprises/${e.enterpriseId}`)} + > + + + + + + + + + + + ))} + +
EnterpriseSectorBlockVintageOutstandingInstalmentScoreStatusTrend
+ {e.name} + {e.enterpriseId} + {SECTORS[e.sector].short}{e.block}{e.vintageMonths} mo{inrCompact(e.loanOutstanding)}{inrCompact(e.monthlyInstalment)} + {e.score} + + + + +
+
+ {!rows.length && } +
+
+ ) +} diff --git a/src/features/officer/ForecastsPage.tsx b/src/features/officer/ForecastsPage.tsx new file mode 100644 index 0000000..789c5f6 --- /dev/null +++ b/src/features/officer/ForecastsPage.tsx @@ -0,0 +1,83 @@ +import { Card, KpiTile } from '../../components/ui/bits' +import { ForecastFan } from '../../components/charts/ForecastFan' +import { BandMigrationBar } from '../../components/charts/BandMigrationBar' +import { bandMigration, portfolioForecast, sectorRollup } from '../../lib/demo/ops' +import { inrCompact } from '../../lib/demo/seed' +import { useScope } from './officerScope' + +/** + * Portfolio-level view of the same forecast the enterprise pages show one at a + * time: the h = 1…6 aggregate, how each sector pack is carrying risk, and how + * the book migrated between watchlist bands over the last six months. + */ +export function ForecastsPage() { + const { block } = useScope() + const aggregate = portfolioForecast(block) + const sectors = sectorRollup(block) + const migration = bandMigration(block) + + const worst = aggregate.reduce((a, p) => (p.p10 - p.obligation < a.p10 - a.obligation ? p : a), aggregate[0]) + const covered = aggregate.filter((p) => p.p10 >= p.obligation).length + + return ( +
+
+ + = 5 ? 'good' : 'watch'} /> + + +
+ +
+ +
+ +
+
+ + +
+ +
+
+
+ + +
+ + + + + + + + + + + + + + {sectors.map((s) => ( + + + + + + + + + + ))} + +
Sector packEnterprisesHigh riskWatchStandardAvg scoreMix
{s.label}{s.count}{s.red}{s.watch}{s.standard}{s.avgScore} + + + + + +
+
+
+
+ ) +} diff --git a/src/features/officer/OfficerDashboard.tsx b/src/features/officer/OfficerDashboard.tsx new file mode 100644 index 0000000..76df816 --- /dev/null +++ b/src/features/officer/OfficerDashboard.tsx @@ -0,0 +1,248 @@ +import { useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import { get } from '../../lib/api' +import type { Enterprise, PortfolioSummary } from '../../lib/api' +import { + DriverBar, + GhostBtn, + KpiTile, + PrimaryBtn, + RiskBadge, + ScoreDial, + Sparkline, +} from '../../components/ui/bits' +import { + DRIVER_META, + SECTORS, + breachPoint, + forecastFor, + reasonCodesFor, + subScoresFor, +} from '../../lib/demo/enterprises' +import { portfolioSummary } from '../../lib/demo/ops' +import { inr, monthRef } from '../../lib/demo/seed' +import { exportCsv } from '../../lib/csv' +import { useScope } from './officerScope' +import { ScheduleVisitModal } from './ScheduleVisitModal' + +/** + * The console's home screen and the deck's headline wireframe: KPI tiles, the + * "Enterprises by risk" register, and a risk detail panel that explains the + * selected enterprise in the officer's own language. + */ +export function OfficerDashboard() { + const navigate = useNavigate() + const { block, month } = useScope() + const [selectedId, setSelectedId] = useState(null) + const [scheduling, setScheduling] = useState(null) + + const { data } = useQuery({ + queryKey: ['portfolio', block, month], + queryFn: () => get('/officer/portfolio', { block, month }), + }) + + // Demo fallback: with the backend down, the screen still shows the full book. + const summary = data ?? portfolioSummary(block, month) + const rows = summary.rows + + const selected = useMemo( + () => rows.find((e) => e.enterpriseId === selectedId) ?? rows[0], + [rows, selectedId], + ) + + function handleExport() { + exportCsv( + `udyamlinc-portfolio-${block}-${month}.csv`, + ['Enterprise ID', 'Name', 'Sector', 'Block', 'Score', 'Band', 'Loan outstanding', 'Instalment'], + rows.map((e) => [ + e.enterpriseId, + e.name, + SECTORS[e.sector].label, + e.block, + e.score, + e.band, + e.loanOutstanding, + e.monthlyInstalment, + ]), + ) + } + + return ( +
+ {/* ── KPI tiles ─────────────────────────────────────── */} +
+ = 0 ? '+' : ''}${summary.totalDelta} this month`} + /> + = 0 ? '+' : ''}${summary.redDelta} since last week`} + tone="red" + /> + = 0 ? '+' : ''}${summary.watchDelta} since last week`} + tone="watch" + /> + 0 ? '+' : ''}${summary.standardDelta} since last week`} + tone="good" + /> +
+ +
+ {/* ── enterprises by risk ─────────────────────────── */} +
+
+

Enterprises by risk

+ updated {summary.updatedMinutesAgo} minutes ago + + Export CSV + +
+ +
+ + + + + + + + + + + + + {rows.slice(0, 40).map((e) => ( + setSelectedId(e.enterpriseId)} + className={`cursor-pointer ${e.enterpriseId === selected?.enterpriseId ? '!bg-green-soft' : ''}`} + > + + + + + + + + + ))} + +
EnterpriseSectorBlockScoreStatus6-month trend +
+
+ + {e.name[0]} + + + {e.name} + {e.enterpriseId} + +
+
{SECTORS[e.sector].short}{e.block} + {e.score} + + + + + + +
+
+ + {rows.length > 40 && ( +
+ Showing the 40 highest-risk of {rows.length} — the full register is under Enterprises. +
+ )} +
+ + {/* ── risk detail ─────────────────────────────────── */} + {selected && setScheduling(selected)} />} +
+ + {scheduling && setScheduling(null)} />} +
+ ) +} + +/** Right-hand panel: score dial, top drivers, the reason sentence and two actions. */ +function RiskDetail({ enterprise, onSchedule }: { enterprise: Enterprise; onSchedule: () => void }) { + const navigate = useNavigate() + const sub = subScoresFor(enterprise) + const reason = reasonCodesFor(enterprise)[0] + const points = forecastFor(enterprise) + const breach = breachPoint(points) + const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null + + return ( +
+
+

Risk detail

+
+ {enterprise.name} · {enterprise.enterpriseId} +
+
+ +
+
+ +
+ +
TOP DRIVERS
+
+ {DRIVER_META.map((d) => ( + + ))} +
+ +
+

+ {reason.sentenceEn} + {breachRef && ` Shortfall expected by ${breachRef.fullEn}.`} +

+ {breach && ( +

+ Projected gap {inr(breach.obligation - breach.p10)} at P10 +

+ )} +
+ +
+ + Schedule visit + + navigate(`/officer/enterprises/${enterprise.enterpriseId}`)}> + Full forecast + +
+
+
+ ) +} diff --git a/src/features/officer/OfficerShell.tsx b/src/features/officer/OfficerShell.tsx new file mode 100644 index 0000000..433cb1c --- /dev/null +++ b/src/features/officer/OfficerShell.tsx @@ -0,0 +1,220 @@ +import { NavLink, Outlet, useNavigate } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { clearSessions, get, officerSession } from '../../lib/api' +import type { Freshness } from '../../lib/api' +import { demoFreshness, worstStaleness } from '../../lib/demo/feeds' +import { UdyamMark } from '../../components/ui/bits' +import { BLOCKS } from '../../lib/demo/enterprises' +import { ALL_BLOCKS, alertsFor, weeklyVisitProgress } from '../../lib/demo/ops' +import { monthRef, monthWindow } from '../../lib/demo/seed' +import { OfficerScopeProvider, useScope } from './officerScope' + +/* + * Field officer console shell — dark forest sidebar on the left, identity bar + * across the top, work area on the right. This is the layout on the deck's + * wireframe slide; it deliberately differs from BharatLinc's horizontal MegaNav + * because the console is navigated by module, not by tab. + */ + +const NAV: { label: string; to: string; end?: boolean }[] = [ + { label: 'Dashboard', to: '/officer', end: true }, + { label: 'Enterprises', to: '/officer/enterprises' }, + { label: 'Risk alerts', to: '/officer/alerts' }, + { label: 'Forecasts', to: '/officer/forecasts' }, + { label: 'Field visits', to: '/officer/visits' }, + { label: 'Trip-wires', to: '/officer/rules' }, + { label: 'Data feeds', to: '/officer/feeds' }, + { label: 'Reports', to: '/officer/reports' }, + { label: 'Settings', to: '/officer/settings' }, +] + +/** + * Data age, in the chrome rather than on a screen someone has to go and find. + * + *

A score computed from a three-week-old mandi price looks exactly like one + * computed this morning, so the age travels with every screen. Amber past ten + * days, which is the tolerance the price-derived features carry. + */ +function FreshnessChip() { + const { data, isError } = useQuery({ + queryKey: ['freshness'], + queryFn: () => get('/admin/ingestion/freshness'), + }) + const rows = data ?? demoFreshness() + const worst = worstStaleness(rows) + const stale = worst > 10 + + return ( + `${r.districtName}: mandi ${r.marketStalenessDays ?? '—'}d`).join(' · ') + } + > + Data {worst}d old + + ) +} + +export function OfficerShell() { + return ( + + + + ) +} + +function ShellBody() { + const navigate = useNavigate() + const session = officerSession() + const { block, month, setBlock, setMonth } = useScope() + const openAlerts = alertsFor(block).filter((a) => a.state === 'OPEN').length + const progress = weeklyVisitProgress(block) + const months = monthWindow(4, -3).reverse() // current month first, three back + + const initials = (session?.fullName ?? 'Field Officer') + .split(' ') + .map((w) => w[0]) + .join('') + .slice(0, 2) + .toUpperCase() + + function signOut() { + clearSessions() + navigate('/login', { replace: true }) + } + + return ( +

+ {/* ── sidebar ─────────────────────────────────────────── */} + + + {/* ── work area ───────────────────────────────────────── */} +
+
+
+
Portfolio overview
+
+ {block === ALL_BLOCKS ? (session?.branch ?? 'All blocks') : `${block} block`} · {monthRef(new Date(`${month}-01T00:00:00`)).fullEn} +
+
+ +
+ + + + + + + + 🔔 + {openAlerts > 0 && } + + +
+ + {initials} + +
+
{session?.fullName ?? 'Field Officer'}
+
Field Officer
+
+ +
+
+
+ +
+ +
+ +
+ UdyamLinc · Predicting stress. Protecting enterprises. — Team LincFusion, LincPay Solutions Pvt. Ltd. +
+
+
+ ) +} diff --git a/src/features/officer/ReportsPage.tsx b/src/features/officer/ReportsPage.tsx new file mode 100644 index 0000000..8e0eb1a --- /dev/null +++ b/src/features/officer/ReportsPage.tsx @@ -0,0 +1,149 @@ +import { Card, GhostBtn, KpiTile } from '../../components/ui/bits' +import { ModelComparisonBar } from '../../components/charts/ModelComparisonBar' +import { SECTORS } from '../../lib/demo/enterprises' +import { BENCHMARKS, alertsFor, scopedEnterprises, sectorRollup, visitsFor } from '../../lib/demo/ops' +import { exportCsv } from '../../lib/csv' +import { useScope } from './officerScope' + +/** + * MIS exports and the supervisory pack. The model figures are the ones the deck + * publishes — measured on the simulated 2,400-enterprise dataset, not on live + * institutional data, and labelled as such. + */ +export function ReportsPage() { + const { block, month } = useScope() + const enterprises = scopedEnterprises(block) + const alerts = alertsFor(block) + const visits = visitsFor(block) + + const REPORTS: { name: string; detail: string; run: () => void }[] = [ + { + name: 'Portfolio register', + detail: 'Every enterprise with its band, score and obligations.', + run: () => + exportCsv( + `udyam-portfolio-${block}-${month}.csv`, + ['Enterprise ID', 'Name', 'Sector', 'Block', 'Score', 'Band', 'Outstanding', 'Instalment'], + enterprises.map((e) => [ + e.enterpriseId, + e.name, + SECTORS[e.sector].label, + e.block, + e.score, + e.band, + e.loanOutstanding, + e.monthlyInstalment, + ]), + ), + }, + { + name: 'Early warning register', + detail: 'Open and dispositioned alerts with reason codes and projected gaps.', + run: () => + exportCsv( + `udyam-alerts-${block}-${month}.csv`, + ['Alert ID', 'Enterprise', 'Band', 'Origin', 'Score', 'Shortfall', 'Breach month', 'State', 'Reason'], + alerts.map((a) => [ + a.alertId, + a.enterpriseName, + a.band, + a.origin, + a.score, + a.shortfall, + a.breachMonth, + a.state, + a.reason.sentenceEn, + ]), + ), + }, + { + name: 'Intervention log', + detail: 'Field visits, recommended actions, due dates and recorded outcomes.', + run: () => + exportCsv( + `udyam-visits-${block}-${month}.csv`, + ['Visit ID', 'Enterprise', 'Action', 'Block', 'Due', 'State', 'Outcome', 'Officer'], + visits.map((v) => [v.visitId, v.enterpriseName, v.action, v.block, v.dueDate, v.state, v.outcome ?? '', v.officer]), + ), + }, + { + name: 'Sector pack summary', + detail: 'Band mix and average score for each of the five packs.', + run: () => + exportCsv( + `udyam-sectors-${block}-${month}.csv`, + ['Sector', 'Enterprises', 'High risk', 'Watch', 'Standard', 'Average score'], + sectorRollup(block).map((s) => [s.label, s.count, s.red, s.watch, s.standard, s.avgScore]), + ), + }, + ] + + return ( +
+
+ + + + +
+ +
+ +
+ +

+ Measured on a simulated {BENCHMARKS.datasetSize.toLocaleString('en-IN')}-enterprise dataset across{' '} + {BENCHMARKS.sectorsCovered} sectors and {BENCHMARKS.districtsCovered} districts. Production calibration + will use institutional historical portfolios under a data-sharing agreement. +

+
+
+ + +
    + {[ + 'Time-series split — no forward leakage across folds.', + 'Backtested against simulated stress events.', + 'Precision measured at the Red band, where the cost of a false positive is one visit.', + 'Sector packs evaluated independently before pooling.', + ].map((t) => ( +
  • + + {t} +
  • + ))} +
+
+
+ + +
+ + + + + + + + + + {REPORTS.map((r, i) => ( + + + + + + + ))} + +
ReportContentsRows +
{r.name}{r.detail} + {[enterprises.length, alerts.length, visits.length, sectorRollup(block).length][i]} + + Download CSV +
+
+
+
+ ) +} diff --git a/src/features/officer/RulesPage.tsx b/src/features/officer/RulesPage.tsx new file mode 100644 index 0000000..a8e6f14 --- /dev/null +++ b/src/features/officer/RulesPage.tsx @@ -0,0 +1,184 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { get } from '../../lib/api' +import type { FeatureValueRow, RuleOutcome, RuleStatus, TripWireRule } from '../../lib/api' +import { Card, Empty } from '../../components/ui/bits' +import { DEMO_FEATURES, DEMO_RULES, demoFeatureValues, demoRuleOutcomes } from '../../lib/demo/feeds' +import { scopedEnterprises } from '../../lib/demo/ops' +import { useScope } from './officerScope' + +/** + * The trip-wires, and why each one did or did not fire. + * + *

The rules are deliberately independent of the model: they read features and + * never the score, so an alert one of them raised still stands after the model + * has been retrained. What this screen adds is the part that is otherwise + * invisible — a rule that is quiet because the enterprise is fine looks + * identical to a rule that is quiet because its feed has been down for a + * fortnight, and only one of those is good news. + */ +export function RulesPage() { + const { block } = useScope() + const enterprises = scopedEnterprises(block) + const [enterpriseId, setEnterpriseId] = useState(enterprises[0]?.enterpriseId ?? 'MSME-4417') + + const rulesQ = useQuery({ queryKey: ['rules'], queryFn: () => get('/admin/rules') }) + const outcomesQ = useQuery({ + queryKey: ['rule-outcomes', enterpriseId], + queryFn: () => get(`/admin/scoring/explain/${enterpriseId}`), + }) + const valuesQ = useQuery({ + queryKey: ['feature-values', enterpriseId], + queryFn: () => get(`/admin/features/values/${enterpriseId}`), + }) + + const demo = rulesQ.isError || outcomesQ.isError || valuesQ.isError + const rules = rulesQ.data ?? DEMO_RULES + const outcomes = outcomesQ.data ?? demoRuleOutcomes(enterpriseId) + const values = valuesQ.data ?? demoFeatureValues(enterpriseId) + + const byCode = new Map(outcomes.map((o) => [o.ruleCode, o])) + const catalogue = new Map(DEMO_FEATURES.map((d) => [d.code, d])) + + const blocked = outcomes.filter((o) => o.status.startsWith('SKIPPED')).length + + return ( +

+
+
+

Trip-wires

+

+ Deterministic rules, evaluated against the feature snapshot and never against the score. Business teams + edit the thresholds; nobody deploys. +

+
+
+ {demo && Demo data} + +
+
+ + {blocked > 0 && ( +
+ {blocked} of {outcomes.length} rules could not run for this enterprise — a required feature is + missing or too old. That is the rules working as intended, not a fault: acting on stale data is worse than + not acting. The reason for each is in the table below. +
+ )} + + +
+ {rules.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + + {rules.map((rule) => { + const outcome = byCode.get(rule.code) + return ( + + + + + + + + + + ) + })} + +
CodeRuleExpressionSeverityToleranceOutcomeWhy
{rule.code} +
{rule.label}
+
{rule.labelHi}
+
{rule.expression} + + {rule.severity} + + {rule.maxStalenessDays}d{outcome ? : —}{outcome?.detail ?? '—'}
+ )} +
+
+ + +
+

+ A null value is a value: it records that we did not know, rather than that we never looked. The reason + column is why. +

+ + + + + + + + + + + + + {values.map((v) => { + const def = catalogue.get(v.featureCode) + const tolerance = def?.stalenessToleranceDays ?? null + const stale = tolerance !== null && v.stalenessDays !== null && v.stalenessDays > tolerance + return ( + + + + + + + + + ) + })} + +
FeatureFamilyValueAgeToleranceMissing reason
{v.featureCode}{def?.family ?? '—'} + {v.value === null ? 'null' : v.value} + + {v.stalenessDays === null ? '—' : `${v.stalenessDays}d`} + {tolerance === null ? 'never stale' : `${tolerance}d`} + {v.missingReason ? {v.missingReason} : ''} +
+
+
+
+ ) +} + +const OUTCOME_LABEL: Record = { + FIRED: { text: 'Fired', cls: 'pill-bad' }, + NOT_FIRED: { text: 'Not fired', cls: 'pill-ok' }, + SKIPPED_STALE: { text: 'Skipped · stale', cls: 'pill-pend' }, + SKIPPED_MISSING: { text: 'Skipped · missing', cls: 'pill-pend' }, + SKIPPED_SECTOR: { text: 'Other sector', cls: 'pill' }, + ERROR: { text: 'Expression error', cls: 'pill-bad' }, +} + +function OutcomePill({ status }: { status: RuleStatus }) { + const meta = OUTCOME_LABEL[status] ?? { text: status, cls: 'pill' } + return {meta.text} +} diff --git a/src/features/officer/ScheduleVisitModal.tsx b/src/features/officer/ScheduleVisitModal.tsx new file mode 100644 index 0000000..7c9fc30 --- /dev/null +++ b/src/features/officer/ScheduleVisitModal.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import type { Enterprise } from '../../lib/api' +import { officerSession } from '../../lib/api' +import { Field, GhostBtn, Modal, PrimaryBtn, inputCls } from '../../components/ui/bits' +import { PLAYBOOK } from '../../lib/demo/ops' +import { dayOffsetIso } from '../../lib/demo/seed' +import { scheduleVisit } from '../../lib/visitStore' + +/** + * Schedule an intervention from the playbook. Actions are pre-filtered to the + * enterprise's band, so the officer picks from what the model actually + * recommends rather than a free-text field. + */ +export function ScheduleVisitModal({ enterprise, onClose }: { enterprise: Enterprise; onClose: () => void }) { + const navigate = useNavigate() + const options = PLAYBOOK.filter((p) => p.appliesTo === enterprise.band) + const [code, setCode] = useState(options[0]?.code ?? PLAYBOOK[0].code) + const [dueDate, setDueDate] = useState(dayOffsetIso(3)) + + function save() { + const action = PLAYBOOK.find((p) => p.code === code) ?? PLAYBOOK[0] + scheduleVisit({ + enterpriseId: enterprise.enterpriseId, + enterpriseName: enterprise.name, + block: enterprise.block, + action: action.label, + actionHi: action.labelHi, + priority: enterprise.band === 'RED' ? 1 : 2, + dueDate, + officer: officerSession()?.fullName ?? 'Field Officer', + }) + onClose() + navigate('/officer/visits') + } + + return ( + +
+ {enterprise.enterpriseId} · {enterprise.block} block · stress score{' '} + {enterprise.score} +
+ + + + + +

+ {(PLAYBOOK.find((p) => p.code === code) ?? PLAYBOOK[0]).detail} +

+ + + setDueDate(e.target.value)} /> + + +
+ Cancel + Schedule +
+
+ ) +} diff --git a/src/features/officer/SettingsPage.tsx b/src/features/officer/SettingsPage.tsx new file mode 100644 index 0000000..97087fd --- /dev/null +++ b/src/features/officer/SettingsPage.tsx @@ -0,0 +1,148 @@ +import { useState } from 'react' +import { API_BASE, officerSession } from '../../lib/api' +import type { Lang, Sector } from '../../lib/api' +import { Card, Field, GhostBtn, PrimaryBtn, inputCls } from '../../components/ui/bits' +import { BAND_THRESHOLD, SECTORS, SECTOR_LIST } from '../../lib/demo/enterprises' +import { useLang } from '../../lib/i18n' +import { useOffline } from '../../lib/useOffline' +import { lastSyncedAt } from '../../lib/ledgerStore' + +/** + * Console settings. Band thresholds are editable because the deck's claim is + * that they map to the *institution's own* watchlist categories rather than a + * fixed vendor scale — changing them here is a local preview, not a model change. + */ +export function SettingsPage() { + const session = officerSession() + const offline = useOffline() + const { lang, setLang } = useLang('en') + const [red, setRed] = useState(BAND_THRESHOLD.red) + const [watch, setWatch] = useState(BAND_THRESHOLD.watch) + const [packs, setPacks] = useState>( + () => Object.fromEntries(SECTOR_LIST.map((s) => [s, true])) as Record, + ) + const [saved, setSaved] = useState(false) + + const synced = lastSyncedAt() + + return ( +
+ +
+

+ An enterprise escalates to High risk at or above the red threshold, and to Watch at or above + the watch threshold. Set these to match your own watchlist categories. +

+
+ + setRed(Number(e.target.value))} + /> + + + setWatch(Number(e.target.value))} + /> + +
+
+ + + +
+
+ { + setSaved(true) + setTimeout(() => setSaved(false), 1800) + }} + > + Save thresholds + + { + setRed(BAND_THRESHOLD.red) + setWatch(BAND_THRESHOLD.watch) + }} + > + Reset + + {saved && Saved for this session.} +
+
+
+ + +
+

+ Each pack is modelled separately. Disabling one hides its enterprises from scoring runs. +

+ {SECTOR_LIST.map((s) => ( + + ))} +
+
+ + +
+ {[ + ['Signed in as', session?.fullName ?? '—'], + ['Username', session?.username ?? '—'], + ['Role', session?.role ?? '—'], + ['Scope', session?.branch ?? '—'], + ['Blocks', (session?.blocks ?? []).join(', ') || '—'], + ].map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+
+ + +
+
+ API endpoint + {API_BASE} +
+
+ Connection + {offline ? 'Offline' : 'Online'} +
+
+ Last ledger sync + + {synced ? new Date(synced).toLocaleString() : 'Never'} + +
+ + + +
+
+
+ ) +} diff --git a/src/features/officer/VisitsPage.tsx b/src/features/officer/VisitsPage.tsx new file mode 100644 index 0000000..f233d53 --- /dev/null +++ b/src/features/officer/VisitsPage.tsx @@ -0,0 +1,170 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { officerSession } from '../../lib/api' +import type { FieldVisit, VisitState } from '../../lib/api' +import { + ActionChip, + Empty, + Field, + GhostBtn, + KpiTile, + Modal, + PrimaryBtn, + StatusPill, + inputCls, +} from '../../components/ui/bits' +import { todayIso } from '../../lib/demo/seed' +import { logOutcome, useVisits } from '../../lib/visitStore' +import { useScope } from './officerScope' + +const FILTERS: { key: VisitState | 'ALL'; label: string }[] = [ + { key: 'ALL', label: 'All' }, + { key: 'PLANNED', label: 'Planned' }, + { key: 'OVERDUE', label: 'Overdue' }, + { key: 'DONE', label: 'Done' }, +] + +/** + * The field officer playbook: ranked interventions with due dates. Logging an + * outcome closes the loop the deck's process flow describes — the outcome and + * any override feed the next retraining cycle. + */ +export function VisitsPage() { + const navigate = useNavigate() + const { block } = useScope() + const officer = officerSession()?.fullName ?? 'Field Officer' + const { visits } = useVisits(block, officer) + const [filter, setFilter] = useState('ALL') + const [logging, setLogging] = useState(null) + + const rows = visits + .filter((v) => filter === 'ALL' || v.state === filter) + .sort((a, b) => a.priority - b.priority || a.dueDate.localeCompare(b.dueDate)) + + const done = visits.filter((v) => v.state === 'DONE').length + const overdue = visits.filter((v) => v.state === 'OVERDUE').length + const planned = visits.filter((v) => v.state === 'PLANNED').length + + return ( +
+
+ + + + +
+ +
+ {FILTERS.map((f) => ( + setFilter(f.key)}> + {f.label} + + ))} + {rows.length} shown +
+ +
+
+ + + + + + + + + + + + + + {rows.map((v) => ( + + + + + + + + + + + ))} + +
PriorityEnterpriseRecommended actionBlockDueStateOutcome +
+ + {v.priority === 1 ? 'Act first' : 'Routine'} + + + + {v.enterpriseId} + {v.action}{v.block} + {v.dueDate} + + + {v.outcome ?? '—'} + {v.state !== 'DONE' && setLogging(v)}>Log outcome} +
+
+ {!rows.length && } +
+ + {logging && setLogging(null)} />} +
+ ) +} + +const PRESET_OUTCOMES = [ + 'Stock and receivables verified against the ledger.', + 'Instalment date moved to align with the earning season.', + 'Working-capital top-up recommended to the branch.', + 'Owner unavailable — revisit required.', + 'No action needed; figures explained by a one-off event.', +] + +function LogOutcomeModal({ visit, onClose }: { visit: FieldVisit; onClose: () => void }) { + const [preset, setPreset] = useState(PRESET_OUTCOMES[0]) + const [note, setNote] = useState('') + + function save() { + logOutcome(visit.visitId, note.trim() ? `${preset} ${note.trim()}` : preset) + onClose() + } + + return ( + +
+ {visit.visitId} · {visit.action} · due {visit.dueDate} +
+ + + + + + +