Compare commits

...

4 Commits

41 changed files with 2416 additions and 194 deletions

403
package-lock.json generated
View File

@ -11,7 +11,8 @@
"lucide-react": "^0.400.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
"react-router-dom": "^7.18.1",
"recharts": "^3.9.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@ -577,6 +578,32 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
@ -841,6 +868,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
@ -1124,6 +1163,69 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@ -1159,7 +1261,7 @@
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@ -1175,6 +1277,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz",
@ -1575,6 +1683,15 @@
],
"license": "CC-BY-4.0"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -1614,9 +1731,130 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -1635,6 +1873,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -1673,6 +1917,16 @@
"node": ">=10.13.0"
}
},
"node_modules/es-toolkit": {
"version": "1.49.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
"integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -1881,6 +2135,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -2056,6 +2316,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "11.1.11",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz",
"integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@ -2066,6 +2336,15 @@
"node": ">=0.8.19"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@ -2709,6 +2988,36 @@
"react": "^19.2.7"
}
},
"node_modules/react-is": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
@ -2747,6 +3056,57 @@
"react-dom": ">=18"
}
},
"node_modules/recharts": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz",
"integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
@ -2857,6 +3217,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@ -2994,6 +3360,37 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",

View File

@ -13,7 +13,8 @@
"lucide-react": "^0.400.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
"react-router-dom": "^7.18.1",
"recharts": "^3.9.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",

0
response.json Normal file
View File

View File

