Compare commits
No commits in common. "785885c383920b328c552ea5978f36b4cae76f5c" and "d5e4ebee5d4f58e50f716f6a51b838e12b922fde" have entirely different histories.
785885c383
...
d5e4ebee5d
403
package-lock.json
generated
403
package-lock.json
generated
@ -11,8 +11,7 @@
|
||||
"lucide-react": "^0.400.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"recharts": "^3.9.2"
|
||||
"react-router-dom": "^7.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@ -578,32 +577,6 @@
|
||||
"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",
|
||||
@ -868,18 +841,6 @@
|
||||
"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",
|
||||
@ -1163,69 +1124,6 @@
|
||||
"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",
|
||||
@ -1261,7 +1159,7 @@
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@ -1277,12 +1175,6 @@
|
||||
"@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",
|
||||
@ -1683,15 +1575,6 @@
|
||||
],
|
||||
"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",
|
||||
@ -1731,130 +1614,9 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"dev": 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",
|
||||
@ -1873,12 +1635,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@ -1917,16 +1673,6 @@
|
||||
"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",
|
||||
@ -2135,12 +1881,6 @@
|
||||
"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",
|
||||
@ -2316,16 +2056,6 @@
|
||||
"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",
|
||||
@ -2336,15 +2066,6 @@
|
||||
"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",
|
||||
@ -2988,36 +2709,6 @@
|
||||
"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",
|
||||
@ -3056,57 +2747,6 @@
|
||||
"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",
|
||||
@ -3217,12 +2857,6 @@
|
||||
"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",
|
||||
@ -3360,37 +2994,6 @@
|
||||
"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",
|
||||
|
||||
@ -13,8 +13,7 @@
|
||||
"lucide-react": "^0.400.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"recharts": "^3.9.2"
|
||||
"react-router-dom": "^7.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
18
src/App.tsx
18
src/App.tsx
@ -2,10 +2,7 @@ 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 { OrdersPage } from './screens/OrdersPage'
|
||||
import { CallsPage } from './screens/CallsPage'
|
||||
import { StoresPage } from './screens/StoresPage'
|
||||
import { DailyLogsPage } from './screens/DailyLogsPage'
|
||||
import { WorkflowPage } from './screens/WorkflowPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@ -14,17 +11,8 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<ConsoleLayout />}>
|
||||
<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 path="/:tab" element={<WorkflowPage />} />
|
||||
<Route path="/:tab/:instanceId" element={<WorkflowPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/orders" replace />} />
|
||||
</Routes>
|
||||
|
||||
@ -230,30 +230,6 @@ 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
|
||||
|
||||
@ -29,21 +29,10 @@ export interface RecordViewField {
|
||||
is_search: boolean;
|
||||
}
|
||||
|
||||
export interface TileItem {
|
||||
tile_uid: string;
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface RecordViewResponse {
|
||||
config: {
|
||||
fields: RecordViewField[];
|
||||
filter_options?: Record<string, string[]>;
|
||||
};
|
||||
config: { fields: RecordViewField[] };
|
||||
data: Record<string, unknown>[];
|
||||
pagination?: { page: number; limit: number; total_count: number; total_pages: number };
|
||||
tile_values?: TileItem[];
|
||||
chart_data?: unknown[];
|
||||
}
|
||||
|
||||
export interface RecordViewParams {
|
||||
@ -91,8 +80,6 @@ export interface FormScreenField {
|
||||
mapped_workflow_field?: string;
|
||||
type?: string; // 'mapped' | 'local'
|
||||
properties?: FormScreenFieldProps | null;
|
||||
/** Present when data_type === 'grid' */
|
||||
columns?: FormScreenField[];
|
||||
}
|
||||
|
||||
export interface FormScreenGridItem {
|
||||
@ -134,10 +121,6 @@ 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 {
|
||||
@ -149,16 +132,6 @@ 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) ---
|
||||
|
||||
@ -2,17 +2,26 @@ 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 (deprecated, using audit endpoint now). */
|
||||
dvUid?: string;
|
||||
/** detailview template uid. */
|
||||
dvUid: string;
|
||||
/** Instance to render. */
|
||||
instanceId: number | string;
|
||||
/** Card header title. */
|
||||
@ -24,28 +33,12 @@ export interface DetailViewProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to resolve a human-readable activity name from its UID.
|
||||
* 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.
|
||||
*/
|
||||
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[]>([]);
|
||||
export function DetailView({ client, dvUid, instanceId, title, fields, columns = 2 }: DetailViewProps) {
|
||||
const [resp, setResp] = useState<DetailResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -55,8 +48,8 @@ export function DetailView({ client, instanceId, title, columns = 2 }: DetailVie
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await client.audit(instanceId);
|
||||
if (live) setEntries(r);
|
||||
const r = await client.detailView(dvUid, instanceId);
|
||||
if (live) setResp(r as DetailResponse);
|
||||
} catch (e) {
|
||||
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
|
||||
} finally {
|
||||
@ -67,112 +60,38 @@ export function DetailView({ client, instanceId, title, columns = 2 }: DetailVie
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [client, instanceId]);
|
||||
}, [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;
|
||||
|
||||
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}>
|
||||
return (
|
||||
<Card title={title}>
|
||||
{error ? (
|
||||
<EmptyState title="Couldn’t load record" hint={error} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card title={title}>
|
||||
) : loading ? (
|
||||
<div className="py-8 flex justify-center">
|
||||
<Spinner label="Loading…" />
|
||||
</div>
|
||||
</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>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,374 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,89 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,61 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,85 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
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 }))]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,145 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,80 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
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';
|
||||
@ -1,87 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -19,12 +19,11 @@ 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', actions, children }: ModalProps) {
|
||||
export function Modal({ open, onClose, title, subtitle, width = 'md', children }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@ -62,16 +61,13 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', actions, c
|
||||
{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"
|
||||
className="shrink-0 -mr-1 -mt-1 p-1.5 rounded-md text-faint hover:text-strong hover:bg-black/5"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="shrink-0 -mr-1 -mt-1 p-1.5 rounded-md text-faint hover:text-strong hover:bg-black/5"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-5 scrollbar-slim">{children}</div>
|
||||
</div>
|
||||
|
||||
@ -13,7 +13,7 @@ export interface PaginationProps {
|
||||
/** Pager: "Showing a–b 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 <= 0) return null;
|
||||
if (total <= pageSize) 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;
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -2,8 +2,6 @@ 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';
|
||||
|
||||
@ -3,8 +3,8 @@ import { ORDER_BOOKING } from '../../api/config';
|
||||
import { RecordView } from './RecordView';
|
||||
import type { WiredRecordViewProps } from './OrdersView';
|
||||
|
||||
/** Calls record view (Calls/Visits workflow). */
|
||||
export function CallsView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps) {
|
||||
/** Calls record view (Order Booking workflow). */
|
||||
export function CallsView({ onRowClick, pageSize }: WiredRecordViewProps) {
|
||||
return (
|
||||
<RecordView
|
||||
client={orderBookingClient}
|
||||
@ -12,9 +12,6 @@ export function CallsView({ onRowClick, pageSize, headerActions, rowActions, ref
|
||||
title="Calls"
|
||||
onRowClick={onRowClick}
|
||||
pageSize={pageSize}
|
||||
headerActions={headerActions}
|
||||
rowActions={rowActions}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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, headerActions, rowActions, refreshKey }: WiredRecordViewProps) {
|
||||
export function DailyLogsView({ onRowClick, pageSize }: WiredRecordViewProps) {
|
||||
return (
|
||||
<RecordView
|
||||
client={dailyReportsClient}
|
||||
@ -12,9 +12,6 @@ export function DailyLogsView({ onRowClick, pageSize, headerActions, rowActions,
|
||||
title="Daily Logs"
|
||||
onRowClick={onRowClick}
|
||||
pageSize={pageSize}
|
||||
headerActions={headerActions}
|
||||
rowActions={rowActions}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,13 +5,10 @@ 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, headerActions, rowActions, refreshKey }: WiredRecordViewProps) {
|
||||
export function OrdersView({ onRowClick, pageSize }: WiredRecordViewProps) {
|
||||
return (
|
||||
<RecordView
|
||||
client={orderBookingClient}
|
||||
@ -19,9 +16,6 @@ export function OrdersView({ onRowClick, pageSize, headerActions, rowActions, re
|
||||
title="Orders"
|
||||
onRowClick={onRowClick}
|
||||
pageSize={pageSize}
|
||||
headerActions={headerActions}
|
||||
rowActions={rowActions}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,9 +8,6 @@ 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). */
|
||||
@ -27,12 +24,6 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -43,19 +34,15 @@ export interface RecordViewProps {
|
||||
export function RecordView({
|
||||
client,
|
||||
rvUid,
|
||||
title = 'Records',
|
||||
title,
|
||||
columns,
|
||||
pageSize = 50,
|
||||
pageSize = 25,
|
||||
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);
|
||||
@ -73,24 +60,13 @@ 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, filters: filtersParam });
|
||||
console.log("RECORD VIEW RESP:", r);
|
||||
const r = await client.recordView(rvUid, { page, limit: pageSize, search: debounced });
|
||||
if (live) setResp(r);
|
||||
} catch (e) {
|
||||
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
|
||||
@ -102,7 +78,7 @@ export function RecordView({
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [client, rvUid, page, pageSize, debounced, filtersParam, refreshKey]);
|
||||
}, [client, rvUid, page, pageSize, debounced]);
|
||||
|
||||
const fields: RecordViewField[] = useMemo(() => {
|
||||
const all = resp?.config.fields ?? [];
|
||||
@ -116,114 +92,67 @@ 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}
|
||||
>
|
||||
<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}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search…"
|
||||
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>
|
||||
<Card
|
||||
title={title}
|
||||
pad={false}
|
||||
action={
|
||||
<div className="relative w-[240px] max-w-full">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-faint pointer-events-none" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search…"
|
||||
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>
|
||||
|
||||
{error ? (
|
||||
<EmptyState title="Couldn’t load records" hint={error} />
|
||||
) : loading && !resp ? (
|
||||
<div className="p-10 flex justify-center">
|
||||
<Spinner label="Loading records…" />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState title="No records" hint={debounced ? 'Nothing matches your search.' : undefined} />
|
||||
) : (
|
||||
<div className="overflow-x-auto scrollbar-slim">
|
||||
<table className="w-full border-collapse min-w-[640px]">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-border-subtle">
|
||||
{fields.map((f) => (
|
||||
<th
|
||||
key={f.field_key}
|
||||
className="text-left px-[18px] py-[11px] text-[10px] font-bold uppercase tracking-[0.06em] text-faint whitespace-nowrap"
|
||||
>
|
||||
{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>
|
||||
{rows.map((row, i) => (
|
||||
<tr
|
||||
key={rowKey ? rowKey(row, i) : String(row.instance_id ?? i)}
|
||||
onClick={onRowClick ? () => onRowClick(row, i) : undefined}
|
||||
className={cn(
|
||||
'bg-surface border-b border-border-subtle transition-colors duration-150',
|
||||
onRowClick && 'cursor-pointer hover:bg-slate-50',
|
||||
)}
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<EmptyState title="Couldn’t load records" hint={error} />
|
||||
) : loading && !resp ? (
|
||||
<div className="p-10 flex justify-center">
|
||||
<Spinner label="Loading records…" />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState title="No records" hint={debounced ? 'Nothing matches your search.' : undefined} />
|
||||
) : (
|
||||
<div className="overflow-x-auto scrollbar-slim">
|
||||
<table className="w-full border-collapse min-w-[640px]">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-border-subtle">
|
||||
{fields.map((f) => (
|
||||
<th
|
||||
key={f.field_key}
|
||||
className="text-left px-[18px] py-[11px] text-[10px] font-bold uppercase tracking-[0.06em] text-faint whitespace-nowrap"
|
||||
>
|
||||
{fields.map((f) => (
|
||||
<td key={f.field_key} className="px-[18px] py-3 text-sm text-body whitespace-nowrap">
|
||||
{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>
|
||||
{f.output_label}
|
||||
</th>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination page={page} pageSize={pageSize} total={total} onPage={setPage} />
|
||||
</Card>
|
||||
</div>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr
|
||||
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',
|
||||
onRowClick && 'cursor-pointer hover:bg-slate-50',
|
||||
)}
|
||||
>
|
||||
{fields.map((f) => (
|
||||
<td key={f.field_key} className="px-[18px] py-3 text-sm text-body whitespace-nowrap">
|
||||
{formatValue(row[f.field_key])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination page={page} pageSize={pageSize} total={total} onPage={setPage} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import { RecordView } from './RecordView';
|
||||
import type { WiredRecordViewProps } from './OrdersView';
|
||||
|
||||
/** Store record view (Store workflow). */
|
||||
export function StoresView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps & { headerActions?: React.ReactNode }) {
|
||||
export function StoresView({ onRowClick, pageSize }: WiredRecordViewProps) {
|
||||
return (
|
||||
<RecordView
|
||||
client={storeClient}
|
||||
@ -12,9 +12,6 @@ export function StoresView({ onRowClick, pageSize, headerActions, rowActions, re
|
||||
title="Stores"
|
||||
onRowClick={onRowClick}
|
||||
pageSize={pageSize}
|
||||
headerActions={headerActions}
|
||||
rowActions={rowActions}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -33,12 +33,7 @@ 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 {
|
||||
|
||||
@ -1,93 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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 { SCREENS } from './tabs';
|
||||
import { TABS } from './tabs';
|
||||
|
||||
/** Auth-guarded shell: navy top bar + tab nav + routed <Outlet>. */
|
||||
export function ConsoleLayout() {
|
||||
@ -25,32 +25,12 @@ export function ConsoleLayout() {
|
||||
if (!authed) return <Navigate to="/login" replace />;
|
||||
|
||||
return (
|
||||
<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="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="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
|
||||
@ -67,10 +47,29 @@ export function ConsoleLayout() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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 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>
|
||||
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,87 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,93 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,87 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
33
src/screens/WorkflowPage.tsx
Normal file
33
src/screens/WorkflowPage.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -14,10 +14,10 @@ import {
|
||||
type WiredDetailViewProps,
|
||||
} from '../components/dv';
|
||||
|
||||
export type ScreenKey = 'orders' | 'calls' | 'stores' | 'daily';
|
||||
export type TabKey = 'orders' | 'calls' | 'stores' | 'daily';
|
||||
|
||||
export interface ScreenDef {
|
||||
key: ScreenKey;
|
||||
export interface TabDef {
|
||||
key: TabKey;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
View: (p: WiredRecordViewProps) => React.JSX.Element;
|
||||
@ -26,13 +26,13 @@ export interface ScreenDef {
|
||||
noun: string;
|
||||
}
|
||||
|
||||
export const SCREENS: ScreenDef[] = [
|
||||
export const TABS: TabDef[] = [
|
||||
{ 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 screenByKey(key: string | undefined): ScreenDef | undefined {
|
||||
return SCREENS.find((t) => t.key === key);
|
||||
export function tabByKey(key: string | undefined): TabDef | undefined {
|
||||
return TABS.find((t) => t.key === key);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user