@ -2,7 +2,10 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { AuthProvider } from './auth/AuthProvider'
import { LoginPage } from './screens/LoginPage'
import { ConsoleLayout } from './screens/ConsoleLayout'
import { WorkflowPage } from './screens/WorkflowPage'
import { OrdersPage } from './screens/OrdersPage'
import { CallsPage } from './screens/CallsPage'
import { StoresPage } from './screens/StoresPage'
import { DailyLogsPage } from './screens/DailyLogsPage'
function App() {
return (
@ -11,8 +14,17 @@ function App() {
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ConsoleLayout />}>
<Route path="/:tab" element={<WorkflowPage />} />
<Route path="/:tab/:instanceId" element={<WorkflowPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="/orders/:instanceId" element={<OrdersPage />} />
<Route path="/calls" element={<CallsPage />} />
<Route path="/calls/:instanceId" element={<CallsPage />} />
<Route path="/stores" element={<StoresPage />} />
<Route path="/stores/:instanceId" element={<StoresPage />} />
<Route path="/daily" element={<DailyLogsPage />} />
<Route path="/daily/:instanceId" element={<DailyLogsPage />} />
</Route>
<Route path="*" element={<Navigate to="/orders" replace />} />
</Routes>

View File

@ -230,6 +230,30 @@ export class ZinoClient {
});
}
// --- WF Lookup Records (cross-workflow references) ---
/** Fetch records for a wf_lookup field. */
wfLookupRecords(
opts: {
activityId: string;
fieldId: string;
formData?: Record<string, unknown>;
search?: string;
limit?: number;
offset?: number;
}
): Promise<{ data: Array<Record<string, unknown>>; records?: Array<Record<string, unknown>> } | Array<Record<string, unknown>>> {
return this.request('POST', `/app/${APP_ID}/wf-lookup/records`, {
workflow_uuid: this.workflowUuid,
activity_id: opts.activityId,
field_id: opts.fieldId,
form_data: opts.formData ?? {},
search: opts.search ?? '',
limit: opts.limit ?? 100,
offset: opts.offset ?? 0,
});
}
// --- Dataset-backed select options (state/city cascade etc.) ---
/** Fetch options for a dataset-backed select. The server resolves the field's

View File

@ -29,10 +29,21 @@ export interface RecordViewField {
is_search: boolean;
}
export interface TileItem {
tile_uid: string;
key: string;
value: unknown;
}
export interface RecordViewResponse {
config: { fields: RecordViewField[] };
config: {
fields: RecordViewField[];
filter_options?: Record<string, string[]>;
};
data: Record<string, unknown>[];
pagination?: { page: number; limit: number; total_count: number; total_pages: number };
tile_values?: TileItem[];
chart_data?: unknown[];
}
export interface RecordViewParams {
@ -80,6 +91,8 @@ export interface FormScreenField {
mapped_workflow_field?: string;
type?: string; // 'mapped' | 'local'
properties?: FormScreenFieldProps | null;
/** Present when data_type === 'grid' */
columns?: FormScreenField[];
}
export interface FormScreenGridItem {
@ -121,6 +134,10 @@ export interface FieldDefault {
/** Read-only in the form — value is computed (e.g. premium_amount by custom_js). */
disabled?: boolean;
value?: unknown;
prefill?: {
type: string;
value: string;
};
}
export interface FormScreenResponse {
@ -132,6 +149,16 @@ export interface FormScreenResponse {
field_rules?: FieldRule[];
/** Per-field designer defaults (e.g. `{ assign_city: { hidden: true } }`). */
field_defaults?: Record<string, FieldDefault>;
/** Existing instance data if instance_id was passed. */
data?: Record<string, unknown>;
/** Existing instance data is sometimes returned as prefill_data. */
prefill_data?: Record<string, unknown>;
/** Subsequent activities to run in a chain after this form completes. */
activity_chain?: Array<{
activity_uid: string;
activity_name: string;
prefill_mappings?: any[];
}>;
}
// --- Audit (GET /app/{appId}/view/audit) ---

View File

@ -2,26 +2,17 @@ import { useEffect, useState } from 'react';
import { cn } from '../../lib/cn';
import { formatValue } from '../../lib/format';
import type { ZinoClient } from '../../api/client';
import type { AuditEntry } from '../../api/types';
import { WORKFLOWS } from '../../api/config';
import { Card } from '../reusable/Card';
import { Spinner } from '../reusable/Spinner';
import { EmptyState } from '../reusable/EmptyState';
interface DetailField {
field_key: string;
output_label: string;
data_type: string;
}
interface DetailResponse {
config: { fields: DetailField[] };
data: Record<string, unknown>;
}
export interface DetailViewProps {
/** Workflow-bound client (see api/clients.ts). */
client: ZinoClient;
/** detailview template uid. */
dvUid: string;
/** detailview template uid (deprecated, using audit endpoint now). */
dvUid?: string;
/** Instance to render. */
instanceId: number | string;
/** Card header title. */
@ -33,12 +24,28 @@ export interface DetailViewProps {
}
/**
* Generic Zino detail view. Fetches `GET /app/{id}/view/detailview/{uid}` and
* renders config.fields as a labeled definition grid. Wire it to a specific
* view via the thin wrappers in this folder.
* Helper to resolve a human-readable activity name from its UID.
*/
export function DetailView({ client, dvUid, instanceId, title, fields, columns = 2 }: DetailViewProps) {
const [resp, setResp] = useState<DetailResponse | null>(null);
function getActivityName(uid: string): string | undefined {
for (const wf of Object.values(WORKFLOWS)) {
for (const [actName, actDef] of Object.entries(wf.activities)) {
if (actDef.uid === uid) {
return actName
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
}
}
}
return undefined;
}
/**
* Generic Zino detail view. Fetches `GET /app/{id}/view/audit` and
* renders the audit trail as a labeled definition grid.
*/
export function DetailView({ client, instanceId, title, columns = 2 }: DetailViewProps) {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -48,8 +55,8 @@ export function DetailView({ client, dvUid, instanceId, title, fields, columns =
setLoading(true);
setError(null);
try {
const r = await client.detailView(dvUid, instanceId);
if (live) setResp(r as DetailResponse);
const r = await client.audit(instanceId);
if (live) setEntries(r);
} catch (e) {
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
} finally {
@ -60,38 +67,112 @@ export function DetailView({ client, dvUid, instanceId, title, fields, columns =
return () => {
live = false;
};
}, [client, dvUid, instanceId]);
const all = resp?.config.fields ?? [];
const shown = fields
? fields
.map((k) => all.find((f) => f.field_key === k) ?? { field_key: k, output_label: k, data_type: 'string' })
: all;
}, [client, instanceId]);
const gridCols = { 1: 'grid-cols-1', 2: 'grid-cols-1 sm:grid-cols-2', 3: 'grid-cols-1 sm:grid-cols-3' }[columns];
if (error) {
return (
<Card title={title}>
{error ? (
<EmptyState title="Couldnt load record" hint={error} />
) : loading ? (
</Card>
);
}
if (loading) {
return (
<Card title={title}>
<div className="py-8 flex justify-center">
<Spinner label="Loading…" />
</div>
) : shown.length === 0 ? (
<EmptyState title="No fields to display" />
) : (
<dl className={cn('grid gap-x-6 gap-y-4', gridCols)}>
{shown.map((f) => (
<div key={f.field_key} className="flex flex-col gap-1 min-w-0">
<dt className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint">{f.output_label}</dt>
<dd className="m-0 text-sm text-strong font-medium break-words">
{formatValue(resp?.data[f.field_key])}
</dd>
</div>
))}
</dl>
)}
</Card>
);
}
if (entries.length === 0) {
return (
<Card title={title}>
<EmptyState title="No history found" />
</Card>
);
}
return (
<div className="flex flex-col gap-4">
{entries.map((entry, i) => {
const actName = getActivityName(entry.activity_id);
const headerTitle = actName || (i === 0 && title ? title : 'Update');
const updatedAt = new Date(entry.created_at).toLocaleString();
return (
<Card
key={entry.id}
title={headerTitle}
action={<span className="text-xs font-medium text-faint">{updatedAt}</span>}
>
<dl className={cn('grid gap-x-6 gap-y-4', gridCols)}>
{Object.entries(entry.data).map(([key, value]) => {
const isGridArray =
Array.isArray(value) &&
value.length > 0 &&
typeof value[0] === 'object' &&
value[0] !== null &&
!('original_name' in value[0]) &&
!('url' in value[0]);
if (isGridArray) {
const rows = value as Record<string, unknown>[];
// Use all unique keys found across all rows just in case they differ slightly
const headers = Array.from(new Set(rows.flatMap(r => Object.keys(r))));
return (
<div key={key} className="flex flex-col gap-2 min-w-0 col-span-full mt-2 mb-4">
<dt className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint">
{key.replace(/_/g, ' ')}
</dt>
<dd className="m-0 text-sm text-strong font-medium overflow-x-auto rounded border border-border-subtle shadow-sm">
<table className="min-w-full divide-y divide-border-subtle text-left bg-white">
<thead className="bg-slate-50">
<tr>
{headers.map(h => (
<th key={h} className="px-4 py-2 text-[10px] font-bold uppercase text-muted tracking-wide whitespace-nowrap">
{h.replace(/_/g, ' ')}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border-subtle">
{rows.map((row, idx) => (
<tr key={idx} className="hover:bg-slate-50/50">
{headers.map(h => (
<td key={h} className="px-4 py-2 whitespace-nowrap text-sm text-strong">
{formatValue(row[h])}
</td>
))}
</tr>
))}
</tbody>
</table>
</dd>
</div>
);
}
return (
<div key={key} className="flex flex-col gap-1 min-w-0">
<dt className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint">
{key.replace(/_/g, ' ')}
</dt>
<dd className="m-0 text-sm text-strong font-medium break-words">
{formatValue(value)}
</dd>
</div>
);
})}
</dl>
</Card>
);
})}
</div>
);
}

View File

@ -0,0 +1,374 @@
import { useState, useEffect } from 'react';
import type { ZinoClient } from '../../api/client';
import type { FormScreenResponse, FormScreenField } from '../../api/types';
import { Button } from '../buttons/Button';
import { Spinner } from '../reusable/Spinner';
import {
FileInput,
SmartGridField,
GeolocationInput,
PhoneInput,
SelectField,
TextField,
EmailField,
TextAreaField,
DateField,
TimeField,
WfLookupField,
RadioField,
} from './fields';
export interface DynamicFormProps {
client: ZinoClient;
activityId: string;
instanceId?: number | string;
onSuccess?: () => void;
onCancel?: () => void;
ignorePrefill?: boolean;
}
export function DynamicForm({ client, activityId: initialActivityId, instanceId: initialInstanceId, onSuccess, onCancel, ignorePrefill }: DynamicFormProps) {
const [currentActivityId, setCurrentActivityId] = useState(initialActivityId);
const [currentInstanceId, setCurrentInstanceId] = useState<number | string | undefined>(initialInstanceId);
const [chainQueue, setChainQueue] = useState<Array<{ activity_uid: string; activity_name: string }>>([]);
useEffect(() => {
setCurrentActivityId(initialActivityId);
setCurrentInstanceId(initialInstanceId);
setChainQueue([]);
}, [initialActivityId, initialInstanceId]);
const [schema, setSchema] = useState<FormScreenResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
setLoading(true);
client.formSchema(currentActivityId, currentInstanceId)
.then(res => {
if (mounted) {
setSchema(res);
const defaultValues: Record<string, unknown> = {};
if (res.field_defaults) {
Object.entries(res.field_defaults).forEach(([fieldId, def]) => {
if (def.value != null) {
defaultValues[fieldId] = def.value;
} else if (def.prefill) {
if (def.prefill.value === 'current_date') {
defaultValues[fieldId] = new Date().toISOString().split('T')[0];
} else if (def.prefill.value === 'current_time') {
defaultValues[fieldId] = new Date().toTimeString().split(' ')[0].substring(0, 5);
} else if (def.prefill.value === 'current_user_id') {
const user = client.currentUser();
defaultValues[fieldId] = user ? Number(user.id) : '';
} else {
defaultValues[fieldId] = def.prefill.value;
}
}
});
}
const imageFieldIds = new Set(
res.fields.filter(f => f.data_type === 'image' || f.data_type === 'file').map(f => f.id)
);
if (!ignorePrefill) {
if (res.prefill_data) {
Object.entries(res.prefill_data).forEach(([k, v]) => {
if (!imageFieldIds.has(k)) defaultValues[k] = v;
});
} else if (res.data) {
Object.entries(res.data).forEach(([k, v]) => {
if (!imageFieldIds.has(k)) defaultValues[k] = v;
});
}
}
setValues(defaultValues);
setLoading(false);
}
})
.catch((err: any) => {
if (mounted) {
setError(err?.message || 'Failed to load schema');
setLoading(false);
}
});
return () => { mounted = false; };
}, [client, currentActivityId, currentInstanceId]);
const [values, setValues] = useState<Record<string, unknown>>({});
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const handleFieldChange = (fieldId: string, newVal: unknown) => {
setValues(prev => {
const next = { ...prev, [fieldId]: newVal };
// Auto-calculate order_details totals
if (fieldId === 'order_details' && Array.isArray(newVal)) {
let totalBags = 0;
let totalKgs = 0;
newVal.forEach(row => {
totalBags += Number(row.bags) || 0;
totalKgs += Number(row.row_kgs) || 0;
});
next['total_bags'] = totalBags;
next['total_kgs'] = totalKgs;
}
return next;
});
};
if (loading) {
return <div className="p-8 flex justify-center"><Spinner label="Loading form..." /></div>;
}
if (error || !schema) {
return <div className="p-4 text-ruby-600">Failed to load form: {error}</div>;
}
// Filter out disabled fields (usually server-generated IDs)
const fields = schema.fields.filter(f => !f.properties?.disabled);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setSubmitError(null);
try {
const payload: Record<string, unknown> = {};
for (const f of fields) {
const val = values[f.id];
if (val == null) continue;
if (f.data_type === 'phone' && typeof val === 'string') {
const phoneNum = val.replace(/^\+91\s*/, '').trim();
payload[f.id] = {
dial_code: '+91',
phone: phoneNum,
phone_with_dial_code: `+91${phoneNum}`
};
} else if ((f.data_type === 'image' || f.data_type === 'file') && Array.isArray(val) && val.length > 0 && val[0] instanceof File) {
const uploadedFiles = [];
for (const file of val) {
const fileMeta = await client.uploadFile(file, { activityId: currentActivityId, fieldId: f.id });
uploadedFiles.push(fileMeta);
}
payload[f.id] = uploadedFiles;
} else if ((f.data_type === 'image' || f.data_type === 'file') && val instanceof File) {
const fileMeta = await client.uploadFile(val, { activityId: currentActivityId, fieldId: f.id });
payload[f.id] = [fileMeta];
} else {
payload[f.id] = val;
}
}
let res;
if (currentInstanceId != null) {
res = await client.performActivity(currentInstanceId, currentActivityId, payload);
} else {
res = await client.startInstance(currentActivityId, payload);
}
let pending = [...chainQueue];
const newActivities = (schema.activity_chain || []).filter(
a => a.activity_uid !== currentActivityId
);
// Remove any existing occurrences from pending to avoid duplicates
pending = pending.filter(p => !newActivities.some(n => n.activity_uid === p.activity_uid));
// Prepend the new activities for depth-first execution (nested chaining)
pending = [...newActivities, ...pending];
const nextActivity = pending.shift();
if (nextActivity) {
setChainQueue(pending);
setCurrentActivityId(nextActivity.activity_uid);
setCurrentInstanceId(res.instance_id ?? currentInstanceId);
} else {
onSuccess?.();
}
} catch (err: any) {
setSubmitError(err.message || 'Failed to submit form');
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{fields.map(f => {
const type = f.data_type;
const val = values[f.id];
const isDisabled = schema.field_defaults?.[f.id]?.disabled;
const renderField = () => {
if (type === 'wf_lookup') {
return (
<WfLookupField
label={f.name}
required={f.mandatory}
value={(val as string | number) ?? ''}
client={client}
config={f.properties?.wf_lookup_config}
activityId={currentActivityId}
fieldId={f.id}
formData={values}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('select') || type.startsWith('multiselect')) {
return (
<SelectField
label={f.name}
required={f.mandatory}
value={(val as string) ?? ''}
options={f.properties?.options || []}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type === 'radio') {
return (
<RadioField
label={f.name}
required={f.mandatory}
value={(val as string) ?? ''}
options={f.properties?.options || []}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('image') || type.startsWith('file')) {
return (
<FileInput
label={f.name}
type={type}
required={f.mandatory}
value={val}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('grid')) {
return (
<SmartGridField
label={f.name}
columns={f.columns || []}
value={(val as Record<string, unknown>[]) || []}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('geolocation')) {
return (
<GeolocationInput
label={f.name}
required={f.mandatory}
value={(val as { latitude: number; longitude: number }) || null}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('phone')) {
const phoneStr = typeof val === 'object' && val !== null ? (val as any).phone || '' : (val as string) ?? '';
return (
<PhoneInput
label={f.name}
required={f.mandatory}
value={phoneStr}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('email')) {
return (
<EmailField
label={f.name}
required={f.mandatory}
value={(val as string) ?? ''}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('textarea') || type.startsWith('text_area') || type.startsWith('longtext')) {
return (
<TextAreaField
label={f.name}
required={f.mandatory}
value={(val as string) ?? ''}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('date')) {
return (
<DateField
label={f.name}
required={f.mandatory}
value={(val as string) ?? ''}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('time')) {
return (
<TimeField
label={f.name}
required={f.mandatory}
value={(val as string) ?? ''}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
return (
<TextField
label={f.name}
required={f.mandatory}
type={type}
value={(val as string) ?? ''}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
};
const content = renderField();
return isDisabled ? (
<fieldset key={f.id} disabled className="opacity-60 pointer-events-none">
{content}
</fieldset>
) : (
<div key={f.id}>{content}</div>
);
})}
{submitError && <div className="text-sm text-ruby-600 mt-2">{submitError}</div>}
<div className="flex items-center justify-end gap-3 mt-4 pt-4 border-t border-border-subtle">
{onCancel && (
<Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}>
Cancel
</Button>
)}
<Button type="submit" variant="primary" disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit'}
</Button>
</div>
</form>
);
}

View File

@ -0,0 +1,23 @@
import { Input } from '../../reusable/Input';
export function DateField({
label,
required,
value,
onChange,
}: {
label: string;
required?: boolean;
value: string;
onChange: (val: string) => void;
}) {
return (
<Input
label={label}
required={required}
type="date"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
/>
);
}

View File

@ -0,0 +1,23 @@
import { Input } from '../../reusable/Input';
export function EmailField({
label,
required,
value,
onChange,
}: {
label: string;
required?: boolean;
value: string;
onChange: (val: string) => void;
}) {
return (
<Input
label={label}
required={required}
type="email"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
/>
);
}

View File

@ -0,0 +1,89 @@
import { useState, useEffect } from 'react';
export function ImagePreview({ file }: { file: File }) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
const objectUrl = URL.createObjectURL(file);
setUrl(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [file]);
if (!url) return null;
return <img src={url} alt="Preview" className="object-cover w-full h-full" />;
}
export function FileInput({
label,
type,
required,
value,
onChange,
}: {
label: string;
type: 'image' | 'file' | string;
required?: boolean;
value: File[] | undefined | null | unknown;
onChange: (files: File[]) => void;
}) {
const files = Array.isArray(value) ? value : (value instanceof File ? [value] : []);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const newFiles = Array.from(e.target.files);
onChange([...files, ...newFiles]);
}
// reset input so the same file can be selected again if needed
e.target.value = '';
};
const removeFile = (index: number) => {
const newFiles = [...files];
newFiles.splice(index, 1);
onChange(newFiles);
};
return (
<div className="flex flex-col gap-1.5 font-sans">
<span className="text-sm font-medium text-muted">
{label}
{required && <span className="text-ruby-600"> *</span>}
</span>
<div className="flex flex-col gap-3">
<input
type="file"
multiple
required={required && files.length === 0}
accept={type === 'image' ? 'image/*' : undefined}
onChange={handleFileChange}
className="w-full text-sm text-strong file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-slate-100 file:text-navy-700 hover:file:bg-slate-200"
/>
{files.length > 0 && (
<div className="flex flex-wrap gap-3">
{files.map((file, i) => (
<div key={i} className="relative w-24 h-24 rounded-md overflow-hidden border border-border-default shadow-sm bg-slate-50 flex-shrink-0 group">
{type === 'image' ? (
<ImagePreview file={file} />
) : (
<div className="w-full h-full flex items-center justify-center p-2 text-xs text-center break-all overflow-hidden text-muted">
{file.name}
</div>
)}
<button
type="button"
onClick={() => removeFile(i)}
className="absolute top-1 right-1 bg-ruby-600/90 hover:bg-ruby-700 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
title="Remove file"
>
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,61 @@
import { useState } from 'react';
import { Button } from '../../buttons/Button';
export function GeolocationInput({
label,
required,
value,
onChange,
}: {
label: string;
required?: boolean;
value: { latitude: number; longitude: number } | null;
onChange: (val: { latitude: number; longitude: number } | null) => void;
}) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchLocation = () => {
if (!navigator.geolocation) {
setError('Geolocation is not supported by your browser.');
return;
}
setLoading(true);
setError(null);
navigator.geolocation.getCurrentPosition(
(position) => {
onChange({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
});
setLoading(false);
},
(err) => {
setError(err.message);
setLoading(false);
},
{ enableHighAccuracy: true }
);
};
return (
<div className="flex flex-col gap-1.5 font-sans">
<span className="text-sm font-medium text-muted">
{label}
{required && <span className="text-ruby-600"> *</span>}
</span>
<div className="flex items-center gap-3">
<Button type="button" variant="secondary" size="sm" onClick={fetchLocation} disabled={loading}>
{loading ? 'Fetching...' : 'Get Location'}
</Button>
{value && (
<span className="text-sm text-strong">
{value.latitude.toFixed(5)}, {value.longitude.toFixed(5)}
</span>
)}
</div>
{error && <span className="text-xs text-ruby-600">{error}</span>}
{required && !value && <input type="text" className="sr-only" required />}
</div>
);
}

View File

@ -0,0 +1,85 @@
import { Button } from '../../buttons/Button';
import { Select } from '../../reusable/Select';
import { Input } from '../../reusable/Input';
import type { FormScreenField } from '../../../api/types';
export function GridInput({
label,
columns,
value = [],
onChange,
}: {
label: string;
columns: FormScreenField[];
value: Record<string, unknown>[];
onChange: (val: Record<string, unknown>[]) => void;
}) {
const addRow = () => {
onChange([...value, {}]);
};
const removeRow = (idx: number) => {
const next = [...value];
next.splice(idx, 1);
onChange(next);
};
const updateRow = (idx: number, fieldId: string, val: unknown) => {
const next = [...value];
next[idx] = { ...next[idx], [fieldId]: val };
onChange(next);
};
return (
<div className="flex flex-col gap-2 font-sans border border-border-default rounded-md p-4 bg-slate-50">
<span className="text-sm font-semibold text-strong mb-2">{label}</span>
{value.length === 0 ? (
<span className="text-sm text-faint italic">No rows added.</span>
) : (
<div className="flex flex-col gap-4">
{value.map((row, i) => (
<div key={i} className="flex flex-col gap-3 p-3 bg-white border border-border-subtle rounded relative shadow-sm">
<div className="absolute top-2 right-2">
<button type="button" onClick={() => removeRow(i)} className="text-xs text-ruby-600 font-medium hover:underline">
Remove
</button>
</div>
<span className="text-xs font-bold text-muted uppercase tracking-wider">Row {i + 1}</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{columns.map(col => {
const val = row[col.id];
if (col.data_type === 'select' || col.data_type === 'multiselect') {
const opts = col.properties?.options || [];
return (
<Select
key={col.id}
label={col.name}
required={col.mandatory}
value={(val as string) ?? ''}
onChange={(e) => updateRow(i, col.id, e.target.value)}
options={[{ value: '', label: 'Select...' }, ...opts.map((o: any) => ({ value: String(o.value), label: o.label }))]}
/>
);
}
return (
<Input
key={col.id}
label={col.name}
required={col.mandatory}
type={col.data_type === 'number' ? 'number' : col.data_type === 'email' ? 'email' : 'text'}
value={(val as string) ?? ''}
onChange={(e) => updateRow(i, col.id, col.data_type === 'number' ? Number(e.target.value) : e.target.value)}
/>
);
})}
</div>
</div>
))}
</div>
)}
<Button type="button" variant="secondary" size="sm" onClick={addRow} className="mt-2 self-start">
+ Add Row
</Button>
</div>
);
}

View File

@ -0,0 +1,32 @@
import { Input } from '../../reusable/Input';
export function PhoneInput({
label,
required,
value,
onChange,
}: {
label: string;
required?: boolean;
value: string;
onChange: (val: string) => void;
}) {
return (
<Input
label={label}
required={required}
type="text"
inputMode="numeric"
prefix="+91"
maxLength={10}
placeholder="10-digit number"
value={value ?? ''}
onChange={(e) => {
const numericOnly = e.target.value.replace(/\D/g, '');
if (numericOnly.length <= 10) {
onChange(numericOnly);
}
}}
/>
);
}

View File

@ -0,0 +1,38 @@
export interface RadioFieldProps {
label: string;
required?: boolean;
value: string;
options: { label: string; value: string }[];
onChange: (val: string) => void;
}
export function RadioField({ label, required, value, options, onChange }: RadioFieldProps) {
// Fallback to Yes/No if no options provided
const opts = options?.length ? options : [
{ label: 'Yes', value: 'yes' },
{ label: 'No', value: 'no' }
];
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-semibold text-strong">
{label}
{required && <span className="text-ruby-600 ml-1">*</span>}
</label>
<div className="flex flex-wrap gap-4 mt-1">
{opts.map((opt) => (
<label key={String(opt.value)} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
value={String(opt.value)}
checked={String(value) === String(opt.value)}
onChange={(e) => onChange(e.target.value)}
className="w-4 h-4 text-primary bg-card border-border-default focus:ring-1 focus:ring-primary focus:outline-none cursor-pointer"
/>
<span className="text-sm text-body">{opt.label}</span>
</label>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,25 @@
import { Select } from '../../reusable/Select';
export function SelectField({
label,
required,
value,
options,
onChange,
}: {
label: string;
required?: boolean;
value: string;
options: any[];
onChange: (val: string) => void;
}) {
return (
<Select
label={label}
required={required}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
options={[{ value: '', label: 'Select...' }, ...options.map((o: any) => ({ value: String(o.value), label: o.label }))]}
/>
);
}

View File

@ -0,0 +1,145 @@
import { Button } from '../../buttons/Button';
import { Select } from '../../reusable/Select';
import { Input } from '../../reusable/Input';
import type { FormScreenField } from '../../../api/types';
export function SmartGridField({
label,
columns,
value = [],
onChange,
}: {
label: string;
columns: FormScreenField[];
value: Record<string, unknown>[];
onChange: (val: Record<string, unknown>[]) => void;
}) {
const addRow = () => onChange([...value, {}]);
const removeRow = (idx: number) => {
const next = [...value];
next.splice(idx, 1);
onChange(next);
};
const updateRow = (idx: number, fieldId: string, val: unknown) => {
const next = [...value];
let row = { ...next[idx], [fieldId]: val };
const colDef = columns.find(c => c.id === fieldId);
// If product category changes, clear out the rest of the row's data
if (colDef && (colDef.id === 'product_category' || colDef.name === 'Product Category')) {
Object.keys(row).forEach(k => {
if (k !== fieldId) {
row[k] = '';
}
});
}
// Auto-fill dataset keys from _raw
if (colDef && (colDef.data_type === 'select' || colDef.data_type === 'multiselect')) {
const option = colDef.properties?.options?.find(o => String(o.value) === String(val));
if (option && option._raw) {
for (const key of Object.keys(option._raw)) {
// Match column ID with or without underscores (e.g., brcode <-> br_code)
const targetCol = columns.find(c => c.id === key || c.id.replace(/_/g, '') === key.replace(/_/g, ''));
if (targetCol && targetCol.id !== fieldId) {
row[targetCol.id] = option._raw[key];
}
}
}
}
// Auto-calculate row_kgs if sku and bags are present
const skuVal = Number(row.sku) || 0;
const bagsVal = Number(row.bags) || 0;
row.row_kgs = skuVal * bagsVal;
next[idx] = row;
onChange(next);
};
return (
<div className="flex flex-col gap-2 font-sans border border-border-default rounded-md p-4 bg-slate-50">
<span className="text-sm font-semibold text-strong mb-2">{label}</span>
{value.length === 0 ? (
<span className="text-sm text-faint italic">No rows added.</span>
) : (
<div className="flex flex-col gap-4">
{value.map((row, i) => (
<div key={i} className="flex flex-col gap-3 p-3 bg-white border border-border-subtle rounded relative shadow-sm">
<div className="absolute top-2 right-2">
<button type="button" onClick={() => removeRow(i)} className="text-xs text-ruby-600 font-medium hover:underline">
Remove
</button>
</div>
<span className="text-xs font-bold text-muted uppercase tracking-wider">Row {i + 1}</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{columns.map(col => {
const val = row[col.id];
if (col.data_type === 'select' || col.data_type === 'multiselect') {
// Cascade filter options
const allOpts = col.properties?.options || [];
let filteredOpts = allOpts;
// Specific logic for product_name depending on product_category
if (col.id === 'product_name' || col.name === 'Product Name') {
const catCol = columns.find(c => c.id === 'product_category' || c.name === 'Product Category');
if (catCol) {
const selectedCategory = row[catCol.id] as string;
if (selectedCategory) {
filteredOpts = allOpts.filter(opt => {
const labelStr = String(opt.label || opt.value || '');
return labelStr.startsWith(selectedCategory);
});
}
}
} else {
// Generic _raw cascading for other fields just in case
filteredOpts = allOpts.filter(opt => {
if (!opt._raw) return true;
for (const [rowKey, rowVal] of Object.entries(row)) {
if (rowKey === col.id || rowVal == null || rowVal === '') continue;
const rawKey = Object.keys(opt._raw).find(rk => rk === rowKey || rk.replace(/_/g, '') === rowKey.replace(/_/g, ''));
if (rawKey && String(opt._raw[rawKey]) !== String(rowVal)) {
return false;
}
}
return true;
});
}
return (
<Select
key={col.id}
label={col.name}
required={col.mandatory}
value={(val as string) ?? ''}
onChange={(e) => updateRow(i, col.id, e.target.value)}
options={[{ value: '', label: 'Select...' }, ...filteredOpts.map((o: any) => ({ value: String(o.value), label: o.label }))]}
/>
);
}
return (
<Input
key={col.id}
label={col.name}
required={col.mandatory}
type={col.data_type === 'number' ? 'number' : col.data_type === 'email' ? 'email' : 'text'}
value={(val as string) ?? ''}
onChange={(e) => updateRow(i, col.id, col.data_type === 'number' ? Number(e.target.value) : e.target.value)}
/>
);
})}
</div>
</div>
))}
</div>
)}
<Button type="button" variant="secondary" size="sm" onClick={addRow} className="mt-2 self-start">
+ Add Row
</Button>
</div>
);
}

View File

@ -0,0 +1,22 @@
import { Textarea } from '../../reusable/Textarea';
export function TextAreaField({
label,
required,
value,
onChange,
}: {
label: string;
required?: boolean;
value: string;
onChange: (val: string) => void;
}) {
return (
<Textarea
label={label}
required={required}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
/>
);
}

View File

@ -0,0 +1,25 @@
import { Input } from '../../reusable/Input';
export function TextField({
label,
type,
required,
value,
onChange,
}: {
label: string;
type: string;
required?: boolean;
value: string;
onChange: (val: string) => void;
}) {
return (
<Input
label={label}
required={required}
type={type === 'number' ? 'number' : type === 'email' ? 'email' : 'text'}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
/>
);
}

View File

@ -0,0 +1,23 @@
import { Input } from '../../reusable/Input';
export function TimeField({
label,
required,
value,
onChange,
}: {
label: string;
required?: boolean;
value: string;
onChange: (val: string) => void;
}) {
return (
<Input
label={label}
required={required}
type="time"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
/>
);
}

View File

@ -0,0 +1,80 @@
import { useState, useEffect } from 'react';
import type { ZinoClient } from '../../../api/client';
import { SelectField } from './SelectField';
export interface WfLookupFieldProps {
label: string;
required?: boolean;
value: string | number;
onChange: (val: string) => void;
client: ZinoClient;
config: any; // wf_lookup_config
activityId: string;
fieldId: string;
formData: Record<string, unknown>;
}
export function WfLookupField({ label, required, value, onChange, client, config, activityId, fieldId, formData }: WfLookupFieldProps) {
const [options, setOptions] = useState<{ label: string; value: string }[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let mounted = true;
const wfUuid = config?.workflow_uuid;
if (!wfUuid) {
setLoading(false);
return;
}
setLoading(true);
client.wfLookupRecords({
activityId,
fieldId,
formData,
limit: 200
})
.then(res => {
if (!mounted) return;
// The API might return { data: [...] } or { records: [...] } or just an array
const arr = Array.isArray(res) ? res : (res.data || res.records || []);
const displayFields = config.display_fields || [];
const opts = arr.map((row: any) => {
// Join the display fields to form the label
const labelParts = displayFields
.map((df: any) => row[df.field_id])
.filter((v: any) => v != null && v !== '');
const labelText = labelParts.length > 0
? labelParts.join(' - ')
: `ID: ${row.instance_id || row.id}`;
return {
value: String(row.instance_id || row.id),
label: labelText
};
});
setOptions(opts);
})
.catch(err => {
console.error("Failed to load wf_lookup records:", err);
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [client, config]);
return (
<SelectField
label={loading ? `${label} (Loading...)` : label}
required={required}
value={String(value || '')}
onChange={onChange}
options={options}
/>
);
}

View File

@ -0,0 +1,13 @@
export * from './FileInput';
export * from './GridInput';
export * from './GeolocationInput';
export * from './PhoneInput';
export * from './SelectField';
export * from './TextField';
export * from './EmailField';
export * from './TextAreaField';
export * from './DateField';
export * from './TimeField';
export * from './WfLookupField';
export * from './RadioField';
export * from './SmartGridField';

View File

@ -0,0 +1,87 @@
import { Card } from './Card';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer
} from 'recharts';
export interface ChartRow {
dimension: string;
value: number;
}
export interface ChartConfig {
chart_uid: string;
key: string;
rows: ChartRow[];
}
export interface AnalyticsChartProps {
data?: unknown[];
}
export function AnalyticsChart({ data }: AnalyticsChartProps) {
if (!data || !Array.isArray(data) || data.length === 0) return null;
const charts = data as ChartConfig[];
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#14B8A6'];
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full">
{charts.map((chart, idx) => {
const title = (chart.key || `Chart ${idx + 1}`)
.replace(/_/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase());
// Safely format dimension string (fallbacks for empty strings)
const formattedRows = (chart.rows || []).map(r => ({
...r,
dimension: String(r.dimension || '').trim() || 'Unknown'
}));
if (formattedRows.length === 0) return null;
return (
<Card key={chart.chart_uid || idx} title={title} className="shadow-sm border-t-4 border-t-indigo-500">
<div className="h-[320px] w-full mt-4">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={formattedRows} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E2E8F0" />
<XAxis
dataKey="dimension"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#64748B' }}
dy={10}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#64748B' }}
allowDecimals={false}
/>
<Tooltip
cursor={{ fill: '#F8FAFC' }}
contentStyle={{ borderRadius: '8px', border: '1px solid #E2E8F0', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)', fontSize: '14px', fontFamily: 'inherit' }}
labelStyle={{ fontWeight: 'bold', color: '#0F172A', marginBottom: '4px' }}
/>
<Bar
dataKey="value"
name="Value"
fill={colors[idx % colors.length]}
radius={[4, 4, 0, 0]}
maxBarSize={40}
/>
</BarChart>
</ResponsiveContainer>
</div>
</Card>
);
})}
</div>
);
}

View File

@ -19,11 +19,12 @@ export interface ModalProps {
subtitle?: ReactNode;
/** @default "md" */
width?: ModalWidth;
actions?: ReactNode;
children?: ReactNode;
}
/** Portal modal host — backdrop, Esc / click-out close, scroll-locked body. */
export function Modal({ open, onClose, title, subtitle, width = 'md', children }: ModalProps) {
export function Modal({ open, onClose, title, subtitle, width = 'md', actions, children }: ModalProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
@ -61,6 +62,8 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', children }
{title && <h3 className="m-0 text-md font-semibold text-strong truncate">{title}</h3>}
{subtitle && <div className="text-xs text-faint mt-0.5">{subtitle}</div>}
</div>
<div className="flex items-center gap-2">
{actions && <div className="flex items-center gap-2 mr-2">{actions}</div>}
<button
onClick={onClose}
aria-label="Close"
@ -68,6 +71,7 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', children }
>
<X size={18} />
</button>
</div>
</header>
<div className="flex-1 min-h-0 overflow-y-auto p-5 scrollbar-slim">{children}</div>
</div>

View File

@ -13,7 +13,7 @@ export interface PaginationProps {
/** Pager: "Showing ab of N" + Prev/Next. Renders nothing when the set fits on
* one page. Shared by the record-view tables. */
export function Pagination({ page, pageSize, total, onPage }: PaginationProps) {
if (total <= pageSize) return null;
if (total <= 0) return null;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(Math.max(page, 1), totalPages);
const start = (safePage - 1) * pageSize;

View File

@ -0,0 +1,60 @@
import { Card } from "./Card";
import type { TileItem } from "../../api/types";
export interface StatsTilesProps {
tiles?: TileItem[];
}
const colors = [
"from-blue-500 to-cyan-500",
"from-emerald-500 to-green-500",
"from-orange-500 to-amber-500",
"from-violet-500 to-fuchsia-500",
"from-pink-500 to-rose-500",
"from-indigo-500 to-blue-500",
];
export function StatsTiles({ tiles }: StatsTilesProps) {
if (!tiles?.length) return null;
return (
<div className="grid grid-cols-2 gap-5 md:grid-cols-4">
{tiles.map((tile, idx) => {
const displayLabel = (tile.key || `tile_${idx}`)
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
const gradient = colors[idx % colors.length];
return (
<Card
key={tile.tile_uid || idx}
className="group relative overflow-hidden rounded-2xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl"
>
{/* Top Gradient */}
<div
className={`absolute left-0 top-0 h-1.5 w-full bg-gradient-to-r ${gradient}`}
/>
{/* Background Decoration */}
<div
className={`absolute -right-6 -top-6 h-24 w-24 rounded-full bg-gradient-to-br ${gradient} opacity-10 transition-all duration-300 group-hover:scale-125`}
/>
<div className="relative flex flex-col gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.15em] text-gray-500">
{displayLabel}
</p>
<h2 className="text-4xl font-bold tracking-tight text-gray-900">
{(tile.value as React.ReactNode) ?? "-"}
</h2>
<div className="h-1 w-12 rounded-full bg-gray-200 transition-all duration-300 group-hover:w-20 group-hover:bg-blue-500" />
</div>
</Card>
);
})}
</div>
);
}

View File

@ -0,0 +1,50 @@
import type { TextareaHTMLAttributes } from 'react';
import { cn } from '../../lib/cn';
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
hint?: string;
error?: string;
className?: string;
}
export function Textarea({
label,
hint,
error,
required,
className,
disabled,
...rest
}: TextareaProps) {
return (
<label className={cn('flex flex-col gap-1.5 font-sans', disabled && 'cursor-not-allowed', className)}>
{label && (
<span className="text-sm font-medium text-muted">
{label}
{required && <span className="text-ruby-600"> *</span>}
</span>
)}
<div
className={cn(
'flex rounded-md border transition-[border-color,box-shadow] duration-150 overflow-hidden',
disabled ? 'bg-sunk border-border-subtle' : 'bg-card',
error ? 'border-ruby-600' : !disabled && 'border-border-default focus-within:ring-2 focus-within:ring-navy-600/20 focus-within:border-navy-600',
)}
>
<textarea
required={required}
disabled={disabled}
className={cn(
'flex-1 w-full min-h-[80px] p-3 border-none outline-none bg-transparent font-sans text-base resize-y',
disabled ? 'text-muted cursor-not-allowed' : 'text-strong',
)}
{...rest}
/>
</div>
{(hint || error) && (
<span className={cn('text-xs', error ? 'text-ruby-600' : 'text-faint')}>{error || hint}</span>
)}
</label>
);
}

View File

@ -2,6 +2,8 @@ export { Card } from './Card';
export type { CardProps, Elevation } from './Card';
export { Badge } from './Badge';
export type { BadgeProps, BadgeTone } from './Badge';
export { Textarea } from './Textarea';
export type { TextareaProps } from './Textarea';
export { Input } from './Input';
export type { InputProps } from './Input';
export { Select } from './Select';

View File

@ -3,8 +3,8 @@ import { ORDER_BOOKING } from '../../api/config';
import { RecordView } from './RecordView';
import type { WiredRecordViewProps } from './OrdersView';
/** Calls record view (Order Booking workflow). */
export function CallsView({ onRowClick, pageSize }: WiredRecordViewProps) {
/** Calls record view (Calls/Visits workflow). */
export function CallsView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps) {
return (
<RecordView
client={orderBookingClient}
@ -12,6 +12,9 @@ export function CallsView({ onRowClick, pageSize }: WiredRecordViewProps) {
title="Calls"
onRowClick={onRowClick}
pageSize={pageSize}
headerActions={headerActions}
rowActions={rowActions}
refreshKey={refreshKey}
/>
);
}

View File

@ -4,7 +4,7 @@ import { RecordView } from './RecordView';
import type { WiredRecordViewProps } from './OrdersView';
/** Daily Logs record view (Daily Reports workflow). */
export function DailyLogsView({ onRowClick, pageSize }: WiredRecordViewProps) {
export function DailyLogsView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps) {
return (
<RecordView
client={dailyReportsClient}
@ -12,6 +12,9 @@ export function DailyLogsView({ onRowClick, pageSize }: WiredRecordViewProps) {
title="Daily Logs"
onRowClick={onRowClick}
pageSize={pageSize}
headerActions={headerActions}
rowActions={rowActions}
refreshKey={refreshKey}
/>
);
}

View File

@ -5,10 +5,13 @@ import { RecordView } from './RecordView';
export interface WiredRecordViewProps {
onRowClick?: (row: Record<string, unknown>, index: number) => void;
pageSize?: number;
headerActions?: React.ReactNode;
rowActions?: (row: Record<string, unknown>) => React.ReactNode;
refreshKey?: number;
}
/** Orders record view (Order Booking workflow). */
export function OrdersView({ onRowClick, pageSize }: WiredRecordViewProps) {
export function OrdersView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps) {
return (
<RecordView
client={orderBookingClient}
@ -16,6 +19,9 @@ export function OrdersView({ onRowClick, pageSize }: WiredRecordViewProps) {
title="Orders"
onRowClick={onRowClick}
pageSize={pageSize}
headerActions={headerActions}
rowActions={rowActions}
refreshKey={refreshKey}
/>
);
}

View File

@ -8,6 +8,9 @@ import { Card } from '../reusable/Card';
import { Pagination } from '../reusable/Pagination';
import { Spinner } from '../reusable/Spinner';
import { EmptyState } from '../reusable/EmptyState';
import { StatsTiles } from '../reusable/StatsTiles';
import { AnalyticsChart } from '../reusable/AnalyticsChart';
import { Select } from '../reusable/Select';
export interface RecordViewProps {
/** Workflow-bound client (see api/clients.ts). */
@ -24,6 +27,12 @@ export interface RecordViewProps {
onRowClick?: (row: Record<string, unknown>, index: number) => void;
/** Extract a stable key for a row. @default row.instance_id ?? index */
rowKey?: (row: Record<string, unknown>, index: number) => string;
/** Additional actions to render in the header next to search box */
headerActions?: React.ReactNode;
/** Custom actions to render at the end of each row. */
rowActions?: (row: Record<string, unknown>) => React.ReactNode;
/** Pass a new value to trigger a refresh. */
refreshKey?: number;
}
/**
@ -34,15 +43,19 @@ export interface RecordViewProps {
export function RecordView({
client,
rvUid,
title,
title = 'Records',
columns,
pageSize = 25,
pageSize = 50,
onRowClick,
rowKey,
headerActions,
rowActions,
refreshKey,
}: RecordViewProps) {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [debounced, setDebounced] = useState('');
const [activeFilters, setActiveFilters] = useState<Record<string, string>>({});
const [resp, setResp] = useState<RecordViewResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -60,13 +73,24 @@ export function RecordView({
};
}, [search]);
const filtersParam = useMemo(() => {
return Object.entries(activeFilters)
.filter(([_, val]) => val)
.map(([key, val]) => ({
field_key: key,
value: val,
data_type: 'string',
}));
}, [activeFilters]);
useEffect(() => {
let live = true;
async function run() {
setLoading(true);
setError(null);
try {
const r = await client.recordView(rvUid, { page, limit: pageSize, search: debounced });
const r = await client.recordView(rvUid, { page, limit: pageSize, search: debounced, filters: filtersParam });
console.log("RECORD VIEW RESP:", r);
if (live) setResp(r);
} catch (e) {
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
@ -78,7 +102,7 @@ export function RecordView({
return () => {
live = false;
};
}, [client, rvUid, page, pageSize, debounced]);
}, [client, rvUid, page, pageSize, debounced, filtersParam, refreshKey]);
const fields: RecordViewField[] = useMemo(() => {
const all = resp?.config.fields ?? [];
@ -92,12 +116,46 @@ export function RecordView({
const rows = resp?.data ?? [];
const total = resp?.pagination?.total_count ?? rows.length;
const tileValues = resp?.tile_values;
const chartData = resp?.chart_data;
return (
<div className="flex flex-col gap-6 w-full">
<StatsTiles tiles={tileValues} />
<AnalyticsChart data={chartData} />
<Card
title={title}
action={headerActions}
pad={false}
action={
<div className="relative w-[240px] max-w-full">
>
<div className="flex flex-wrap items-center justify-between gap-4 p-4 border-b border-border-subtle bg-slate-50/50">
<div className="flex flex-wrap items-center gap-3">
{resp?.config?.filter_options && Object.entries(resp.config.filter_options).map(([key, options]) => {
if (!options || options.length === 0) return null;
// Find the field in config to get its proper label
const fieldDef = resp.config.fields.find(f => f.field_key === key);
const label = fieldDef?.output_label || key;
const opts = options.map(o => ({ value: o, label: o }));
return (
<Select
key={key}
value={activeFilters[key] || ''}
onChange={(e) => {
setActiveFilters(prev => ({ ...prev, [key]: e.target.value }));
setPage(1);
}}
options={[{ value: '', label: `All ${label}` }, ...opts]}
className="w-40 shrink-0"
/>
);
})}
</div>
<div className="relative w-full sm:w-[240px] shrink-0">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-faint pointer-events-none" />
<input
value={search}
@ -106,8 +164,8 @@ export function RecordView({
className="w-full h-9 rounded-md border border-border-default bg-card pl-9 pr-3 font-sans text-sm text-strong outline-none focus-ring"
/>
</div>
}
>
</div>
{error ? (
<EmptyState title="Couldnt load records" hint={error} />
) : loading && !resp ? (
@ -129,6 +187,11 @@ export function RecordView({
{f.output_label}
</th>
))}
{rowActions && (
<th className="sticky right-0 bg-inherit px-4 py-3 text-left text-[10px] font-bold uppercase tracking-[0.06em] text-faint border-b border-border-subtle z-10 w-24">
Actions
</th>
)}
</tr>
</thead>
<tbody>
@ -137,7 +200,7 @@ export function RecordView({
key={rowKey ? rowKey(row, i) : String(row.instance_id ?? i)}
onClick={onRowClick ? () => onRowClick(row, i) : undefined}
className={cn(
'border-b border-border-subtle transition-colors duration-150',
'bg-surface border-b border-border-subtle transition-colors duration-150',
onRowClick && 'cursor-pointer hover:bg-slate-50',
)}
>
@ -146,6 +209,13 @@ export function RecordView({
{formatValue(row[f.field_key])}
</td>
))}
{rowActions && (
<td className="sticky right-0 bg-inherit px-4 py-3 whitespace-nowrap border-b border-border-subtle z-10">
<div onClick={(e) => e.stopPropagation()}>
{rowActions(row)}
</div>
</td>
)}
</tr>
))}
</tbody>
@ -154,5 +224,6 @@ export function RecordView({
)}
<Pagination page={page} pageSize={pageSize} total={total} onPage={setPage} />
</Card>
</div>
);
}

View File

@ -4,7 +4,7 @@ import { RecordView } from './RecordView';
import type { WiredRecordViewProps } from './OrdersView';
/** Store record view (Store workflow). */
export function StoresView({ onRowClick, pageSize }: WiredRecordViewProps) {
export function StoresView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps & { headerActions?: React.ReactNode }) {
return (
<RecordView
client={storeClient}
@ -12,6 +12,9 @@ export function StoresView({ onRowClick, pageSize }: WiredRecordViewProps) {
title="Stores"
onRowClick={onRowClick}
pageSize={pageSize}
headerActions={headerActions}
rowActions={rowActions}
refreshKey={refreshKey}
/>
);
}

View File

@ -33,7 +33,12 @@ export function formatValue(value: unknown): string {
// File / image blob
if ('original_name' in o && o.original_name != null) return String(o.original_name);
if ('url' in o && o.url != null) return String(o.url);
if ('name' in o && o.name != null) return String(o.name);
if ('value' in o && o.value != null) return String(o.value);
// Custom workflow lookups
if ('business_name_2' in o && o.business_name_2 != null) return String(o.business_name_2);
if ('business_name_3' in o && o.business_name_3 != null) return String(o.business_name_3);
if ('business_name' in o && o.business_name != null) return String(o.business_name);
}
try {

93
src/screens/CallsPage.tsx Normal file
View File

@ -0,0 +1,93 @@
import { useNavigate, useParams } from 'react-router-dom';
import { Modal } from '../components/reusable';
import { CallsView } from '../components/rv';
import { CallDetail } from '../components/dv';
import { useState } from 'react';
import { Button } from '../components/buttons/Button';
import { Plus } from 'lucide-react';
import { DynamicForm } from '../components/forms/DynamicForm';
import { ORDER_BOOKING } from '../api/config';
import { orderBookingClient } from '../api/clients';
export function CallsPage() {
const params = useParams();
const instanceId = params.instanceId ? Number(params.instanceId) : undefined;
const navigate = useNavigate();
const [isCreating, setIsCreating] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [activeActivity, setActiveActivity] = useState<{ id: string; name: string } | null>(null);
return (
<>
<CallsView
refreshKey={refreshKey}
onRowClick={(row) => {
const id = row.instance_id as number | string | undefined;
if (id != null) navigate(`/calls/${id}`);
}}
headerActions={
<Button size="sm" iconLeft={<Plus size={14} />} onClick={() => setIsCreating(true)}>
Log Visit
</Button>
}
/>
<Modal
open={isCreating}
onClose={() => setIsCreating(false)}
title="Log Visit"
width="md"
>
<DynamicForm
client={orderBookingClient}
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
onSuccess={() => {
setIsCreating(false);
setRefreshKey(k => k + 1);
}}
onCancel={() => setIsCreating(false)}
/>
</Modal>
<Modal
open={instanceId != null}
onClose={() => navigate(`/calls`)}
title={instanceId != null ? `Call #${instanceId}` : undefined}
width="lg"
actions={
<>
<Button size="sm" variant="secondary" onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.POTENTIAL_MINING.uid, name: 'Potential Mining' })}>
Potential Mining
</Button>
<Button size="sm" onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.PLACE_ORDER.uid, name: 'Place Order' })}>
Place Order
</Button>
</>
}
>
{instanceId != null && <CallDetail instanceId={instanceId} />}
</Modal>
<Modal
open={activeActivity != null}
onClose={() => setActiveActivity(null)}
title={activeActivity?.name}
width="md"
>
{activeActivity && instanceId != null && (
<DynamicForm
client={orderBookingClient}
activityId={activeActivity.id}
instanceId={instanceId}
ignorePrefill={activeActivity.id === ORDER_BOOKING.activities.PLACE_ORDER.uid}
onSuccess={() => {
setActiveActivity(null);
setRefreshKey(k => k + 1);
}}
onCancel={() => setActiveActivity(null)}
/>
)}
</Modal>
</>
);
}

View File

@ -6,7 +6,7 @@ import { useAuth } from '../auth/context';
import { onAuthErrorAll, orderBookingClient } from '../api/clients';
import { APP_ID } from '../api/config';
import { Button } from '../components/buttons';
import { TABS } from './tabs';
import { SCREENS } from './tabs';
/** Auth-guarded shell: navy top bar + tab nav + routed <Outlet>. */
export function ConsoleLayout() {
@ -25,12 +25,32 @@ export function ConsoleLayout() {
if (!authed) return <Navigate to="/login" replace />;
return (
<div className="min-h-screen bg-app">
<header className="sticky top-0 z-10 flex items-center justify-between gap-3 px-6 h-[60px] bg-navy-grad border-b border-border-navy">
<div className="min-h-screen bg-app flex flex-col">
<header className="sticky top-0 z-10 shrink-0 flex items-center justify-between gap-3 px-6 h-[60px] bg-navy-grad border-b border-border-navy">
<div className="flex flex-col">
<span className="text-md font-extrabold text-on-navy tracking-[-0.01em] leading-none">Krishna Sales</span>
<span className="text-2xs text-on-navy-muted">Field Sales · Sandbox {APP_ID}</span>
</div>
<nav className="flex items-center gap-1 h-full">
{SCREENS.map((t) => {
const Icon = t.icon;
return (
<NavLink
key={t.key}
to={`/${t.key}`}
className={({ isActive }) =>
cn(
'flex items-center gap-1.5 no-underline font-sans text-sm font-medium px-3 py-1.5 rounded-md transition-all duration-150',
isActive ? 'bg-white/10 text-white' : 'text-on-navy-muted hover:text-white hover:bg-white/5',
)
}
>
<Icon size={16} />
{t.label}
</NavLink>
);
})}
</nav>
<div className="flex items-center gap-3">
{user?.name && <span className="text-sm text-on-navy hidden sm:inline">{user.name}</span>}
<Button
@ -47,29 +67,10 @@ export function ConsoleLayout() {
</div>
</header>
<main className="max-w-[1240px] mx-auto p-6 flex flex-col gap-5">
<nav className="flex gap-0.5 bg-slate-100 rounded-md p-[3px] w-fit">
{TABS.map((t) => {
const Icon = t.icon;
return (
<NavLink
key={t.key}
to={`/${t.key}`}
className={({ isActive }) =>
cn(
'inline-flex items-center gap-1.5 no-underline font-sans text-xs font-semibold px-3.5 py-2 rounded-sm transition-all duration-150',
isActive ? 'bg-card text-strong shadow-xs' : 'bg-transparent text-muted hover:text-strong',
)
}
>
<Icon size={14} />
{t.label}
</NavLink>
);
})}
</nav>
<main className="flex-1 overflow-y-auto p-6">
<div className="max-w-[1240px] w-full mx-auto flex flex-col gap-5">
<Outlet />
</div>
</main>
</div>
);

View File

@ -0,0 +1,87 @@
import { useNavigate, useParams } from 'react-router-dom';
import { Modal } from '../components/reusable';
import { DailyLogsView } from '../components/rv';
import { DailyLogDetail } from '../components/dv';
import { useState } from 'react';
import { Button } from '../components/buttons/Button';
import { Plus } from 'lucide-react';
import { DynamicForm } from '../components/forms/DynamicForm';
import { DAILY_REPORTS } from '../api/config';
import { dailyReportsClient } from '../api/clients';
export function DailyLogsPage() {
const params = useParams();
const instanceId = params.instanceId ? Number(params.instanceId) : undefined;
const navigate = useNavigate();
const [isCreating, setIsCreating] = useState(false);
const [punchOutInstanceId, setPunchOutInstanceId] = useState<number | string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
return (
<>
<DailyLogsView
refreshKey={refreshKey}
onRowClick={(row) => {
const id = row.instance_id as number | string | undefined;
if (id != null) navigate(`/daily/${id}`);
}}
headerActions={
<Button size="sm" iconLeft={<Plus size={14} />} onClick={() => setIsCreating(true)}>
Punch In
</Button>
}
rowActions={(row) => (
<Button size="sm" variant="secondary" onClick={() => setPunchOutInstanceId(row.instance_id as string | number)}>
Punch Out
</Button>
)}
/>
<Modal
open={isCreating}
onClose={() => setIsCreating(false)}
title="Punch In"
width="md"
>
<DynamicForm
client={dailyReportsClient}
activityId={DAILY_REPORTS.activities.INIT.uid}
onSuccess={() => {
setIsCreating(false);
setRefreshKey(k => k + 1);
}}
onCancel={() => setIsCreating(false)}
/>
</Modal>
<Modal
open={punchOutInstanceId != null}
onClose={() => setPunchOutInstanceId(null)}
title="Punch Out"
width="md"
>
{punchOutInstanceId != null && (
<DynamicForm
client={dailyReportsClient}
activityId={DAILY_REPORTS.activities.PUNCH_OUT.uid}
instanceId={punchOutInstanceId}
onSuccess={() => {
setPunchOutInstanceId(null);
setRefreshKey(k => k + 1);
}}
onCancel={() => setPunchOutInstanceId(null)}
/>
)}
</Modal>
<Modal
open={instanceId != null}
onClose={() => navigate(`/daily`)}
title={instanceId != null ? `Daily Log #${instanceId}` : undefined}
width="lg"
>
{instanceId != null && <DailyLogDetail instanceId={instanceId} />}
</Modal>
</>
);
}

View File

@ -0,0 +1,93 @@
import { useNavigate, useParams } from 'react-router-dom';
import { Modal } from '../components/reusable';
import { OrdersView } from '../components/rv';
import { OrderDetail } from '../components/dv';
import { useState } from 'react';
import { Button } from '../components/buttons/Button';
import { Plus } from 'lucide-react';
import { DynamicForm } from '../components/forms/DynamicForm';
import { ORDER_BOOKING } from '../api/config';
import { orderBookingClient } from '../api/clients';
export function OrdersPage() {
const params = useParams();
const instanceId = params.instanceId ? Number(params.instanceId) : undefined;
const navigate = useNavigate();
const [isCreating, setIsCreating] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [activeActivity, setActiveActivity] = useState<{ id: string; name: string } | null>(null);
return (
<>
<OrdersView
refreshKey={refreshKey}
onRowClick={(row) => {
const id = row.instance_id as number | string | undefined;
if (id != null) navigate(`/orders/${id}`);
}}
headerActions={
<Button size="sm" iconLeft={<Plus size={14} />} onClick={() => setIsCreating(true)}>
Place Order
</Button>
}
/>
<Modal
open={isCreating}
onClose={() => setIsCreating(false)}
title="Place Order"
width="md"
>
<DynamicForm
client={orderBookingClient}
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
onSuccess={() => {
setIsCreating(false);
setRefreshKey(k => k + 1);
}}
onCancel={() => setIsCreating(false)}
/>
</Modal>
<Modal
open={instanceId != null}
onClose={() => navigate(`/orders`)}
title={instanceId != null ? `Order #${instanceId}` : undefined}
width="lg"
actions={
<>
<Button size="sm" variant="secondary" onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.POTENTIAL_MINING.uid, name: 'Potential Mining' })}>
Potential Mining
</Button>
<Button size="sm" onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.PLACE_ORDER.uid, name: 'Place Order' })}>
Place Order
</Button>
</>
}
>
{instanceId != null && <OrderDetail instanceId={instanceId} />}
</Modal>
<Modal
open={activeActivity != null}
onClose={() => setActiveActivity(null)}
title={activeActivity?.name}
width="md"
>
{activeActivity && instanceId != null && (
<DynamicForm
client={orderBookingClient}
activityId={activeActivity.id}
instanceId={instanceId}
ignorePrefill={activeActivity.id === ORDER_BOOKING.activities.PLACE_ORDER.uid}
onSuccess={() => {
setActiveActivity(null);
setRefreshKey(k => k + 1);
}}
onCancel={() => setActiveActivity(null)}
/>
)}
</Modal>
</>
);
}

View File

@ -0,0 +1,87 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Modal } from '../components/reusable';
import { StoresView } from '../components/rv';
import { StoreDetail } from '../components/dv';
import { Button } from '../components/buttons/Button';
import { Plus } from 'lucide-react';
import { DynamicForm } from '../components/forms/DynamicForm';
import { STORE } from '../api/config';
import { storeClient } from '../api/clients';
export function StoresPage() {
const params = useParams();
const instanceId = params.instanceId ? Number(params.instanceId) : undefined;
const navigate = useNavigate();
const [isCreating, setIsCreating] = useState(false);
const [editingInstanceId, setEditingInstanceId] = useState<number | string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
return (
<>
<StoresView
refreshKey={refreshKey}
onRowClick={(row) => {
const id = row.instance_id as number | string | undefined;
if (id != null) navigate(`/stores/${id}`);
}}
headerActions={
<Button size="sm" iconLeft={<Plus size={14} />} onClick={() => setIsCreating(true)}>
Create Store
</Button>
}
rowActions={(row) => (
<Button size="sm" variant="secondary" onClick={() => setEditingInstanceId(row.instance_id as string | number)}>
Edit
</Button>
)}
/>
<Modal
open={isCreating}
onClose={() => setIsCreating(false)}
title="Create Store"
width="md"
>
<DynamicForm
client={storeClient}
activityId={STORE.activities.INIT.uid}
onSuccess={() => {
setIsCreating(false);
setRefreshKey(k => k + 1);
}}
onCancel={() => setIsCreating(false)}
/>
</Modal>
<Modal
open={editingInstanceId != null}
onClose={() => setEditingInstanceId(null)}
title="Edit Store"
width="md"
>
{editingInstanceId != null && (
<DynamicForm
client={storeClient}
activityId={STORE.activities.EDIT_STORE.uid}
instanceId={editingInstanceId}
onSuccess={() => {
setEditingInstanceId(null);
setRefreshKey(k => k + 1);
}}
onCancel={() => setEditingInstanceId(null)}
/>
)}
</Modal>
<Modal
open={instanceId != null && !isCreating}
onClose={() => navigate(`/stores`)}
title={instanceId != null ? `Store #${instanceId}` : undefined}
width="lg"
>
{instanceId != null && <StoreDetail instanceId={instanceId} />}
</Modal>
</>
);
}

View File

@ -1,33 +0,0 @@
import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { Modal } from '../components/reusable';
import { tabByKey } from './tabs';
/** Record view for /:tab, with a deep-linkable detail modal at /:tab/:instanceId. */
export function WorkflowPage() {
const { tab, instanceId } = useParams();
const navigate = useNavigate();
const def = tabByKey(tab);
// Unknown tab → default to orders.
if (!def) return <Navigate to="/orders" replace />;
const { View, Detail, noun, key } = def;
return (
<>
<View onRowClick={(row) => {
const id = row.instance_id as number | string | undefined;
if (id != null) navigate(`/${key}/${id}`);
}} />
<Modal
open={instanceId != null}
onClose={() => navigate(`/${key}`)}
title={instanceId != null ? `${noun} #${instanceId}` : undefined}
width="lg"
>
{instanceId != null && <Detail instanceId={instanceId} />}
</Modal>
</>
);
}

View File

@ -14,10 +14,10 @@ import {
type WiredDetailViewProps,
} from '../components/dv';
export type TabKey = 'orders' | 'calls' | 'stores' | 'daily';
export type ScreenKey = 'orders' | 'calls' | 'stores' | 'daily';
export interface TabDef {
key: TabKey;
export interface ScreenDef {
key: ScreenKey;
label: string;
icon: LucideIcon;
View: (p: WiredRecordViewProps) => React.JSX.Element;
@ -26,13 +26,13 @@ export interface TabDef {
noun: string;
}
export const TABS: TabDef[] = [
export const SCREENS: ScreenDef[] = [
{ key: 'orders', label: 'Orders', icon: ShoppingCart, View: OrdersView, Detail: OrderDetail, noun: 'Order' },
{ key: 'calls', label: 'Calls', icon: Phone, View: CallsView, Detail: CallDetail, noun: 'Call' },
{ key: 'stores', label: 'Stores', icon: Store, View: StoresView, Detail: StoreDetail, noun: 'Store' },
{ key: 'daily', label: 'Daily Logs', icon: ClipboardList, View: DailyLogsView, Detail: DailyLogDetail, noun: 'Daily Log' },
];
export function tabByKey(key: string | undefined): TabDef | undefined {
return TABS.find((t) => t.key === key);
export function screenByKey(key: string | undefined): ScreenDef | undefined {
return SCREENS.find((t) => t.key === key);
}