Compare commits
No commits in common. "4de2241f6eb689c502e355183a76cb99a76d7dad" and "2571aa699676ac8b47d4bb86aad9ca043f019fe3" have entirely different histories.
4de2241f6e
...
2571aa6996
@ -2,8 +2,8 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/seera-logo.png?v=1774269853" />
|
||||
<link rel="apple-touch-icon" href="/seera-logo.png?v=1774269853" />
|
||||
<link rel="icon" type="image/png" href="/seera-logo.png?v=1768316563" />
|
||||
<link rel="apple-touch-icon" href="/seera-logo.png?v=1768316563" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Seera Arabia Asset Management System" />
|
||||
<title>Seera Arabia - Asset Management System</title>
|
||||
|
||||
439
asm_app/package-lock.json
generated
439
asm_app/package-lock.json
generated
@ -12,7 +12,6 @@
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"axios": "^1.12.2",
|
||||
"frappe-react-sdk": "^1.13.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i18next": "^25.7.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"leaflet": "^1.9.4",
|
||||
@ -24,7 +23,6 @@
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-toastify": "^11.0.5",
|
||||
"recharts": "^3.8.1",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -672,42 +670,6 @@
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"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/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.47",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
|
||||
@ -749,18 +711,6 @@
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"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/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@ -806,69 +756,6 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"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/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@ -952,12 +839,6 @@
|
||||
"@types/react-router": "*"
|
||||
}
|
||||
},
|
||||
"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.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
|
||||
@ -1423,15 +1304,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.0.tgz",
|
||||
@ -1745,15 +1617,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@ -1773,127 +1636,6 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"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",
|
||||
@ -1912,12 +1654,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",
|
||||
@ -2062,16 +1798,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@ -2314,12 +2040,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",
|
||||
@ -2717,19 +2437,6 @@
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.7.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.2.tgz",
|
||||
@ -2778,16 +2485,6 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@ -2815,15 +2512,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-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@ -3628,13 +3316,6 @@
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||
@ -3648,29 +3329,6 @@
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+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-refresh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
@ -3767,57 +3425,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||
"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": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"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.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@ -4167,15 +3774,6 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
@ -4199,12 +3797,6 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"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.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@ -4370,37 +3962,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"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": "7.2.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz",
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "node -e \"require('fs').mkdirSync(require('path').join('..','asm_ui_app','public','asm_app'),{recursive:true})\" && node scripts/inject-image-version.js && vite build --base=/assets/asm_ui_app/asm_app/ && yarn copy-html-entry && yarn copy-public-assets",
|
||||
"build": "node scripts/inject-image-version.js && vite build --base=/assets/asm_ui_app/asm_app/ && yarn copy-html-entry && yarn copy-public-assets",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"copy-html-entry": "cp ../asm_ui_app/public/asm_app/index.html ../asm_ui_app/www/asm_app.html",
|
||||
@ -16,7 +16,6 @@
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"axios": "^1.12.2",
|
||||
"frappe-react-sdk": "^1.13.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i18next": "^25.7.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"leaflet": "^1.9.4",
|
||||
@ -28,7 +27,6 @@
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-toastify": "^11.0.5",
|
||||
"recharts": "^3.8.1",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -1,344 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Replace Arabic strings mistakenly stored under locales/en/translation.json."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
EN_PATH = ROOT / "src/locales/en/translation.json"
|
||||
|
||||
AR_RE = re.compile(r"[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]")
|
||||
|
||||
# Hand-authored English for sections that were entirely Arabic in en file
|
||||
SECTIONS: dict = {
|
||||
"items": {
|
||||
"title": "Items",
|
||||
"itemDetails": "Item details",
|
||||
"newItem": "New item",
|
||||
"addItem": "Add new item",
|
||||
"itemId": "Item ID",
|
||||
"itemCode": "Item code",
|
||||
"itemName": "Item name",
|
||||
"itemGroup": "Item group",
|
||||
"stockUOM": "Stock UOM",
|
||||
"partDescription": "Part description",
|
||||
"brand": "Brand",
|
||||
"valuationRate": "Valuation rate",
|
||||
"openingStock": "Opening stock",
|
||||
"lastCalibrationDate": "Last calibration date",
|
||||
"nextCalibrationDate": "Next calibration date",
|
||||
"selectItem": "Select item",
|
||||
"selectItemGroup": "Select item group",
|
||||
"selectHospital": "Select hospital",
|
||||
"serialNo": "Serial no.",
|
||||
"dateIn": "Date in",
|
||||
"watts": "Watts",
|
||||
"volts": "Volts",
|
||||
"type": "Type",
|
||||
"code": "Code",
|
||||
"viewDetails": "View details",
|
||||
"editItem": "Edit item",
|
||||
"duplicateItem": "Duplicate item",
|
||||
"deleteItem": "Delete item",
|
||||
"basicInformation": "Basic information",
|
||||
"inventoryDetails": "Inventory details",
|
||||
"stockInformation": "Stock information",
|
||||
"isStockItem": "Is stock item",
|
||||
"isFixedAsset": "Is fixed asset",
|
||||
"balanceQty": "Balance qty",
|
||||
"calibrationInformation": "Calibration information",
|
||||
"additionalInformation": "Additional information",
|
||||
"refreshBalanceQty": "Refresh balance qty",
|
||||
"warrantyMonths": "Warranty (months)",
|
||||
"errorLoadingItems": "Error loading items",
|
||||
"loadingItems": "Loading items...",
|
||||
"deleteConfirmMessage": "Are you sure you want to delete this item? This cannot be undone.",
|
||||
"backToInventory": "Back to inventory",
|
||||
"loadingItem": "Loading item...",
|
||||
"errorLoadingItem": "Error loading item",
|
||||
"createNewItem": "Create new item",
|
||||
"itemCodeLabel": "Item code",
|
||||
"itemUpdatedSuccessfully": "Item updated successfully!",
|
||||
"pleaseSaveFirst": "Please save the item first before submitting.",
|
||||
"submittedSuccessfully": "Item submitted successfully!",
|
||||
"failedToSave": "Failed to save",
|
||||
"failedToSubmit": "Failed to submit",
|
||||
"noItemsFound": "No items found",
|
||||
"createFirstItem": "Create your first item",
|
||||
"listTitle": "Inventory",
|
||||
"listTotal": "Total: {{count}} item(s)",
|
||||
"failedToLoadItems": "Failed to load items.",
|
||||
"listAddItem": "Add item",
|
||||
"export": {
|
||||
"title": "Export items",
|
||||
"selectData": "Select data to export",
|
||||
"selectedRows": "Selected rows",
|
||||
"selectedCount": "Export {{count}} selected item(s)",
|
||||
"currentPage": "Current page",
|
||||
"currentPageCount": "Export {{count}} item(s) on current page",
|
||||
"allWithFilters": "All records (current filters)",
|
||||
"allWithFiltersCount": "Export all {{count}} item(s) matching filters",
|
||||
"csvDesc": "Comma-separated values",
|
||||
"excelDesc": "XLSX spreadsheet",
|
||||
"columnsToExport": "Columns to export",
|
||||
"selectAll": "Select all",
|
||||
"resetToDefault": "Reset to default",
|
||||
"columnsSelected": "{{count}} column(s) selected",
|
||||
"exporting": "Exporting...",
|
||||
"exportButton": "Export",
|
||||
"exportingSelected": "Exporting {{count}} selected row(s)",
|
||||
"exportingPage": "Exporting {{count}} row(s) from current page",
|
||||
"exportingAll": "Exporting all {{count}} row(s)",
|
||||
},
|
||||
},
|
||||
"issues": {
|
||||
"title": "Issues",
|
||||
"issueDetails": "Issue details",
|
||||
"newIssue": "New ticket",
|
||||
"addIssue": "Add new ticket",
|
||||
"issueId": "Ticket ID",
|
||||
"subject": "Subject",
|
||||
"raisedBy": "Raised by",
|
||||
"contact": "Contact",
|
||||
"issueType": "Issue type",
|
||||
"openingDate": "Opening date",
|
||||
"resolutionDate": "Resolution date",
|
||||
"resolvedBy": "Resolved by",
|
||||
"firstRespondedOn": "First responded on",
|
||||
"resolutionDetails": "Resolution details",
|
||||
"selectIssue": "Select issue",
|
||||
"allPriorities": "All priorities",
|
||||
"allCompanies": "All companies",
|
||||
"viewDetails": "View details",
|
||||
"editIssue": "Edit issue",
|
||||
"deleteIssue": "Delete issue",
|
||||
"enterSubject": "Enter subject",
|
||||
"selectPriority": "Select priority",
|
||||
"selectIssueType": "Select issue type",
|
||||
"describeIssue": "Describe the issue...",
|
||||
"contactInformation": "Contact information",
|
||||
"createNewIssue": "Create new support ticket",
|
||||
"resolution": "Resolution",
|
||||
"describeResolution": "Describe how the issue was resolved...",
|
||||
"selectCompany": "Select company",
|
||||
"statusInformation": "Status information",
|
||||
"currentStatus": "Current status",
|
||||
"timeline": "Timeline",
|
||||
"loadingIssues": "Loading tickets...",
|
||||
"errorLoadingIssues": "Error loading tickets",
|
||||
"deleteConfirmMessage": "Are you sure you want to delete this ticket? This cannot be undone.",
|
||||
"deletedSuccessfully": "Ticket deleted successfully!",
|
||||
"createWorkOrderFromIssue": "Create work order",
|
||||
"listTitle": "Support tickets",
|
||||
"listTotal": "Total",
|
||||
"listSelected": "selected",
|
||||
"statsTotalIssues": "Total tickets",
|
||||
"statsOpen": "Open",
|
||||
"statsResolved": "Resolved",
|
||||
"statsClosed": "Closed",
|
||||
"noIssuesFound": "No tickets found",
|
||||
"createFirstIssue": "Create your first ticket",
|
||||
"export": {
|
||||
"title": "Export tickets",
|
||||
"selectData": "Select data to export",
|
||||
"selectedRows": "Selected rows",
|
||||
"selectedCount": "Export {{count}} selected ticket(s)",
|
||||
"currentPage": "Current page",
|
||||
"currentPageCount": "Export {{count}} ticket(s) on current page",
|
||||
"allWithFilters": "All records (current filters)",
|
||||
"allWithFiltersCount": "Export all {{count}} ticket(s) matching filters",
|
||||
"exportFormat": "Export format",
|
||||
"csv": "CSV",
|
||||
"csvDesc": "Comma-separated values",
|
||||
"excel": "Excel",
|
||||
"excelDesc": "XLSX spreadsheet",
|
||||
"columnsToExport": "Columns to export",
|
||||
"selectAll": "Select all",
|
||||
"resetToDefault": "Reset to default",
|
||||
"columnsSelected": "{{count}} column(s) selected",
|
||||
"exporting": "Exporting...",
|
||||
"exportButton": "Export",
|
||||
"exportingSelected": "Exporting {{count}} selected row(s)",
|
||||
"exportingPage": "Exporting {{count}} row(s) from current page",
|
||||
"exportingAll": "Export all {{count}} row(s)",
|
||||
},
|
||||
"status": {
|
||||
"open": "Open",
|
||||
"replied": "Replied",
|
||||
"on_hold": "On hold",
|
||||
"resolved": "Resolved",
|
||||
"closed": "Closed",
|
||||
},
|
||||
"priority": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"critical": "Critical",
|
||||
},
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
"userDetails": "User details",
|
||||
"newUser": "New user",
|
||||
"addUser": "Add new user",
|
||||
"searchUsers": "Search users...",
|
||||
"manageUsers": "Manage user accounts and permissions",
|
||||
"noUsersFound": "No users found",
|
||||
"tryAdjustingSearch": "Try adjusting your search.",
|
||||
"noUsersAvailable": "No users available.",
|
||||
"backToDashboard": "Back to dashboard",
|
||||
"refresh": "Refresh",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"noEmail": "No email",
|
||||
"created": "Created",
|
||||
},
|
||||
"events": {
|
||||
"title": "Events",
|
||||
"eventDetails": "Event details",
|
||||
"newEvent": "New event",
|
||||
"addEvent": "Add event",
|
||||
"upcomingEvents": "Upcoming events",
|
||||
"eventsFromFrappe": "Events from Frappe",
|
||||
"noEventsFound": "No events found",
|
||||
"noEventsScheduled": "No events scheduled.",
|
||||
"refreshEvents": "Refresh events",
|
||||
},
|
||||
"pagination": {
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"goTo": "Go to",
|
||||
"go": "Go",
|
||||
"page": "Page",
|
||||
"showingToOf": "Showing {{start}} to {{end}} of {{total}} {{label}}",
|
||||
"showingTo": "Showing {{start}} to {{end}} {{label}}",
|
||||
"items": "items",
|
||||
"assets": "assets",
|
||||
"workOrders": "work orders",
|
||||
"issues": "tickets",
|
||||
"teams": "teams",
|
||||
"inspections": "inspections",
|
||||
"plans": "plans",
|
||||
},
|
||||
"linkField": {
|
||||
"loading": "Loading...",
|
||||
"noResultsFound": "No results found",
|
||||
"createNewDoctype": "Create new {{doctype}}",
|
||||
"selectLabel": "Select {{label}}",
|
||||
},
|
||||
"supportPlans": {
|
||||
"loadingSupportPlans": "Loading support plans...",
|
||||
"errorLoadingSupportPlans": "Error loading support plans",
|
||||
"deleteConfirmMessage": "Are you sure you want to delete this support plan? This cannot be undone.",
|
||||
"planId": "Plan ID",
|
||||
"deletedSuccessfully": "Support plan deleted successfully!",
|
||||
"deleteSupportPlan": "Delete support plan",
|
||||
"noSupportPlansFound": "No support plans found",
|
||||
"createFirstSupportPlan": "Create your first support plan",
|
||||
"table": {
|
||||
"planName": "Plan name",
|
||||
"type": "Type",
|
||||
"frequency": "Frequency",
|
||||
"contractValue": "Contract value",
|
||||
"warrantyStatus": "Warranty status",
|
||||
"contractStatus": "Contract status",
|
||||
},
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"expired": "Expired",
|
||||
"pending": "Pending",
|
||||
"terminated": "Terminated early",
|
||||
},
|
||||
"statusLabel": {
|
||||
"warrantyPrefix": "W:",
|
||||
"contractPrefix": "C:",
|
||||
},
|
||||
"viewDetails": "View support plan",
|
||||
"editSupportPlan": "Edit support plan",
|
||||
"listTitle": "Support plans",
|
||||
"statsTotalPlans": "Total plans",
|
||||
"statsContracts": "Contracts",
|
||||
"statsWarranties": "Warranties",
|
||||
"statsActive": "Active",
|
||||
"export": {
|
||||
"title": "Export support plans",
|
||||
"selectData": "Select data to export",
|
||||
"selectedRows": "Selected rows",
|
||||
"selectedCount": "Export {{count}} selected plan(s)",
|
||||
"currentPage": "Current page",
|
||||
"currentPageCount": "Export {{count}} plan(s) on current page",
|
||||
"allWithFilters": "All records (current filters)",
|
||||
"allWithFiltersCount": "Export all {{count}} plan(s) matching filters",
|
||||
"exportFormat": "Export format",
|
||||
"csv": "CSV",
|
||||
"csvDesc": "Comma-separated values",
|
||||
"excel": "Excel",
|
||||
"excelDesc": "XLSX spreadsheet",
|
||||
"columnsToExport": "Columns to export",
|
||||
"selectAll": "Select all",
|
||||
"resetToDefault": "Reset to default",
|
||||
"columnsSelected": "{{count}} column(s) selected",
|
||||
"exporting": "Exporting...",
|
||||
"exportButton": "Export",
|
||||
"exportingSelected": "Exporting {{count}} selected row(s)",
|
||||
"exportingPage": "Exporting {{count}} row(s) from current page",
|
||||
"exportingAll": "Export all {{count}} row(s)",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def humanize_key(key: str) -> str:
|
||||
s = re.sub(r"([a-z])([A-Z])", r"\1 \2", key)
|
||||
s = s.replace("_", " ").strip()
|
||||
if not s:
|
||||
return key
|
||||
words = s.split()
|
||||
out = []
|
||||
for w in words:
|
||||
wl = w.lower()
|
||||
if wl == "wo":
|
||||
out.append("WO")
|
||||
elif wl == "id":
|
||||
out.append("ID")
|
||||
elif wl == "url":
|
||||
out.append("URL")
|
||||
else:
|
||||
out.append(w.capitalize())
|
||||
text = " ".join(out)
|
||||
lk = key.lower()
|
||||
if "loading" in lk:
|
||||
return text if text.endswith("...") else f"{text}..."
|
||||
if lk.startswith("error") or "failed" in lk:
|
||||
return f"Error: {text.lower()}" if "error" not in text.lower() else text
|
||||
return text
|
||||
|
||||
|
||||
def replace_arabic_strings(obj, path: tuple[str, ...] = ()):
|
||||
if isinstance(obj, dict):
|
||||
return {k: replace_arabic_strings(v, path + (k,)) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [replace_arabic_strings(v, path) for v in obj]
|
||||
if isinstance(obj, str) and AR_RE.search(obj):
|
||||
leaf = path[-1] if path else "text"
|
||||
return humanize_key(leaf)
|
||||
return obj
|
||||
|
||||
|
||||
def main() -> None:
|
||||
data = json.loads(EN_PATH.read_text(encoding="utf-8"))
|
||||
for name, patch in SECTIONS.items():
|
||||
data[name] = patch
|
||||
|
||||
for sec in ("inspections", "activeMap", "maintenanceCalendarPage"):
|
||||
if sec in data:
|
||||
data[sec] = replace_arabic_strings(data[sec], (sec,))
|
||||
|
||||
EN_PATH.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"Wrote {EN_PATH}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,6 +1,25 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
// import React from 'react';
|
||||
// import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
// import Test from './pages/Test';
|
||||
// import Login from './pages/Login';
|
||||
|
||||
// const App: React.FC = () => {
|
||||
// return (
|
||||
// <Router basename="/react_ui">
|
||||
// <Routes>
|
||||
// <Route path="/test" element={<Test />} />
|
||||
// <Route path="/login" element={<Login />} />
|
||||
// <Route path="*" element={<Navigate to="/test" replace />} />
|
||||
|
||||
// </Routes>
|
||||
// </Router>
|
||||
// );
|
||||
// };
|
||||
|
||||
// export default App;
|
||||
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { bootstrapFrappeUserFromSession } from './utils/bootstrapFrappeUserFromSession';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import ModernDashboard from './pages/ModernDashboard';
|
||||
@ -27,7 +46,6 @@ import Sidebar from './components/Sidebar';
|
||||
import Header from './components/Header';
|
||||
import IssueList from './pages/IssueList';
|
||||
import IssueDetail from './pages/IssueDetail';
|
||||
import SupportTroubleshoot from './pages/SupportTroubleshoot';
|
||||
import MaintenanceTeamList from './pages/MaintenanceTeamList';
|
||||
import MaintenanceTeamDetail from './pages/MaintenanceTeamDetail';
|
||||
import InspectionList from './pages/InspectionList';
|
||||
@ -35,182 +53,241 @@ import InspectionDetail from './pages/InspectionDetail';
|
||||
import SupportPlanList from './pages/SupportPlanList';
|
||||
import SupportPlanDetail from './pages/SupportPlanDetail';
|
||||
import UserProfilePage from './pages/UserProfilePage';
|
||||
import ProjectModulePage from './pages/ProjectModulePage';
|
||||
import ProjectReportsDashboard from './pages/ProjectReportsDashboard';
|
||||
import ProjectList from './pages/ProjectList';
|
||||
import ProjectDetail from './pages/ProjectDetail';
|
||||
import TaskList from './pages/TaskList';
|
||||
import TaskDetail from './pages/TaskDetail';
|
||||
import TimesheetList from './pages/TimesheetList';
|
||||
import TimesheetDetail from './pages/TimesheetDetail';
|
||||
import ActivityTypeList from './pages/ActivityTypeList';
|
||||
import ActivityTypeDetail from './pages/ActivityTypeDetail';
|
||||
import ProjectTemplateList from './pages/ProjectTemplateList';
|
||||
import ProjectTemplateDetail from './pages/ProjectTemplateDetail';
|
||||
import CustomerList from './pages/CustomerList';
|
||||
import CustomerDetail from './pages/CustomerDetail';
|
||||
import EmployeeList from './pages/EmployeeList';
|
||||
import EmployeeDetail from './pages/EmployeeDetail';
|
||||
import SalesInvoiceList from './pages/SalesInvoiceList';
|
||||
import SalesInvoiceDetail from './pages/SalesInvoiceDetail';
|
||||
import SalesOrderList from './pages/SalesOrderList';
|
||||
import SalesOrderDetail from './pages/SalesOrderDetail';
|
||||
import PurchaseOrderList from './pages/PurchaseOrderList';
|
||||
import PurchaseOrderDetail from './pages/PurchaseOrderDetail';
|
||||
import DeliveryNoteList from './pages/DeliveryNoteList';
|
||||
import DeliveryNoteDetail from './pages/DeliveryNoteDetail';
|
||||
import MaterialRequestList from './pages/MaterialRequestList';
|
||||
import MaterialRequestDetail from './pages/MaterialRequestDetail';
|
||||
import PurchaseReceiptList from './pages/PurchaseReceiptList';
|
||||
import PurchaseReceiptDetail from './pages/PurchaseReceiptDetail';
|
||||
import PaymentEntryList from './pages/PaymentEntryList';
|
||||
import PaymentEntryDetail from './pages/PaymentEntryDetail';
|
||||
import SfdaEntriesList from './pages/SfdaEntriesList';
|
||||
import SfdaEntriesDetail from './pages/SfdaEntriesDetail';
|
||||
import { SidebarLayoutProvider } from './contexts/SidebarLayoutContext';
|
||||
|
||||
// Layout with Sidebar and Header
|
||||
const LayoutWithSidebar: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const user = localStorage.getItem('user');
|
||||
const userEmail = user ? JSON.parse(user).email : '';
|
||||
|
||||
return (
|
||||
<SidebarLayoutProvider>
|
||||
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-900">
|
||||
<Sidebar userEmail={userEmail} />
|
||||
<div className="asm-app-main flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<Header userEmail={userEmail} />
|
||||
<div className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900">{children}</div>
|
||||
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-900">
|
||||
<Sidebar userEmail={userEmail} />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<Header userEmail={userEmail} />
|
||||
<div className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayoutProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Protected Route Component
|
||||
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [status, setStatus] = useState<'loading' | 'authed' | 'guest'>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
if (localStorage.getItem('user')) {
|
||||
if (!cancelled) setStatus('authed');
|
||||
return;
|
||||
}
|
||||
const result = await bootstrapFrappeUserFromSession();
|
||||
if (!cancelled) setStatus(result.ok ? 'authed' : 'guest');
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="flex flex-col items-center gap-3 text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
className="h-10 w-10 animate-spin text-indigo-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm">Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'guest') {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
const user = localStorage.getItem('user');
|
||||
return user ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
};
|
||||
|
||||
const App: React.FC = () => (
|
||||
<Router basename="/asm_app">
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute><LayoutWithSidebar><ModernDashboard /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/assets" element={<ProtectedRoute><LayoutWithSidebar><AssetList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/assets/:assetName" element={<ProtectedRoute><LayoutWithSidebar><AssetDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/work-orders" element={<ProtectedRoute><LayoutWithSidebar><WorkOrderList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/work-orders/:workOrderName/troubleshoot" element={<ProtectedRoute><LayoutWithSidebar><SupportTroubleshoot /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/work-orders/:workOrderName" element={<ProtectedRoute><LayoutWithSidebar><WorkOrderDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/maintenance" element={<ProtectedRoute><LayoutWithSidebar><AssetMaintenanceList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/maintenance/:logName" element={<ProtectedRoute><LayoutWithSidebar><AssetMaintenanceDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/ppm" element={<ProtectedRoute><LayoutWithSidebar><PPMList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/ppm/:ppmName" element={<ProtectedRoute><LayoutWithSidebar><PPMDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/ppm-planner" element={<ProtectedRoute><LayoutWithSidebar><PPMPlannerList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/ppm-planner/new" element={<ProtectedRoute><LayoutWithSidebar><PPMPlanner /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/ppm-planner/:scheduleName" element={<ProtectedRoute><LayoutWithSidebar><PPMPlannerDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/maintenance-calendar" element={<ProtectedRoute><LayoutWithSidebar><YearlyPPMPlannerPage /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/maintenance-calendar/month-view" element={<ProtectedRoute><LayoutWithSidebar><MaintenanceCalendarPage /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/yearly-ppm-planner" element={<ProtectedRoute><LayoutWithSidebar><YearlyPPMPlannerPage /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/active-map" element={<ProtectedRoute><LayoutWithSidebar><ActiveMap /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/inventory" element={<ProtectedRoute><LayoutWithSidebar><ItemList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/inventory/:itemName" element={<ProtectedRoute><LayoutWithSidebar><ItemDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/users" element={<ProtectedRoute><LayoutWithSidebar><UsersList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/events" element={<ProtectedRoute><LayoutWithSidebar><EventsList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/old-dashboard" element={<ProtectedRoute><LayoutWithSidebar><Dashboard /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/maintenance-teams" element={<ProtectedRoute><LayoutWithSidebar><MaintenanceTeamList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/maintenance-teams/:teamName" element={<ProtectedRoute><LayoutWithSidebar><MaintenanceTeamDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/inspections" element={<ProtectedRoute><LayoutWithSidebar><InspectionList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/inspections/:inspectionName" element={<ProtectedRoute><LayoutWithSidebar><InspectionDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/procurement" element={<ProtectedRoute><LayoutWithSidebar><ComingSoon title="Procurement" /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/sla" element={<ProtectedRoute><LayoutWithSidebar><SupportPlanList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/sla/:slaName" element={<ProtectedRoute><LayoutWithSidebar><SupportPlanDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/support" element={<ProtectedRoute><LayoutWithSidebar><IssueList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/support/troubleshoot" element={<ProtectedRoute><LayoutWithSidebar><SupportTroubleshoot /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/support/:issueName" element={<ProtectedRoute><LayoutWithSidebar><IssueDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/user-profile" element={<ProtectedRoute><LayoutWithSidebar><UserProfilePage /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/projects" element={<ProtectedRoute><LayoutWithSidebar><ProjectModulePage /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/projects/reports" element={<ProtectedRoute><LayoutWithSidebar><ProjectReportsDashboard /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/projects/project-updates" element={<Navigate to="/projects" replace />} />
|
||||
<Route path="/projects/project-updates/:updateName" element={<Navigate to="/projects" replace />} />
|
||||
<Route path="/projects/list" element={<ProtectedRoute><LayoutWithSidebar><ProjectList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/projects/list/:projectName" element={<ProtectedRoute><LayoutWithSidebar><ProjectDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/projects/tasks" element={<ProtectedRoute><LayoutWithSidebar><TaskList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/projects/tasks/:taskName" element={<ProtectedRoute><LayoutWithSidebar><TaskDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/projects/timesheets" element={<ProtectedRoute><LayoutWithSidebar><TimesheetList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/projects/timesheets/:timesheetName" element={<ProtectedRoute><LayoutWithSidebar><TimesheetDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/projects/activity-types" element={<ProtectedRoute><LayoutWithSidebar><ActivityTypeList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/projects/activity-types/:activityTypeName" element={<ProtectedRoute><LayoutWithSidebar><ActivityTypeDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/projects/templates" element={<ProtectedRoute><LayoutWithSidebar><ProjectTemplateList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/projects/templates/:templateName" element={<ProtectedRoute><LayoutWithSidebar><ProjectTemplateDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/customers" element={<ProtectedRoute><LayoutWithSidebar><CustomerList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/customers/:customerName" element={<ProtectedRoute><LayoutWithSidebar><CustomerDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/employees" element={<ProtectedRoute><LayoutWithSidebar><EmployeeList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/employees/:employeeName" element={<ProtectedRoute><LayoutWithSidebar><EmployeeDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/invoices" element={<ProtectedRoute><LayoutWithSidebar><SalesInvoiceList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/invoices/:invoiceName" element={<ProtectedRoute><LayoutWithSidebar><SalesInvoiceDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/sales-orders" element={<ProtectedRoute><LayoutWithSidebar><SalesOrderList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/sales-orders/:soName" element={<ProtectedRoute><LayoutWithSidebar><SalesOrderDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/purchase-orders" element={<ProtectedRoute><LayoutWithSidebar><PurchaseOrderList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/purchase-orders/:poName" element={<ProtectedRoute><LayoutWithSidebar><PurchaseOrderDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/delivery-notes" element={<ProtectedRoute><LayoutWithSidebar><DeliveryNoteList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/delivery-notes/:dnName" element={<ProtectedRoute><LayoutWithSidebar><DeliveryNoteDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/material-requests" element={<ProtectedRoute><LayoutWithSidebar><MaterialRequestList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/material-requests/:mrName" element={<ProtectedRoute><LayoutWithSidebar><MaterialRequestDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/purchase-receipts" element={<ProtectedRoute><LayoutWithSidebar><PurchaseReceiptList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/purchase-receipts/:prName" element={<ProtectedRoute><LayoutWithSidebar><PurchaseReceiptDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/payment-entries" element={<ProtectedRoute><LayoutWithSidebar><PaymentEntryList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/payment-entries/:peName" element={<ProtectedRoute><LayoutWithSidebar><PaymentEntryDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/sfda-entries" element={<ProtectedRoute><LayoutWithSidebar><SfdaEntriesList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/sfda-entries/:entryName" element={<ProtectedRoute><LayoutWithSidebar><SfdaEntriesDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<Router basename="/asm_app">
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route path="/dashboard" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><ModernDashboard /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/assets" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><AssetList /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/assets/:assetName" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><AssetDetail /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/work-orders" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><WorkOrderList /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/work-orders/:workOrderName" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><WorkOrderDetail /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/maintenance" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><AssetMaintenanceList /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/maintenance/:logName" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><AssetMaintenanceDetail /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/ppm" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><PPMList /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/ppm/:ppmName" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><PPMDetail /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/ppm-planner" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><PPMPlannerList /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/ppm-planner/new" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><PPMPlanner /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/ppm-planner/:scheduleName" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><PPMPlannerDetail /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/maintenance-calendar" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><YearlyPPMPlannerPage /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/maintenance-calendar/month-view" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><MaintenanceCalendarPage /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/yearly-ppm-planner" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><YearlyPPMPlannerPage /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/active-map" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><ActiveMap /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/inventory" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><ItemList /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/inventory/:itemName" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><ItemDetail /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/users" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><UsersList /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/events" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><EventsList /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/old-dashboard" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><Dashboard /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
export default App;
|
||||
{/* <Route path="/maintenance-team" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><ComingSoon title="Maintenance Team" /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} /> */}
|
||||
<Route path="/maintenance-teams" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><MaintenanceTeamList /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/maintenance-teams/:teamName" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><MaintenanceTeamDetail /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/inspections" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><InspectionList /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/inspections/:inspectionName" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><InspectionDetail /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/procurement" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><ComingSoon title="Procurement" /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/sla" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><SupportPlanList/></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/sla/:slaName" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><SupportPlanDetail/></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* <Route path="/support" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><ComingSoon title="Support" /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} /> */}
|
||||
<Route path="/support" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><IssueList /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/support/:issueName" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><IssueDetail /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
|
||||
<Route path="/user-profile" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><UserProfilePage /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
|
||||
{/* Default redirect */}
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,61 +1,26 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Moon, Sun, Languages, LogOut, Menu, UserCircle } from 'lucide-react';
|
||||
import { Moon, Sun, Languages, LogOut } from 'lucide-react';
|
||||
import NotificationBell from './NotificationBell';
|
||||
import { useSidebarLayout } from '../contexts/SidebarLayoutContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface HeaderProps {
|
||||
userEmail?: string;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = () => {
|
||||
const Header: React.FC<HeaderProps> = ({ userEmail }) => {
|
||||
const navigate = useNavigate();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { language, changeLanguage } = useLanguage();
|
||||
const { t } = useTranslation();
|
||||
const { openMobileSidebar } = useSidebarLayout();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [userFullName, setUserFullName] = useState<string>('');
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
// Fetch full name — same two-call pattern as Sidebar
|
||||
useEffect(() => {
|
||||
const fetchUserFullName = async () => {
|
||||
try {
|
||||
const userResponse = await fetch('/api/method/frappe.auth.get_logged_user', {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
});
|
||||
const userData = await userResponse.json();
|
||||
const email = userData.message;
|
||||
|
||||
if (email) {
|
||||
const fullNameResponse = await fetch(
|
||||
`/api/resource/User/${encodeURIComponent(email)}?fields=["full_name"]`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
const fullNameData = await fullNameResponse.json();
|
||||
if (fullNameData.data?.full_name) {
|
||||
setUserFullName(fullNameData.data.full_name);
|
||||
} else {
|
||||
setUserFullName(email);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching user full name:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserFullName();
|
||||
}, []);
|
||||
// const handleLogout = () => {
|
||||
// localStorage.removeItem('user');
|
||||
// localStorage.removeItem('sid');
|
||||
// navigate('/login');
|
||||
// };
|
||||
|
||||
const handleLogout = async () => {
|
||||
localStorage.removeItem('user');
|
||||
@ -90,82 +55,49 @@ const Header: React.FC<HeaderProps> = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="h-14 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 flex items-center justify-between gap-2 flex-shrink-0">
|
||||
{/* Mobile menu button */}
|
||||
<header className="h-14 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 flex items-center justify-end gap-2 flex-shrink-0">
|
||||
{/* User Email (optional, can be shown on hover or always) */}
|
||||
{/* {userEmail && (
|
||||
<div className="hidden md:block text-sm text-gray-600 dark:text-gray-400 mr-2">
|
||||
{userEmail}
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
{/* Notification Bell */}
|
||||
<div className="relative">
|
||||
<NotificationBell />
|
||||
</div>
|
||||
|
||||
{/* Language Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={openMobileSidebar}
|
||||
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300 -ms-1"
|
||||
aria-label={t('common.menu', { defaultValue: 'Open menu' })}
|
||||
title={t('common.menu', { defaultValue: 'Menu' })}
|
||||
onClick={() => changeLanguage(language === 'en' ? 'ar' : 'en')}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300"
|
||||
title={t('common.language')}
|
||||
>
|
||||
<Menu size={22} />
|
||||
<Languages size={20} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 lg:flex-none" aria-hidden="true" />
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300"
|
||||
title={theme === 'light' ? t('common.darkMode') : t('common.lightMode')}
|
||||
>
|
||||
{theme === 'light' ? <Moon size={20} /> : <Sun size={20} />}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
|
||||
{/* User Profile Icon with tooltip */}
|
||||
{userFullName && (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/user-profile')}
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
// className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300"
|
||||
// title={userFullName}
|
||||
className="p-2 rounded-lg bg-[#7911cc] hover:bg-[#6a0fb5] transition-colors text-white"
|
||||
title={userFullName}
|
||||
>
|
||||
<UserCircle size={20} />
|
||||
</button>
|
||||
{showTooltip && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 px-3 py-1.5 bg-gray-800 dark:bg-gray-700 text-white text-xs rounded-lg whitespace-nowrap shadow-lg">
|
||||
{userFullName}
|
||||
{/* Tooltip arrow */}
|
||||
<div className="absolute -top-1 right-3 w-2 h-2 bg-gray-800 dark:bg-gray-700 rotate-45" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notification Bell */}
|
||||
<div className="relative">
|
||||
<NotificationBell />
|
||||
</div>
|
||||
|
||||
{/* Language Toggle */}
|
||||
<button
|
||||
onClick={() => changeLanguage(language === 'en' ? 'ar' : 'en')}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300"
|
||||
title={t('common.language')}
|
||||
>
|
||||
<Languages size={20} />
|
||||
</button>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300"
|
||||
title={theme === 'light' ? t('common.darkMode') : t('common.lightMode')}
|
||||
>
|
||||
{theme === 'light' ? <Moon size={20} /> : <Sun size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-red-600 dark:text-red-400"
|
||||
title={t('common.logout')}
|
||||
>
|
||||
<LogOut size={20} />
|
||||
</button>
|
||||
|
||||
</div>
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-red-600 dark:text-red-400"
|
||||
title={t('common.logout')}
|
||||
>
|
||||
<LogOut size={20} />
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
export default Header;
|
||||
|
||||
|
||||
|
||||
@ -12,12 +12,9 @@ interface LinkFieldProps {
|
||||
doctype: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** When true, only the input is rendered (use an outer <label> / FL). */
|
||||
hideLabel?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
/** Frappe filter rows `[['DocType', 'field', 'op', value]]` or legacy dict form */
|
||||
filters?: any;
|
||||
filters?: Record<string, any>;
|
||||
compact?: boolean;
|
||||
usePortal?: boolean;
|
||||
// New props for QuickCreate functionality
|
||||
@ -35,12 +32,11 @@ const LinkField: React.FC<LinkFieldProps> = ({
|
||||
doctype,
|
||||
value,
|
||||
onChange,
|
||||
hideLabel = false,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
filters,
|
||||
compact = false,
|
||||
usePortal = true,
|
||||
usePortal = false,
|
||||
// QuickCreate props with defaults
|
||||
allowQuickCreate = false, // Default to false - must explicitly enable per field
|
||||
onQuickCreateSuccess,
|
||||
@ -404,15 +400,10 @@ const searchLink = useCallback(async (text: string = '', force: boolean = false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`relative w-full ${compact ? 'mb-2' : hideLabel ? 'mb-0' : 'mb-4'}`}
|
||||
>
|
||||
{!hideLabel && (
|
||||
<label className={`block font-medium text-gray-700 dark:text-gray-300 ${compact ? 'text-[10px] mb-0.5' : 'text-sm mb-1'}`}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div ref={containerRef} className={`relative w-full ${compact ? 'mb-2' : 'mb-4'}`}>
|
||||
<label className={`block font-medium text-gray-700 dark:text-gray-300 ${compact ? 'text-[10px] mb-0.5' : 'text-sm mb-1'}`}>
|
||||
{label}
|
||||
</label>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
|
||||
@ -95,14 +95,7 @@ const NotificationBell: React.FC = () => {
|
||||
} else if (normalizedType === 'Inspection') {
|
||||
console.log('[NotificationBell] Navigating to inspection:', `/inspections/${docName}`);
|
||||
navigate(`/inspections/${docName}`);
|
||||
} else if (normalizedType === 'Issue') {
|
||||
console.log('[NotificationBell] Navigating to issue', `/issues/${docName}`);
|
||||
navigate(`/support/${docName}`);
|
||||
}else if (normalizedType === 'SFDA Entries') {
|
||||
console.log('[NotificationBell] Navigating to SFDA Entries', `/sfda-entries/${docName}`);
|
||||
navigate(`/sfda-entries/${docName}`);
|
||||
}
|
||||
else {
|
||||
}else {
|
||||
// Fallback: Try to open in Frappe if route not found
|
||||
console.warn(`[NotificationBell] Unknown document type: ${docType}, opening in Frappe`);
|
||||
const frappeRoute = docType.toLowerCase().replace(/\s+/g, '-').replace(/_/g, '-');
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useSidebarLayout } from '../contexts/SidebarLayoutContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
@ -18,13 +17,11 @@ import {
|
||||
FileText,
|
||||
HelpCircle,
|
||||
UserCircle,
|
||||
Trash2,
|
||||
FolderOpen,
|
||||
Building2
|
||||
Trash2
|
||||
|
||||
} from 'lucide-react';
|
||||
|
||||
import { FaChevronDown, FaChevronUp, FaClipboardCheck } from 'react-icons/fa';
|
||||
import { FaClipboardCheck } from 'react-icons/fa';
|
||||
|
||||
interface SidebarLink {
|
||||
id: string;
|
||||
@ -50,40 +47,13 @@ const ADMIN_ROLES = [
|
||||
const TECHNICIAN_ROLE = 'Technician';
|
||||
const END_USER_ROLE = 'End User';
|
||||
|
||||
/** True when viewport is lg (1024px) or wider — desktop sidebar in-flow */
|
||||
function useLgUp(): boolean {
|
||||
const [lg, setLg] = useState(() =>
|
||||
typeof window !== 'undefined' ? window.matchMedia('(min-width: 1024px)').matches : false
|
||||
);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(min-width: 1024px)');
|
||||
const onChange = () => setLg(mq.matches);
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
return lg;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isProjectsExpanded, setIsProjectsExpanded] = useState(true);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isLgUp = useLgUp();
|
||||
|
||||
const { isRTL } = useLanguage();
|
||||
const { t } = useTranslation();
|
||||
const { mobileSidebarOpen, closeMobileSidebar } = useSidebarLayout();
|
||||
|
||||
const afterNav = () => {
|
||||
if (!isLgUp) closeMobileSidebar();
|
||||
};
|
||||
|
||||
const edgeBorderClass = isRTL
|
||||
? 'border-l border-gray-200 dark:border-gray-700'
|
||||
: 'border-r border-gray-200 dark:border-gray-700';
|
||||
|
||||
const activeEdgeBorderClass = isRTL ? 'border-r-4 border-white' : 'border-l-4 border-white';
|
||||
|
||||
// ✅ Role-based state
|
||||
const [userRoles, setUserRoles] = useState<{
|
||||
@ -107,10 +77,10 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
// Version is automatically updated by build script based on file modification time
|
||||
const imageVersion = import.meta.env.DEV
|
||||
? `?v=${Date.now()}`
|
||||
: `?v=1774269853`; // Auto-updated by build script
|
||||
: `?v=1768316563`; // Auto-updated by build script
|
||||
const logoVersion = import.meta.env.DEV
|
||||
? `?v=${Date.now()}`
|
||||
: `?v=1774269853`; // Auto-updated by build script
|
||||
: `?v=1768316563`; // Auto-updated by build script
|
||||
const backgroundImageUrl = baseUrl.endsWith('/')
|
||||
? `${baseUrl}sidebar-background.jpg${imageVersion}`
|
||||
: `${baseUrl}/sidebar-background.jpg${imageVersion}`;
|
||||
@ -246,8 +216,8 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
}
|
||||
|
||||
// Define what each role can see
|
||||
const endUserLinks = ['work-orders', 'support', 'assets', 'inventory', 'projects'];
|
||||
const technicianLinks = ['work-orders', 'inspections', 'procurement', 'support', 'active-map', 'assets', 'inventory', 'projects'];
|
||||
const endUserLinks = ['work-orders', 'support', 'assets', 'inventory'];
|
||||
const technicianLinks = ['work-orders', 'inspections', 'procurement', 'support', 'active-map', 'assets', 'inventory'];
|
||||
|
||||
// Check visibility based on roles (union of permissions)
|
||||
let canSee = false;
|
||||
@ -317,13 +287,6 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
path: '/inventory',
|
||||
visible: getVisibility('inventory')
|
||||
},
|
||||
{
|
||||
id: 'projects',
|
||||
title: t('sidebar.projects'),
|
||||
icon: <FolderOpen size={20} />,
|
||||
path: '/projects',
|
||||
visible: getVisibility('projects')
|
||||
},
|
||||
{
|
||||
id: 'work-orders',
|
||||
title: t('common.workOrders'),
|
||||
@ -338,13 +301,6 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
path: '/inspections',
|
||||
visible: getVisibility('inspections')
|
||||
},
|
||||
{
|
||||
id: 'sfda-entries',
|
||||
title: t('sidebar.sfdaEntries'),
|
||||
icon: <FileText size={20} />,
|
||||
path: '/sfda-entries',
|
||||
visible: userRoles.isAdmin
|
||||
},
|
||||
// {
|
||||
// id: 'maintenance',
|
||||
// title: t('common.maintenance'),
|
||||
@ -387,13 +343,6 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
path: '/maintenance-teams',
|
||||
visible: userRoles.isAdmin // Only admin
|
||||
},
|
||||
{
|
||||
id: 'facility-management',
|
||||
title: 'Facility Management',
|
||||
icon: <Building2 size={20} />,
|
||||
path: '/facility-management-external',
|
||||
visible: userRoles.isAdmin
|
||||
},
|
||||
{
|
||||
id: 'procurement',
|
||||
title: t('sidebar.procurement'),
|
||||
@ -468,103 +417,63 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
// }
|
||||
];
|
||||
|
||||
const projectsLink = links.find(l => l.id === 'projects');
|
||||
const visibleLinks = links.filter(link => link.visible && link.id !== 'projects');
|
||||
const visibleLinks = links.filter(link => link.visible);
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path;
|
||||
};
|
||||
|
||||
const isProjectsActive = location.pathname === '/projects' || location.pathname.startsWith('/projects/');
|
||||
|
||||
useEffect(() => {
|
||||
closeMobileSidebar();
|
||||
}, [location.pathname, closeMobileSidebar]);
|
||||
|
||||
// ✅ Handle User Profile click
|
||||
const handleUserProfileClick = () => {
|
||||
navigate('/user-profile');
|
||||
};
|
||||
|
||||
const mobileDrawerShell = isRTL
|
||||
? `
|
||||
fixed inset-y-0 right-0 z-50 h-screen
|
||||
transition-transform duration-300 ease-in-out
|
||||
${mobileSidebarOpen ? 'translate-x-0' : 'translate-x-full'}
|
||||
lg:relative lg:inset-auto lg:right-auto lg:z-auto lg:translate-x-0
|
||||
`
|
||||
: `
|
||||
fixed inset-y-0 left-0 z-50 h-screen
|
||||
transition-transform duration-300 ease-in-out
|
||||
${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
lg:relative lg:inset-auto lg:left-auto lg:z-auto lg:translate-x-0
|
||||
`;
|
||||
|
||||
|
||||
// ✅ Show loading state while fetching roles
|
||||
if (userRoles.isLoading) {
|
||||
return (
|
||||
<>
|
||||
{mobileSidebarOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="asm-app-sidebar-backdrop fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={closeMobileSidebar}
|
||||
aria-label={t('common.close', { defaultValue: 'Close menu' })}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`
|
||||
asm-app-sidebar
|
||||
flex
|
||||
h-screen
|
||||
w-64
|
||||
flex-col
|
||||
items-center
|
||||
justify-center
|
||||
shadow-xl
|
||||
${edgeBorderClass}
|
||||
${mobileDrawerShell}
|
||||
`}
|
||||
style={{
|
||||
backgroundImage: `url(${backgroundImageUrl})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 z-0 bg-black/60 dark:bg-black/70" />
|
||||
<div className="relative z-10 text-white">
|
||||
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-white" />
|
||||
<p className="mt-2 text-sm">{t('common.loading')}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
relative
|
||||
h-screen
|
||||
w-64
|
||||
flex
|
||||
flex-col
|
||||
items-center
|
||||
justify-center
|
||||
shadow-xl
|
||||
border-r border-gray-200 dark:border-gray-700
|
||||
`}
|
||||
style={{
|
||||
backgroundImage: `url(${backgroundImageUrl})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat'
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/60 dark:bg-black/70 z-0"></div>
|
||||
<div className="relative z-10 text-white">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto"></div>
|
||||
<p className="mt-2 text-sm">{t('common.loading')}</p>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{mobileSidebarOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="asm-app-sidebar-backdrop fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={closeMobileSidebar}
|
||||
aria-label={t('common.close', { defaultValue: 'Close menu' })}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`
|
||||
asm-app-sidebar
|
||||
h-screen
|
||||
transition-all
|
||||
duration-300
|
||||
<div
|
||||
className={`
|
||||
relative
|
||||
h-screen
|
||||
transition-all
|
||||
duration-300
|
||||
ease-in-out
|
||||
flex
|
||||
flex
|
||||
flex-col
|
||||
shadow-xl
|
||||
${edgeBorderClass}
|
||||
border-r border-gray-200 dark:border-gray-700
|
||||
${isCollapsed ? 'w-16' : 'w-64'}
|
||||
${mobileDrawerShell}
|
||||
`}
|
||||
style={{
|
||||
backgroundImage: `url(${backgroundImageUrl})`,
|
||||
@ -622,155 +531,53 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isLgUp) setIsCollapsed(!isCollapsed);
|
||||
else closeMobileSidebar();
|
||||
}}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="text-white dark:text-white hover:bg-white/20 dark:hover:bg-white/20 p-2 rounded-lg transition-colors"
|
||||
aria-label={
|
||||
isLgUp
|
||||
? isCollapsed
|
||||
? t('common.expandSidebar', { defaultValue: 'Expand sidebar' })
|
||||
: t('common.collapseSidebar', { defaultValue: 'Collapse sidebar' })
|
||||
: t('common.closeMenu', { defaultValue: 'Close menu' })
|
||||
}
|
||||
>
|
||||
{isLgUp ? (isCollapsed ? <Menu size={20} /> : <X size={20} />) : <X size={20} />}
|
||||
{isCollapsed ? <Menu size={20} /> : <X size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<nav className="flex-1 overflow-y-auto py-4">
|
||||
{visibleLinks.map((link) => (
|
||||
<React.Fragment key={link.id}>
|
||||
{link.id === 'facility-management' ? (
|
||||
|
||||
<a
|
||||
href="/space-management/?from=asm"
|
||||
target="_self"
|
||||
onClick={afterNav}
|
||||
className={`
|
||||
flex
|
||||
items-center
|
||||
px-4
|
||||
py-3
|
||||
text-white dark:text-white
|
||||
hover:bg-white/20 dark:hover:bg-white/20
|
||||
hover:text-white dark:hover:text-white
|
||||
transition-all
|
||||
duration-200
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
`}
|
||||
title={isCollapsed ? link.title : ''}
|
||||
>
|
||||
<span>{link.icon}</span>
|
||||
{!isCollapsed && (
|
||||
<span className={`${isRTL ? 'mr-4' : 'ml-4'} font-medium`}>{link.title}</span>
|
||||
)}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
to={link.path}
|
||||
onClick={afterNav}
|
||||
className={`
|
||||
flex
|
||||
items-center
|
||||
px-4
|
||||
py-3
|
||||
text-white dark:text-white
|
||||
hover:bg-white/20 dark:hover:bg-white/20
|
||||
hover:text-white dark:hover:text-white
|
||||
transition-all
|
||||
duration-200
|
||||
${isActive(link.path) ? `bg-white/30 dark:bg-white/30 text-white dark:text-white ${activeEdgeBorderClass}` : ''}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
`}
|
||||
title={isCollapsed ? link.title : ''}
|
||||
>
|
||||
<span>{link.icon}</span>
|
||||
{!isCollapsed && (
|
||||
<span className={`${isRTL ? 'mr-4' : 'ml-4'} font-medium`}>{link.title}</span>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Insert Project Management right after Inventory */}
|
||||
{link.id === 'inventory' && projectsLink?.visible && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isCollapsed) {
|
||||
navigate('/projects');
|
||||
afterNav();
|
||||
return;
|
||||
}
|
||||
setIsProjectsExpanded((v) => !v);
|
||||
}}
|
||||
className={`
|
||||
w-full
|
||||
flex
|
||||
items-center
|
||||
px-4
|
||||
py-3
|
||||
text-white dark:text-white
|
||||
hover:bg-white/20 dark:hover:bg-white/20
|
||||
transition-all duration-200
|
||||
${isProjectsActive ? `bg-white/30 dark:bg-white/30 text-white dark:text-white ${activeEdgeBorderClass}` : ''}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
`}
|
||||
title={isCollapsed ? projectsLink.title : ''}
|
||||
>
|
||||
<span>{projectsLink.icon}</span>
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className={`${isRTL ? 'mr-4' : 'ml-4'} font-medium flex-1 text-left`}>{projectsLink.title}</span>
|
||||
<span className="opacity-80">
|
||||
{isProjectsExpanded ? <FaChevronUp size={12} /> : <FaChevronDown size={12} />}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!isCollapsed && isProjectsExpanded && (
|
||||
<div className={`${isRTL ? 'pr-6' : 'pl-6'} pb-2`}>
|
||||
<Link
|
||||
to="/projects"
|
||||
onClick={afterNav}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2 text-white/90 hover:text-white
|
||||
hover:bg-white/15 rounded-lg transition-colors
|
||||
${location.pathname === '/projects' ? 'bg-white/20' : ''}
|
||||
`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-white/70" />
|
||||
<span className="text-sm font-medium">Projects</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/projects/reports"
|
||||
onClick={afterNav}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2 text-white/90 hover:text-white
|
||||
hover:bg-white/15 rounded-lg transition-colors
|
||||
${location.pathname.startsWith('/projects/reports') ? 'bg-white/20' : ''}
|
||||
`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-white/70" />
|
||||
<span className="text-sm font-medium">Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
key={link.id}
|
||||
to={link.path}
|
||||
className={`
|
||||
flex
|
||||
items-center
|
||||
px-4
|
||||
py-3
|
||||
text-white dark:text-white
|
||||
hover:bg-white/20 dark:hover:bg-white/20
|
||||
hover:text-white dark:hover:text-white
|
||||
transition-all
|
||||
duration-200
|
||||
${isActive(link.path) ? 'bg-white/30 dark:bg-white/30 text-white dark:text-white border-l-4 border-white' : ''}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
`}
|
||||
title={isCollapsed ? link.title : ''}
|
||||
>
|
||||
<span>{link.icon}</span>
|
||||
{!isCollapsed && (
|
||||
<span className={`${isRTL ? 'mr-4' : 'ml-4'} font-medium`}>{link.title}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User Info & Version (Bottom) */}
|
||||
{/* <div className={`${isCollapsed ? 'p-2' : 'p-4'} border-t border-white/10 backdrop-blur-sm bg-white/5 space-y-3 relative z-10`}> */}
|
||||
<div className={`${isCollapsed ? 'p-1' : 'p-2'} border-t border-white/10 backdrop-blur-sm bg-white/5 relative z-10`}>
|
||||
{/* {!isCollapsed && (userFullName || userEmail) && (
|
||||
<div className={`${isCollapsed ? 'p-2' : 'p-4'} border-t border-white/10 backdrop-blur-sm bg-white/5 space-y-3 relative z-10`}>
|
||||
{/* {!isCollapsed && userEmail && (
|
||||
<div>
|
||||
<div className="text-white/80 dark:text-white/80 text-xs truncate">
|
||||
{t('sidebar.loggedInAs')}
|
||||
</div>
|
||||
<div className="text-white dark:text-white text-sm font-medium truncate">
|
||||
{userEmail}
|
||||
</div> */}
|
||||
{!isCollapsed && (userFullName || userEmail) && (
|
||||
<div>
|
||||
<div className="text-white/80 dark:text-white/80 text-xs truncate">
|
||||
{t('sidebar.loggedInAs')}
|
||||
@ -779,7 +586,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
{userFullName || userEmail}
|
||||
</div>
|
||||
|
||||
|
||||
{/* ✅ User Profile Button */}
|
||||
<button
|
||||
onClick={handleUserProfileClick}
|
||||
className={`
|
||||
@ -798,10 +605,10 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
</button>
|
||||
|
||||
</div>
|
||||
)} */}
|
||||
)}
|
||||
|
||||
{/* Collapsed state - just show icon button */}
|
||||
{/* {isCollapsed && (
|
||||
{isCollapsed && (
|
||||
<button
|
||||
onClick={handleUserProfileClick}
|
||||
className={`
|
||||
@ -817,19 +624,18 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
>
|
||||
<UserCircle size={20} />
|
||||
</button>
|
||||
)} */}
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="text-[10px] text-white/50 dark:text-white/50 text-center leading-none">
|
||||
<div className="text-xs text-white/70 dark:text-white/70 text-center">
|
||||
{t('sidebar.version')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,797 +0,0 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { FaArrowLeft, FaCheckCircle, FaExclamationTriangle, FaPhoneAlt, FaRedo, FaThLarge } from 'react-icons/fa';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type NodeId = string;
|
||||
|
||||
interface QuestionNode {
|
||||
type: 'question';
|
||||
id: NodeId;
|
||||
ref: string; // e.g. "7-7-1"
|
||||
question: string;
|
||||
yes: NodeId;
|
||||
no: NodeId;
|
||||
}
|
||||
|
||||
interface ActionNode {
|
||||
type: 'action';
|
||||
id: NodeId;
|
||||
ref: string;
|
||||
variant: 'fix' | 'escalate' | 'info';
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface ChecklistNode {
|
||||
type: 'checklist';
|
||||
id: NodeId;
|
||||
ref: string;
|
||||
title: string;
|
||||
steps: string[]; // sequential numbered steps
|
||||
}
|
||||
|
||||
type FlowNode = QuestionNode | ActionNode | ChecklistNode;
|
||||
|
||||
interface FlowTree {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
symptom: string; // short label shown on selector button
|
||||
startId: NodeId;
|
||||
nodes: Record<NodeId, FlowNode>;
|
||||
}
|
||||
|
||||
// ── Flowchart data from GE Voluson 730 Service Manual Section 7-7 ─────────────
|
||||
|
||||
const FLOWS: FlowTree[] = [
|
||||
// ── 7-7-1 System does not Power On / Boot Up ─────────────────────────────
|
||||
{
|
||||
id: 'boot',
|
||||
label: 'System does not Power On / Boot Up',
|
||||
icon: '⚡',
|
||||
symptom: 'Won\'t power on',
|
||||
startId: 'q1',
|
||||
nodes: {
|
||||
q1: {
|
||||
type: 'question', id: 'q1', ref: '7-7-1',
|
||||
question: 'Is the scanner plugged into a wall outlet?',
|
||||
yes: 'q2', no: 'a_plug',
|
||||
},
|
||||
a_plug: {
|
||||
type: 'action', id: 'a_plug', ref: '7-7-1', variant: 'fix',
|
||||
title: 'Plug in the scanner',
|
||||
body: 'Make sure the scanner is plugged into a standard wall outlet. Do NOT use a red emergency power outlet.',
|
||||
},
|
||||
q2: {
|
||||
type: 'question', id: 'q2', ref: '7-7-1',
|
||||
question: 'Is there AC voltage at the wall outlet and is the circuit breaker ON?',
|
||||
yes: 'q3', no: 'a_power',
|
||||
},
|
||||
a_power: {
|
||||
type: 'action', id: 'a_power', ref: '7-7-1', variant: 'escalate',
|
||||
title: 'No mains power detected',
|
||||
body: 'Check the circuit breaker and confirm mains supply. If the breaker is tripped, reset it. If AC is still absent, contact facilities — this is a building power issue.',
|
||||
},
|
||||
q3: {
|
||||
type: 'question', id: 'q3', ref: '7-7-1',
|
||||
question: 'Is there fan noise within a few seconds of pressing the ON/OFF switch?',
|
||||
yes: 'a_ready', no: 'a_no_fan',
|
||||
},
|
||||
a_ready: {
|
||||
type: 'action', id: 'a_ready', ref: '7-7-1', variant: 'fix',
|
||||
title: 'System is booting normally',
|
||||
body: 'Fan noise confirms the Primary Power Supply is working. Wait for the system to fully boot. The ON/OFF button should illuminate amber (standby) then green (ready).',
|
||||
},
|
||||
a_no_fan: {
|
||||
type: 'action', id: 'a_no_fan', ref: '7-7-1', variant: 'escalate',
|
||||
title: 'Primary Power Supply suspect',
|
||||
body: 'No fan noise indicates the Primary Power Supply (CPN) may be faulty.\n• Check the power switch above the mains plug on the CPN — ensure it is ON.\n• Check all connections to and from the Power Supply.\n• Check the cable from the Power Supply to the GEF module (see Section 7-3 for voltage check points).\n• If connections are OK but still no power, replace the Primary Power Supply. Contact your dealer.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── 7-7-2 Noise in Image ─────────────────────────────────────────────────
|
||||
{
|
||||
id: 'noise',
|
||||
label: 'Noise in Image',
|
||||
icon: '📡',
|
||||
symptom: 'Image noise / artefacts',
|
||||
startId: 'q1',
|
||||
nodes: {
|
||||
q1: {
|
||||
type: 'question', id: 'q1', ref: '7-7-2',
|
||||
question: 'Are there any electrical devices near the scanner (monitors, phones, other medical equipment)?',
|
||||
yes: 'a_move', no: 'a_probe',
|
||||
},
|
||||
a_move: {
|
||||
type: 'action', id: 'a_move', ref: '7-7-2', variant: 'fix',
|
||||
title: 'Remove interfering electrical devices',
|
||||
body: 'Keep the scanner away from devices that may cause electromagnetic interference. Try a different wall socket on a separate circuit. Re-test image quality after moving the device.',
|
||||
},
|
||||
a_probe: {
|
||||
type: 'action', id: 'a_probe', ref: '7-7-2', variant: 'fix',
|
||||
title: 'Check the probe / transducer',
|
||||
body: 'No obvious nearby interference detected. Inspect the probe:\n• Check the probe cable for kinks or damage.\n• Try a different probe connector port.\n• If noise persists with all probes, contact your dealer — possible GEF board issue.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── 7-7-3 Trackball ──────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'trackball',
|
||||
label: 'Trackball Low Sensitivity',
|
||||
icon: '🖱️',
|
||||
symptom: 'Trackball not working',
|
||||
startId: 'q1',
|
||||
nodes: {
|
||||
q1: {
|
||||
type: 'question', id: 'q1', ref: '7-7-3',
|
||||
question: 'Does the trackball move at all when rolled?',
|
||||
yes: 'a_clean', no: 'a_replace',
|
||||
},
|
||||
a_clean: {
|
||||
type: 'action', id: 'a_clean', ref: '7-7-3', variant: 'fix',
|
||||
title: 'Clean the trackball',
|
||||
body: 'The ball moves but is sluggish — it needs cleaning.\n• Remove the ball from the trackball housing.\n• Clean the ball and the internal rollers with a dry lint-free cloth.\n• Reinsert and test sensitivity.',
|
||||
},
|
||||
a_replace: {
|
||||
type: 'action', id: 'a_replace', ref: '7-7-3', variant: 'escalate',
|
||||
title: 'Replace the trackball unit',
|
||||
body: 'The trackball does not respond at all — the unit is likely faulty.\n• Replacement part: KTZ208256 (Trackball top fixation ring, Section 8-4).\n• Contact your dealer for unit replacement if not in stock.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── 7-7-4 System does not Power Off / Shutdown ──────────────────────────
|
||||
{
|
||||
id: 'shutdown',
|
||||
label: 'System does not Power Off / Shutdown',
|
||||
icon: '🔴',
|
||||
symptom: 'Won\'t shut down',
|
||||
startId: 'q1',
|
||||
nodes: {
|
||||
q1: {
|
||||
type: 'question', id: 'q1', ref: '7-7-4',
|
||||
question: 'After pressing the OFF button, does the system start the shutdown procedure at all?',
|
||||
yes: 'q2', no: 'a_switch',
|
||||
},
|
||||
a_switch: {
|
||||
type: 'action', id: 'a_switch', ref: '7-7-4', variant: 'escalate',
|
||||
title: 'Check ON/OFF switch and cable',
|
||||
body: 'The shutdown procedure does not start — the ON/OFF switch or its cable may be faulty.\n• Inspect the switch cable.\n• If the cable is OK, the Primary Power Supply (CPN) is likely defective.\n• Contact your dealer.',
|
||||
},
|
||||
q2: {
|
||||
type: 'question', id: 'q2', ref: '7-7-4',
|
||||
question: 'Does the system eventually power off automatically (within ~3 minutes)?',
|
||||
yes: 'a_normal', no: 'a_cpn',
|
||||
},
|
||||
a_normal: {
|
||||
type: 'action', id: 'a_normal', ref: '7-7-4', variant: 'fix',
|
||||
title: 'Shutdown is working normally',
|
||||
body: 'The system shuts down automatically after approximately 3 minutes as designed. This is normal behaviour — the OS completes its shutdown sequence before cutting power.',
|
||||
},
|
||||
a_cpn: {
|
||||
type: 'action', id: 'a_cpn', ref: '7-7-4', variant: 'escalate',
|
||||
title: 'Primary Power Supply (CPN) defective',
|
||||
body: 'The system starts shutdown but never fully powers off — the CPN is likely defective.\n• Contact your dealer for CPN replacement.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── 7-7-5 Monitor Troubleshooting ────────────────────────────────────────
|
||||
{
|
||||
id: 'monitor',
|
||||
label: 'Monitor Troubleshooting',
|
||||
icon: '🖥️',
|
||||
symptom: 'Monitor / display issue',
|
||||
startId: 'q1',
|
||||
nodes: {
|
||||
q1: {
|
||||
type: 'question', id: 'q1', ref: '7-7-5',
|
||||
question: 'Is there any image at all on the monitor (even partial or distorted)?',
|
||||
yes: 'q2', no: 'a_no_image',
|
||||
},
|
||||
a_no_image: {
|
||||
type: 'action', id: 'a_no_image', ref: '7-7-5', variant: 'fix',
|
||||
title: 'No image — check power and cables',
|
||||
body: 'Check these items in order:\n• Power cord is properly connected to the monitor and wall.\n• Mains supply switch is set to ON.\n• Video cable is properly connected — no bent pins.\n• If the Power Saving indicator is lit, the monitor is in sleep mode — move the mouse or press a key.\n• If Power indicator flashes alternately green and orange: potential monitor failure — contact your dealer.',
|
||||
},
|
||||
q2: {
|
||||
type: 'question', id: 'q2', ref: '7-7-5',
|
||||
question: 'Is the image fuzzy or the colour non-uniform?',
|
||||
yes: 'a_fuzzy', no: 'a_position',
|
||||
},
|
||||
a_fuzzy: {
|
||||
type: 'action', id: 'a_fuzzy', ref: '7-7-5', variant: 'fix',
|
||||
title: 'Fuzzy image or colour issues',
|
||||
body: '• Turn monitor off then on to activate Auto-Degauss (fixes colour non-uniformity).\n• Adjust contrast and brightness settings.\n• Check that no extra video cable is connected to the second video output with the other end loose — this increases video output level and causes fuzz.\n• If problem persists after adjustments, contact your dealer.',
|
||||
},
|
||||
a_position: {
|
||||
type: 'action', id: 'a_position', ref: '7-7-5', variant: 'fix',
|
||||
title: 'Image not centred or sized correctly',
|
||||
body: 'Adjust picture location, picture size, picture rotation, or pincushion distortion via the monitor\'s on-screen menu. Some video modes do not fill the screen to the edge — this is normal at higher refresh rates.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── 7-7-7 Printer Troubleshooting ────────────────────────────────────────
|
||||
{
|
||||
id: 'printer',
|
||||
label: 'Printer Troubleshooting',
|
||||
icon: '🖨️',
|
||||
symptom: 'Printer not working',
|
||||
startId: 'q1',
|
||||
nodes: {
|
||||
q1: {
|
||||
type: 'question', id: 'q1', ref: '7-7-7',
|
||||
question: 'Is the printer properly connected (signal and power cables secured at both ends)?',
|
||||
yes: 'q2', no: 'a_cables',
|
||||
},
|
||||
a_cables: {
|
||||
type: 'action', id: 'a_cables', ref: '7-7-7', variant: 'fix',
|
||||
title: 'Reconnect printer cables',
|
||||
body: 'Check all signal and power supply cable connections between the printer and the scanner. Reseat all connectors firmly. Power-cycle the printer after reconnecting.',
|
||||
},
|
||||
q2: {
|
||||
type: 'question', id: 'q2', ref: '7-7-7',
|
||||
question: 'Is there paper/film loaded in the printer?',
|
||||
yes: 'q3', no: 'a_paper',
|
||||
},
|
||||
a_paper: {
|
||||
type: 'action', id: 'a_paper', ref: '7-7-7', variant: 'fix',
|
||||
title: 'Load printer paper / film',
|
||||
body: 'Insert the correct printer paper or thermal film. Ensure it is loaded in the correct orientation. Close the paper tray firmly.',
|
||||
},
|
||||
q3: {
|
||||
type: 'question', id: 'q3', ref: '7-7-7',
|
||||
question: 'Is the Print key correctly configured in the system settings?',
|
||||
yes: 'q4', no: 'a_config',
|
||||
},
|
||||
a_config: {
|
||||
type: 'action', id: 'a_config', ref: '7-7-7', variant: 'fix',
|
||||
title: 'Configure the Print key',
|
||||
body: 'Go to System Setup → Peripherals and configure the Print key(s) to point to the correct printer. Save the configuration and retry printing.',
|
||||
},
|
||||
q4: {
|
||||
type: 'question', id: 'q4', ref: '7-7-7',
|
||||
question: 'Is printing unavailable even by manual operation on the printer itself?',
|
||||
yes: 'a_gef_cables', no: 'q5',
|
||||
},
|
||||
a_gef_cables: {
|
||||
type: 'action', id: 'a_gef_cables', ref: '7-7-7', variant: 'escalate',
|
||||
title: 'Check GEF to backpanel signal cables',
|
||||
body: 'Check the signal cables between the GEF board and the Back Panel. If connections are OK, the printer unit may need replacement — contact your dealer.',
|
||||
},
|
||||
q5: {
|
||||
type: 'question', id: 'q5', ref: '7-7-7',
|
||||
question: 'Is there still no image on the printout?',
|
||||
yes: 'a_video_out', no: 'a_printer_ok',
|
||||
},
|
||||
a_printer_ok: {
|
||||
type: 'action', id: 'a_printer_ok', ref: '7-7-7', variant: 'fix',
|
||||
title: 'Printer is OK',
|
||||
body: 'The printer is functioning correctly. The issue was likely a temporary communication error. Monitor for recurrence.',
|
||||
},
|
||||
a_video_out: {
|
||||
type: 'action', id: 'a_video_out', ref: '7-7-7', variant: 'escalate',
|
||||
title: 'Check video output / replace CKV',
|
||||
body: 'No image on printout despite printer responding:\n• Test video output from the system.\n• If video output is faulty → Replace CKV board.\n• If video output is OK → Replace the printer unit.\n• Contact your dealer for board/unit replacement.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── 7-7-10 Network Troubleshooting ───────────────────────────────────────
|
||||
{
|
||||
id: 'network',
|
||||
label: 'Network — No Connection',
|
||||
icon: '🌐',
|
||||
symptom: 'No network connection',
|
||||
startId: 'q1',
|
||||
nodes: {
|
||||
q1: {
|
||||
type: 'question', id: 'q1', ref: '7-7-10',
|
||||
question: 'Is the network cable connected at both ends — scanner and wall socket?',
|
||||
yes: 'q2', no: 'a_cable',
|
||||
},
|
||||
a_cable: {
|
||||
type: 'action', id: 'a_cable', ref: '7-7-10', variant: 'fix',
|
||||
title: 'Reconnect the network cable',
|
||||
body: 'Plug the network cable firmly into both the scanner\'s network port and the wall network socket. Ensure the clip locks in place at both ends.',
|
||||
},
|
||||
q2: {
|
||||
type: 'question', id: 'q2', ref: '7-7-10',
|
||||
question: 'Does replacing the cable with a known-good cable restore the connection?',
|
||||
yes: 'a_bad_cable', no: 'q3',
|
||||
},
|
||||
a_bad_cable: {
|
||||
type: 'action', id: 'a_bad_cable', ref: '7-7-10', variant: 'fix',
|
||||
title: 'Faulty cable — replace it',
|
||||
body: 'The original cable was faulty. Replace it with the known-good cable permanently. Label and dispose of the faulty cable.',
|
||||
},
|
||||
q3: {
|
||||
type: 'question', id: 'q3', ref: '7-7-10',
|
||||
question: 'Can you ping from the scanner to a PC on the same network?',
|
||||
yes: 'a_soft', no: 'a_internal',
|
||||
},
|
||||
a_soft: {
|
||||
type: 'action', id: 'a_soft', ref: '7-7-10', variant: 'fix',
|
||||
title: 'Hardware OK — check network settings',
|
||||
body: 'Ping is successful, so the hardware path inside the scanner is working. The issue is likely:\n• Wrong IP address or subnet configured on the scanner.\n• VLAN or firewall blocking DICOM traffic.\n• Contact your IT/network team to verify the scanner\'s network configuration.',
|
||||
},
|
||||
a_internal: {
|
||||
type: 'action', id: 'a_internal', ref: '7-7-10', variant: 'escalate',
|
||||
title: 'Internal cable — check LAN connector',
|
||||
body: 'No ping response indicates a hardware problem inside the scanner.\n• Check the cable between the network connector on the Back Panel and the LAN connector on the GEF board.\n• If the internal cable is OK, the GEF board\'s network interface may be faulty.\n• Contact your dealer.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── 7-7-6 Unable to Record to VCR ───────────────────────────────────────
|
||||
{
|
||||
id: 'vcr',
|
||||
label: 'Unable to Record to VCR',
|
||||
icon: '📼',
|
||||
symptom: 'Cannot record to VCR',
|
||||
startId: 'q1',
|
||||
nodes: {
|
||||
q1: {
|
||||
type: 'question', id: 'q1', ref: '7-7-6',
|
||||
question: 'Is the VCR properly connected (all signal and power cables secured)?',
|
||||
yes: 'q2', no: 'a_cables',
|
||||
},
|
||||
a_cables: {
|
||||
type: 'action', id: 'a_cables', ref: '7-7-6', variant: 'fix',
|
||||
title: 'Reconnect VCR cables',
|
||||
body: 'Check all signal and power supply cable connections to the VCR unit. Reseat all connectors firmly at both ends. Power-cycle the VCR after reconnecting.',
|
||||
},
|
||||
q2: {
|
||||
type: 'question', id: 'q2', ref: '7-7-6',
|
||||
question: 'Is a tape inserted and rewound in the VCR?',
|
||||
yes: 'q3', no: 'a_tape',
|
||||
},
|
||||
a_tape: {
|
||||
type: 'action', id: 'a_tape', ref: '7-7-6', variant: 'fix',
|
||||
title: 'Insert and rewind the tape',
|
||||
body: 'Put the VCR tape into the device and rewind it to the beginning before attempting to record.',
|
||||
},
|
||||
q3: {
|
||||
type: 'question', id: 'q3', ref: '7-7-6',
|
||||
question: 'Is recording impossible even by manual operation on the VCR itself?',
|
||||
yes: 'q4', no: 'a_signal_cables',
|
||||
},
|
||||
a_signal_cables: {
|
||||
type: 'action', id: 'a_signal_cables', ref: '7-7-6', variant: 'fix',
|
||||
title: 'Check signal cables between VCR and Internal I/O',
|
||||
body: 'The VCR works manually but not from the scanner — check the signal cable connections between the VCR and the Internal I/O board. Reseat all connectors.',
|
||||
},
|
||||
q4: {
|
||||
type: 'question', id: 'q4', ref: '7-7-6',
|
||||
question: 'Is the video output of the system working correctly?',
|
||||
yes: 'a_replace_vcr', no: 'a_replace_ckv',
|
||||
},
|
||||
a_replace_vcr: {
|
||||
type: 'action', id: 'a_replace_vcr', ref: '7-7-6', variant: 'escalate',
|
||||
title: 'Replace the VCR unit',
|
||||
body: 'The system video output is OK but the VCR still will not record — the VCR unit is faulty.\n• Contact your dealer for VCR unit replacement.',
|
||||
},
|
||||
a_replace_ckv: {
|
||||
type: 'action', id: 'a_replace_ckv', ref: '7-7-6', variant: 'escalate',
|
||||
title: 'Replace the CKV board',
|
||||
body: 'The system video output is faulty — the CKV board needs replacement.\n• Contact your dealer for CKV board replacement.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── 7-7-7-1 CD-RW Troubleshooting ────────────────────────────────────────
|
||||
{
|
||||
id: 'cdrw',
|
||||
label: 'CD-RW Troubleshooting',
|
||||
icon: '💿',
|
||||
symptom: 'CD-RW drive issue',
|
||||
startId: 'c1',
|
||||
nodes: {
|
||||
c1: {
|
||||
type: 'checklist', id: 'c1', ref: '7-7-7-1',
|
||||
title: 'CD-RW Backup Procedure',
|
||||
steps: [
|
||||
'Insert an empty CD-RW disc into the drive.',
|
||||
'Press the NETWORK key on the control panel to enter Sonoview.',
|
||||
'Click the "Open" icon to display the list of exams.',
|
||||
'Select the exam(s) you want to back up.',
|
||||
'Choose "CD-ROM" as the destination drive.',
|
||||
'Verify the backed-up images are visible on the CD-ROM after the process completes.',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── 7-7-8 MOD Troubleshooting ────────────────────────────────────────────
|
||||
{
|
||||
id: 'mod',
|
||||
label: 'MOD (MO Disk) Troubleshooting',
|
||||
icon: '💾',
|
||||
symptom: 'MO disk drive issue',
|
||||
startId: 'c1',
|
||||
nodes: {
|
||||
c1: {
|
||||
type: 'checklist', id: 'c1', ref: '7-7-8',
|
||||
title: 'MOD Backup Procedure',
|
||||
steps: [
|
||||
'Insert an empty MO disk into the drive.',
|
||||
'Press the NETWORK key on the control panel to enter Sonoview.',
|
||||
'Click the "Open" icon to display the list of exams.',
|
||||
'Select the exam(s) you want to back up.',
|
||||
'Choose "MO" as the destination drive.',
|
||||
'Verify the backed-up images are visible on the MO disk after the process completes.',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── 7-7-9 Audio Test ─────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'audio',
|
||||
label: 'Audio Test',
|
||||
icon: '🔊',
|
||||
symptom: 'Audio / speaker issue',
|
||||
startId: 'c1',
|
||||
nodes: {
|
||||
c1: {
|
||||
type: 'checklist', id: 'c1', ref: '7-7-9',
|
||||
title: 'Loudspeaker Audio Test',
|
||||
steps: [
|
||||
'Start a probe on the scanner.',
|
||||
'Select PW-Mode (Pulsed Wave Doppler).',
|
||||
'Locate the volume digipot on the control panel.',
|
||||
'Slowly increase the volume from 0 to 96 dB.',
|
||||
'Listen to both loudspeakers — both should produce clear audio at each volume level.',
|
||||
'If one or both speakers produce no sound or distorted sound, contact your dealer.',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ── Breadcrumb trail type ─────────────────────────────────────────────────────
|
||||
|
||||
interface HistoryEntry {
|
||||
nodeId: NodeId;
|
||||
answer?: 'yes' | 'no';
|
||||
}
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface UltrasoundFlowchartProps {
|
||||
/** Called when the technician completes a flow or escalates — optional */
|
||||
onComplete?: (flowId: string, terminal: ActionNode) => void;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const UltrasoundFlowchart: React.FC<UltrasoundFlowchartProps> = ({ onComplete }) => {
|
||||
const [selectedFlow, setSelectedFlow] = useState<FlowTree | null>(null);
|
||||
const [currentNodeId, setCurrentNodeId] = useState<NodeId>('');
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
|
||||
// ── Navigation ───────────────────────────────────────────────────────────
|
||||
|
||||
const selectFlow = useCallback((flow: FlowTree) => {
|
||||
setSelectedFlow(flow);
|
||||
setCurrentNodeId(flow.startId);
|
||||
setHistory([]);
|
||||
}, []);
|
||||
|
||||
const answer = useCallback(
|
||||
(choice: 'yes' | 'no') => {
|
||||
if (!selectedFlow) return;
|
||||
const node = selectedFlow.nodes[currentNodeId];
|
||||
if (node.type !== 'question') return;
|
||||
|
||||
setHistory((h) => [...h, { nodeId: currentNodeId, answer: choice }]);
|
||||
setCurrentNodeId(choice === 'yes' ? node.yes : node.no);
|
||||
},
|
||||
[selectedFlow, currentNodeId],
|
||||
);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (!history.length) {
|
||||
// Back to problem selector
|
||||
setSelectedFlow(null);
|
||||
setCurrentNodeId('');
|
||||
setHistory([]);
|
||||
return;
|
||||
}
|
||||
const prev = history[history.length - 1];
|
||||
setCurrentNodeId(prev.nodeId);
|
||||
setHistory((h) => h.slice(0, -1));
|
||||
}, [history]);
|
||||
|
||||
const restart = useCallback(() => {
|
||||
if (!selectedFlow) return;
|
||||
setCurrentNodeId(selectedFlow.startId);
|
||||
setHistory([]);
|
||||
}, [selectedFlow]);
|
||||
|
||||
const backToSelector = useCallback(() => {
|
||||
setSelectedFlow(null);
|
||||
setCurrentNodeId('');
|
||||
setHistory([]);
|
||||
}, []);
|
||||
|
||||
// ── Derived state ────────────────────────────────────────────────────────
|
||||
|
||||
const currentNode = selectedFlow ? selectedFlow.nodes[currentNodeId] : null;
|
||||
const isTerminal = currentNode?.type === 'action';
|
||||
|
||||
// Notify parent when reaching a terminal
|
||||
React.useEffect(() => {
|
||||
if (isTerminal && selectedFlow && currentNode?.type === 'action') {
|
||||
onComplete?.(selectedFlow.id, currentNode);
|
||||
}
|
||||
}, [isTerminal, selectedFlow, currentNode, onComplete]);
|
||||
|
||||
// ── Breadcrumb labels ────────────────────────────────────────────────────
|
||||
|
||||
const breadcrumbs = history.map((h) => {
|
||||
if (!selectedFlow) return null;
|
||||
const n = selectedFlow.nodes[h.nodeId];
|
||||
if (n.type !== 'question') return null;
|
||||
return { question: n.question, answer: h.answer! };
|
||||
}).filter(Boolean) as { question: string; answer: 'yes' | 'no' }[];
|
||||
|
||||
// ── Shared style tokens (matching SupportTroubleshoot.tsx) ───────────────
|
||||
|
||||
const TEAL_GRADIENT = 'bg-gradient-to-r from-blue-500 to-teal-600';
|
||||
const TEAL_GRADIENT_HOVER = 'hover:from-blue-600 hover:to-teal-700';
|
||||
|
||||
const BTN_YES =
|
||||
'flex-1 min-h-[52px] flex items-center justify-center gap-2 py-3 px-4 rounded-2xl text-white text-sm font-semibold shadow-md transition-all active:scale-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-400 focus-visible:ring-offset-2 bg-gradient-to-r from-teal-500 to-green-500 hover:from-teal-600 hover:to-green-600';
|
||||
|
||||
const BTN_NO =
|
||||
'flex-1 min-h-[52px] flex items-center justify-center gap-2 py-3 px-4 rounded-2xl text-white text-sm font-semibold shadow-md transition-all active:scale-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-2 bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600';
|
||||
|
||||
const BTN_BACK =
|
||||
'flex items-center gap-1.5 text-sm font-medium text-blue-700 dark:text-teal-300 hover:text-blue-900 dark:hover:text-white transition-colors min-h-[44px] px-1';
|
||||
|
||||
const BTN_RESTART =
|
||||
'flex items-center gap-1.5 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors min-h-[44px] px-1';
|
||||
|
||||
// ── Terminal card colours ────────────────────────────────────────────────
|
||||
|
||||
const terminalStyle = (variant: ActionNode['variant']) => {
|
||||
if (variant === 'fix')
|
||||
return {
|
||||
bg: 'bg-green-50 dark:bg-green-900/20',
|
||||
border: 'border-green-200 dark:border-green-800',
|
||||
icon: <FaCheckCircle className="text-green-500 text-xl shrink-0 mt-0.5" />,
|
||||
badge: 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300',
|
||||
badgeLabel: 'Self-fix action',
|
||||
};
|
||||
if (variant === 'escalate')
|
||||
return {
|
||||
bg: 'bg-amber-50 dark:bg-amber-900/20',
|
||||
border: 'border-amber-200 dark:border-amber-800',
|
||||
icon: <FaPhoneAlt className="text-amber-500 text-xl shrink-0 mt-0.5" />,
|
||||
badge: 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300',
|
||||
badgeLabel: 'Contact your dealer',
|
||||
};
|
||||
return {
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
border: 'border-blue-200 dark:border-blue-800',
|
||||
icon: <FaExclamationTriangle className="text-blue-500 text-xl shrink-0 mt-0.5" />,
|
||||
badge: 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300',
|
||||
badgeLabel: 'Information',
|
||||
};
|
||||
};
|
||||
|
||||
// ── Render: problem selector ─────────────────────────────────────────────
|
||||
|
||||
if (!selectedFlow) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Instruction row */}
|
||||
<p className="text-[10px] sm:text-[11px] font-semibold tracking-wide text-gray-500 dark:text-gray-400 uppercase">
|
||||
Select a problem type
|
||||
</p>
|
||||
|
||||
{/* Flow selector grid */}
|
||||
<div className="grid grid-cols-1 gap-2 sm:gap-3">
|
||||
{FLOWS.map((flow) => (
|
||||
<button
|
||||
key={flow.id}
|
||||
type="button"
|
||||
onClick={() => selectFlow(flow)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3.5 rounded-2xl text-left text-sm font-medium transition-all min-h-[56px] bg-white/80 dark:bg-gray-800/90 border border-blue-100 dark:border-gray-700 hover:border-teal-400 dark:hover:border-teal-500 hover:bg-blue-50/60 dark:hover:bg-teal-950/30 active:scale-[0.99] shadow-sm group"
|
||||
>
|
||||
<span className="text-xl shrink-0 w-7 text-center">{flow.icon}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-gray-800 dark:text-gray-100 font-semibold text-sm leading-snug group-hover:text-teal-700 dark:group-hover:text-teal-300 transition-colors">
|
||||
{flow.symptom}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-300 dark:text-gray-600 group-hover:text-teal-400 transition-colors text-lg">›</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Render: active flowchart ─────────────────────────────────────────────
|
||||
|
||||
if (!currentNode) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Top navigation row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button type="button" onClick={goBack} className={BTN_BACK}>
|
||||
<FaArrowLeft className="text-sm" />
|
||||
{history.length === 0 ? 'All problems' : 'Back'}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={backToSelector} className={BTN_RESTART} title="Back to problem list">
|
||||
<FaThLarge className="text-sm" />
|
||||
</button>
|
||||
{history.length > 0 && (
|
||||
<button type="button" onClick={restart} className={BTN_RESTART} title="Restart this flowchart">
|
||||
<FaRedo className="text-sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flow title chip */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">{selectedFlow.icon}</span>
|
||||
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full text-white ${TEAL_GRADIENT}`}>
|
||||
{currentNode.ref}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{selectedFlow.symptom}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb trail */}
|
||||
{breadcrumbs.length > 0 && (
|
||||
<div className="flex flex-col gap-1 px-3 py-2.5 rounded-xl bg-gray-50 dark:bg-gray-900/50 border border-gray-100 dark:border-gray-700">
|
||||
{breadcrumbs.map((b, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className={`shrink-0 font-bold mt-0.5 ${b.answer === 'yes' ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`}>
|
||||
{b.answer === 'yes' ? 'YES' : 'NO '}
|
||||
</span>
|
||||
<span className="leading-relaxed line-clamp-2">{b.question}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Question node ── */}
|
||||
{currentNode.type === 'question' && (
|
||||
<div className="rounded-2xl border border-blue-200/60 dark:border-gray-700 bg-white/95 dark:bg-gray-800/95 shadow-lg shadow-blue-500/5 overflow-hidden">
|
||||
{/* Teal header bar */}
|
||||
<div className={`h-1.5 w-full ${TEAL_GRADIENT}`} />
|
||||
|
||||
<div className="p-5 sm:p-6 space-y-5">
|
||||
{/* Step indicator */}
|
||||
<p className="text-[10px] font-semibold tracking-widest text-gray-400 dark:text-gray-500 uppercase">
|
||||
Step {history.length + 1}
|
||||
</p>
|
||||
|
||||
{/* Question */}
|
||||
<h3 className="text-base sm:text-lg font-bold text-gray-900 dark:text-white leading-snug">
|
||||
{currentNode.question}
|
||||
</h3>
|
||||
|
||||
{/* YES / NO buttons */}
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button type="button" onClick={() => answer('yes')} className={BTN_YES}>
|
||||
<span className="text-lg">✓</span>
|
||||
YES
|
||||
</button>
|
||||
<button type="button" onClick={() => answer('no')} className={BTN_NO}>
|
||||
<span className="text-lg">✗</span>
|
||||
NO
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Terminal / action node ── */}
|
||||
{currentNode.type === 'action' && (() => {
|
||||
const style = terminalStyle(currentNode.variant);
|
||||
return (
|
||||
<div className={`rounded-2xl border ${style.border} ${style.bg} overflow-hidden shadow-md`}>
|
||||
<div className="p-5 sm:p-6 space-y-4">
|
||||
{/* Badge */}
|
||||
<span className={`inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full ${style.badge}`}>
|
||||
{style.icon}
|
||||
{style.badgeLabel}
|
||||
</span>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-base sm:text-lg font-bold text-gray-900 dark:text-white leading-snug">
|
||||
{currentNode.title}
|
||||
</h3>
|
||||
|
||||
{/* Body — preserve newlines */}
|
||||
<div className="rounded-xl bg-white/70 dark:bg-gray-900/40 border border-white/80 dark:border-gray-700 p-4">
|
||||
{currentNode.body.split('\n').map((line, i) => (
|
||||
<p key={i} className={`text-sm text-gray-700 dark:text-gray-300 leading-relaxed ${i > 0 && line.startsWith('•') ? 'mt-1.5' : i > 0 ? 'mt-2' : ''}`}>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Manual reference */}
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
GE Voluson 730 Service Manual · Section {currentNode.ref}
|
||||
</p>
|
||||
|
||||
{/* Restart / back buttons */}
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={restart}
|
||||
className="flex-1 min-h-[48px] flex items-center justify-center gap-2 py-3 px-4 rounded-2xl text-sm font-semibold border-2 border-blue-300/80 dark:border-teal-700/60 text-blue-700 dark:text-teal-300 bg-white/80 dark:bg-gray-800/80 hover:bg-blue-50 dark:hover:bg-teal-950/30 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<FaRedo className="text-xs" />
|
||||
Restart flow
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={backToSelector}
|
||||
className="flex-1 min-h-[48px] flex items-center justify-center gap-2 py-3 px-4 rounded-2xl text-sm font-semibold border-2 border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300 bg-white/80 dark:bg-gray-800/80 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<FaThLarge className="text-xs" />
|
||||
All problems
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* ── Checklist node ── */}
|
||||
{currentNode.type === 'checklist' && (
|
||||
<div className="rounded-2xl border border-teal-200/60 dark:border-gray-700 bg-white/95 dark:bg-gray-800/95 shadow-lg overflow-hidden">
|
||||
<div className={`h-1.5 w-full ${TEAL_GRADIENT}`} />
|
||||
<div className="p-5 sm:p-6 space-y-4">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300">
|
||||
📋 Step-by-step procedure
|
||||
</span>
|
||||
<h3 className="text-base sm:text-lg font-bold text-gray-900 dark:text-white leading-snug">
|
||||
{currentNode.title}
|
||||
</h3>
|
||||
<div className="rounded-xl bg-gray-50 dark:bg-gray-900/50 border border-gray-100 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{currentNode.steps.map((step, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3.5">
|
||||
<span className={`shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold text-white mt-0.5 ${TEAL_GRADIENT}`}>
|
||||
{i + 1}
|
||||
</span>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{step}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
GE Voluson 730 Service Manual · Section {currentNode.ref}
|
||||
</p>
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={backToSelector}
|
||||
className="flex-1 min-h-[48px] flex items-center justify-center gap-2 py-3 px-4 rounded-2xl text-sm font-semibold border-2 border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300 bg-white/80 dark:bg-gray-800/80 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<FaThLarge className="text-xs" />
|
||||
All problems
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UltrasoundFlowchart;
|
||||
@ -1,191 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
import VoiceStatusWidget from './VoiceStatusWidget';
|
||||
|
||||
interface VoiceStatusModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedRows: Set<string>;
|
||||
onUpdateSuccess: () => void;
|
||||
doctype?: string;
|
||||
fieldname?: string;
|
||||
statusOptions?: StatusOption[];
|
||||
widgetTitle?: string;
|
||||
showLanguageToggle?: boolean;
|
||||
noSelectionLabel?: string;
|
||||
}
|
||||
|
||||
export interface StatusOption {
|
||||
value: string;
|
||||
label: string;
|
||||
/** Arabic display label shown in pills and hints */
|
||||
arLabel?: string;
|
||||
keywords: string[];
|
||||
color: 'completed' | 'in-progress' | 'not-started';
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// ─── Work Order statuses (English only) ──────────────────────────────────────
|
||||
const WO_STATUS_OPTIONS: StatusOption[] = [
|
||||
{
|
||||
value: 'Open',
|
||||
label: 'Open',
|
||||
keywords: ['open'],
|
||||
color: 'not-started',
|
||||
icon: '⏳',
|
||||
},
|
||||
{
|
||||
value: 'Work In Progress',
|
||||
label: 'Work In Progress',
|
||||
keywords: ['work in progress'],
|
||||
color: 'in-progress',
|
||||
icon: '🔄',
|
||||
},
|
||||
{
|
||||
value: 'Closed',
|
||||
label: 'Closed',
|
||||
keywords: ['closed'],
|
||||
color: 'completed',
|
||||
icon: '✅',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Project statuses (English + Arabic) ─────────────────────────────────────
|
||||
export const PROJECT_STATUS_OPTIONS: StatusOption[] = [
|
||||
{
|
||||
value: 'Open',
|
||||
label: 'Open',
|
||||
arLabel: 'مفتوح',
|
||||
keywords: [
|
||||
// English
|
||||
'open',
|
||||
// Arabic — multiple common pronunciations / spellings
|
||||
'مفتوح', 'مفتوحة', 'افتح', 'open',
|
||||
],
|
||||
color: 'not-started',
|
||||
icon: '📂',
|
||||
},
|
||||
{
|
||||
value: 'Completed',
|
||||
label: 'Completed',
|
||||
arLabel: 'مكتمل',
|
||||
keywords: [
|
||||
// English
|
||||
'completed', 'complete', 'done', 'finish', 'finished',
|
||||
// Arabic
|
||||
'مكتمل', 'مكتملة', 'اكتمل', 'منجز', 'منتهي', 'منتهية', 'تم',
|
||||
],
|
||||
color: 'completed',
|
||||
icon: '✅',
|
||||
},
|
||||
{
|
||||
value: 'Cancelled',
|
||||
label: 'Cancelled',
|
||||
arLabel: 'ملغي',
|
||||
keywords: [
|
||||
// English
|
||||
'cancelled', 'canceled', 'cancel',
|
||||
// Arabic
|
||||
'ملغي', 'ملغاة', 'ملغى', 'الغي', 'إلغاء',
|
||||
],
|
||||
color: 'in-progress',
|
||||
icon: '🚫',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
const VoiceStatusModal: React.FC<VoiceStatusModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedRows,
|
||||
onUpdateSuccess,
|
||||
doctype = 'Work_Order',
|
||||
fieldname = 'repair_status',
|
||||
statusOptions = WO_STATUS_OPTIONS,
|
||||
widgetTitle = 'Voice Status Update',
|
||||
showLanguageToggle = false,
|
||||
noSelectionLabel = 'row',
|
||||
}) => {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const selectedCount = selectedRows.size;
|
||||
|
||||
const handleStatusConfirmed = async (status: string) => {
|
||||
if (selectedCount === 0) return;
|
||||
|
||||
const rowsToUpdate = Array.from(selectedRows);
|
||||
setIsUpdating(true);
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const docName of rowsToUpdate) {
|
||||
try {
|
||||
const res = await fetch('/api/method/frappe.client.set_value', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Frappe-CSRF-Token': (window as any).csrf_token || 'fetch',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ doctype, name: docName, fieldname, value: status }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.exc || data.exception) throw new Error(data.exc || data.exception);
|
||||
successCount++;
|
||||
} catch (err) {
|
||||
console.error(`Failed to update ${docName}:`, err);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
setIsUpdating(false);
|
||||
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
setTimeout(() => { onUpdateSuccess(); onClose(); }, 2000);
|
||||
} else if (successCount > 0) {
|
||||
onUpdateSuccess();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 flex items-center justify-center z-[80] p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !isUpdating) onClose(); }}
|
||||
>
|
||||
<div className="relative animate-scale-in">
|
||||
<button
|
||||
onClick={() => { if (!isUpdating) onClose(); }}
|
||||
className="absolute -top-3 -right-3 z-10 w-8 h-8 rounded-full bg-gray-600 hover:bg-gray-500 text-white flex items-center justify-center shadow-lg transition-colors"
|
||||
disabled={isUpdating}
|
||||
title="Close"
|
||||
>
|
||||
<FaTimes size={12} />
|
||||
</button>
|
||||
|
||||
<VoiceStatusWidget
|
||||
onStatusConfirmed={handleStatusConfirmed}
|
||||
selectedCount={selectedCount}
|
||||
selectedNames={Array.from(selectedRows)}
|
||||
isUpdating={isUpdating}
|
||||
statusOptions={statusOptions}
|
||||
widgetTitle={widgetTitle}
|
||||
showLanguageToggle={showLanguageToggle}
|
||||
noSelectionLabel={noSelectionLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes scale-in {
|
||||
from { transform: scale(0.92); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
.animate-scale-in { animation: scale-in 0.2s ease-out; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceStatusModal;
|
||||
@ -1,95 +0,0 @@
|
||||
.vsw-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
padding: 28px 28px 24px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
|
||||
font-family: inherit;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.vsw-header { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
|
||||
.vsw-logo { width: 36px; height: 36px; border-radius: 8px; background: linear-gradient(135deg,#3b82f6,#2563eb); display: flex; align-items: center; justify-content: center; font-size: 16px; }
|
||||
.vsw-brand { font-weight: 700; font-size: 15px; letter-spacing: 0.03em; color: #1e293b; }
|
||||
.vsw-brand span { color: #2563eb; }
|
||||
.vsw-selected-badge { margin-left: auto; background: #eff6ff; border: 1px solid #bfdbfe; color: #1d4ed8; font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 100px; }
|
||||
|
||||
.vsw-title { font-size: 22px; font-weight: 700; margin-bottom: 4px; color: #1e293b; }
|
||||
.vsw-subtitle { font-size: 13px; color: #64748b; margin-bottom: 16px; }
|
||||
|
||||
.vsw-wo-list { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
|
||||
.vsw-wo-tag { background: #eff6ff; border: 1px solid #bfdbfe; color: #1d4ed8; font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 6px; }
|
||||
.vsw-wo-tag--more { background: #f1f5f9; border-color: #e2e8f0; color: #64748b; }
|
||||
|
||||
.vsw-status-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }
|
||||
.vsw-pill { display: flex; align-items: center; gap: 6px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 100px; padding: 6px 14px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.2s; color: #64748b; user-select: none; }
|
||||
.vsw-pill:hover { border-color: #3b82f6; color: #1e293b; background: #eff6ff; }
|
||||
.vsw-pill--active { color: #1e293b; }
|
||||
.vsw-pill--completed { border-color: #22c55e; background: #f0fdf4; color: #15803d; }
|
||||
.vsw-pill--in-progress { border-color: #f59e0b; background: #fffbeb; color: #b45309; }
|
||||
.vsw-pill--not-started { border-color: #ef4444; background: #fef2f2; color: #dc2626; }
|
||||
|
||||
.vsw-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
|
||||
.vsw-dot--completed { background: #22c55e; }
|
||||
.vsw-dot--in-progress { background: #f59e0b; }
|
||||
.vsw-dot--not-started { background: #ef4444; }
|
||||
|
||||
.vsw-action-area { display: flex; flex-direction: column; align-items: center; gap: 12px; margin-bottom: 20px; min-height: 80px; justify-content: center; }
|
||||
|
||||
.vsw-start-btn { width: 100%; padding: 14px; border-radius: 12px; border: none; background: linear-gradient(135deg,#3b82f6,#2563eb); color: #fff; font-size: 16px; font-weight: 600; font-family: inherit; cursor: pointer; transition: all 0.2s; }
|
||||
.vsw-start-btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
|
||||
.vsw-start-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.vsw-status-message { display: flex; align-items: center; gap: 10px; font-size: 14px; font-weight: 600; padding: 16px 20px; border-radius: 10px; width: 100%; }
|
||||
.vsw-status-message--speaking { flex-direction: column; background: #eff6ff; border: 1px solid #bfdbfe; color: #2563eb; gap: 8px; }
|
||||
.vsw-status-message--detected { background: #f0fdf4; border: 1px solid #bbf7d0; color: #16a34a; font-size: 16px; }
|
||||
|
||||
.vsw-speaking-icon { font-size: 28px; animation: vsw-bounce 0.6s ease-in-out infinite alternate; }
|
||||
.vsw-speaking-text { font-size: 14px; font-weight: 600; color: #2563eb; }
|
||||
.vsw-speaking-dots { display: flex; gap: 4px; }
|
||||
.vsw-speaking-dots span { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #3b82f6; animation: vsw-dot 1.2s ease-in-out infinite; }
|
||||
.vsw-speaking-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.vsw-speaking-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
.vsw-speak-btn { width: 100%; padding: 14px 16px; border-radius: 12px; border: 1px solid #bfdbfe; background: #eff6ff; color: #1d4ed8; font-family: inherit; cursor: pointer; display: flex; align-items: center; gap: 12px; transition: all 0.2s; animation: vsw-pulse 1.8s ease-in-out infinite; }
|
||||
.vsw-speak-btn:hover { background: #dbeafe; transform: translateY(-1px); }
|
||||
.vsw-speak-btn-icon { width: 40px; height: 40px; border-radius: 50%; background: #2563eb; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
|
||||
.vsw-speak-btn-title { display: block; font-size: 15px; font-weight: 600; color: #1d4ed8; }
|
||||
.vsw-speak-btn-hint { display: block; font-size: 11px; color: #3b82f6; margin-top: 2px; }
|
||||
|
||||
.vsw-listening-area { display: flex; flex-direction: column; align-items: center; gap: 8px; width: 100%; }
|
||||
.vsw-mic-active { font-size: 36px; animation: vsw-bounce 1s ease-in-out infinite; }
|
||||
.vsw-waveform { display: flex; align-items: center; gap: 3px; height: 24px; opacity: 0; transition: opacity 0.3s; }
|
||||
.vsw-waveform--active { opacity: 1; }
|
||||
.vsw-bar { width: 3px; border-radius: 2px; background: linear-gradient(to top,#3b82f6,#60a5fa); transition: height 0.08s ease; }
|
||||
.vsw-mic-label { font-size: 13px; color: #64748b; font-weight: 500; }
|
||||
.vsw-mic-label--listening { color: #ef4444; font-weight: 600; animation: vsw-fade 1s ease-in-out infinite; }
|
||||
|
||||
.vsw-transcript { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 12px 16px; font-size: 14px; color: #94a3b8; font-style: italic; margin-bottom: 14px; line-height: 1.6; }
|
||||
.vsw-transcript--filled { color: #1e293b; font-style: normal; border-color: #3b82f6; background: #eff6ff; }
|
||||
|
||||
.vsw-result { border-radius: 10px; padding: 12px 16px; display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 14px; margin-bottom: 14px; }
|
||||
.vsw-result--completed { background: #f0fdf4; border: 1px solid #bbf7d0; color: #15803d; }
|
||||
.vsw-result--in-progress { background: #fffbeb; border: 1px solid #fde68a; color: #b45309; }
|
||||
.vsw-result--not-started { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; }
|
||||
.vsw-result-icon { font-size: 18px; }
|
||||
|
||||
.vsw-divider { border: none; border-top: 1px solid #e2e8f0; margin: 14px 0; }
|
||||
|
||||
.vsw-actions { display: flex; gap: 10px; }
|
||||
.vsw-btn { flex: 1; padding: 10px 18px; border-radius: 8px; border: none; font-family: inherit; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
|
||||
.vsw-btn--primary { background: #2563eb; color: #fff; }
|
||||
.vsw-btn--primary:hover:not(:disabled) { background: #1d4ed8; transform: translateY(-1px); }
|
||||
.vsw-btn--primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.vsw-btn--confirmed { background: #16a34a !important; }
|
||||
.vsw-btn--ghost { background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; }
|
||||
.vsw-btn--ghost:hover { background: #e2e8f0; color: #1e293b; }
|
||||
|
||||
@keyframes vsw-bounce { 0%,100%{transform:scale(1)} 50%{transform:scale(1.1)} }
|
||||
@keyframes vsw-dot { 0%,80%,100%{transform:translateY(0);opacity:0.4} 40%{transform:translateY(-5px);opacity:1} }
|
||||
@keyframes vsw-pulse { 0%,100%{box-shadow:0 0 0 0 rgba(59,130,246,0.3)} 50%{box-shadow:0 0 0 8px rgba(59,130,246,0)} }
|
||||
@keyframes vsw-fade { 0%,100%{opacity:1} 50%{opacity:0.5} }
|
||||
|
||||
.vsw-status-message--notunderstood { background: #fffbeb; border: 1px solid #fde68a; color: #b45309; font-size: 13px; }
|
||||
@ -1,445 +0,0 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import "./VoiceStatusWidget.css";
|
||||
// Pre-recorded MP3s — no browser TTS dependency (fixes Android)
|
||||
import arPromptAudio from "../assets/audio/ar_prompt.mp3";
|
||||
import arNoSelectionAudio from "../assets/audio/ar_no_selection.mp3";
|
||||
import enStatusPromptAudio from "../assets/audio/en_status_prompt.mp3";
|
||||
import enNoSelectionAudio from "../assets/audio/en_no_selection_prompt.mp3";
|
||||
|
||||
// ─── Defaults (Work Order) ────────────────────────────────────────────────────
|
||||
const DEFAULT_STATUS_OPTIONS = [
|
||||
{ value: "Open", label: "Open", keywords: ["open"], color: "not-started", icon: "⏳" },
|
||||
{ value: "Work In Progress", label: "Work In Progress", keywords: ["work in progress"], color: "in-progress", icon: "🔄" },
|
||||
{ value: "Closed", label: "Closed", keywords: ["closed"], color: "completed", icon: "✅" },
|
||||
];
|
||||
|
||||
function detectStatus(text, options) {
|
||||
if (!text) return null;
|
||||
const t = text.toLowerCase().trim();
|
||||
for (const opt of options) {
|
||||
if (opt.keywords.some((k) => new RegExp(`(^|[\\s،,])${k}([\\s،,]|$)`, "i").test(t)))
|
||||
return opt.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const NUM_BARS = 18;
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
|
||||
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
export default function VoiceStatusWidget({
|
||||
onStatusConfirmed,
|
||||
selectedCount = 0,
|
||||
selectedNames = [],
|
||||
isUpdating = false,
|
||||
statusOptions = DEFAULT_STATUS_OPTIONS,
|
||||
widgetTitle = "Voice Status Update",
|
||||
showLanguageToggle = false,
|
||||
noSelectionLabel = "project",
|
||||
}) {
|
||||
const [phase, setPhase] = useState(selectedCount === 0 ? "noselection" : "prompted");
|
||||
const [transcript, setTranscript] = useState("");
|
||||
const [detectedStatus, setDetectedStatus] = useState(null);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [barHeights, setBarHeights] = useState(Array(NUM_BARS).fill(4));
|
||||
const [lang, setLang] = useState("en");
|
||||
|
||||
const audioCtxRef = useRef(null);
|
||||
const sourceRef = useRef(null);
|
||||
const animFrameRef = useRef(null);
|
||||
const recEnRef = useRef(null);
|
||||
const recArRef = useRef(null);
|
||||
const streamRef = useRef(null);
|
||||
const autoStopRef = useRef(null);
|
||||
const calledRef = useRef(false);
|
||||
const detectedRef = useRef(null);
|
||||
|
||||
const enLabels = statusOptions.map((o) => o.label).join(", ");
|
||||
const arLabels = statusOptions.map((o) => o.arLabel || o.label).join("، ");
|
||||
|
||||
const enPrompt = `Please say the status: ${enLabels}.`;
|
||||
const arPrompt = `من فضلك قل الحالة: ${arLabels}.`;
|
||||
|
||||
const noSelectionMsg = `Please select at least one ${noSelectionLabel} from the list in order to continue.`;
|
||||
const arNoSelectionMsg = "من فضلك اختر مشروعاً واحداً على الأقل من القائمة للمتابعة";
|
||||
|
||||
const speakHint = lang === "ar"
|
||||
? `قل: ${arLabels}`
|
||||
: `Say: ${enLabels}`;
|
||||
|
||||
function playPromptForLang(currentLang) {
|
||||
const isAr = currentLang === "ar";
|
||||
const audioFile = isAr ? arPromptAudio : enStatusPromptAudio;
|
||||
const audio = new Audio(audioFile);
|
||||
audio.onended = () => setPhase("ready");
|
||||
audio.onerror = () => setPhase("ready");
|
||||
audio.play().catch(() => setPhase("ready"));
|
||||
}
|
||||
|
||||
// Mount
|
||||
useEffect(() => {
|
||||
if (selectedCount === 0) {
|
||||
// Play English MP3 first, then Arabic MP3
|
||||
const enAudio = new Audio(enNoSelectionAudio);
|
||||
enAudio.onended = () => {
|
||||
const arAudio = new Audio(arNoSelectionAudio);
|
||||
arAudio.play().catch(() => {});
|
||||
};
|
||||
enAudio.onerror = () => {
|
||||
const arAudio = new Audio(arNoSelectionAudio);
|
||||
arAudio.play().catch(() => {});
|
||||
};
|
||||
enAudio.play().catch(() => {});
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => playPromptForLang(lang), 1000);
|
||||
return () => { clearTimeout(timer); cleanup(); };
|
||||
}, []);
|
||||
|
||||
// Re-play when language tab changes
|
||||
const isFirstLangRender = useRef(true);
|
||||
useEffect(() => {
|
||||
if (isFirstLangRender.current) { isFirstLangRender.current = false; return; }
|
||||
if (selectedCount === 0) return;
|
||||
if (phase === "listening" || phase === "detected") return;
|
||||
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||
setPhase("prompted");
|
||||
const capturedLang = lang;
|
||||
const timer = setTimeout(() => playPromptForLang(capturedLang), 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [lang]);
|
||||
|
||||
function cleanup() {
|
||||
if (autoStopRef.current) clearTimeout(autoStopRef.current);
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
if (sourceRef.current) { sourceRef.current.disconnect(); sourceRef.current = null; }
|
||||
if (audioCtxRef.current) { audioCtxRef.current.close(); audioCtxRef.current = null; }
|
||||
if (streamRef.current) { streamRef.current.getTracks().forEach((t) => t.stop()); streamRef.current = null; }
|
||||
[recEnRef, recArRef].forEach((ref) => {
|
||||
if (ref.current) { try { ref.current.stop(); } catch (_) {} ref.current = null; }
|
||||
});
|
||||
setBarHeights(Array(NUM_BARS).fill(4));
|
||||
}
|
||||
|
||||
async function startWaveform() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
audioCtxRef.current = ctx;
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 64;
|
||||
const src = ctx.createMediaStreamSource(stream);
|
||||
sourceRef.current = src;
|
||||
src.connect(analyser);
|
||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||
function frame() {
|
||||
animFrameRef.current = requestAnimationFrame(frame);
|
||||
analyser.getByteFrequencyData(data);
|
||||
setBarHeights(
|
||||
Array.from({ length: NUM_BARS }, (_, i) => {
|
||||
const idx = Math.floor((i * data.length) / NUM_BARS);
|
||||
return 4 + (data[idx] / 255) * 22;
|
||||
})
|
||||
);
|
||||
}
|
||||
frame();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function buildRecognition(language, onResult) {
|
||||
if (!SR) return null;
|
||||
const rec = new SR();
|
||||
rec.lang = language;
|
||||
rec.interimResults = false; // No interim — only final results, prevents Android duplicates
|
||||
rec.continuous = false; // Single utterance — stops after one complete phrase
|
||||
rec.maxAlternatives = 3;
|
||||
rec.onresult = (e) => {
|
||||
// Use resultIndex to get only the new result, not the full cumulative array
|
||||
const r = e.results[e.resultIndex];
|
||||
if (r && r.isFinal) {
|
||||
const text = r[0].transcript.trim();
|
||||
if (text) onResult(text, true);
|
||||
}
|
||||
};
|
||||
rec.onerror = () => {};
|
||||
rec.onend = () => {};
|
||||
return rec;
|
||||
}
|
||||
|
||||
async function handleSpeak() {
|
||||
setPhase("listening");
|
||||
setTranscript("");
|
||||
detectedRef.current = null;
|
||||
await startWaveform();
|
||||
|
||||
if (!SR) { cleanup(); setPhase("ready"); return; }
|
||||
|
||||
const recLang = lang === "ar" ? "ar-SA" : "en-US";
|
||||
let finalTextAccum = "";
|
||||
|
||||
const handleResult = (text, isFinal) => {
|
||||
// Guard: if already detected, ignore all subsequent results (fixes Android duplicate firing)
|
||||
if (calledRef.current || detectedRef.current) return;
|
||||
setTranscript(text);
|
||||
const s = detectStatus(text, statusOptions);
|
||||
if (s) {
|
||||
// Set BOTH flags immediately — before cleanup/setTimeout to block any further results
|
||||
calledRef.current = true;
|
||||
detectedRef.current = s;
|
||||
setDetectedStatus(s);
|
||||
setPhase("detected");
|
||||
cleanup();
|
||||
setTimeout(() => {
|
||||
onStatusConfirmed?.(s);
|
||||
}, 800);
|
||||
return;
|
||||
}
|
||||
if (isFinal) finalTextAccum += text + " ";
|
||||
};
|
||||
|
||||
const handleFinalNoMatch = () => {
|
||||
if (detectedRef.current || calledRef.current) return;
|
||||
if (!finalTextAccum.trim()) return;
|
||||
setPhase("notunderstood");
|
||||
cleanup();
|
||||
const isAr = lang === "ar";
|
||||
if (isAr) {
|
||||
// For Arabic retry — just go back to ready, text shown in UI
|
||||
setTimeout(() => setPhase("ready"), 1500);
|
||||
} else {
|
||||
smartSpeak({
|
||||
text: `Please pick only from one of the values provided: ${enLabels}.`,
|
||||
lang: "en-US",
|
||||
onEnd: () => setPhase("ready"),
|
||||
onError: () => setPhase("ready"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const rec = buildRecognition(recLang, handleResult);
|
||||
if (!rec) { cleanup(); setPhase("ready"); return; }
|
||||
|
||||
recEnRef.current = rec;
|
||||
rec.onend = () => {
|
||||
if (!detectedRef.current && !calledRef.current) handleFinalNoMatch();
|
||||
};
|
||||
try { rec.start(); } catch (_) {}
|
||||
|
||||
autoStopRef.current = setTimeout(() => {
|
||||
cleanup();
|
||||
setPhase((prev) => (prev === "listening" ? "ready" : prev));
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
function handleManualSelect(status) {
|
||||
if (calledRef.current) return;
|
||||
setDetectedStatus(status);
|
||||
detectedRef.current = status;
|
||||
setTranscript(`Selected: ${status}`);
|
||||
setPhase("detected");
|
||||
cleanup();
|
||||
setTimeout(() => {
|
||||
if (onStatusConfirmed && !calledRef.current) {
|
||||
calledRef.current = true;
|
||||
onStatusConfirmed(status);
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
cleanup();
|
||||
calledRef.current = false;
|
||||
detectedRef.current = null;
|
||||
setTranscript("");
|
||||
setDetectedStatus(null);
|
||||
setConfirmed(false);
|
||||
if (selectedCount === 0) {
|
||||
setPhase("noselection");
|
||||
smartSpeak({ text: noSelectionMsg, lang: "en-US", onEnd: () => {}, onError: () => {} });
|
||||
} else {
|
||||
setPhase("prompted");
|
||||
setTimeout(() => playPromptForLang(lang), 500);
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = detectedStatus ? statusOptions.find((o) => o.value === detectedStatus) : null;
|
||||
const isArabic = lang === "ar";
|
||||
|
||||
return (
|
||||
<div className="vsw-card">
|
||||
<div className="vsw-header">
|
||||
<div className="vsw-logo">🛠️</div>
|
||||
<div className="vsw-brand">SEERA<span>-ASM</span></div>
|
||||
{selectedCount > 0 && <span className="vsw-selected-badge">{selectedCount} selected</span>}
|
||||
</div>
|
||||
|
||||
<div className="vsw-title">{widgetTitle}</div>
|
||||
<div className="vsw-subtitle">
|
||||
{selectedCount === 0
|
||||
? `No ${noSelectionLabel}s selected`
|
||||
: `Updating ${selectedCount} record${selectedCount > 1 ? "s" : ""}`}
|
||||
</div>
|
||||
|
||||
{/* 2-tab language toggle */}
|
||||
{showLanguageToggle && selectedCount > 0 && (
|
||||
<div style={{
|
||||
display: "flex", gap: 6, marginBottom: 14,
|
||||
background: "#f1f5f9", borderRadius: 10, padding: 4,
|
||||
}}>
|
||||
{[{ key: "en", label: "English" }, { key: "ar", label: "عربي" }].map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setLang(key)}
|
||||
style={{
|
||||
flex: 1, padding: "6px 0", borderRadius: 7, border: "none",
|
||||
fontSize: 12, fontWeight: 600, cursor: "pointer", transition: "all 0.15s",
|
||||
background: lang === key ? "#fff" : "transparent",
|
||||
color: lang === key ? "#2563eb" : "#64748b",
|
||||
boxShadow: lang === key ? "0 1px 4px rgba(0,0,0,0.1)" : "none",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No selection */}
|
||||
{phase === "noselection" && (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 12, padding: 16, marginBottom: 16,
|
||||
background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 10,
|
||||
}}>
|
||||
<span style={{ fontSize: 20, flexShrink: 0 }}>⚠️</span>
|
||||
<div style={{ width: "100%" }}>
|
||||
{/* English */}
|
||||
<p style={{ fontWeight: 700, fontSize: 14, color: "#92400e", margin: "0 0 2px" }}>
|
||||
No {noSelectionLabel}s selected
|
||||
</p>
|
||||
<p style={{ fontSize: 13, color: "#b45309", margin: "0 0 10px", lineHeight: 1.5 }}>
|
||||
Please select at least one {noSelectionLabel} from the list in order to continue.
|
||||
</p>
|
||||
{/* Arabic */}
|
||||
<p style={{ fontWeight: 700, fontSize: 14, color: "#92400e", margin: "0 0 2px", direction: "rtl", textAlign: "right" }}>
|
||||
لم يتم اختيار أي مشروع
|
||||
</p>
|
||||
<p style={{ fontSize: 13, color: "#b45309", margin: 0, lineHeight: 1.5, direction: "rtl", textAlign: "right" }}>
|
||||
من فضلك اختر مشروعاً واحداً على الأقل من القائمة للمتابعة
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCount > 0 && (
|
||||
<>
|
||||
{selectedNames.length > 0 && (
|
||||
<div className="vsw-wo-list">
|
||||
{selectedNames.slice(0, 3).map((n) => <span key={n} className="vsw-wo-tag">{n}</span>)}
|
||||
{selectedNames.length > 3 && <span className="vsw-wo-tag vsw-wo-tag--more">+{selectedNames.length - 3} more</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="vsw-status-row">
|
||||
{statusOptions.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
className={`vsw-pill ${detectedStatus === opt.value ? `vsw-pill--active vsw-pill--${opt.color}` : ""}`}
|
||||
onClick={() => handleManualSelect(opt.value)}
|
||||
title="Click to select manually"
|
||||
>
|
||||
<span className={`vsw-dot vsw-dot--${opt.color}`} />
|
||||
<span>{isArabic ? (opt.arLabel || opt.label) : opt.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="vsw-action-area">
|
||||
{phase === "prompted" && (
|
||||
<div className="vsw-status-message vsw-status-message--speaking">
|
||||
<div className="vsw-speaking-icon">🔊</div>
|
||||
<div className="vsw-speaking-text">{isArabic ? "يرجى الانتظار…" : "Please wait…"}</div>
|
||||
<div className="vsw-speaking-dots"><span /><span /><span /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "ready" && (
|
||||
<>
|
||||
{/* Arabic text prompt — shown since no Arabic audio */}
|
||||
{isArabic && (
|
||||
<div style={{
|
||||
width: "100%", padding: "12px 16px", marginBottom: 8,
|
||||
background: "#f0f9ff", border: "1px solid #bae6fd",
|
||||
borderRadius: 10, textAlign: "right", direction: "rtl",
|
||||
}}>
|
||||
<p style={{ margin: 0, fontSize: 14, fontWeight: 600, color: "#0369a1" }}>
|
||||
{arPrompt}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button className="vsw-speak-btn" onClick={handleSpeak}>
|
||||
<div className="vsw-speak-btn-icon">🎙️</div>
|
||||
<div className="vsw-speak-btn-text">
|
||||
<span className="vsw-speak-btn-title">{isArabic ? "اضغط للتحدث" : "Tap to Speak"}</span>
|
||||
<span className="vsw-speak-btn-hint">{speakHint}</span>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === "listening" && (
|
||||
<div className="vsw-listening-area">
|
||||
<div className="vsw-mic-active">🔴</div>
|
||||
<div className="vsw-waveform vsw-waveform--active">
|
||||
{barHeights.map((h, i) => <div key={i} className="vsw-bar" style={{ height: `${h}px` }} />)}
|
||||
</div>
|
||||
<div className="vsw-mic-label vsw-mic-label--listening">
|
||||
{isArabic ? "جاري الاستماع… تحدث الآن" : "Listening… speak now"}
|
||||
</div>
|
||||
{transcript && (
|
||||
<div style={{
|
||||
marginTop: 8, padding: "6px 12px", background: "#f8fafc",
|
||||
border: "1px solid #e2e8f0", borderRadius: 8, fontSize: 13,
|
||||
color: "#1e293b", maxWidth: "100%", direction: isArabic ? "rtl" : "ltr",
|
||||
}}>
|
||||
{transcript}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "detected" && cfg && (
|
||||
<div className="vsw-status-message vsw-status-message--detected">
|
||||
{cfg.icon} {isArabic ? "تم اكتشاف الحالة!" : "Status detected!"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "notunderstood" && (
|
||||
<div className="vsw-status-message vsw-status-message--notunderstood">
|
||||
⚠️ {isArabic ? "من فضلك اختر من القيم المتاحة" : "Please pick only from one of the values provided"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{transcript && phase !== "listening" && (
|
||||
<div className="vsw-transcript vsw-transcript--filled" style={{ direction: isArabic ? "rtl" : "ltr" }}>
|
||||
{transcript}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<hr className="vsw-divider" />
|
||||
|
||||
<div className="vsw-actions">
|
||||
<button className="vsw-btn vsw-btn--ghost" onClick={handleReset} disabled={isUpdating}>
|
||||
{isArabic ? "إعادة" : "Reset"}
|
||||
</button>
|
||||
{isUpdating && <button className="vsw-btn vsw-btn--primary" disabled>{isArabic ? "جاري التحديث…" : "Updating…"}</button>}
|
||||
{confirmed && <button className="vsw-btn vsw-btn--confirmed vsw-btn--primary" disabled>{isArabic ? "تم التحديث!" : "Updated!"}</button>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,146 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
import VoiceTaskUpdateWidget from './VoiceTaskUpdateWidget';
|
||||
|
||||
interface VoiceTaskUpdateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
taskName: string;
|
||||
onUpdateSuccess: () => void;
|
||||
}
|
||||
|
||||
function todayFrappe(): string {
|
||||
const d = new Date();
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
const VoiceTaskUpdateModal: React.FC<VoiceTaskUpdateModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
taskName,
|
||||
onUpdateSuccess,
|
||||
}) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleUpdateConfirmed = async (text: string) => {
|
||||
if (!taskName || !text.trim()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMsg('');
|
||||
|
||||
try {
|
||||
// ── Step 1: fetch current document ──────────────────────────────
|
||||
const getRes = await fetch(
|
||||
`/api/resource/Task/${encodeURIComponent(taskName)}`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
const getData = await getRes.json();
|
||||
|
||||
if (getData.exc || getData.exception) {
|
||||
throw new Error(getData.exc || getData.exception || 'Failed to fetch task');
|
||||
}
|
||||
|
||||
const doc = getData.data;
|
||||
|
||||
const existingRows: any[] = (doc.custom_task_updates || []).map((r: any) => ({
|
||||
name: r.name,
|
||||
update_: r.update_,
|
||||
date: r.date,
|
||||
task: r.task,
|
||||
}));
|
||||
|
||||
// ── Step 2: append new row ───────────────────────────────────────
|
||||
const newRow = {
|
||||
doctype: 'Updates',
|
||||
update_: text.trim(),
|
||||
date: todayFrappe(),
|
||||
task: taskName,
|
||||
};
|
||||
|
||||
const updatedRows = [...existingRows, newRow];
|
||||
|
||||
// ── Step 3: PUT back ─────────────────────────────────────────────
|
||||
const putRes = await fetch(
|
||||
`/api/resource/Task/${encodeURIComponent(taskName)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Frappe-CSRF-Token': (window as any).csrf_token || 'fetch',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
custom_task_updates: updatedRows,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const putData = await putRes.json();
|
||||
|
||||
if (putData.exc || putData.exception) {
|
||||
throw new Error(putData.exc || putData.exception || 'Failed to save update');
|
||||
}
|
||||
|
||||
// ── Success: close immediately then refetch ───────────────────────
|
||||
// Close first so the widget never gets a chance to replay the prompt
|
||||
onClose();
|
||||
onUpdateSuccess();
|
||||
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
setErrorMsg(`Failed to save: ${msg}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 flex items-center justify-center z-[80] p-4"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !isSubmitting) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="relative animate-scale-in">
|
||||
<button
|
||||
onClick={() => { if (!isSubmitting) onClose(); }}
|
||||
className="absolute -top-3 -right-3 z-10 w-8 h-8 rounded-full bg-gray-600 hover:bg-gray-500 text-white flex items-center justify-center shadow-lg transition-colors"
|
||||
disabled={isSubmitting}
|
||||
title="Close"
|
||||
>
|
||||
<FaTimes size={12} />
|
||||
</button>
|
||||
|
||||
{errorMsg && (
|
||||
<div className="mb-3 px-4 py-2 bg-red-50 border border-red-300 rounded-lg text-red-700 text-sm font-medium flex items-center gap-2">
|
||||
⚠️ {errorMsg}
|
||||
<button onClick={() => setErrorMsg('')} className="ml-auto text-red-400 hover:text-red-600">
|
||||
<FaTimes size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VoiceTaskUpdateWidget
|
||||
onUpdateConfirmed={handleUpdateConfirmed}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes scale-in {
|
||||
from { transform: scale(0.92); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
.animate-scale-in { animation: scale-in 0.2s ease-out; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceTaskUpdateModal;
|
||||
@ -1,422 +0,0 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import arTaskPromptAudio from "../assets/audio/ar_task_prompt.mp3";
|
||||
import enTaskPromptAudio from "../assets/audio/en_task_prompt.mp3";
|
||||
|
||||
const NUM_BARS = 18;
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
|
||||
export default function VoiceTaskUpdateWidget({
|
||||
onUpdateConfirmed,
|
||||
isSubmitting = false,
|
||||
}) {
|
||||
// phase: prompted | ready | listening | preview | saved
|
||||
const [phase, setPhase] = useState("prompted");
|
||||
const [transcript, setTranscript] = useState("");
|
||||
const [editableText, setEditableText] = useState("");
|
||||
const [barHeights, setBarHeights] = useState(Array(NUM_BARS).fill(4));
|
||||
// "en" | "ar"
|
||||
const [lang, setLang] = useState("en");
|
||||
|
||||
const audioCtxRef = useRef(null);
|
||||
const sourceRef = useRef(null);
|
||||
const animFrameRef = useRef(null);
|
||||
const recognitionRef = useRef(null);
|
||||
const streamRef = useRef(null);
|
||||
const autoStopRef = useRef(null);
|
||||
const transcriptRef = useRef(""); // tracks live transcript for onend access
|
||||
|
||||
const isArabic = lang === "ar";
|
||||
|
||||
// ── Mount ─────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => playPromptForLang(lang), 800);
|
||||
return () => { clearTimeout(timer); cleanup(); };
|
||||
}, []);
|
||||
|
||||
// ── Replay when language tab changes ─────────────────────────────────────
|
||||
const isFirstLangRender = useRef(true);
|
||||
useEffect(() => {
|
||||
if (isFirstLangRender.current) { isFirstLangRender.current = false; return; }
|
||||
if (phase === "listening" || phase === "preview" || phase === "saved") return;
|
||||
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||
setPhase("prompted");
|
||||
const capturedLang = lang;
|
||||
const timer = setTimeout(() => playPromptForLang(capturedLang), 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [lang]);
|
||||
|
||||
function playPromptForLang(currentLang) {
|
||||
// Use pre-recorded MP3 for both languages — fixes Android speechSynthesis issues
|
||||
const audioFile = currentLang === "ar" ? arTaskPromptAudio : enTaskPromptAudio;
|
||||
const audio = new Audio(audioFile);
|
||||
audio.onended = () => setPhase("ready");
|
||||
audio.onerror = () => setPhase("ready");
|
||||
audio.play().catch(() => setPhase("ready"));
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (autoStopRef.current) clearTimeout(autoStopRef.current);
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
if (sourceRef.current) { sourceRef.current.disconnect(); sourceRef.current = null; }
|
||||
if (audioCtxRef.current) { audioCtxRef.current.close(); audioCtxRef.current = null; }
|
||||
if (streamRef.current) { streamRef.current.getTracks().forEach((t) => t.stop()); streamRef.current = null; }
|
||||
if (recognitionRef.current) {
|
||||
try { recognitionRef.current.stop(); } catch (_) {}
|
||||
recognitionRef.current = null;
|
||||
}
|
||||
setBarHeights(Array(NUM_BARS).fill(4));
|
||||
}
|
||||
|
||||
async function startWaveform() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
audioCtxRef.current = ctx;
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 64;
|
||||
const src = ctx.createMediaStreamSource(stream);
|
||||
sourceRef.current = src;
|
||||
src.connect(analyser);
|
||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||
function frame() {
|
||||
animFrameRef.current = requestAnimationFrame(frame);
|
||||
analyser.getByteFrequencyData(data);
|
||||
setBarHeights(
|
||||
Array.from({ length: NUM_BARS }, (_, i) => {
|
||||
const idx = Math.floor((i * data.length) / NUM_BARS);
|
||||
return 4 + (data[idx] / 255) * 22;
|
||||
})
|
||||
);
|
||||
}
|
||||
frame();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function handleSpeak() {
|
||||
setPhase("listening");
|
||||
setTranscript("");
|
||||
setEditableText("");
|
||||
transcriptRef.current = "";
|
||||
await startWaveform();
|
||||
|
||||
if (!SR) { cleanup(); setPhase("ready"); return; }
|
||||
|
||||
const rec = new SR();
|
||||
recognitionRef.current = rec;
|
||||
rec.lang = isArabic ? "ar-SA" : "en-US";
|
||||
rec.interimResults = false; // No interim — only get final results, prevents duplicates
|
||||
rec.continuous = false; // Single utterance mode
|
||||
rec.maxAlternatives = 1;
|
||||
|
||||
rec.onresult = (e) => {
|
||||
// Take only the last result's transcript — Android may send multiple onresult events
|
||||
// but each contains the best single transcript so far. The last one is most accurate.
|
||||
const lastResult = e.results[e.resultIndex];
|
||||
if (lastResult && lastResult.isFinal) {
|
||||
const text = lastResult[0].transcript.trim();
|
||||
transcriptRef.current = text;
|
||||
setTranscript(text);
|
||||
}
|
||||
};
|
||||
|
||||
rec.onend = () => {
|
||||
cleanup();
|
||||
const result = transcriptRef.current.trim();
|
||||
if (result.length > 0) {
|
||||
setEditableText(result);
|
||||
setPhase("preview");
|
||||
} else {
|
||||
setPhase("ready");
|
||||
}
|
||||
};
|
||||
|
||||
rec.onerror = () => { cleanup(); setPhase("ready"); };
|
||||
rec.start();
|
||||
|
||||
// Auto-stop after 15s as safety fallback
|
||||
autoStopRef.current = setTimeout(() => {
|
||||
if (recognitionRef.current) recognitionRef.current.stop();
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
function handleStopListening() {
|
||||
if (recognitionRef.current) recognitionRef.current.stop();
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!editableText.trim() || isSubmitting) return;
|
||||
onUpdateConfirmed(editableText.trim());
|
||||
setPhase("saved");
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
cleanup();
|
||||
setTranscript("");
|
||||
setEditableText("");
|
||||
transcriptRef.current = "";
|
||||
setPhase("ready");
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
cleanup();
|
||||
setTranscript("");
|
||||
setEditableText("");
|
||||
transcriptRef.current = "";
|
||||
setPhase("prompted");
|
||||
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||
setTimeout(() => playPromptForLang(lang), 400);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
{/* Header */}
|
||||
<div style={styles.header}>
|
||||
<div style={styles.logo}>📝</div>
|
||||
<div style={styles.brand}>SEERA<span style={styles.brandAccent}>-ASM</span></div>
|
||||
<span style={styles.badge}>Task Update</span>
|
||||
</div>
|
||||
|
||||
<div style={styles.title}>Voice Task Update</div>
|
||||
<div style={styles.subtitle}>
|
||||
Speak your progress note — it will be added to Task Updates
|
||||
</div>
|
||||
|
||||
{/* ── 2-tab language toggle ── */}
|
||||
<div style={{
|
||||
display: "flex", gap: 6, marginBottom: 16,
|
||||
background: "#f1f5f9", borderRadius: 10, padding: 4,
|
||||
}}>
|
||||
{[{ key: "en", label: "English" }, { key: "ar", label: "عربي" }].map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setLang(key)}
|
||||
style={{
|
||||
flex: 1, padding: "6px 0", borderRadius: 7, border: "none",
|
||||
fontSize: 12, fontWeight: 600, cursor: "pointer", transition: "all 0.15s",
|
||||
background: lang === key ? "#fff" : "transparent",
|
||||
color: lang === key ? "#7c3aed" : "#64748b",
|
||||
boxShadow: lang === key ? "0 1px 4px rgba(0,0,0,0.1)" : "none",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action area */}
|
||||
<div style={styles.actionArea}>
|
||||
|
||||
{phase === "prompted" && (
|
||||
<div style={{ ...styles.statusMsg, ...styles.speakingMsg }}>
|
||||
<div style={styles.speakingIcon}>🔊</div>
|
||||
<div style={styles.speakingText}>
|
||||
{isArabic ? "يرجى الانتظار…" : "Please wait…"}
|
||||
</div>
|
||||
<div style={styles.dots}>
|
||||
<span style={styles.dot} />
|
||||
<span style={{ ...styles.dot, animationDelay: "0.2s" }} />
|
||||
<span style={{ ...styles.dot, animationDelay: "0.4s" }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "ready" && (
|
||||
<button style={styles.speakBtn} onClick={handleSpeak}>
|
||||
<div style={styles.speakBtnIcon}>🎙️</div>
|
||||
<div>
|
||||
<span style={styles.speakBtnTitle}>
|
||||
{isArabic ? "اضغط للتحدث" : "Tap to Speak"}
|
||||
</span>
|
||||
<span style={styles.speakBtnHint}>
|
||||
{isArabic ? "تحدث عن تحديث مهمتك بوضوح" : "Speak your task update clearly"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{phase === "listening" && (
|
||||
<div style={styles.listeningArea}>
|
||||
<div style={styles.micActive}>🔴</div>
|
||||
<div style={styles.waveform}>
|
||||
{barHeights.map((h, i) => (
|
||||
<div key={i} style={{ ...styles.bar, height: `${h}px` }} />
|
||||
))}
|
||||
</div>
|
||||
<div style={styles.micLabel}>
|
||||
{isArabic ? "جاري الاستماع… تحدث الآن" : "Listening… speak your update"}
|
||||
</div>
|
||||
{transcript && (
|
||||
<div style={styles.liveTranscript}>
|
||||
<em style={{ color: "#64748b", fontSize: 12 }}>
|
||||
{isArabic ? "يسمع:" : "Hearing:"}
|
||||
</em>
|
||||
<p style={{
|
||||
margin: "4px 0 0", fontSize: 13, color: "#1e293b",
|
||||
direction: isArabic ? "rtl" : "ltr",
|
||||
}}>
|
||||
{transcript}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button style={styles.stopBtn} onClick={handleStopListening}>
|
||||
{isArabic ? "⏹ انتهيت" : "⏹ Done Speaking"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "preview" && (
|
||||
<div style={styles.previewArea}>
|
||||
<div style={styles.previewLabel}>
|
||||
{isArabic ? "✅ تم! راجع وعدّل إذا لزم:" : "✅ Got it! Review and edit if needed:"}
|
||||
</div>
|
||||
<textarea
|
||||
style={{ ...styles.textarea, direction: isArabic ? "rtl" : "ltr" }}
|
||||
value={editableText}
|
||||
onChange={e => setEditableText(e.target.value)}
|
||||
rows={4}
|
||||
placeholder={isArabic ? "نص التحديث…" : "Your update text…"}
|
||||
autoFocus
|
||||
/>
|
||||
<p style={styles.previewHint}>
|
||||
{isArabic ? "📅 سيتم إضافة تاريخ اليوم تلقائياً" : "📅 Today's date will be added automatically"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "saved" && (
|
||||
<div style={{ ...styles.statusMsg, ...styles.savedMsg }}>
|
||||
{isArabic ? "✅ تمت إضافة التحديث!" : "✅ Update added to task!"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom actions */}
|
||||
<div style={styles.actions}>
|
||||
{phase !== "saved" && (
|
||||
<button style={styles.ghostBtn} onClick={handleReset} disabled={isSubmitting}>
|
||||
{isArabic ? "إعادة" : "Reset"}
|
||||
</button>
|
||||
)}
|
||||
{phase === "listening" && (
|
||||
<button style={{ ...styles.primaryBtn, background: "#dc2626" }} onClick={handleStopListening}>
|
||||
{isArabic ? "إيقاف" : "Stop"}
|
||||
</button>
|
||||
)}
|
||||
{phase === "preview" && (
|
||||
<>
|
||||
<button style={styles.ghostBtn} onClick={handleRetry}>
|
||||
{isArabic ? "🔄 إعادة التسجيل" : "🔄 Re-record"}
|
||||
</button>
|
||||
<button
|
||||
style={{ ...styles.primaryBtn, ...(isSubmitting ? styles.disabledBtn : {}) }}
|
||||
onClick={handleConfirm}
|
||||
disabled={isSubmitting || !editableText.trim()}
|
||||
>
|
||||
{isSubmitting
|
||||
? (isArabic ? "جاري الحفظ…" : "Saving…")
|
||||
: (isArabic ? "✅ إضافة التحديث" : "✅ Add Update")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{phase === "saved" && (
|
||||
<button style={styles.ghostBtn} onClick={handleReset}>
|
||||
{isArabic ? "إضافة تحديث آخر" : "Add Another"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
card: {
|
||||
background: "#ffffff", border: "1px solid #e2e8f0", borderRadius: 16,
|
||||
padding: "28px 28px 24px", width: "100%", maxWidth: 480,
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.12)", fontFamily: "inherit", color: "#1e293b",
|
||||
},
|
||||
header: { display: "flex", alignItems: "center", gap: 10, marginBottom: 20 },
|
||||
logo: {
|
||||
width: 36, height: 36, borderRadius: 8,
|
||||
background: "linear-gradient(135deg,#8b5cf6,#7c3aed)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", fontSize: 16,
|
||||
},
|
||||
brand: { fontWeight: 700, fontSize: 15, letterSpacing: "0.03em", color: "#1e293b" },
|
||||
brandAccent: { color: "#7c3aed" },
|
||||
badge: {
|
||||
marginLeft: "auto", background: "#f5f3ff", border: "1px solid #ddd6fe",
|
||||
color: "#6d28d9", fontSize: 11, fontWeight: 600, padding: "3px 10px", borderRadius: 100,
|
||||
},
|
||||
title: { fontSize: 22, fontWeight: 700, marginBottom: 4 },
|
||||
subtitle: { fontSize: 13, color: "#64748b", marginBottom: 12 },
|
||||
actionArea: {
|
||||
display: "flex", flexDirection: "column", alignItems: "center",
|
||||
gap: 12, marginBottom: 20, minHeight: 100, justifyContent: "center",
|
||||
},
|
||||
statusMsg: {
|
||||
display: "flex", alignItems: "center", gap: 10, fontSize: 14,
|
||||
fontWeight: 600, padding: "16px 20px", borderRadius: 10, width: "100%",
|
||||
},
|
||||
speakingMsg: {
|
||||
flexDirection: "column", background: "#f5f3ff",
|
||||
border: "1px solid #ddd6fe", color: "#7c3aed", gap: 8,
|
||||
},
|
||||
savedMsg: {
|
||||
background: "#f0fdf4", border: "1px solid #bbf7d0",
|
||||
color: "#16a34a", fontSize: 16, justifyContent: "center",
|
||||
},
|
||||
speakingIcon: { fontSize: 28 },
|
||||
speakingText: { fontSize: 14, fontWeight: 600, color: "#7c3aed" },
|
||||
dots: { display: "flex", gap: 4 },
|
||||
dot: {
|
||||
display: "inline-block", width: 6, height: 6, borderRadius: "50%",
|
||||
background: "#8b5cf6", animation: "vsw-dot 1.2s ease-in-out infinite",
|
||||
},
|
||||
speakBtn: {
|
||||
width: "100%", padding: "14px 16px", borderRadius: 12,
|
||||
border: "1px solid #ddd6fe", background: "#f5f3ff", color: "#5b21b6",
|
||||
fontFamily: "inherit", cursor: "pointer", display: "flex",
|
||||
alignItems: "center", gap: 12, transition: "all 0.2s",
|
||||
},
|
||||
speakBtnIcon: {
|
||||
width: 40, height: 40, borderRadius: "50%", background: "#7c3aed",
|
||||
color: "#fff", display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 18, flexShrink: 0,
|
||||
},
|
||||
speakBtnTitle: { display: "block", fontSize: 15, fontWeight: 600, color: "#5b21b6" },
|
||||
speakBtnHint: { display: "block", fontSize: 11, color: "#8b5cf6", marginTop: 2 },
|
||||
listeningArea: { display: "flex", flexDirection: "column", alignItems: "center", gap: 8, width: "100%" },
|
||||
micActive: { fontSize: 36 },
|
||||
waveform: { display: "flex", alignItems: "center", gap: 3, height: 30 },
|
||||
bar: { width: 3, borderRadius: 2, background: "linear-gradient(to top,#8b5cf6,#c4b5fd)", transition: "height 0.08s ease" },
|
||||
micLabel: { fontSize: 13, color: "#ef4444", fontWeight: 600 },
|
||||
liveTranscript: {
|
||||
width: "100%", background: "#f8fafc", border: "1px solid #e2e8f0",
|
||||
borderRadius: 8, padding: "8px 12px", marginTop: 4,
|
||||
},
|
||||
stopBtn: {
|
||||
marginTop: 8, padding: "8px 20px", borderRadius: 8,
|
||||
border: "1px solid #fecaca", background: "#fef2f2",
|
||||
color: "#dc2626", fontFamily: "inherit", fontSize: 13, fontWeight: 600, cursor: "pointer",
|
||||
},
|
||||
previewArea: { width: "100%" },
|
||||
previewLabel: { fontSize: 13, fontWeight: 600, color: "#16a34a", marginBottom: 8 },
|
||||
textarea: {
|
||||
width: "100%", padding: "10px 12px", border: "1px solid #c4b5fd",
|
||||
borderRadius: 8, fontSize: 14, fontFamily: "inherit", color: "#1e293b",
|
||||
background: "#fafafa", resize: "vertical", outline: "none", boxSizing: "border-box",
|
||||
},
|
||||
previewHint: { fontSize: 11, color: "#64748b", marginTop: 6 },
|
||||
actions: { display: "flex", gap: 10 },
|
||||
ghostBtn: {
|
||||
flex: 1, padding: "10px 18px", borderRadius: 8,
|
||||
border: "1px solid #e2e8f0", background: "#f1f5f9",
|
||||
color: "#475569", fontFamily: "inherit", fontSize: 14, fontWeight: 600, cursor: "pointer",
|
||||
},
|
||||
primaryBtn: {
|
||||
flex: 1, padding: "10px 18px", borderRadius: 8, border: "none",
|
||||
background: "#7c3aed", color: "#fff", fontFamily: "inherit",
|
||||
fontSize: 14, fontWeight: 600, cursor: "pointer",
|
||||
},
|
||||
disabledBtn: { opacity: 0.45, cursor: "not-allowed" },
|
||||
};
|
||||
@ -1,5 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React, { useState } from 'react';
|
||||
import { useWorkflow } from '../hooks/useWorkflow.ts';
|
||||
import type { WorkflowTransition } from '../services/workflowService';
|
||||
import { FaSpinner, FaExclamationTriangle, FaInfoCircle } from 'react-icons/fa';
|
||||
@ -8,21 +7,9 @@ interface WorkflowActionsProps {
|
||||
doctype: string;
|
||||
docname: string | null;
|
||||
workflowState?: string;
|
||||
/** Merged doc + form for workflow condition evaluation */
|
||||
docData?: Record<string, any>;
|
||||
onActionComplete?: (action: string, success: boolean) => void;
|
||||
onStateChange?: () => void;
|
||||
/** Fired when canEdit / workflow is resolved (existing docs only) */
|
||||
onWorkflowMeta?: (meta: { canEdit: boolean }) => void;
|
||||
/** Word used in confirm dialogs, e.g. "Material Request" */
|
||||
documentLabel?: string;
|
||||
showStateInfo?: boolean;
|
||||
/** Label above the current state (default: "Workflow State") */
|
||||
stateHeading?: string;
|
||||
/** Purple note when user has full workflow access (e.g. System Manager) */
|
||||
showFullAccessNote?: boolean;
|
||||
/** Omit the whole block when there is no active workflow for this doctype */
|
||||
hideWhenNoWorkflow?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@ -30,27 +17,17 @@ const WorkflowActions: React.FC<WorkflowActionsProps> = ({
|
||||
doctype,
|
||||
docname,
|
||||
workflowState,
|
||||
docData,
|
||||
onActionComplete,
|
||||
onStateChange,
|
||||
onWorkflowMeta,
|
||||
documentLabel = 'document',
|
||||
showStateInfo = true,
|
||||
stateHeading = 'Workflow State',
|
||||
showFullAccessNote = false,
|
||||
hideWhenNoWorkflow = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
transitions,
|
||||
loading,
|
||||
actionLoading,
|
||||
error,
|
||||
applyAction,
|
||||
canEdit,
|
||||
isSystemManager,
|
||||
workflowInfo,
|
||||
getStateStyle,
|
||||
getButtonStyle,
|
||||
getIcon,
|
||||
@ -59,20 +36,14 @@ const WorkflowActions: React.FC<WorkflowActionsProps> = ({
|
||||
docname,
|
||||
workflowState,
|
||||
enabled: !!docname,
|
||||
docData,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!docname) return;
|
||||
onWorkflowMeta?.({ canEdit });
|
||||
}, [docname, canEdit, onWorkflowMeta]);
|
||||
|
||||
const [confirmAction, setConfirmAction] = useState<string | null>(null);
|
||||
|
||||
// Actions that require confirmation
|
||||
const actionsRequiringConfirmation = ['Reject', 'Cancel', 'Close'];
|
||||
|
||||
const handleActionClick = async (action: string, nextState?: string) => {
|
||||
const handleActionClick = async (action: string) => {
|
||||
// Check if action requires confirmation
|
||||
if (actionsRequiringConfirmation.includes(action) && confirmAction !== action) {
|
||||
setConfirmAction(action);
|
||||
@ -81,7 +52,7 @@ const WorkflowActions: React.FC<WorkflowActionsProps> = ({
|
||||
|
||||
setConfirmAction(null);
|
||||
|
||||
const success = await applyAction(action, nextState);
|
||||
const success = await applyAction(action);
|
||||
|
||||
if (onActionComplete) {
|
||||
onActionComplete(action, success);
|
||||
@ -100,10 +71,6 @@ const WorkflowActions: React.FC<WorkflowActionsProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hideWhenNoWorkflow && !loading && !workflowInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stateStyle = workflowState ? getStateStyle(workflowState) : getStateStyle('Draft');
|
||||
|
||||
return (
|
||||
@ -113,7 +80,7 @@ const WorkflowActions: React.FC<WorkflowActionsProps> = ({
|
||||
<div className={`p-4 rounded-lg border ${stateStyle.bg} ${stateStyle.border}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{stateHeading}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Workflow State</p>
|
||||
<p className={`text-lg font-semibold ${stateStyle.text}`}>
|
||||
{workflowState}
|
||||
</p>
|
||||
@ -151,16 +118,13 @@ const WorkflowActions: React.FC<WorkflowActionsProps> = ({
|
||||
Confirm Action
|
||||
</p>
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-1">
|
||||
Are you sure you want to <strong>{confirmAction}</strong> this {documentLabel}?
|
||||
Are you sure you want to <strong>{confirmAction}</strong> this work order?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const t = transitions.find(tr => tr.action === confirmAction);
|
||||
handleActionClick(confirmAction, t?.next_state);
|
||||
}}
|
||||
onClick={() => handleActionClick(confirmAction)}
|
||||
disabled={actionLoading}
|
||||
className="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-sm rounded-md disabled:opacity-50"
|
||||
>
|
||||
@ -187,22 +151,15 @@ const WorkflowActions: React.FC<WorkflowActionsProps> = ({
|
||||
{/* Available Actions */}
|
||||
{!loading && transitions.length > 0 && !confirmAction && (
|
||||
<div className="space-y-2">
|
||||
{showFullAccessNote && isSystemManager && (
|
||||
<div className="p-2 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg mb-2">
|
||||
<p className="text-xs text-purple-700 dark:text-purple-300">
|
||||
{t('workOrders.detail.systemManagerNote')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
<FaInfoCircle size={12} />
|
||||
Available Actions ({transitions.length})
|
||||
Available Actions
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{transitions.map((transition: WorkflowTransition, index: number) => (
|
||||
<button
|
||||
key={`${transition.action}-${index}`}
|
||||
onClick={() => handleActionClick(transition.action, transition.next_state)}
|
||||
onClick={() => handleActionClick(transition.action)}
|
||||
disabled={actionLoading}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 flex items-center gap-2 ${getButtonStyle(transition.action)}`}
|
||||
title={`Move to: ${transition.next_state}`}
|
||||
@ -218,17 +175,12 @@ const WorkflowActions: React.FC<WorkflowActionsProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Show next states info */}
|
||||
<div className="mt-3 pt-2 border-t border-gray-200 dark:border-gray-600">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{t('issues.actionResults')}
|
||||
</p>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{transitions.map((tr: WorkflowTransition, i: number) => (
|
||||
<span key={i} className="inline-block mr-3">
|
||||
{tr.action} → <span className="font-medium">{tr.next_state}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{transitions.map((t: WorkflowTransition, i: number) => (
|
||||
<span key={i} className="inline-block mr-3">
|
||||
{t.action} → <span className="font-medium">{t.next_state}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -7,9 +7,10 @@ interface ApiConfig {
|
||||
}
|
||||
|
||||
const API_CONFIG: ApiConfig = {
|
||||
// Same-origin relative URLs match logged-in Frappe session. Set VITE_FRAPPE_BASE_URL only when
|
||||
// the SPA is hosted on a different origin than the API (then rebuild).
|
||||
BASE_URL: import.meta.env.VITE_FRAPPE_BASE_URL || '',
|
||||
// Backend URL - Use proxy in development, direct URL in production
|
||||
BASE_URL: import.meta.env.DEV
|
||||
? '' // Use relative URLs in development (goes through Vite proxy)
|
||||
: import.meta.env.VITE_FRAPPE_BASE_URL || 'https://kfsh-dammam-asm.seeraarabia.com',
|
||||
|
||||
// API Endpoints
|
||||
ENDPOINTS: {
|
||||
@ -75,10 +76,8 @@ const API_CONFIG: ApiConfig = {
|
||||
|
||||
// Authentication
|
||||
LOGIN: '/api/method/login',
|
||||
RESET_PASSWORD: '/api/method/frappe.core.doctype.user.user.reset_password',
|
||||
LOGOUT: '/api/method/logout',
|
||||
CSRF_TOKEN: '/api/method/frappe.sessions.get_csrf_token',
|
||||
TWO_FACTOR_STATUS: '/api/method/asm_ui_app.api.two_factor.get_two_factor_status',
|
||||
|
||||
// File Upload
|
||||
UPLOAD_FILE: '/api/method/upload_file',
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
/** Default company and currency when not specified in URL or on the loaded document. */
|
||||
export const DEFAULT_COMPANY = 'Seera Arabia';
|
||||
export const DEFAULT_CURRENCY = 'SAR';
|
||||
|
||||
/** Default sales tax template for new Sales Order / Delivery Note / Sales Invoice. */
|
||||
export const DEFAULT_SALES_TAXES_TEMPLATE = 'KSA Tax Charges - SA';
|
||||
|
||||
/** Frappe child tables use `rate`; editors often use `tax_rate`. */
|
||||
export function taxRatePercent(tx: { tax_rate?: number; rate?: number }): number {
|
||||
const v = tx.tax_rate ?? tx.rate;
|
||||
return typeof v === 'number' && !Number.isNaN(v) ? v : 0;
|
||||
}
|
||||
|
||||
/** Show SAR when document still has legacy INR (org default is SAR). */
|
||||
export function displayTxnCurrency(code?: string | null): string {
|
||||
const c = (code || '').trim();
|
||||
if (!c || c === 'INR') return DEFAULT_CURRENCY;
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Display amounts with SAR prefix (org standard; avoids ₹ / INR in UI). */
|
||||
export function formatOrgCurrencyAmount(value?: number | null): string {
|
||||
const n = Number(value ?? 0);
|
||||
return `SAR ${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
import React, { createContext, useCallback, useContext, useState } from 'react';
|
||||
|
||||
interface SidebarLayoutContextType {
|
||||
mobileSidebarOpen: boolean;
|
||||
openMobileSidebar: () => void;
|
||||
closeMobileSidebar: () => void;
|
||||
}
|
||||
|
||||
const SidebarLayoutContext = createContext<SidebarLayoutContextType | undefined>(undefined);
|
||||
|
||||
export const SidebarLayoutProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
const openMobileSidebar = useCallback(() => {
|
||||
setMobileSidebarOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeMobileSidebar = useCallback(() => {
|
||||
setMobileSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SidebarLayoutContext.Provider
|
||||
value={{ mobileSidebarOpen, openMobileSidebar, closeMobileSidebar }}
|
||||
>
|
||||
{children}
|
||||
</SidebarLayoutContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSidebarLayout = () => {
|
||||
const context = useContext(SidebarLayoutContext);
|
||||
if (!context) {
|
||||
throw new Error('useSidebarLayout must be used within SidebarLayoutProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@ -1,70 +0,0 @@
|
||||
/** Per-step hero images (Unsplash). Order matches `workOrders.troubleshootTree.steps.<category>` in i18n. */
|
||||
|
||||
export type TroubleshootCategory =
|
||||
| 'air_conditioning'
|
||||
| 'power'
|
||||
| 'building_systems'
|
||||
| 'applications_account'
|
||||
| 'ultrasound';
|
||||
|
||||
/** auto=format&fit=crop keeps payloads small; w=960 matches card width. */
|
||||
const q = 'auto=format&fit=crop&w=960&q=80';
|
||||
|
||||
export const TROUBLESHOOT_STEP_IMAGE_URLS: Record<TroubleshootCategory, readonly string[]> = {
|
||||
air_conditioning: [
|
||||
`https://images.unsplash.com/photo-1770625467384-304e461ef1be?${q}`,
|
||||
`https://images.unsplash.com/photo-1590620284812-3216c0ea0a75?${q}`,
|
||||
`https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?${q}`,
|
||||
`https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?${q}`,
|
||||
`https://images.unsplash.com/photo-1770816306659-adcf99a11aeb?${q}`,
|
||||
`https://images.unsplash.com/photo-1585771724684-38269d6639fd?${q}`,
|
||||
],
|
||||
power: [
|
||||
`https://images.unsplash.com/photo-1621905251918-48416bd8575a?${q}`,
|
||||
`https://images.unsplash.com/photo-1558618666-fcd25c85cd64?${q}`,
|
||||
`https://images.unsplash.com/photo-1558494949-ef010cbdcc31?${q}`,
|
||||
`https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?${q}`,
|
||||
`https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?${q}`,
|
||||
`https://images.unsplash.com/photo-1621905251918-48416bd8575a?${q}`,
|
||||
],
|
||||
building_systems: [
|
||||
`https://images.unsplash.com/photo-1600585154526-990dced4db0d?${q}`,
|
||||
`https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?${q}`,
|
||||
`https://images.unsplash.com/photo-1556911220-e15b29be8c8f?${q}`,
|
||||
`https://images.unsplash.com/photo-1691465576659-938b08b99959?${q}`,
|
||||
`https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?${q}`,
|
||||
`https://images.unsplash.com/photo-1497366754035-f200968a6e72?${q}`,
|
||||
],
|
||||
applications_account: [
|
||||
`https://images.unsplash.com/photo-1460925895917-afdab827c52f?${q}`,
|
||||
`https://images.unsplash.com/photo-1544197150-b99a580bb7a8?${q}`,
|
||||
`https://images.unsplash.com/photo-1563013544-824ae1b704d3?${q}`,
|
||||
`https://images.unsplash.com/photo-1517694712202-14dd9538aa97?${q}`,
|
||||
`https://images.unsplash.com/photo-1555066931-4365d14bab8c?${q}`,
|
||||
`https://images.unsplash.com/photo-1544197150-b99a580bb7a8?${q}`,
|
||||
],
|
||||
ultrasound: [
|
||||
// Step 1 — Power supply & environment: electrical plug into wall socket (verified)
|
||||
`https://images.unsplash.com/photo-1565049981953-379c9c2a5d48?${q}`,
|
||||
// Step 2 — Probe & transducer check: doctor in blue gloves operating ultrasound machine (verified)
|
||||
`https://images.unsplash.com/photo-1691933880037-ce9d151ab922?${q}`,
|
||||
// Step 3 — Image quality & monitor: hospital ICU monitor screen with readings (verified)
|
||||
`https://images.unsplash.com/photo-1624004015322-a94d3a4eff39?${q}`,
|
||||
// Step 4 — Shutdown & restart: server room cables and switches close-up (verified)
|
||||
`https://images.unsplash.com/photo-1667264501379-c1537934c7ab?${q}`,
|
||||
// Step 5 — Error codes & diagnostics: laptop with red error screen (verified)
|
||||
`https://images.unsplash.com/flagged/photo-1560854350-13c0b47a3180?${q}`,
|
||||
// Step 6 — Escalation to GE/SAMAMA: technician / support service call
|
||||
`https://images.unsplash.com/photo-1521791136064-7986c2920216?${q}`,
|
||||
// Step 7 — Printer troubleshooting: network / data center cables close-up (verified)
|
||||
`https://images.unsplash.com/photo-1691435828932-911a7801adfb?${q}`,
|
||||
// Step 8 — Network troubleshooting: ethernet cable LAN close-up (verified)
|
||||
`https://images.unsplash.com/photo-1578016980868-197203ff4b02?${q}`,
|
||||
],
|
||||
};
|
||||
|
||||
export function troubleshootStepImageUrl(category: TroubleshootCategory, stepIndex: number): string | undefined {
|
||||
const list = TROUBLESHOOT_STEP_IMAGE_URLS[category];
|
||||
if (!list || !list.length || stepIndex < 0) return undefined;
|
||||
return list[Math.min(stepIndex, list.length - 1)];
|
||||
}
|
||||
@ -35,41 +35,17 @@ export function useInspectionList(params: InspectionListParams = {}): UseInspect
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
const paramsKey = JSON.stringify(params);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [listResponse, count] = await Promise.all([
|
||||
inspectionService.getInspections(params),
|
||||
inspectionService.getInspectionCount(params.filters || {}),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setInspections(listResponse.data);
|
||||
setTotalCount(count);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch inspections';
|
||||
setError(errorMessage);
|
||||
console.error('Error fetching inspections:', err);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [paramsKey]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
const fetchInspections = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch inspections and count in parallel
|
||||
const [listResponse, count] = await Promise.all([
|
||||
inspectionService.getInspections(params),
|
||||
inspectionService.getInspectionCount(params.filters || {}),
|
||||
inspectionService.getInspectionCount(params.filters || {})
|
||||
]);
|
||||
|
||||
setInspections(listResponse.data);
|
||||
setTotalCount(count);
|
||||
} catch (err) {
|
||||
@ -79,14 +55,18 @@ export function useInspectionList(params: InspectionListParams = {}): UseInspect
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [paramsKey]);
|
||||
}, [JSON.stringify(params)]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInspections();
|
||||
}, [fetchInspections]);
|
||||
|
||||
return {
|
||||
inspections,
|
||||
loading,
|
||||
error,
|
||||
totalCount,
|
||||
refetch,
|
||||
refetch: fetchInspections
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -8,36 +8,14 @@ export const useIssueList = (params: IssueListParams = {}) => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
const paramsKey = JSON.stringify(params);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await issueService.getIssues(params);
|
||||
if (cancelled) return;
|
||||
setIssues(response.data);
|
||||
const count = await issueService.getIssueCount(params.filters);
|
||||
if (cancelled) return;
|
||||
setTotalCount(count);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch issues');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [paramsKey]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
const fetchIssues = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await issueService.getIssues(params);
|
||||
setIssues(response.data);
|
||||
|
||||
// Get total count for pagination
|
||||
const count = await issueService.getIssueCount(params.filters);
|
||||
setTotalCount(count);
|
||||
} catch (err) {
|
||||
@ -45,14 +23,18 @@ export const useIssueList = (params: IssueListParams = {}) => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [paramsKey]);
|
||||
}, [JSON.stringify(params)]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchIssues();
|
||||
}, [fetchIssues]);
|
||||
|
||||
return {
|
||||
issues,
|
||||
loading,
|
||||
error,
|
||||
totalCount,
|
||||
refetch,
|
||||
refetch: fetchIssues,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Row selection for paginated list + export. Selection is scoped to the current page:
|
||||
* "select all" replaces the set with the current page's names; changing `resetKey` clears selection.
|
||||
*/
|
||||
export function useListPageSelection<T extends { name: string }>(pageRows: T[], resetKey: string | number) {
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRows(new Set());
|
||||
}, [resetKey]);
|
||||
|
||||
const toggleRow = useCallback((name: string) => {
|
||||
setSelectedRows(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAllOnPage = useCallback(() => {
|
||||
setSelectedRows(prev => {
|
||||
const pageIds = pageRows.map(r => r.name);
|
||||
if (pageIds.length === 0) return new Set();
|
||||
const allOnPage = pageIds.every(id => prev.has(id));
|
||||
if (allOnPage) return new Set();
|
||||
return new Set(pageIds);
|
||||
});
|
||||
}, [pageRows]);
|
||||
|
||||
const allOnPageSelected = pageRows.length > 0 && pageRows.every(r => selectedRows.has(r.name));
|
||||
const someOnPageSelected =
|
||||
pageRows.some(r => selectedRows.has(r.name)) && !allOnPageSelected;
|
||||
|
||||
return {
|
||||
selectedRows,
|
||||
toggleRow,
|
||||
toggleAllOnPage,
|
||||
allOnPageSelected,
|
||||
someOnPageSelected,
|
||||
};
|
||||
}
|
||||
@ -12,36 +12,14 @@ export const useMaintenanceTeamList = (params: MaintenanceTeamListParams = {}) =
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
const paramsKey = JSON.stringify(params);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await maintenanceTeamService.getMaintenanceTeams(params);
|
||||
if (cancelled) return;
|
||||
setTeams(response.data);
|
||||
const count = await maintenanceTeamService.getMaintenanceTeamCount(params.filters);
|
||||
if (cancelled) return;
|
||||
setTotalCount(count);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch maintenance teams');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [paramsKey]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
const fetchTeams = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await maintenanceTeamService.getMaintenanceTeams(params);
|
||||
setTeams(response.data);
|
||||
|
||||
// Get total count for pagination
|
||||
const count = await maintenanceTeamService.getMaintenanceTeamCount(params.filters);
|
||||
setTotalCount(count);
|
||||
} catch (err) {
|
||||
@ -49,14 +27,18 @@ export const useMaintenanceTeamList = (params: MaintenanceTeamListParams = {}) =
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [paramsKey]);
|
||||
}, [JSON.stringify(params)]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTeams();
|
||||
}, [fetchTeams]);
|
||||
|
||||
return {
|
||||
teams,
|
||||
loading,
|
||||
error,
|
||||
totalCount,
|
||||
refetch,
|
||||
refetch: fetchTeams,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -12,9 +12,9 @@ export function useNotifications() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await notificationService.getNotifications();
|
||||
const filtered = data
|
||||
.filter(Boolean)
|
||||
.filter((n) => !n.subject?.startsWith('Failed to send email'));
|
||||
const filtered = data.filter(
|
||||
(n) => !n.subject?.startsWith('Failed to send email')
|
||||
);
|
||||
setNotifications(filtered);
|
||||
setUnreadCount(filtered.filter((n) => !n.read).length);
|
||||
// setNotifications(data);
|
||||
|
||||
@ -1,470 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import projectService, {
|
||||
type Project,
|
||||
type Task,
|
||||
type Timesheet,
|
||||
type ProjectTemplate,
|
||||
type ActivityType,
|
||||
type ProjectListParams,
|
||||
} from '../services/projectService';
|
||||
|
||||
/** Normalized key so pagination fields always affect deps (JSON.stringify drops `undefined`). */
|
||||
function listQueryKey(p: ProjectListParams): string {
|
||||
return JSON.stringify({
|
||||
filters: p.filters ?? {},
|
||||
appendFilters: p.appendFilters ?? [],
|
||||
fields: p.fields ?? null,
|
||||
limit_start: p.limit_start ?? 0,
|
||||
limit_page_length: p.limit_page_length ?? 20,
|
||||
order_by: p.order_by ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Project list ────────────────────────────────────────────────────────────
|
||||
export const useProjectList = (params: ProjectListParams = {}) => {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
const paramsKey = listQueryKey(params);
|
||||
const fetchSeqRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const reqId = ++fetchSeqRef.current;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [response, count] = await Promise.all([
|
||||
projectService.getProjects(params),
|
||||
projectService.getProjectCount(params.filters),
|
||||
]);
|
||||
if (cancelled || reqId !== fetchSeqRef.current) return;
|
||||
setProjects(response.data);
|
||||
setTotalCount(count);
|
||||
} catch (err) {
|
||||
if (cancelled || reqId !== fetchSeqRef.current) return;
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch projects');
|
||||
} finally {
|
||||
if (!cancelled && reqId === fetchSeqRef.current) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [paramsKey]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
const reqId = ++fetchSeqRef.current;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [response, count] = await Promise.all([
|
||||
projectService.getProjects(params),
|
||||
projectService.getProjectCount(params.filters),
|
||||
]);
|
||||
if (reqId !== fetchSeqRef.current) return;
|
||||
setProjects(response.data);
|
||||
setTotalCount(count);
|
||||
} catch (err) {
|
||||
if (reqId !== fetchSeqRef.current) return;
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch projects');
|
||||
} finally {
|
||||
if (reqId === fetchSeqRef.current) setLoading(false);
|
||||
}
|
||||
}, [paramsKey]);
|
||||
|
||||
return { projects, loading, error, totalCount, refetch };
|
||||
};
|
||||
|
||||
// ─── Project detail ──────────────────────────────────────────────────────────
|
||||
export const useProjectDetails = (projectName: string | null) => {
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchProject = useCallback(async () => {
|
||||
if (!projectName) { setProject(null); return; }
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setProject(await projectService.getProject(projectName));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch project');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectName]);
|
||||
|
||||
useEffect(() => { fetchProject(); }, [fetchProject]);
|
||||
return { project, loading, error, refetch: fetchProject };
|
||||
};
|
||||
|
||||
// ─── Task list (generic, filterable by project) ──────────────────────────────
|
||||
export const useTaskList = (params: ProjectListParams = {}) => {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
const paramsKey = listQueryKey(params);
|
||||
const fetchSeqRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const reqId = ++fetchSeqRef.current;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [response, count] = await Promise.all([
|
||||
projectService.getTasks(params),
|
||||
projectService.getTaskCount(params.filters),
|
||||
]);
|
||||
if (cancelled || reqId !== fetchSeqRef.current) return;
|
||||
setTasks(response.data);
|
||||
setTotalCount(count);
|
||||
} catch (err) {
|
||||
if (cancelled || reqId !== fetchSeqRef.current) return;
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch tasks');
|
||||
} finally {
|
||||
if (!cancelled && reqId === fetchSeqRef.current) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [paramsKey]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
const reqId = ++fetchSeqRef.current;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [response, count] = await Promise.all([
|
||||
projectService.getTasks(params),
|
||||
projectService.getTaskCount(params.filters),
|
||||
]);
|
||||
if (reqId !== fetchSeqRef.current) return;
|
||||
setTasks(response.data);
|
||||
setTotalCount(count);
|
||||
} catch (err) {
|
||||
if (reqId !== fetchSeqRef.current) return;
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch tasks');
|
||||
} finally {
|
||||
if (reqId === fetchSeqRef.current) setLoading(false);
|
||||
}
|
||||
}, [paramsKey]);
|
||||
|
||||
return { tasks, loading, error, totalCount, refetch };
|
||||
};
|
||||
|
||||
// ─── Tasks for a project ─────────────────────────────────────────────────────
|
||||
export const useProjectTasks = (projectName: string | null) => {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
if (!projectName) { setTasks([]); return; }
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { data } = await projectService.getTasksForProject(projectName);
|
||||
setTasks(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch tasks');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectName]);
|
||||
|
||||
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
||||
return { tasks, loading, error, refetch: fetchTasks };
|
||||
};
|
||||
|
||||
// ─── Task detail ─────────────────────────────────────────────────────────────
|
||||
export const useTaskDetails = (taskName: string | null) => {
|
||||
const [task, setTask] = useState<Task | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchTask = useCallback(async () => {
|
||||
if (!taskName) { setTask(null); return; }
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setTask(await projectService.getTask(taskName));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch task');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskName]);
|
||||
|
||||
useEffect(() => { fetchTask(); }, [fetchTask]);
|
||||
return { task, loading, error, refetch: fetchTask };
|
||||
};
|
||||
|
||||
// ─── Timesheet list (generic) ────────────────────────────────────────────────
|
||||
export const useTimesheetList = (params: ProjectListParams = {}) => {
|
||||
const [timesheets, setTimesheets] = useState<Timesheet[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
const paramsKey = listQueryKey(params);
|
||||
const fetchSeqRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const reqId = ++fetchSeqRef.current;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [response, count] = await Promise.all([
|
||||
projectService.getTimesheets(params),
|
||||
projectService.getTimesheetCount(params.filters || {}, params.appendFilters || []),
|
||||
]);
|
||||
if (cancelled || reqId !== fetchSeqRef.current) return;
|
||||
setTimesheets(response.data);
|
||||
setTotalCount(count);
|
||||
} catch (err) {
|
||||
if (cancelled || reqId !== fetchSeqRef.current) return;
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch timesheets');
|
||||
} finally {
|
||||
if (!cancelled && reqId === fetchSeqRef.current) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [paramsKey]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
const reqId = ++fetchSeqRef.current;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [response, count] = await Promise.all([
|
||||
projectService.getTimesheets(params),
|
||||
projectService.getTimesheetCount(params.filters || {}, params.appendFilters || []),
|
||||
]);
|
||||
if (reqId !== fetchSeqRef.current) return;
|
||||
setTimesheets(response.data);
|
||||
setTotalCount(count);
|
||||
} catch (err) {
|
||||
if (reqId !== fetchSeqRef.current) return;
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch timesheets');
|
||||
} finally {
|
||||
if (reqId === fetchSeqRef.current) setLoading(false);
|
||||
}
|
||||
}, [paramsKey]);
|
||||
|
||||
return { timesheets, loading, error, totalCount, refetch };
|
||||
};
|
||||
|
||||
// ─── Timesheets for a project ────────────────────────────────────────────────
|
||||
export const useProjectTimesheets = (projectName: string | null) => {
|
||||
const [timesheets, setTimesheets] = useState<Timesheet[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchTimesheets = useCallback(async () => {
|
||||
if (!projectName) { setTimesheets([]); return; }
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { data } = await projectService.getTimesheetsForProject(projectName);
|
||||
setTimesheets(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch timesheets');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectName]);
|
||||
|
||||
useEffect(() => { fetchTimesheets(); }, [fetchTimesheets]);
|
||||
return { timesheets, loading, error, refetch: fetchTimesheets };
|
||||
};
|
||||
|
||||
// ─── Timesheet detail ────────────────────────────────────────────────────────
|
||||
export const useTimesheetDetails = (name: string | null) => {
|
||||
const [timesheet, setTimesheet] = useState<Timesheet | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchTimesheet = useCallback(async () => {
|
||||
if (!name) { setTimesheet(null); return; }
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setTimesheet(await projectService.getTimesheet(name));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch timesheet');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [name]);
|
||||
|
||||
useEffect(() => { fetchTimesheet(); }, [fetchTimesheet]);
|
||||
return { timesheet, loading, error, refetch: fetchTimesheet };
|
||||
};
|
||||
|
||||
// ─── Project Templates ────────────────────────────────────────────────────────
|
||||
export const useProjectTemplates = (params: ProjectListParams = {}) => {
|
||||
const [templates, setTemplates] = useState<ProjectTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
const paramsKey = listQueryKey(params);
|
||||
const fetchSeqRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const reqId = ++fetchSeqRef.current;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [r, count] = await Promise.all([
|
||||
projectService.getProjectTemplates(params),
|
||||
projectService.getProjectTemplateCount(params.filters || {}),
|
||||
]);
|
||||
if (cancelled || reqId !== fetchSeqRef.current) return;
|
||||
setTemplates(r.data);
|
||||
setTotalCount(count);
|
||||
} catch { /* silent */ } finally { if (!cancelled && reqId === fetchSeqRef.current) setLoading(false); }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [paramsKey]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
const reqId = ++fetchSeqRef.current;
|
||||
try {
|
||||
setLoading(true);
|
||||
const [r, count] = await Promise.all([
|
||||
projectService.getProjectTemplates(params),
|
||||
projectService.getProjectTemplateCount(params.filters || {}),
|
||||
]);
|
||||
if (reqId !== fetchSeqRef.current) return;
|
||||
setTemplates(r.data);
|
||||
setTotalCount(count);
|
||||
} catch { /* silent */ } finally { if (reqId === fetchSeqRef.current) setLoading(false); }
|
||||
}, [paramsKey]);
|
||||
|
||||
return { templates, loading, totalCount, refetch };
|
||||
};
|
||||
|
||||
export const useProjectTemplateDetails = (name: string | null) => {
|
||||
const [template, setTemplate] = useState<ProjectTemplate | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchTemplate = useCallback(async () => {
|
||||
if (!name) { setTemplate(null); return; }
|
||||
try {
|
||||
setLoading(true); setError(null);
|
||||
setTemplate(await projectService.getProjectTemplate(name));
|
||||
} catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch template'); }
|
||||
finally { setLoading(false); }
|
||||
}, [name]);
|
||||
|
||||
useEffect(() => { fetchTemplate(); }, [fetchTemplate]);
|
||||
return { template, loading, error, refetch: fetchTemplate };
|
||||
};
|
||||
|
||||
// ─── Activity Types ───────────────────────────────────────────────────────────
|
||||
export const useActivityTypeList = (params: ProjectListParams = {}) => {
|
||||
const [activityTypes, setActivityTypes] = useState<ActivityType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
const paramsKey = listQueryKey(params);
|
||||
const fetchSeqRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const reqId = ++fetchSeqRef.current;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [r, count] = await Promise.all([
|
||||
projectService.getActivityTypes(params),
|
||||
projectService.getActivityTypeCount(params.filters || {}),
|
||||
]);
|
||||
if (cancelled || reqId !== fetchSeqRef.current) return;
|
||||
setActivityTypes(r.data);
|
||||
setTotalCount(count);
|
||||
} catch { /* silent */ } finally { if (!cancelled && reqId === fetchSeqRef.current) setLoading(false); }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [paramsKey]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
const reqId = ++fetchSeqRef.current;
|
||||
try {
|
||||
setLoading(true);
|
||||
const [r, count] = await Promise.all([
|
||||
projectService.getActivityTypes(params),
|
||||
projectService.getActivityTypeCount(params.filters || {}),
|
||||
]);
|
||||
if (reqId !== fetchSeqRef.current) return;
|
||||
setActivityTypes(r.data);
|
||||
setTotalCount(count);
|
||||
} catch { /* silent */ } finally { if (reqId === fetchSeqRef.current) setLoading(false); }
|
||||
}, [paramsKey]);
|
||||
|
||||
return { activityTypes, loading, totalCount, refetch };
|
||||
};
|
||||
|
||||
export const useActivityTypeDetails = (name: string | null) => {
|
||||
const [activityType, setActivityType] = useState<ActivityType | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchActivityType = useCallback(async () => {
|
||||
if (!name) { setActivityType(null); return; }
|
||||
try {
|
||||
setLoading(true); setError(null);
|
||||
setActivityType(await projectService.getActivityType(name));
|
||||
} catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch activity type'); }
|
||||
finally { setLoading(false); }
|
||||
}, [name]);
|
||||
|
||||
useEffect(() => { fetchActivityType(); }, [fetchActivityType]);
|
||||
return { activityType, loading, error, refetch: fetchActivityType };
|
||||
};
|
||||
|
||||
// ─── Mutations ───────────────────────────────────────────────────────────────
|
||||
export const useProjectMutations = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const run = async <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Operation failed';
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
createProject: (data: Partial<Project>) => run(() => projectService.createProject(data)),
|
||||
updateProject: (name: string, data: Partial<Project>) => run(() => projectService.updateProject(name, data)),
|
||||
createTask: (data: Partial<Task>) => run(() => projectService.createTask(data)),
|
||||
updateTask: (name: string, data: Partial<Task>) => run(() => projectService.updateTask(name, data)),
|
||||
createTimesheet: (data: Partial<Timesheet>) => run(() => projectService.createTimesheet(data)),
|
||||
updateTimesheet: (name: string, data: Partial<Timesheet>) => run(() => projectService.updateTimesheet(name, data)),
|
||||
submitTimesheet: (name: string) => run(() => projectService.submitTimesheet(name)),
|
||||
cancelTimesheet: (name: string) => run(() => projectService.cancelTimesheet(name)),
|
||||
createProjectTemplate: (data: Partial<ProjectTemplate>) => run(() => projectService.createProjectTemplate(data)),
|
||||
updateProjectTemplate: (name: string, data: Partial<ProjectTemplate>) => run(() => projectService.updateProjectTemplate(name, data)),
|
||||
createActivityType: (data: Partial<ActivityType>) => run(() => projectService.createActivityType(data)),
|
||||
updateActivityType: (name: string, data: Partial<ActivityType>) => run(() => projectService.updateActivityType(name, data)),
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
@ -1,200 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import sfdaEntriesService, {
|
||||
type SfdaDeviceEntry,
|
||||
type SfdaEntry,
|
||||
type SfdaEntryListParams,
|
||||
} from '../services/sfdaEntriesService';
|
||||
import assetService from '../services/assetService';
|
||||
|
||||
interface UseSfdaEntryListResult {
|
||||
entries: SfdaEntry[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
totalCount: number;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
interface UseSfdaEntryDetailsResult {
|
||||
entry: SfdaEntry | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useSfdaEntryList(params: SfdaEntryListParams = {}): UseSfdaEntryListResult {
|
||||
const [entries, setEntries] = useState<SfdaEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
const paramsKey = JSON.stringify(params);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [listResponse, count] = await Promise.all([
|
||||
sfdaEntriesService.getEntries(params),
|
||||
sfdaEntriesService.getEntryCount(params.filters || {}),
|
||||
]);
|
||||
setEntries(listResponse.data);
|
||||
setTotalCount(count);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch SFDA entries';
|
||||
setError(errorMessage);
|
||||
console.error('Error fetching SFDA entries:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [paramsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return {
|
||||
entries,
|
||||
loading,
|
||||
error,
|
||||
totalCount,
|
||||
refetch: fetchData,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSfdaEntryDetails(name: string | null): UseSfdaEntryDetailsResult {
|
||||
const [entry, setEntry] = useState<SfdaEntry | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchEntry = useCallback(async () => {
|
||||
if (!name) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await sfdaEntriesService.getEntry(name);
|
||||
setEntry(data);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch SFDA entry';
|
||||
setError(errorMessage);
|
||||
console.error('Error fetching SFDA entry:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [name]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEntry();
|
||||
}, [fetchEntry]);
|
||||
|
||||
return {
|
||||
entry,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchEntry,
|
||||
};
|
||||
}
|
||||
|
||||
function matchingRank(value?: string): number {
|
||||
const normalized = (value || '').trim().toLowerCase();
|
||||
if (normalized === 'yes') return 0;
|
||||
if (normalized === 'no') return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function isMatchingYes(value?: string): boolean {
|
||||
return (value || '').trim().toLowerCase() === 'yes';
|
||||
}
|
||||
|
||||
function collectYesRowLookupValues(rows: SfdaDeviceEntry[]): string[] {
|
||||
const values = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (!isMatchingYes(row.serial_no_matching)) {
|
||||
continue;
|
||||
}
|
||||
for (const field of ['serial_no', 'catalog_number'] as const) {
|
||||
const raw = row[field];
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
for (const part of raw.split(',')) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed) {
|
||||
values.add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(values);
|
||||
}
|
||||
|
||||
function sortDeviceListByMatching(rows: SfdaDeviceEntry[]): SfdaDeviceEntry[] {
|
||||
return [...rows].sort((a, b) => {
|
||||
const rankDiff = matchingRank(a.serial_no_matching) - matchingRank(b.serial_no_matching);
|
||||
if (rankDiff !== 0) {
|
||||
return rankDiff;
|
||||
}
|
||||
return (a.idx ?? 0) - (b.idx ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
interface UseSfdaAssetMatchesResult {
|
||||
sortedDeviceList: SfdaDeviceEntry[];
|
||||
matchedSerialNumbers: Set<string>;
|
||||
matchesLoading: boolean;
|
||||
}
|
||||
|
||||
export function useSfdaAssetMatches(
|
||||
deviceList: SfdaDeviceEntry[] | undefined
|
||||
): UseSfdaAssetMatchesResult {
|
||||
const rows = deviceList || [];
|
||||
const sortedDeviceList = useMemo(() => sortDeviceListByMatching(rows), [rows]);
|
||||
const lookupValues = useMemo(() => collectYesRowLookupValues(rows), [rows]);
|
||||
const lookupKey = lookupValues.join('\0');
|
||||
|
||||
const [matchedSerialNumbers, setMatchedSerialNumbers] = useState<Set<string>>(new Set());
|
||||
const [matchesLoading, setMatchesLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (lookupValues.length === 0) {
|
||||
setMatchedSerialNumbers(new Set());
|
||||
setMatchesLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setMatchesLoading(true);
|
||||
|
||||
assetService
|
||||
.getExistingSerialNumbers(lookupValues)
|
||||
.then((matched) => {
|
||||
if (!cancelled) {
|
||||
setMatchedSerialNumbers(matched);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error resolving SFDA asset matches:', err);
|
||||
if (!cancelled) {
|
||||
setMatchedSerialNumbers(new Set());
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setMatchesLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [lookupKey]);
|
||||
|
||||
return {
|
||||
sortedDeviceList,
|
||||
matchedSerialNumbers,
|
||||
matchesLoading,
|
||||
};
|
||||
}
|
||||
@ -1,11 +1,10 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import workflowService, {
|
||||
type WorkflowTransition,
|
||||
import workflowService, {
|
||||
type WorkflowTransition,
|
||||
type WorkflowInfo,
|
||||
getWorkflowStateStyle,
|
||||
getActionButtonStyle,
|
||||
getActionIcon,
|
||||
hasWorkflowFullAccess,
|
||||
getActionIcon
|
||||
} from '../services/workflowService';
|
||||
|
||||
interface UseWorkflowOptions {
|
||||
@ -77,17 +76,19 @@ export const useWorkflow = ({
|
||||
|
||||
const fetchUserInfo = async () => {
|
||||
try {
|
||||
const [roles, user, isSysManager, fullAccess] = await Promise.all([
|
||||
const [roles, user, isSysManager] = await Promise.all([
|
||||
workflowService.getCurrentUserRoles(),
|
||||
workflowService.getCurrentUser(),
|
||||
workflowService.isSystemManager(),
|
||||
hasWorkflowFullAccess(),
|
||||
]);
|
||||
setUserRoles(roles);
|
||||
setCurrentUser(user);
|
||||
setIsSystemManagerUser(isSysManager || fullAccess);
|
||||
setIsSystemManagerUser(isSysManager);
|
||||
|
||||
if (fullAccess) setCanEdit(true);
|
||||
// System Manager can always edit
|
||||
if (isSysManager) {
|
||||
setCanEdit(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching user info:', err);
|
||||
}
|
||||
|
||||
@ -54,95 +54,18 @@
|
||||
"sidebar": {
|
||||
"title": "أصول سيرا",
|
||||
"loggedInAs": "تم تسجيل الدخول كـ:",
|
||||
"version": "أصول سيرا نظام إدارة الأصول الإصدار 2.26",
|
||||
"version": "أصول سيرا نظام إدارة الأصول الإصدار 1.0",
|
||||
"inventory": "المخزون",
|
||||
"ppmPlanner": "مخطط الصيانة الوقائية",
|
||||
"maintenanceCalendar": "تقويم الصيانة",
|
||||
"activeMap": "الخريطة النشطة",
|
||||
"maintenanceTeam": "فريق الصيانة",
|
||||
"procurement": "المشتريات",
|
||||
"projects": "إدارة المشاريع",
|
||||
"sla": "اتفاقية مستوى الخدمة",
|
||||
"support": "الدعم",
|
||||
"inspection": "التفتيش",
|
||||
"sfdaEntries": "يتذكر SFDA",
|
||||
"userProfile": "الملف الشخصي"
|
||||
},
|
||||
"projects": {
|
||||
"moduleTitle": "إدارة المشاريع",
|
||||
"hubSubtitle": "المشاريع والمهام وجداول الوقت والمستندات المرتبطة.",
|
||||
"reportsCardSub": "مخططات ولقطة محفظة المشاريع.",
|
||||
"title": "المشاريع",
|
||||
"listTotal": "الإجمالي: ",
|
||||
"listProject": "مشروع",
|
||||
"listProjects": "مشاريع",
|
||||
"projectsDoctype": "المشاريع",
|
||||
"tasksDoctype": "المهام",
|
||||
"timesheetDoctype": "جداول الوقت",
|
||||
"projectTemplateDoctype": "قوالب المشاريع",
|
||||
"activityTypeDoctype": "أنواع النشاط",
|
||||
"newProject": "مشروع جديد",
|
||||
"newTask": "مهمة جديدة",
|
||||
"newTimesheet": "جدول وقت جديد",
|
||||
"newProjectTemplate": "قالب مشروع جديد",
|
||||
"newActivityType": "نوع نشاط جديد",
|
||||
"searchPlaceholder": "البحث باسم المشروع…",
|
||||
"noProjects": "لا توجد مشاريع.",
|
||||
"noTasks": "لا توجد مهام.",
|
||||
"noTimesheets": "لا توجد جداول وقت.",
|
||||
"projectName": "اسم المشروع",
|
||||
"customer": "العميل",
|
||||
"expectedEnd": "تاريخ الانتهاء المتوقع",
|
||||
"progress": "التقدم",
|
||||
"project": "المشروع",
|
||||
"assignedTo": "معيّن إلى",
|
||||
"dueDate": "تاريخ الاستحقاق",
|
||||
"timesheetId": "جدول الوقت",
|
||||
"totalHours": "إجمالي الساعات",
|
||||
"taskColumn": "المهمة",
|
||||
"templateCreated": "تم حفظ القالب.",
|
||||
"templateUpdated": "تم تحديث القالب.",
|
||||
"timesheetCreated": "تم حفظ جدول الوقت.",
|
||||
"timesheetUpdated": "تم تحديث جدول الوقت.",
|
||||
"reportsDashboardTitle": "تقارير المشاريع",
|
||||
"reportsDashboardSubtitle": "مؤشرات ومخططات من بيانات المشروع والمهمة وجدول الوقت.",
|
||||
"projectOverviewSection": "نظرة عامة على المشروع",
|
||||
"projectOverviewBanner": "نظرة عامة على المشروع",
|
||||
"reportProjectFilter": "مشروع التقرير",
|
||||
"reportProjectFilterHint": "يؤثر الاختيار على المخططات والمؤشرات والمهام. تظهر لقطة المشروع وتحديثات المشروع فقط عند اختيار مشروع.",
|
||||
"selectProjectPlaceholder": "اختر مشروعًا…",
|
||||
"searchProjectPlaceholder": "بحث بالاسم أو الرقم…",
|
||||
"clearProjectSelection": "إلغاء الاختيار",
|
||||
"noMatchingOpenProjects": "لا توجد مشاريع مفتوحة مطابقة.",
|
||||
"projectPickerLimitNote": "عرض حتى 2000 مشروع مفتوح الأحدث تعديلًا. صفِّ البحث للعثور على مشروع.",
|
||||
"snapshotProjectBudget": "ميزانية المشروع",
|
||||
"snapshotOverallShort": "الإجمالي",
|
||||
"overviewProjectNameLabel": "اسم المشروع :",
|
||||
"overviewDepartmentLabel": "القسم:",
|
||||
"overviewDateLabel": "التاريخ:",
|
||||
"overviewProgressLabel": "التقدم:",
|
||||
"overviewCustomerLabel": "العميل:",
|
||||
"overviewDatesUpToday": "الأيام حتى اليوم",
|
||||
"overviewProjectDuration": "مدة المشروع",
|
||||
"overviewStartDateLabel": "تاريخ بدء المشروع :",
|
||||
"overviewEndDateLabel": "تاريخ انتهاء المشروع :",
|
||||
"projectNotesSection": "ملاحظات المشروع",
|
||||
"reportsCardLabel": "التقارير"
|
||||
},
|
||||
"profile": {
|
||||
"twoFactorTitle": "المصادقة الثنائية (تطبيق OTP)",
|
||||
"twoFactorSidebarTitle": "المصادقة الثنائية",
|
||||
"twoFactorOtpAppNoteShort": "تطبيق المصادقة مطلوب. أعد التعيين فقط عند الحاجة لرمز QR جديد.",
|
||||
"twoFactorLoading": "جاري تحميل إعدادات الأمان…",
|
||||
"twoFactorRequired": "المصادقة الثنائية مطلوبة لحسابك.",
|
||||
"twoFactorNotRequired": "المصادقة الثنائية غير مطلوبة لأدوارك.",
|
||||
"twoFactorDisabledSite": "المصادقة الثنائية غير مفعّلة على هذا الموقع.",
|
||||
"twoFactorOtpAppNote": "استخدم تطبيق مصادقة (Google Authenticator، Authy، إلخ). بعد إعادة التعيين، راجع بريدك عند أول تسجيل دخول لرابط إعداد QR.",
|
||||
"resetOtp": "إعادة تعيين المصادقة",
|
||||
"resetOtpConfirm": "إعادة تعيين سر OTP؟ ستحتاج لمسح رمز QR جديد عند تسجيل الدخول التالي.",
|
||||
"resetOtpSuccess": "تم إعادة تعيين المصادقة. راجع بريدك عند تسجيل الدخول التالي.",
|
||||
"resetOtpFailed": "تعذر إعادة تعيين المصادقة."
|
||||
},
|
||||
"login": {
|
||||
"title": "أصول سيرا",
|
||||
"subtitle": "نظام إدارة الأصول",
|
||||
@ -151,32 +74,7 @@
|
||||
"passwordPlaceholder": "أدخل كلمة المرور",
|
||||
"loginFailed": "فشل تسجيل الدخول. يرجى التحقق من بيانات الاعتماد الخاصة بك.",
|
||||
"demoLogin": "تسجيل دخول تجريبي",
|
||||
"or": "أو",
|
||||
"forgotPassword": "نسيت كلمة المرور؟",
|
||||
"forgotPasswordTitle": "نسيت كلمة المرور؟",
|
||||
"forgotPasswordHint": "أدخل البريد الإلكتروني أو اسم المستخدم الذي تستخدمه لتسجيل الدخول. سنرسل تعليمات إعادة التعيين إلى بريدك المسجل.",
|
||||
"forgotPasswordUserRequired": "يرجى إدخال البريد أو اسم المستخدم.",
|
||||
"forgotPasswordUserPlaceholder": "البريد أو اسم المستخدم",
|
||||
"forgotPasswordSubmit": "إرسال رابط إعادة التعيين",
|
||||
"forgotPasswordClose": "إلغاء",
|
||||
"forgotPasswordSentSuccess": "تم إرسال تعليمات إعادة تعيين كلمة المرور إلى بريدك المسجل. يرجى التحقق من صندوق الوارد.",
|
||||
"forgotPasswordNotFound": "لم يتم العثور على حساب مستخدم بالبريد أو اسم المستخدم المقدم.",
|
||||
"forgotPasswordTimeout": "استغرق الطلب وقتًا طويلاً. حاول مرة أخرى أو تواصل مع المسؤول إذا لم يكن البريد الصادر مُعدًا.",
|
||||
"forgotPasswordCannotReset": "إعادة تعيين كلمة المرور غير متاحة لهذا الحساب.",
|
||||
"forgotPasswordFailed": "تعذر إرسال بريد إعادة التعيين. حاول مرة أخرى لاحقًا.",
|
||||
"finishingSignOut": "جاري إنهاء تسجيل الخروج…",
|
||||
"afterPasswordResetSignIn": "تم تحديث كلمة المرور. يرجى تسجيل الدخول بكلمة المرور الجديدة.",
|
||||
"twoFactorTitle": "التحقق بخطوتين",
|
||||
"twoFactorCodeLabel": "رمز التحقق",
|
||||
"twoFactorCodePlaceholder": "000000",
|
||||
"twoFactorVerify": "تحقق",
|
||||
"twoFactorBackToLogin": "العودة لتسجيل الدخول",
|
||||
"twoFactorOtpAppEnter": "أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.",
|
||||
"twoFactorOtpAppSetupIncomplete": "إعداد المصادقة غير مكتمل. راجع بريدك لرابط رمز QR أو تواصل مع المسؤول.",
|
||||
"twoFactorEmailQrHint": "افتح رابط الإعداد في بريدك على هذا الجهاز لمسح رمز QR، ثم عد هنا لإدخال الرمز.",
|
||||
"twoFactorCodeRequired": "يرجى إدخال رمز التحقق.",
|
||||
"twoFactorInvalid": "رمز التحقق غير صحيح. حاول مرة أخرى.",
|
||||
"twoFactorSessionExpired": "انتهت جلسة التحقق. يرجى تسجيل الدخول مرة أخرى."
|
||||
"or": "أو"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "لوحة التحكم",
|
||||
@ -532,7 +430,6 @@
|
||||
"department": "القسم",
|
||||
"roomNumber": "رقم الغرفة",
|
||||
"location": "الموقع",
|
||||
"recalled": "مسترجع",
|
||||
"selectStatus": "اختر الحالة",
|
||||
"operational": "يعمل",
|
||||
"underMaintenance": "قيد الصيانة",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,232 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaArrowLeft, FaSave, FaEdit, FaTimes, FaCheckCircle, FaTimesCircle, FaSpinner } from 'react-icons/fa';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useActivityTypeDetails, useProjectMutations } from '../hooks/useProject';
|
||||
import type { ActivityType } from '../services/projectService';
|
||||
import ActivityLog from '../components/ActivityLog';
|
||||
|
||||
// Frappe Activity Type:
|
||||
// autoname = "field:activity_type" → name column = activity_type value
|
||||
// Fields: activity_type (Data, mandatory), billing_rate (Currency), costing_rate (Currency), disabled (Check)
|
||||
|
||||
const ActivityTypeDetail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { activityTypeName } = useParams<{ activityTypeName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isNew = activityTypeName === 'new';
|
||||
|
||||
const { activityType, loading, error, refetch } = useActivityTypeDetails(isNew ? null : (activityTypeName || null));
|
||||
const { createActivityType, updateActivityType, loading: saving } = useProjectMutations();
|
||||
const [isEditing, setIsEditing] = useState(isNew);
|
||||
|
||||
const [form, setForm] = useState<Partial<ActivityType>>({
|
||||
activity_type: '',
|
||||
billing_rate: undefined,
|
||||
costing_rate: undefined,
|
||||
disabled: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (activityType && !isNew) {
|
||||
setForm({
|
||||
activity_type: activityType.activity_type || activityType.name || '',
|
||||
billing_rate: activityType.billing_rate,
|
||||
costing_rate: activityType.costing_rate,
|
||||
disabled: activityType.disabled ?? 0,
|
||||
});
|
||||
}
|
||||
}, [activityType, isNew]);
|
||||
|
||||
const set = (k: keyof ActivityType, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.activity_type?.trim()) { toast.error('Activity Type name is required'); return; }
|
||||
try {
|
||||
if (isNew) {
|
||||
// Frappe uses autoname = "field:activity_type" — send activity_type field
|
||||
// Frappe auto-sets name = activity_type value
|
||||
const payload: Partial<ActivityType> = {
|
||||
activity_type: form.activity_type!.trim(),
|
||||
...(form.billing_rate !== undefined ? { billing_rate: form.billing_rate } : {}),
|
||||
...(form.costing_rate !== undefined ? { costing_rate: form.costing_rate } : {}),
|
||||
disabled: form.disabled ?? 0,
|
||||
};
|
||||
const created = await createActivityType(payload);
|
||||
toast.success('Activity Type created', { icon: <FaCheckCircle /> });
|
||||
navigate(`/projects/activity-types/${encodeURIComponent(created.name)}`);
|
||||
} else {
|
||||
// For update, only send mutable fields (not activity_type/name which can't change)
|
||||
const payload: Partial<ActivityType> = {
|
||||
...(form.billing_rate !== undefined ? { billing_rate: form.billing_rate } : {}),
|
||||
...(form.costing_rate !== undefined ? { costing_rate: form.costing_rate } : {}),
|
||||
disabled: form.disabled ?? 0,
|
||||
};
|
||||
await updateActivityType(activityTypeName!, payload);
|
||||
toast.success('Activity Type updated', { icon: <FaCheckCircle /> });
|
||||
setIsEditing(false);
|
||||
refetch();
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'), { icon: <FaTimesCircle /> });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
if (activityType) {
|
||||
setForm({
|
||||
activity_type: activityType.activity_type || activityType.name || '',
|
||||
billing_rate: activityType.billing_rate,
|
||||
costing_rate: activityType.costing_rate,
|
||||
disabled: activityType.disabled ?? 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const inputCls = (ed: boolean) =>
|
||||
`w-full px-3 py-2 text-sm border rounded-lg ${ed
|
||||
? 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-teal-400'
|
||||
: 'border-transparent bg-gray-50 dark:bg-gray-800 text-gray-800 dark:text-gray-200 cursor-default'}`;
|
||||
|
||||
const editable = isNew || isEditing;
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center min-h-[400px]"><FaSpinner className="animate-spin text-teal-500 text-3xl" /></div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-6 py-6">
|
||||
<ToastContainer position="top-right" autoClose={3500} />
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm mb-6">
|
||||
<button onClick={() => navigate('/projects')} className="text-gray-500 hover:text-indigo-600 dark:text-gray-400">{t('projects.moduleTitle')}</button>
|
||||
<span className="text-gray-400">/</span>
|
||||
<button onClick={() => navigate('/projects/activity-types')} className="text-gray-500 hover:text-teal-600 dark:text-gray-400">{t('projects.activityTypeDoctype')}</button>
|
||||
<span className="text-gray-400">/</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">{isNew ? t('projects.newActivityType') : (activityType?.activity_type || activityType?.name || activityTypeName)}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate('/projects/activity-types')} className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"><FaArrowLeft /></button>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{isNew ? t('projects.newActivityType') : (form.activity_type || activityTypeName)}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!isNew && !isEditing && (
|
||||
<button onClick={() => setIsEditing(true)} className="flex items-center gap-2 px-4 py-2 border border-teal-500 text-teal-600 dark:text-teal-400 rounded-lg hover:bg-teal-50 dark:hover:bg-teal-900/20 text-sm">
|
||||
<FaEdit /> {t('common.edit')}
|
||||
</button>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50 text-sm">
|
||||
{saving ? <FaSpinner className="animate-spin" /> : <FaSave />}
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
{!isNew && (
|
||||
<button onClick={handleCancel} className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-600 dark:text-gray-400 text-sm"><FaTimes /></button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && !isNew && (
|
||||
<div className="mx-6 mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded text-red-700 dark:text-red-300 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Activity Type name → maps to `activity_type` field in Frappe */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||
Activity Type *
|
||||
{isNew && <span className="ml-1 text-gray-400 font-normal normal-case text-xs">(becomes the record name)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.activity_type || ''}
|
||||
onChange={e => set('activity_type', e.target.value)}
|
||||
disabled={!isNew}
|
||||
className={inputCls(isNew)}
|
||||
placeholder="e.g. Design, Development, Testing..."
|
||||
/>
|
||||
{!isNew && <p className="text-xs text-gray-400 mt-1">Activity Type name cannot be changed after creation.</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Billing Rate — standard Frappe field is `billing_rate` */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Billing Rate</label>
|
||||
<input
|
||||
type="number" min={0} step={0.01}
|
||||
value={form.billing_rate ?? ''}
|
||||
onChange={e => set('billing_rate', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
disabled={!editable}
|
||||
className={inputCls(editable)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Costing Rate — standard Frappe field is `costing_rate` */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Costing Rate</label>
|
||||
<input
|
||||
type="number" min={0} step={0.01}
|
||||
value={form.costing_rate ?? ''}
|
||||
onChange={e => set('costing_rate', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
disabled={!editable}
|
||||
className={inputCls(editable)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disabled */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.disabled}
|
||||
onChange={e => set('disabled', e.target.checked ? 1 : 0)}
|
||||
disabled={!editable}
|
||||
className="w-4 h-4 text-red-500 rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Disabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
{!isNew && activityType && (
|
||||
<div className="pt-4 border-t border-gray-100 dark:border-gray-700 grid grid-cols-2 gap-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div><span className="font-medium block">Created</span>{activityType.creation ? new Date(activityType.creation).toLocaleString() : '-'}</div>
|
||||
<div><span className="font-medium block">Modified</span>{activityType.modified ? new Date(activityType.modified).toLocaleString() : '-'}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNew && (
|
||||
<div className="pt-4">
|
||||
<ActivityLog
|
||||
doctype="Activity Type"
|
||||
docname={activityType?.name || activityTypeName || ''}
|
||||
creationDate={activityType?.creation}
|
||||
createdBy={activityType?.owner}
|
||||
compact={false}
|
||||
initialVisible={5}
|
||||
collapsible
|
||||
startCollapsed
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityTypeDetail;
|
||||
@ -1,230 +0,0 @@
|
||||
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaPlus, FaSearch, FaSync, FaTags, FaEye, FaFileExport } from 'react-icons/fa';
|
||||
import { useActivityTypeList } from '../hooks/useProject';
|
||||
import DynamicExportModal from '../components/DynamicExportModal';
|
||||
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const ActivityTypeList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
const apiFilters = useMemo(() => {
|
||||
const f: Record<string, unknown> = {};
|
||||
if (search.trim()) f.activity_type = ['like', `%${search.trim()}%`];
|
||||
return f;
|
||||
}, [search]);
|
||||
|
||||
const { activityTypes, loading, totalCount, refetch } = useActivityTypeList({
|
||||
filters: apiFilters,
|
||||
limit_start: page * PAGE_SIZE,
|
||||
limit_page_length: PAGE_SIZE,
|
||||
order_by: 'name asc',
|
||||
});
|
||||
|
||||
// Ensure pagination always triggers data reload (some environments cache identical queries).
|
||||
useEffect(() => { refetch(); }, [page, apiFilters, refetch]);
|
||||
|
||||
const selectionResetKey = useMemo(() => `${page}|${JSON.stringify(apiFilters)}`, [page, apiFilters]);
|
||||
const {
|
||||
selectedRows,
|
||||
toggleRow,
|
||||
toggleAllOnPage,
|
||||
allOnPageSelected,
|
||||
someOnPageSelected,
|
||||
} = useListPageSelection(activityTypes, selectionResetKey);
|
||||
|
||||
const fetchAllForExport = useCallback(
|
||||
() => fetchAllRowsForExport({ doctype: 'Activity Type', filters: apiFilters, orderBy: 'name asc' }),
|
||||
[apiFilters],
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects')}
|
||||
className="text-sm text-gray-500 hover:text-teal-600 dark:text-gray-400 dark:hover:text-teal-400"
|
||||
>
|
||||
{t('projects.moduleTitle')}
|
||||
</button>
|
||||
<span className="text-gray-400">/</span>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<FaTags className="text-teal-500" /> {t('projects.activityTypeDoctype')}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||
disabled={totalCount === 0 && selectedRows.size === 0}
|
||||
>
|
||||
<FaFileExport /> {t('listPages.export')}
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="p-2 text-gray-500 border border-gray-200 dark:border-gray-600 rounded-lg hover:text-teal-600"
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<FaSync size={14} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects/activity-types/new')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm font-medium"
|
||||
>
|
||||
<FaPlus size={12} /> New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-3 items-center">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<FaSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
placeholder="Search activity type…"
|
||||
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{totalCount} total</span>
|
||||
</div>
|
||||
|
||||
<DynamicExportModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
doctype="Activity Type"
|
||||
selectedCount={selectedRows.size}
|
||||
pageCount={activityTypes.length}
|
||||
totalCount={totalCount}
|
||||
pageData={activityTypes}
|
||||
selectedRows={selectedRows}
|
||||
rowKey="name"
|
||||
onFetchAll={fetchAllForExport}
|
||||
fileNamePrefix="activity_types"
|
||||
/>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||
<th className="w-10 px-2 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-teal-600 focus:ring-teal-500"
|
||||
checked={allOnPageSelected}
|
||||
ref={el => {
|
||||
if (el) el.indeterminate = someOnPageSelected;
|
||||
}}
|
||||
onChange={toggleAllOnPage}
|
||||
aria-label="Select all on page"
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Name</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Billing rate</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Costing rate</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Status</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-3 px-4 w-24"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-10 text-gray-400">Loading…</td>
|
||||
</tr>
|
||||
) : activityTypes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-10 text-gray-400">No activity types found</td>
|
||||
</tr>
|
||||
) : (
|
||||
activityTypes.map((row) => (
|
||||
<tr
|
||||
key={row.name}
|
||||
className={`hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer ${selectedRows.has(row.name) ? 'bg-teal-50/80 dark:bg-teal-900/20' : ''}`}
|
||||
onClick={() => navigate(`/projects/activity-types/${encodeURIComponent(row.name)}`)}
|
||||
>
|
||||
<td className="w-10 px-2 py-3" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-teal-600 focus:ring-teal-500"
|
||||
checked={selectedRows.has(row.name)}
|
||||
onChange={() => toggleRow(row.name)}
|
||||
aria-label={`Select ${row.name}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-medium text-teal-600">{row.activity_type || row.name}</td>
|
||||
<td className="py-3 px-4 text-gray-600 dark:text-gray-300">{row.billing_rate ?? '—'}</td>
|
||||
<td className="py-3 px-4 text-gray-600 dark:text-gray-300">{row.costing_rate ?? '—'}</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${row.disabled ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}>
|
||||
{row.disabled ? 'Disabled' : 'Active'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/projects/activity-types/${encodeURIComponent(row.name)}`);
|
||||
}}
|
||||
className="text-teal-600 hover:text-teal-800 p-1"
|
||||
aria-label="View"
|
||||
>
|
||||
<FaEye />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalCount > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-xs text-gray-500">
|
||||
Page {page + 1} of {totalPages}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
className="px-3 py-1 text-xs border rounded disabled:opacity-40"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={(page + 1) * PAGE_SIZE >= totalCount}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="px-3 py-1 text-xs border rounded disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityTypeList;
|
||||
@ -75,7 +75,6 @@ const AssetDetail: React.FC = () => {
|
||||
custom_total_spare_parts_amount: 0,
|
||||
custom_building: '', // Add if missing
|
||||
custom_room_number: '',
|
||||
custom_recalled: '',
|
||||
is_existing_asset: true,
|
||||
__islocal: false,
|
||||
});
|
||||
@ -739,7 +738,6 @@ const handleRoomNoChangeWithLocation = useCallback(async (roomNo: string) => {
|
||||
custom_end_date: (asset as any).custom_end_date ? (asset as any).custom_end_date.split(' ')[0] : '',
|
||||
custom_building: (asset as any).custom_building || '',
|
||||
custom_room_number: (asset as any).custom_room_number || '',
|
||||
custom_recalled: (asset as any).custom_recalled || '',
|
||||
// Checkbox fields
|
||||
custom_warranty: (asset as any).custom_warranty || false,
|
||||
custom_extended_warranty: (asset as any).custom_extended_warranty || false,
|
||||
@ -3266,25 +3264,6 @@ const handlePPMPlan = async () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recalled - read-only, visible only when set to Yes or No */}
|
||||
{formData.custom_recalled?.trim() && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('assets.detail.recalled')}
|
||||
</label>
|
||||
<select
|
||||
name="custom_recalled"
|
||||
value={formData.custom_recalled}
|
||||
disabled
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white cursor-not-allowed"
|
||||
>
|
||||
<option value=" ">{' '}</option>
|
||||
<option value="Yes">{t('common.yes')}</option>
|
||||
<option value="No">{t('common.no')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -188,21 +188,16 @@ const AssetList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const setSearchParamsRef = useRef(setSearchParams);
|
||||
const page = useMemo(() => {
|
||||
const p = parseInt(searchParams.get('page') || '1', 10);
|
||||
return Math.max(0, Number.isNaN(p) ? 0 : p - 1);
|
||||
}, [searchParams]);
|
||||
const setPage = useCallback((zeroBasedPage: number) => {
|
||||
setSearchParamsRef.current((prev) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set('page', String(zeroBasedPage + 1));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParamsRef.current = setSearchParams;
|
||||
}, [setSearchParams]);
|
||||
const [searchTerm, setSearchTerm] = useState(() => searchParams.get('search') || '');
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
|
||||
@ -431,7 +426,7 @@ const AssetList: React.FC = () => {
|
||||
filtersChangedOnce.current = true;
|
||||
return;
|
||||
}
|
||||
setSearchParamsRef.current((prev) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by');
|
||||
if (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
|
||||
@ -1138,7 +1133,7 @@ const AssetList: React.FC = () => {
|
||||
setSearchTerm('');
|
||||
if (assetNameDebounceRef.current) clearTimeout(assetNameDebounceRef.current);
|
||||
if (serialNumberDebounceRef.current) clearTimeout(serialNumberDebounceRef.current);
|
||||
setSearchParamsRef.current((prev) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end');
|
||||
next.delete('sort_by');
|
||||
|
||||
@ -1,270 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
FaArrowLeft, FaSave, FaEdit, FaTimes,
|
||||
FaCheckCircle, FaTimesCircle, FaSpinner, FaUserFriends,
|
||||
FaChevronDown, FaChevronRight,
|
||||
} from 'react-icons/fa';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import masterService, { Customer } from '../services/masterService';
|
||||
import LinkField from '../components/LinkField';
|
||||
import ActivityLog from '../components/ActivityLog';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
const FL: React.FC<{ children: React.ReactNode; required?: boolean }> = ({ children, required }) => (
|
||||
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||
{children}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
);
|
||||
const RV: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
||||
<div className="px-3 py-2 text-sm text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-800/60 rounded-lg min-h-[38px] flex items-center">
|
||||
{children || <span className="text-gray-400">-</span>}
|
||||
</div>
|
||||
);
|
||||
const inputCls = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-cyan-400';
|
||||
|
||||
const CollapsibleSection: React.FC<{ title: string; children: React.ReactNode; defaultOpen?: boolean }> = ({ title, children, defaultOpen = true }) => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||
<button type="button" onClick={() => setOpen(o => !o)}
|
||||
className="w-full flex items-center justify-between px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">{title}</span>
|
||||
{open ? <FaChevronDown className="text-gray-400 text-xs" /> : <FaChevronRight className="text-gray-400 text-xs" />}
|
||||
</button>
|
||||
{open && <div className="px-6 pb-5">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CUSTOMER_TYPES = ['Company', 'Individual', 'Hospital'];
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
const CustomerDetail: React.FC = () => {
|
||||
const { customerName } = useParams<{ customerName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isNew = customerName === 'new';
|
||||
|
||||
const [customer, setCustomer] = useState<Customer | null>(null);
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(isNew);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const emptyForm: Partial<Customer> = {
|
||||
customer_name: '', customer_type: 'Company', customer_group: 'All Customer Groups',
|
||||
territory: 'All Territories', language: 'en',
|
||||
is_internal_customer: 0, default_commission_rate: 0,
|
||||
so_required: 0, dn_required: 0, is_frozen: 0, disabled: 0,
|
||||
};
|
||||
const [form, setForm] = useState<Partial<Customer>>(emptyForm);
|
||||
|
||||
const syncForm = useCallback((c: Customer) => {
|
||||
setForm({
|
||||
customer_name: c.customer_name || '',
|
||||
customer_type: c.customer_type || 'Company',
|
||||
customer_group: c.customer_group || '',
|
||||
territory: c.territory || '',
|
||||
language: c.language || 'en',
|
||||
is_internal_customer: c.is_internal_customer ?? 0,
|
||||
default_commission_rate: c.default_commission_rate ?? 0,
|
||||
so_required: c.so_required ?? 0,
|
||||
dn_required: c.dn_required ?? 0,
|
||||
is_frozen: c.is_frozen ?? 0,
|
||||
disabled: c.disabled ?? 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNew && customerName) {
|
||||
setLoading(true);
|
||||
masterService.getCustomer(customerName)
|
||||
.then(c => { setCustomer(c); syncForm(c); })
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [customerName, isNew, syncForm]);
|
||||
|
||||
const set = (k: keyof Customer, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.customer_name?.trim()) { toast.error('Customer Name is required'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isNew) {
|
||||
const created = await masterService.createCustomer(form);
|
||||
toast.success('Customer created', { icon: <FaCheckCircle /> });
|
||||
navigate(`/customers/${encodeURIComponent(created.name)}`);
|
||||
} else {
|
||||
const updated = await masterService.updateCustomer(customerName!, form);
|
||||
toast.success('Customer updated', { icon: <FaCheckCircle /> });
|
||||
setCustomer(updated); syncForm(updated); setIsEditing(false);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Save failed', { icon: <FaTimesCircle /> });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => { if (customer) syncForm(customer); setIsEditing(false); };
|
||||
const editable = isNew || isEditing;
|
||||
|
||||
if (loading) return <div className="flex justify-center items-center min-h-[400px]"><FaSpinner className="animate-spin text-cyan-500 text-3xl" /></div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-6 py-6">
|
||||
<ToastContainer position="top-right" autoClose={3500} />
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm mb-6 text-gray-500 dark:text-gray-400">
|
||||
<button onClick={() => navigate('/projects')} className="hover:text-cyan-600">Project Management</button>
|
||||
<span>/</span>
|
||||
<button onClick={() => navigate('/customers')} className="hover:text-cyan-600">Customers</button>
|
||||
<span>/</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">{isNew ? 'New Customer' : customerName}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate('/customers')} className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"><FaArrowLeft /></button>
|
||||
<div className="w-10 h-10 rounded-xl bg-cyan-600 flex items-center justify-center flex-shrink-0">
|
||||
<FaUserFriends className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{isNew ? 'New Customer' : (customer?.customer_name || customerName)}
|
||||
</h1>
|
||||
{!isNew && customer && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded font-medium ${customer.disabled ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}>
|
||||
{customer.disabled ? 'Disabled' : 'Active'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!isNew && !isEditing && (
|
||||
<button onClick={() => setIsEditing(true)} className="flex items-center gap-2 px-4 py-2 bg-cyan-600 text-white rounded-lg hover:bg-cyan-700 text-sm">
|
||||
<FaEdit /> Edit
|
||||
</button>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-cyan-600 text-white rounded-lg hover:bg-cyan-700 disabled:opacity-50 text-sm font-medium">
|
||||
{saving ? <FaSpinner className="animate-spin" /> : <FaSave />}
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{!isNew && <button onClick={handleCancel} className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-600 dark:text-gray-400 text-sm"><FaTimes /></button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="mx-6 mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded text-red-700 dark:text-red-300 text-sm">{error}</div>}
|
||||
|
||||
{/* ── Main fields ── */}
|
||||
<div className="p-6 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4 border-b border-gray-100 dark:border-gray-700">
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<FL required>Customer Name</FL>
|
||||
{editable
|
||||
? <input value={form.customer_name || ''} onChange={e => set('customer_name', e.target.value)} className={inputCls} placeholder="Enter customer name" />
|
||||
: <RV>{form.customer_name}</RV>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FL>Customer Type</FL>
|
||||
{editable
|
||||
? <select value={form.customer_type || 'Company'} onChange={e => set('customer_type', e.target.value)} className={inputCls}>
|
||||
{CUSTOMER_TYPES.map(t => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
: <RV>{form.customer_type}</RV>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FL>Territory</FL>
|
||||
{editable
|
||||
? <LinkField label="Territory" hideLabel doctype="Territory" value={form.territory || ''} onChange={v => set('territory', v)} placeholder="Select territory…" />
|
||||
: <RV>{form.territory}</RV>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FL>Customer Group</FL>
|
||||
{editable
|
||||
? <LinkField label="Customer Group" hideLabel doctype="Customer Group" value={form.customer_group || ''} onChange={v => set('customer_group', v)} placeholder="Select group…" />
|
||||
: <RV>{form.customer_group}</RV>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FL>Language</FL>
|
||||
{editable
|
||||
? <LinkField label="Language" hideLabel doctype="Language" value={form.language || ''} onChange={v => set('language', v)} placeholder="Select language…" />
|
||||
: <RV>{form.language}</RV>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Settings / Defaults ── */}
|
||||
<CollapsibleSection title="Settings" defaultOpen={false}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
|
||||
<div>
|
||||
<FL>Default Commission Rate (%)</FL>
|
||||
{editable
|
||||
? <input type="number" min={0} max={100} step={0.01} value={form.default_commission_rate ?? 0} onChange={e => set('default_commission_rate', parseFloat(e.target.value) || 0)} className={inputCls} />
|
||||
: <RV>{form.default_commission_rate ?? 0}</RV>}
|
||||
</div>
|
||||
|
||||
{[
|
||||
{ field: 'is_internal_customer', label: 'Is Internal Customer' },
|
||||
{ field: 'so_required', label: 'Sales Order Required' },
|
||||
{ field: 'dn_required', label: 'Delivery Note Required' },
|
||||
{ field: 'is_frozen', label: 'Is Frozen' },
|
||||
{ field: 'disabled', label: 'Disabled' },
|
||||
].map(({ field, label }) => (
|
||||
<div key={field} className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={field}
|
||||
checked={!!form[field as keyof Customer]}
|
||||
onChange={e => set(field as keyof Customer, e.target.checked ? 1 : 0)}
|
||||
disabled={!editable}
|
||||
className="w-4 h-4 text-cyan-600 rounded"
|
||||
/>
|
||||
<label htmlFor={field} className="text-sm text-gray-700 dark:text-gray-300">{label}</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* ── Meta ── */}
|
||||
{!isNew && customer && (
|
||||
<div className="px-6 py-4 border-t border-gray-100 dark:border-gray-700 grid grid-cols-2 sm:grid-cols-3 gap-3 text-xs text-gray-400 dark:text-gray-500">
|
||||
<div><span className="font-medium block text-gray-500 dark:text-gray-400">Created By</span>{customer.owner}</div>
|
||||
<div><span className="font-medium block text-gray-500 dark:text-gray-400">Created</span>{customer.creation ? new Date(customer.creation).toLocaleString() : '-'}</div>
|
||||
<div><span className="font-medium block text-gray-500 dark:text-gray-400">Modified</span>{customer.modified ? new Date(customer.modified).toLocaleString() : '-'}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNew && (
|
||||
<div className="px-6 pb-6">
|
||||
<ActivityLog
|
||||
doctype="Customer"
|
||||
docname={customer?.name || customerName || ''}
|
||||
creationDate={customer?.creation}
|
||||
createdBy={customer?.owner}
|
||||
compact={false}
|
||||
initialVisible={5}
|
||||
collapsible
|
||||
startCollapsed
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerDetail;
|
||||
@ -1,220 +0,0 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaPlus, FaSearch, FaSpinner, FaUserFriends, FaArrowLeft, FaFileExport } from 'react-icons/fa';
|
||||
import masterService, { Customer } from '../services/masterService';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import DynamicExportModal from '../components/DynamicExportModal';
|
||||
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||
|
||||
const statusBadge = (disabled?: number) =>
|
||||
disabled
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
||||
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300';
|
||||
|
||||
const CustomerList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const PAGE = 20;
|
||||
|
||||
const apiFilters = useMemo(() => {
|
||||
const f: Record<string, any> = {};
|
||||
if (search.trim()) f.customer_name = ['like', `%${search.trim()}%`];
|
||||
return f;
|
||||
}, [search]);
|
||||
|
||||
const load = useCallback(async (p = 0) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [{ data }, cnt] = await Promise.all([
|
||||
masterService.getCustomers({ limit_start: p * PAGE, limit_page_length: PAGE, filters: apiFilters }),
|
||||
masterService.getCustomerCount(apiFilters),
|
||||
]);
|
||||
setCustomers(data);
|
||||
setTotal(cnt);
|
||||
setPage(p);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to load customers');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiFilters]);
|
||||
|
||||
useEffect(() => { load(0); }, [load]);
|
||||
|
||||
const selectionResetKey = useMemo(() => `${page}|${JSON.stringify(apiFilters)}`, [page, apiFilters]);
|
||||
const {
|
||||
selectedRows,
|
||||
toggleRow,
|
||||
toggleAllOnPage,
|
||||
allOnPageSelected,
|
||||
someOnPageSelected,
|
||||
} = useListPageSelection(customers, selectionResetKey);
|
||||
|
||||
const fetchAllForExport = useCallback(
|
||||
() => fetchAllRowsForExport({ doctype: 'Customer', filters: apiFilters, orderBy: 'modified desc' }),
|
||||
[apiFilters],
|
||||
);
|
||||
|
||||
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ToastContainer position="top-right" autoClose={3000} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button onClick={() => navigate('/projects')} className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-300">
|
||||
<FaArrowLeft />
|
||||
</button>
|
||||
<div className="w-10 h-10 rounded-xl bg-cyan-600 flex items-center justify-center">
|
||||
<FaUserFriends className="text-white text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Customers</h1>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{total} total</p>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap gap-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||
disabled={(total === 0 && selectedRows.size === 0) || loading}
|
||||
>
|
||||
<FaFileExport /> {t('listPages.export')}
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="relative">
|
||||
<FaSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
||||
<input
|
||||
value={search} onChange={handleSearch}
|
||||
placeholder="Search customers…"
|
||||
className="pl-8 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white w-52 focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/customers/new')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-cyan-600 text-white rounded-lg hover:bg-cyan-700 text-sm font-medium"
|
||||
>
|
||||
<FaPlus size={11} /> New Customer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DynamicExportModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
doctype="Customer"
|
||||
selectedCount={selectedRows.size}
|
||||
pageCount={customers.length}
|
||||
totalCount={total}
|
||||
pageData={customers}
|
||||
selectedRows={selectedRows}
|
||||
rowKey="name"
|
||||
onFetchAll={fetchAllForExport}
|
||||
fileNamePrefix="customers"
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-16">
|
||||
<FaSpinner className="animate-spin text-cyan-500 text-2xl" />
|
||||
</div>
|
||||
) : customers.length === 0 ? (
|
||||
<div className="py-16 text-center text-gray-400 dark:text-gray-500">
|
||||
<FaUserFriends className="mx-auto text-4xl mb-3 opacity-30" />
|
||||
<p className="text-sm">No customers found</p>
|
||||
<button onClick={() => navigate('/customers/new')} className="mt-3 text-sm text-cyan-600 hover:underline">+ Create first customer</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header row */}
|
||||
<div className="grid grid-cols-[2.5rem_1fr_1fr_1fr_1fr_100px] bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
<div className="px-2 py-3 flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-cyan-600 focus:ring-cyan-500"
|
||||
checked={allOnPageSelected}
|
||||
ref={el => {
|
||||
if (el) el.indeterminate = someOnPageSelected;
|
||||
}}
|
||||
onChange={toggleAllOnPage}
|
||||
aria-label="Select all on page"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 py-3">Customer Name</div>
|
||||
<div className="px-4 py-3">Type</div>
|
||||
<div className="px-4 py-3">Customer Group</div>
|
||||
<div className="px-4 py-3">Territory</div>
|
||||
<div className="px-4 py-3">Status</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{customers.map(c => (
|
||||
<div
|
||||
key={c.name}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/customers/${encodeURIComponent(c.name)}`)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
navigate(`/customers/${encodeURIComponent(c.name)}`);
|
||||
}
|
||||
}}
|
||||
className={`grid grid-cols-[2.5rem_1fr_1fr_1fr_1fr_100px] w-full text-left hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors cursor-pointer ${selectedRows.has(c.name) ? 'bg-cyan-50/70 dark:bg-cyan-900/15' : ''}`}
|
||||
>
|
||||
<div className="px-2 py-3 flex items-center justify-center" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-cyan-600 focus:ring-cyan-500"
|
||||
checked={selectedRows.has(c.name)}
|
||||
onChange={() => toggleRow(c.name)}
|
||||
aria-label={`Select ${c.name}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-sm font-medium text-indigo-600 dark:text-indigo-400">{c.customer_name || c.name}</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">{c.name}</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{c.customer_type || '-'}</div>
|
||||
<div className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{c.customer_group || '-'}</div>
|
||||
<div className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{c.territory || '-'}</div>
|
||||
<div className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusBadge(c.disabled)}`}>
|
||||
{c.disabled ? 'Disabled' : 'Active'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>Page {page + 1}</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => load(page - 1)} disabled={page === 0} className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700">Prev</button>
|
||||
<button onClick={() => load(page + 1)} disabled={customers.length < PAGE} className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerList;
|
||||
@ -1,818 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
FaArrowLeft, FaSave, FaEdit, FaTimes, FaPlus, FaTrash,
|
||||
FaSpinner, FaTruck, FaPaperPlane, FaFileInvoiceDollar,
|
||||
FaChevronDown, FaChevronRight, FaPencilAlt,
|
||||
} from 'react-icons/fa';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import deliveryNoteService, { DeliveryNote, DeliveryNoteItem } from '../services/deliveryNoteService';
|
||||
import salesOrderService from '../services/salesOrderService';
|
||||
import LinkField from '../components/LinkField';
|
||||
import ActivityLog from '../components/ActivityLog';
|
||||
import { formatFrappeApiError } from '../utils/frappeErrorMessage';
|
||||
import {
|
||||
DEFAULT_COMPANY, DEFAULT_CURRENCY, DEFAULT_SALES_TAXES_TEMPLATE,
|
||||
taxRatePercent, displayTxnCurrency,
|
||||
} from '../constants/orgDefaults';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
const FL: React.FC<{ children: React.ReactNode; required?: boolean }> = ({ children, required }) => (
|
||||
<label className="block text-[11px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||
{children}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
);
|
||||
const RV: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
||||
<div className="px-3 py-2 text-sm text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-800/60 rounded min-h-[34px] flex items-center">
|
||||
{children || <span className="text-gray-400">-</span>}
|
||||
</div>
|
||||
);
|
||||
const inputCls = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-teal-400';
|
||||
const inlineTxt = 'w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-teal-400';
|
||||
const inlineNum = 'w-full px-2 py-1 text-sm text-right border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-teal-400';
|
||||
const editorInput = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-teal-400';
|
||||
const editorNum = 'w-full px-3 py-2 text-sm text-right border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-teal-400';
|
||||
const roField = 'w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-800/60 text-gray-600 dark:text-gray-400 rounded min-h-[34px] flex items-center';
|
||||
|
||||
// ── Collapsible group ─────────────────────────────────────────────────────────
|
||||
const RGroup: React.FC<{ label: string; children: React.ReactNode; defaultOpen?: boolean }> = ({ label, children, defaultOpen = false }) => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden mt-3">
|
||||
<button type="button" onClick={() => setOpen(o => !o)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-[11px] font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider bg-gray-50 dark:bg-gray-800/80 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors text-left">
|
||||
{open ? <FaChevronDown size={9} /> : <FaChevronRight size={9} />}{label}
|
||||
</button>
|
||||
{open && <div className="p-3 grid grid-cols-1 sm:grid-cols-2 gap-3 bg-white dark:bg-gray-800">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Create Dropdown ───────────────────────────────────────────────────────────
|
||||
const CreateDropdown: React.FC<{ items: { label: string; icon: React.ReactNode; onClick: () => void }[] }> = ({ items }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
document.addEventListener('mousedown', h);
|
||||
return () => document.removeEventListener('mousedown', h);
|
||||
}, []);
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button onClick={() => setOpen(o => !o)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm font-medium shadow-sm">
|
||||
Create <FaChevronDown size={10} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-1 w-52 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-50 py-1.5">
|
||||
<div className="px-3 py-1.5 text-[10px] font-bold text-gray-400 uppercase tracking-wider border-b border-gray-100 dark:border-gray-700 mb-1">Create from this note</div>
|
||||
{items.map(({ label, icon, onClick }) => (
|
||||
<button key={label} onClick={() => { onClick(); setOpen(false); }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-teal-50 dark:hover:bg-teal-900/20 hover:text-teal-700 transition-colors text-left">
|
||||
<span className="text-gray-400">{icon}</span>{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── DN Item Row Editor ────────────────────────────────────────────────────────
|
||||
interface TaxRow { charge_type?: string; account_head?: string; description?: string; included_in_print_rate?: number; rate?: number; tax_amount?: number; total?: number; cost_center?: string; account_currency?: string; idx?: number; [k: string]: any; }
|
||||
|
||||
const DNItemRowEditor: React.FC<{
|
||||
item: Partial<DeliveryNoteItem>;
|
||||
rowNo: number;
|
||||
currency: string;
|
||||
onChange: (k: string, v: any) => void;
|
||||
onClose: () => void;
|
||||
onDelete: () => void;
|
||||
onInsertBelow: () => void;
|
||||
}> = ({ item, rowNo, currency, onChange, onClose, onDelete, onInsertBelow }) => {
|
||||
const set = (k: string, v: any) => onChange(k, v);
|
||||
const cur = currency || DEFAULT_CURRENCY;
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={8} className="p-0">
|
||||
<div className="bg-blue-50/60 dark:bg-blue-900/10 border-b border-blue-200 dark:border-blue-800 px-4 py-3">
|
||||
{/* editor header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-bold text-teal-700 dark:text-teal-300 uppercase tracking-wider">Editing Row #{rowNo}</span>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={onInsertBelow} className="px-2 py-1 text-[11px] bg-teal-600 text-white rounded hover:bg-teal-700">Insert Below</button>
|
||||
<button onClick={onDelete} className="px-2 py-1 text-[11px] bg-red-500 text-white rounded hover:bg-red-600">Delete</button>
|
||||
<button onClick={onClose} className="px-2 py-1 text-[11px] bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300">ESC</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* top fields */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div><FL required>Item Code</FL><LinkField label="Item Code" hideLabel doctype="Item" value={item.item_code || ''} onChange={v => set('item_code', v)} /></div>
|
||||
<div><FL>Item Name</FL><input value={item.item_name || ''} onChange={e => set('item_name', e.target.value)} className={editorInput} /></div>
|
||||
</div>
|
||||
|
||||
<RGroup label="Description">
|
||||
<div className="sm:col-span-2">
|
||||
<FL>Description</FL>
|
||||
<textarea value={item.description || ''} onChange={e => set('description', e.target.value)} rows={2} className={editorInput + ' resize-none'} />
|
||||
</div>
|
||||
</RGroup>
|
||||
|
||||
<RGroup label="Quantity and Warehouse" defaultOpen>
|
||||
<div><FL required>Quantity</FL><input type="number" min={0} step="1" value={item.qty ?? 1} onChange={e => set('qty', parseFloat(e.target.value) || 0)} className={editorNum} /></div>
|
||||
<div><FL required>UOM</FL><LinkField label="UOM" hideLabel doctype="UOM" value={item.uom || ''} onChange={v => set('uom', v)} /></div>
|
||||
<div><FL>Stock UOM</FL><LinkField label="Stock UOM" hideLabel doctype="UOM" value={item.stock_uom || ''} onChange={v => set('stock_uom', v)} /></div>
|
||||
<div><FL>UOM Conversion Factor</FL><input type="number" min={0} step="0.0001" value={item.conversion_factor ?? 1} onChange={e => set('conversion_factor', parseFloat(e.target.value) || 1)} className={editorNum} /></div>
|
||||
<div><FL>Stock Qty (auto)</FL><div className={roField}>{((item.qty || 1) * (item.conversion_factor || 1)).toFixed(3)}</div></div>
|
||||
</RGroup>
|
||||
|
||||
<RGroup label={`Discount and Margin (${cur})`} defaultOpen>
|
||||
<div><FL>Price List Rate ({cur})</FL><input type="number" min={0} step="0.01" value={item.price_list_rate ?? 0} onChange={e => set('price_list_rate', parseFloat(e.target.value) || 0)} className={editorNum} /></div>
|
||||
<div><FL>Discount %</FL><input type="number" min={0} max={100} step="0.01" value={item.discount_percentage ?? 0} onChange={e => set('discount_percentage', parseFloat(e.target.value) || 0)} className={editorNum} /></div>
|
||||
<div><FL>Discount Amount ({cur})</FL><input type="number" min={0} step="0.01" value={item.discount_amount ?? 0} onChange={e => set('discount_amount', parseFloat(e.target.value) || 0)} className={editorNum} /></div>
|
||||
<div><FL>Rate ({cur})</FL><input type="number" min={0} step="0.01" value={item.rate ?? 0} onChange={e => set('rate', parseFloat(e.target.value) || 0)} className={editorNum} /></div>
|
||||
<div><FL>Amount ({cur}) (auto)</FL><div className={roField}>{((item.qty || 0) * (item.rate || 0)).toFixed(2)}</div></div>
|
||||
<div className="flex items-center gap-2 pt-4">
|
||||
<input type="checkbox" id={`fi-${rowNo}`} checked={!!item.is_free_item} onChange={e => set('is_free_item', e.target.checked ? 1 : 0)} className="w-4 h-4 text-teal-600 rounded" />
|
||||
<label htmlFor={`fi-${rowNo}`} className="text-sm text-gray-700 dark:text-gray-300">Is Free Item</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-4">
|
||||
<input type="checkbox" id={`gc-${rowNo}`} checked={!!item.grant_commission} onChange={e => set('grant_commission', e.target.checked ? 1 : 0)} className="w-4 h-4 text-teal-600 rounded" />
|
||||
<label htmlFor={`gc-${rowNo}`} className="text-sm text-gray-700 dark:text-gray-300">Grant Commission</label>
|
||||
</div>
|
||||
</RGroup>
|
||||
|
||||
<RGroup label="Warehouse and Reference">
|
||||
<div><FL>Warehouse</FL><LinkField label="Warehouse" hideLabel doctype="Warehouse" value={(item as any).warehouse || ''} onChange={v => set('warehouse', v)} /></div>
|
||||
<div><FL>Against Sales Order</FL><div className={roField}>{(item as any).against_sales_order || '-'}</div></div>
|
||||
</RGroup>
|
||||
|
||||
<RGroup label="Available Quantity">
|
||||
<div><FL>Actual Qty (Warehouse)</FL><div className={roField}>{(item as any).actual_qty ?? 0}</div></div>
|
||||
<div><FL>Company Total Stock</FL><div className={roField}>{(item as any).company_total_stock ?? 0}</div></div>
|
||||
</RGroup>
|
||||
|
||||
<RGroup label="Item Weight Details">
|
||||
<div><FL>Weight Per Unit</FL><input type="number" min={0} step="0.001" value={(item as any).weight_per_unit ?? 0} onChange={e => set('weight_per_unit', parseFloat(e.target.value) || 0)} className={editorNum} /></div>
|
||||
<div><FL>Total Weight (auto)</FL><div className={roField}>{(((item as any).weight_per_unit || 0) * (item.qty || 0)).toFixed(3)}</div></div>
|
||||
</RGroup>
|
||||
|
||||
<RGroup label="Accounting Details">
|
||||
<div className="sm:col-span-2"><FL>Expense Account</FL><LinkField label="Expense Account" hideLabel doctype="Account" value={(item as any).expense_account || ''} onChange={v => set('expense_account', v)} /></div>
|
||||
</RGroup>
|
||||
|
||||
<RGroup label="Accounting Dimensions">
|
||||
<div><FL>Cost Center</FL><LinkField label="Cost Center" hideLabel doctype="Cost Center" value={(item as any).cost_center || ''} onChange={v => set('cost_center', v)} /></div>
|
||||
<div><FL>Project</FL><LinkField label="Project" hideLabel doctype="Project" value={(item as any).project || ''} onChange={v => set('project', v)} /></div>
|
||||
</RGroup>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Tax Row Editor ────────────────────────────────────────────────────────────
|
||||
const DNTaxRowEditor: React.FC<{
|
||||
tax: TaxRow; rowNo: number;
|
||||
onChange: (k: string, v: any) => void;
|
||||
onClose: () => void; onDelete: () => void;
|
||||
}> = ({ tax, rowNo, onChange, onClose, onDelete }) => {
|
||||
const set = (k: string, v: any) => onChange(k, v);
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-0">
|
||||
<div className="bg-blue-50/60 dark:bg-blue-900/10 border-b border-blue-200 dark:border-blue-800 px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-bold text-teal-700 dark:text-teal-300 uppercase tracking-wider">Editing Tax Row #{rowNo}</span>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={onDelete} className="px-2 py-1 text-[11px] bg-red-500 text-white rounded hover:bg-red-600">Delete</button>
|
||||
<button onClick={onClose} className="px-2 py-1 text-[11px] bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300">ESC</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div><FL required>Type</FL>
|
||||
<select value={tax.charge_type || ''} onChange={e => set('charge_type', e.target.value)} className={editorInput}>
|
||||
<option value="">Select…</option>
|
||||
<option value="Actual">Actual</option>
|
||||
<option value="On Net Total">On Net Total</option>
|
||||
<option value="On Previous Row Amount">On Previous Row Amount</option>
|
||||
<option value="On Previous Row Total">On Previous Row Total</option>
|
||||
<option value="On Item Quantity">On Item Quantity</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><FL>Description</FL><input value={tax.description || ''} onChange={e => set('description', e.target.value)} className={editorInput} /></div>
|
||||
<div><FL required>Account Head</FL><LinkField label="Account Head" hideLabel doctype="Account" value={tax.account_head || ''} onChange={v => set('account_head', v)} /></div>
|
||||
<div className="flex items-center gap-2 pt-5">
|
||||
<input type="checkbox" id={`incl-${rowNo}`} checked={!!tax.included_in_print_rate} onChange={e => set('included_in_print_rate', e.target.checked ? 1 : 0)} className="w-4 h-4 text-teal-600 rounded" />
|
||||
<label htmlFor={`incl-${rowNo}`} className="text-sm text-gray-700 dark:text-gray-300">Included in Basic Rate</label>
|
||||
</div>
|
||||
</div>
|
||||
<RGroup label="Accounting Dimensions">
|
||||
<div><FL>Cost Center</FL><LinkField label="Cost Center" hideLabel doctype="Cost Center" value={tax.cost_center || ''} onChange={v => set('cost_center', v)} /></div>
|
||||
<div><FL>Account Currency</FL><input value={tax.account_currency || ''} onChange={e => set('account_currency', e.target.value)} className={editorInput} /></div>
|
||||
</RGroup>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Empty helpers ─────────────────────────────────────────────────────────────
|
||||
const emptyItem = (): Partial<DeliveryNoteItem> => ({ item_code: '', item_name: '', qty: 1, rate: 0, amount: 0, uom: '', conversion_factor: 1 });
|
||||
const emptyTax = (): TaxRow => ({ charge_type: 'On Net Total', account_head: '', rate: 15 });
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
const DeliveryNoteDetail: React.FC = () => {
|
||||
const { dnName } = useParams<{ dnName: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const isNew = dnName === 'new';
|
||||
|
||||
const contextSO = searchParams.get('so') || '';
|
||||
const contextCustomer = searchParams.get('customer') || '';
|
||||
const contextCompany = searchParams.get('company') || DEFAULT_COMPANY;
|
||||
const contextProject = searchParams.get('project') || '';
|
||||
|
||||
const [doc, setDoc] = useState<DeliveryNote | null>(null);
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(isNew);
|
||||
const [expandedItem, setExpandedItem] = useState<number | null>(null);
|
||||
const [expandedTax, setExpandedTax] = useState<number | null>(null);
|
||||
const [taxes, setTaxes] = useState<TaxRow[]>([]);
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const [form, setForm] = useState<Partial<DeliveryNote>>({
|
||||
customer: contextCustomer, company: contextCompany, project: contextProject,
|
||||
posting_date: today, currency: DEFAULT_CURRENCY,
|
||||
taxes_and_charges: DEFAULT_SALES_TAXES_TEMPLATE,
|
||||
items: [],
|
||||
} as any);
|
||||
|
||||
const syncForm = useCallback((d: DeliveryNote) => {
|
||||
setForm({
|
||||
customer: d.customer || '', company: d.company || DEFAULT_COMPANY, project: d.project || '',
|
||||
posting_date: d.posting_date || today,
|
||||
currency: d.currency === 'INR' ? DEFAULT_CURRENCY : (d.currency || DEFAULT_CURRENCY),
|
||||
cost_center: d.cost_center || '', items: d.items || [],
|
||||
selling_price_list: (d as any).selling_price_list || '',
|
||||
price_list_currency: (d as any).price_list_currency || '',
|
||||
conversion_rate: (d as any).conversion_rate || 1,
|
||||
plc_conversion_rate: (d as any).plc_conversion_rate || 1,
|
||||
tax_category: (d as any).tax_category || '',
|
||||
taxes_and_charges: (d as any).taxes_and_charges || '',
|
||||
} as any);
|
||||
setTaxes((d as any).taxes || []);
|
||||
}, [today]);
|
||||
|
||||
// Auto-fetch company currency when company changes on new form
|
||||
useEffect(() => {
|
||||
const company = (form as any).company;
|
||||
if (!isNew || !company) return;
|
||||
fetch(`/api/resource/Company/${encodeURIComponent(company)}`, { credentials: 'include' })
|
||||
.then(r => r.json()).then(b => {
|
||||
if (b.data?.default_currency) {
|
||||
const cur = displayTxnCurrency(b.data.default_currency);
|
||||
setForm(f => ({ ...f, currency: cur,
|
||||
selling_price_list: (f as any).selling_price_list || 'Standard Selling',
|
||||
price_list_currency: (f as any).price_list_currency || cur,
|
||||
} as any));
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [(form as any).company, isNew]);
|
||||
|
||||
// Pre-fill from SO
|
||||
useEffect(() => {
|
||||
if (!isNew || !contextSO) return;
|
||||
salesOrderService.getSalesOrder(contextSO).then(so => {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
customer: so.customer || f.customer,
|
||||
company: so.company || f.company,
|
||||
project: so.project || (f as any).project,
|
||||
cost_center: so.cost_center || (f as any).cost_center,
|
||||
currency: so.currency || (f as any).currency,
|
||||
selling_price_list: (so as any).selling_price_list || 'Standard Selling',
|
||||
price_list_currency: (so as any).price_list_currency || so.currency,
|
||||
conversion_rate: (so as any).conversion_rate || 1,
|
||||
plc_conversion_rate: (so as any).plc_conversion_rate || 1,
|
||||
taxes_and_charges: (so as any).taxes_and_charges || '',
|
||||
tax_category: (so as any).tax_category || '',
|
||||
items: (so.items || []).map(it => ({
|
||||
item_code: it.item_code, item_name: it.item_name,
|
||||
description: (it as any).description || it.item_name || it.item_code,
|
||||
qty: it.qty, uom: it.uom, stock_uom: it.stock_uom,
|
||||
rate: it.rate, amount: it.amount,
|
||||
against_sales_order: contextSO,
|
||||
so_detail: (it as any).name || undefined,
|
||||
conversion_factor: it.conversion_factor ?? 1,
|
||||
warehouse: (it as any).warehouse || undefined,
|
||||
expense_account: (it as any).expense_account || undefined,
|
||||
cost_center: (it as any).cost_center || so.cost_center || undefined,
|
||||
project: (it as any).project || so.project || undefined,
|
||||
})),
|
||||
} as any));
|
||||
// Also load the taxes from SO's tax template
|
||||
if ((so as any).taxes_and_charges) {
|
||||
loadTaxTemplate((so as any).taxes_and_charges);
|
||||
} else if ((so as any).taxes?.length) {
|
||||
setTaxes((so as any).taxes.map((tx: any) => ({
|
||||
charge_type: tx.charge_type, account_head: tx.account_head,
|
||||
description: tx.description, rate: tx.rate ?? tx.tax_rate ?? 0,
|
||||
cost_center: tx.cost_center, account_currency: tx.account_currency,
|
||||
included_in_print_rate: tx.included_in_print_rate ?? 0,
|
||||
})));
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [isNew, contextSO]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) return;
|
||||
setLoading(true);
|
||||
deliveryNoteService.getDeliveryNote(dnName!)
|
||||
.then(d => { setDoc(d); syncForm(d); })
|
||||
.catch(e => toast.error(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [dnName, isNew, syncForm]);
|
||||
|
||||
const set = (k: keyof DeliveryNote, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
const updateItem = (idx: number, k: string, v: any) =>
|
||||
setForm(f => {
|
||||
const items = [...(f.items || [])];
|
||||
const updated: any = { ...items[idx], [k]: v };
|
||||
if (k === 'qty' || k === 'rate') {
|
||||
const qty = parseFloat(String(k === 'qty' ? v : updated.qty)) || 0;
|
||||
const rate = parseFloat(String(k === 'rate' ? v : updated.rate)) || 0;
|
||||
updated.amount = parseFloat((qty * rate).toFixed(4));
|
||||
}
|
||||
items[idx] = updated;
|
||||
return { ...f, items };
|
||||
});
|
||||
|
||||
const handleItemCode = async (idx: number, code: string) => {
|
||||
updateItem(idx, 'item_code', code);
|
||||
if (!code) return;
|
||||
try {
|
||||
const r = await fetch(`/api/resource/Item/${encodeURIComponent(code)}`, { credentials: 'include' });
|
||||
const body = await r.json();
|
||||
const d = body.data;
|
||||
if (!d) return;
|
||||
setForm(f => {
|
||||
const items = [...(f.items || [])];
|
||||
items[idx] = { ...items[idx], item_code: code, item_name: d.item_name || code, stock_uom: d.stock_uom || '', uom: d.sales_uom || d.stock_uom || '', description: d.description || '' };
|
||||
return { ...f, items };
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const addItem = (after?: number) => {
|
||||
setForm(f => {
|
||||
const items = [...(f.items || [])];
|
||||
const pos = after !== undefined ? after + 1 : items.length;
|
||||
items.splice(pos, 0, emptyItem());
|
||||
return { ...f, items };
|
||||
});
|
||||
};
|
||||
const removeItem = (idx: number) => { setForm(f => { const items = [...(f.items || [])]; items.splice(idx, 1); return { ...f, items }; }); setExpandedItem(null); };
|
||||
|
||||
const updateTax = (idx: number, k: string, v: any) => setTaxes(prev => { const t = [...prev]; t[idx] = { ...t[idx], [k]: v }; return t; });
|
||||
const addTax = () => setTaxes(prev => [...prev, emptyTax()]);
|
||||
const removeTax = (idx: number) => { setTaxes(prev => { const t = [...prev]; t.splice(idx, 1); return t; }); setExpandedTax(null); };
|
||||
|
||||
// Totals
|
||||
const netTotal = (form.items || []).reduce((s, it) => s + (it.amount || 0), 0);
|
||||
const taxTotal = taxes.reduce((s, tx) => {
|
||||
const pct = taxRatePercent(tx);
|
||||
if (tx.charge_type === 'On Net Total') return s + netTotal * (pct / 100);
|
||||
if (tx.charge_type === 'Actual') return s + (tx.tax_amount || 0);
|
||||
return s + netTotal * (pct / 100);
|
||||
}, 0);
|
||||
const grandTotal = netTotal + taxTotal;
|
||||
|
||||
const loadTaxTemplate = async (templateName: string) => {
|
||||
if (!templateName) return;
|
||||
try {
|
||||
const r = await fetch(`/api/resource/Sales Taxes and Charges Template/${encodeURIComponent(templateName)}`, { credentials: 'include' });
|
||||
const body = await r.json();
|
||||
const tmpl = body.data;
|
||||
if (tmpl?.taxes?.length) {
|
||||
setTaxes(tmpl.taxes.map((tx: any) => ({
|
||||
charge_type: tx.charge_type, account_head: tx.account_head,
|
||||
description: tx.description, rate: tx.rate,
|
||||
cost_center: tx.cost_center, account_currency: tx.account_currency,
|
||||
included_in_print_rate: tx.included_in_print_rate ?? 0,
|
||||
})));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNew || contextSO) return;
|
||||
void loadTaxTemplate(DEFAULT_SALES_TAXES_TEMPLATE);
|
||||
}, [isNew, contextSO]);
|
||||
|
||||
const buildPayload = (resolvedItems?: any[]): Partial<DeliveryNote> => ({
|
||||
customer: form.customer, company: (form as any).company || undefined,
|
||||
project: (form as any).project || undefined, cost_center: (form as any).cost_center || undefined,
|
||||
posting_date: form.posting_date, currency: form.currency || undefined,
|
||||
selling_price_list: (form as any).selling_price_list || 'Standard Selling',
|
||||
price_list_currency: (form as any).price_list_currency || form.currency || undefined,
|
||||
conversion_rate: (form as any).conversion_rate || 1,
|
||||
plc_conversion_rate: (form as any).plc_conversion_rate || 1,
|
||||
tax_category: (form as any).tax_category || undefined,
|
||||
taxes_and_charges: (form as any).taxes_and_charges || undefined,
|
||||
items: (resolvedItems || form.items || []).filter((it: any) => it.item_code).map((it: any, i: number) => ({
|
||||
item_code: it.item_code, item_name: it.item_name || it.item_code,
|
||||
description: it.description || undefined,
|
||||
qty: it.qty ?? 1, uom: it.uom || undefined, stock_uom: it.stock_uom || undefined,
|
||||
conversion_factor: it.conversion_factor ?? 1,
|
||||
price_list_rate: it.price_list_rate ?? 0,
|
||||
discount_percentage: it.discount_percentage ?? 0,
|
||||
discount_amount: it.discount_amount ?? 0,
|
||||
rate: it.rate ?? 0, amount: it.amount ?? 0,
|
||||
is_free_item: it.is_free_item ?? 0, grant_commission: it.grant_commission ?? 0,
|
||||
warehouse: it.warehouse || undefined, expense_account: it.expense_account || undefined,
|
||||
cost_center: it.cost_center || undefined, project: it.project || (form as any).project || undefined,
|
||||
against_sales_order: it.against_sales_order || undefined,
|
||||
so_detail: it.so_detail || undefined,
|
||||
weight_per_unit: it.weight_per_unit ?? 0, total_weight: it.total_weight ?? 0,
|
||||
idx: i + 1,
|
||||
})),
|
||||
taxes: taxes.filter(tx => tx.account_head).map((tx, i) => ({
|
||||
charge_type: tx.charge_type || 'On Net Total',
|
||||
account_head: tx.account_head, description: tx.description || tx.account_head,
|
||||
included_in_print_rate: tx.included_in_print_rate ?? 0,
|
||||
rate: tx.rate ?? 0, cost_center: tx.cost_center || undefined,
|
||||
account_currency: tx.account_currency || undefined, idx: i + 1,
|
||||
})),
|
||||
} as any);
|
||||
|
||||
/** Fetch SO item row names to fill so_detail on items that have against_sales_order but missing so_detail */
|
||||
const resolveSoDetails = async (items: any[]): Promise<any[]> => {
|
||||
const needsResolve = items.filter(it => it.against_sales_order && !it.so_detail);
|
||||
if (!needsResolve.length) return items;
|
||||
const soName = needsResolve[0].against_sales_order;
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/resource/Sales%20Order%20Item?filters=${encodeURIComponent(JSON.stringify([["parent","=",soName]]))}&fields=${encodeURIComponent(JSON.stringify(["name","item_code","idx"]))}&limit=100`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
const j = await r.json();
|
||||
const soRows: any[] = (j.data || []).sort((a: any, b: any) => (a.idx || 0) - (b.idx || 0));
|
||||
const used = new Set<string>();
|
||||
return items.map(it => {
|
||||
if (!it.against_sales_order || it.so_detail) return it;
|
||||
const match = soRows.find((s: any) => s.item_code === it.item_code && !used.has(s.name));
|
||||
if (match) { used.add(match.name); return { ...it, so_detail: match.name }; }
|
||||
return it;
|
||||
});
|
||||
} catch { return items; }
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.customer) { toast.error('Customer is required'); return; }
|
||||
try {
|
||||
setSaving(true);
|
||||
const resolvedItems = await resolveSoDetails([...(form.items || [])]);
|
||||
if (isNew) {
|
||||
const created = await deliveryNoteService.createDeliveryNote(buildPayload(resolvedItems));
|
||||
toast.success('Delivery Note created');
|
||||
setIsEditing(false);
|
||||
navigate(`/delivery-notes/${created.name}`);
|
||||
} else {
|
||||
const updated = await deliveryNoteService.updateDeliveryNote(dnName!, buildPayload(resolvedItems));
|
||||
setDoc(updated); syncForm(updated);
|
||||
toast.success('Delivery Note saved');
|
||||
setIsEditing(false);
|
||||
}
|
||||
} catch (e: unknown) { toast.error(formatFrappeApiError(e) || 'Error saving'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!dnName || isNew) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const updated = await deliveryNoteService.submitDeliveryNote(dnName);
|
||||
setDoc(updated); syncForm(updated);
|
||||
toast.success('Delivery Note submitted');
|
||||
} catch (e: unknown) { toast.error(formatFrappeApiError(e) || 'Error submitting'); }
|
||||
finally { setSubmitting(false); }
|
||||
};
|
||||
|
||||
const createSI = () => {
|
||||
const p = new URLSearchParams();
|
||||
p.set('dn', dnName!);
|
||||
if (form.customer) p.set('customer', form.customer);
|
||||
if ((form as any).company) p.set('company', String((form as any).company));
|
||||
if ((form as any).project) p.set('project', String((form as any).project));
|
||||
navigate(`/invoices/new?${p.toString()}`);
|
||||
};
|
||||
|
||||
const editable = isNew || isEditing;
|
||||
const isSubmitted = !isNew && doc?.docstatus === 1;
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center min-h-[400px]"><FaSpinner className="animate-spin text-teal-500 text-3xl" /></div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-6 py-6">
|
||||
<ToastContainer position="top-right" autoClose={3500} />
|
||||
|
||||
<div className="flex items-center gap-2 text-sm mb-6 text-gray-500">
|
||||
<button onClick={() => navigate('/projects')} className="hover:text-indigo-600">Project Management</button>
|
||||
<span>/</span>
|
||||
<button onClick={() => navigate('/delivery-notes')} className="hover:text-indigo-600">Delivery Notes</button>
|
||||
<span>/</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">{isNew ? 'New Delivery Note' : dnName}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate('/delivery-notes')} className="text-gray-400 hover:text-gray-700"><FaArrowLeft /></button>
|
||||
<FaTruck className="text-teal-500" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{isNew ? 'New Delivery Note' : (form.customer || dnName)}
|
||||
</h1>
|
||||
{!isNew && <span className="text-sm text-gray-400 font-normal">{dnName}</span>}
|
||||
{!isNew && (
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-semibold ${(() => { const s = doc?.status || ''; if (doc?.docstatus === 2 || s === 'Cancelled') return 'bg-red-100 text-red-700'; if (!doc || doc.docstatus === 0) return 'bg-yellow-100 text-yellow-800'; if (s === 'Completed') return 'bg-green-100 text-green-800'; if (s === 'To Bill') return 'bg-blue-100 text-blue-800'; if (s === 'Return Issued') return 'bg-orange-100 text-orange-800'; if (s === 'Closed') return 'bg-gray-100 text-gray-700'; return 'bg-green-100 text-green-800'; })()}`}>
|
||||
{doc?.docstatus === 2 ? 'Cancelled' : doc?.docstatus === 0 ? 'Draft' : (doc?.status || 'Submitted')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isSubmitted && (
|
||||
<CreateDropdown items={[
|
||||
{ label: 'Sales Invoice', icon: <FaFileInvoiceDollar size={13} />, onClick: createSI },
|
||||
]} />
|
||||
)}
|
||||
{!isNew && !isEditing && doc?.docstatus === 0 && (
|
||||
<button onClick={handleSubmit} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm font-medium">
|
||||
{submitting ? <FaSpinner className="animate-spin" /> : <FaPaperPlane size={12} />} Submit
|
||||
</button>
|
||||
)}
|
||||
{!isNew && !isEditing && !isSubmitted && (
|
||||
<button onClick={() => setIsEditing(true)} className="flex items-center gap-2 px-4 py-2 border border-teal-500 text-teal-600 rounded-lg hover:bg-teal-50 text-sm"><FaEdit /> Edit</button>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50 text-sm font-medium">
|
||||
{saving ? <FaSpinner className="animate-spin" /> : <FaSave />}{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{!isNew && <button onClick={() => { if (doc) syncForm(doc); setIsEditing(false); }} className="px-3 py-2 border border-gray-300 rounded-lg text-gray-600 text-sm"><FaTimes /></button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Fields */}
|
||||
<div className="px-6 pt-5 pb-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
|
||||
<div><FL required>Customer</FL>
|
||||
{editable ? <LinkField label="Customer" hideLabel doctype="Customer" value={form.customer || ''} onChange={v => set('customer', v)} placeholder="Select customer…" /> : <RV>{form.customer}</RV>}
|
||||
</div>
|
||||
<div><FL required>Posting Date</FL>
|
||||
{editable ? <input type="date" value={form.posting_date || ''} onChange={e => set('posting_date', e.target.value)} className={inputCls} /> : <RV>{form.posting_date}</RV>}
|
||||
</div>
|
||||
<div><FL>Company</FL>
|
||||
{editable ? <LinkField label="Company" hideLabel doctype="Company" value={(form as any).company || ''} onChange={v => set('company' as any, v)} placeholder="Select company…" /> : <RV>{(form as any).company}</RV>}
|
||||
</div>
|
||||
<div><FL>Project</FL>
|
||||
{editable ? <LinkField label="Project" hideLabel doctype="Project" value={(form as any).project || ''} onChange={v => set('project' as any, v)} placeholder="Select project…" /> : <RV>{(form as any).project}</RV>}
|
||||
</div>
|
||||
<div><FL>Currency</FL>
|
||||
{editable
|
||||
? <select value={form.currency || DEFAULT_CURRENCY} onChange={e => set('currency', e.target.value)} className={inputCls}>
|
||||
<option value="SAR">SAR</option><option value="USD">USD</option><option value="EUR">EUR</option>
|
||||
</select>
|
||||
: <RV>{form.currency}</RV>}
|
||||
</div>
|
||||
<div><FL>Cost Center</FL>
|
||||
{editable ? <LinkField label="Cost Center" hideLabel doctype="Cost Center" value={(form as any).cost_center || ''} onChange={v => set('cost_center' as any, v)} /> : <RV>{(form as any).cost_center}</RV>}
|
||||
</div>
|
||||
{contextSO && <div><FL>Against Sales Order</FL><RV>{contextSO}</RV></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items Section */}
|
||||
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="px-6 py-3 bg-gray-50 dark:bg-gray-900/20 border-b border-gray-100 dark:border-gray-700">
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Items</span>
|
||||
</div>
|
||||
<div className="px-6 pb-4">
|
||||
<div className="overflow-x-auto -mx-2 mt-3">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-8">No.</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 min-w-[180px]">Item Code <span className="text-red-400">*</span></th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">UOM <span className="text-red-400">*</span></th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-24">Qty <span className="text-red-400">*</span></th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Rate ({form.currency || DEFAULT_CURRENCY})</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Amount ({form.currency || DEFAULT_CURRENCY})</th>
|
||||
{editable && <th className="w-16 py-2 px-2" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(form.items || []).map((it: any, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
<tr className={`border-b border-gray-100 dark:border-gray-700 align-middle ${expandedItem === idx ? 'bg-blue-50/60 dark:bg-blue-900/10' : ''}`}>
|
||||
<td className="py-1.5 px-3 text-gray-400 text-xs">{idx + 1}</td>
|
||||
<td className="py-1.5 px-2 min-w-[180px]">
|
||||
{editable
|
||||
? <LinkField label="Item" hideLabel doctype="Item" value={it.item_code || ''} onChange={v => handleItemCode(idx, v)} placeholder="Item Code" />
|
||||
: <span className="font-medium text-gray-800 dark:text-gray-200">{it.item_code || '-'}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 w-28">
|
||||
{editable
|
||||
? <LinkField label="UOM" hideLabel doctype="UOM" value={it.uom || ''} onChange={v => updateItem(idx, 'uom', v)} placeholder="UOM" />
|
||||
: <span className="text-gray-500 text-sm">{it.uom || '-'}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 w-24">
|
||||
{editable ? <input type="number" min={0} step="1" value={it.qty ?? 1} onChange={e => updateItem(idx, 'qty', parseFloat(e.target.value) || 0)} className={inlineNum} /> : <span className="block text-right text-sm pr-1">{it.qty ?? 0}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 w-28">
|
||||
{editable ? <input type="number" min={0} step="0.01" value={it.rate ?? 0} onChange={e => updateItem(idx, 'rate', parseFloat(e.target.value) || 0)} className={inlineNum} /> : <span className="block text-right text-sm pr-1">{(it.rate ?? 0).toFixed(2)}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-right font-semibold text-gray-900 dark:text-white text-sm">{((it.qty || 0) * (it.rate || 0)).toFixed(2)}</td>
|
||||
{editable && (
|
||||
<td className="py-1.5 px-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setExpandedItem(expandedItem === idx ? null : idx)}
|
||||
className={`p-1.5 rounded text-xs ${expandedItem === idx ? 'bg-teal-600 text-white' : 'text-teal-600 hover:bg-teal-50'}`} title="More fields">
|
||||
<FaPencilAlt size={11} />
|
||||
</button>
|
||||
<button onClick={() => removeItem(idx)} className="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"><FaTrash size={11} /></button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{editable && expandedItem === idx && (
|
||||
<DNItemRowEditor
|
||||
item={it} rowNo={idx + 1} currency={form.currency || DEFAULT_CURRENCY}
|
||||
onChange={(k, v) => { if (k === 'item_code') handleItemCode(idx, v as string); else updateItem(idx, k, v); }}
|
||||
onClose={() => setExpandedItem(null)}
|
||||
onDelete={() => removeItem(idx)}
|
||||
onInsertBelow={() => addItem(idx)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{editable && (
|
||||
<tr><td colSpan={7} className="py-2 px-3">
|
||||
<button onClick={() => addItem()} className="flex items-center gap-1.5 text-teal-600 hover:text-teal-700 text-sm font-medium"><FaPlus size={10} /> Add Row</button>
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Taxes Section */}
|
||||
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="px-6 py-3 bg-gray-50 dark:bg-gray-900/20 border-b border-gray-100 dark:border-gray-700">
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Taxes and Charges</span>
|
||||
</div>
|
||||
<div className="px-6 pt-4 pb-2 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
||||
<div><FL>Tax Category</FL>
|
||||
{editable ? <LinkField label="Tax Category" hideLabel doctype="Tax Category" value={(form as any).tax_category || ''} onChange={v => set('tax_category' as any, v)} placeholder="Select tax category…" /> : <RV>{(form as any).tax_category}</RV>}
|
||||
</div>
|
||||
<div><FL>Sales Taxes and Charges Template</FL>
|
||||
{editable
|
||||
? <LinkField label="Sales Taxes and Charges Template" hideLabel doctype="Sales Taxes and Charges Template" value={(form as any).taxes_and_charges || ''} onChange={v => { set('taxes_and_charges' as any, v); loadTaxTemplate(v); }} placeholder="Select template…" />
|
||||
: <RV>{(form as any).taxes_and_charges}</RV>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-4 mt-3">
|
||||
<div className="overflow-x-auto -mx-2">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-8">No.</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-36">Type <span className="text-red-400">*</span></th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 min-w-[180px]">Account Head <span className="text-red-400">*</span></th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-24">Tax Rate</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Amount</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Total</th>
|
||||
{editable && <th className="w-16 py-2 px-2" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{taxes.map((tx, idx) => {
|
||||
const txAmt = tx.charge_type === 'On Net Total' ? netTotal * ((tx.rate || 0) / 100) : (tx.tax_amount || 0);
|
||||
const txTotal = netTotal + taxes.slice(0, idx + 1).reduce((s, t) => {
|
||||
return s + (t.charge_type === 'On Net Total' ? netTotal * ((t.rate || 0) / 100) : (t.tax_amount || 0));
|
||||
}, 0);
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<tr className={`border-b border-gray-100 dark:border-gray-700 align-middle ${expandedTax === idx ? 'bg-blue-50/60 dark:bg-blue-900/10' : ''}`}>
|
||||
<td className="py-1.5 px-3 text-gray-400 text-xs">{idx + 1}</td>
|
||||
<td className="py-1.5 px-2 w-36">
|
||||
{editable
|
||||
? <select value={tx.charge_type || ''} onChange={e => updateTax(idx, 'charge_type', e.target.value)} className={inlineTxt}>
|
||||
<option value="">Type…</option>
|
||||
<option value="Actual">Actual</option>
|
||||
<option value="On Net Total">On Net Total</option>
|
||||
<option value="On Previous Row Amount">On Previous Row Amount</option>
|
||||
</select>
|
||||
: <span className="text-sm text-gray-700">{tx.charge_type || '-'}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 min-w-[180px]">
|
||||
{editable
|
||||
? <LinkField label="Account Head" hideLabel doctype="Account" value={tx.account_head || ''} onChange={v => updateTax(idx, 'account_head', v)} placeholder="Account…" />
|
||||
: <span className="text-sm text-gray-700">{tx.account_head || '-'}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 w-24">
|
||||
{editable ? <input type="number" min={0} step="0.01" value={tx.rate ?? 0} onChange={e => updateTax(idx, 'rate', parseFloat(e.target.value) || 0)} className={inlineNum} /> : <span className="block text-right text-sm pr-1">{tx.rate ?? 0}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-right text-sm text-gray-700 dark:text-gray-300">{txAmt.toFixed(2)}</td>
|
||||
<td className="py-1.5 px-3 text-right text-sm font-semibold text-gray-900 dark:text-white">{txTotal.toFixed(2)}</td>
|
||||
{editable && (
|
||||
<td className="py-1.5 px-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setExpandedTax(expandedTax === idx ? null : idx)}
|
||||
className={`p-1.5 rounded text-xs ${expandedTax === idx ? 'bg-teal-600 text-white' : 'text-teal-600 hover:bg-teal-50'}`}><FaPencilAlt size={11} /></button>
|
||||
<button onClick={() => removeTax(idx)} className="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"><FaTrash size={11} /></button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{editable && expandedTax === idx && (
|
||||
<DNTaxRowEditor tax={tx} rowNo={idx + 1}
|
||||
onChange={(k, v) => updateTax(idx, k, v)}
|
||||
onClose={() => setExpandedTax(null)} onDelete={() => removeTax(idx)} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{editable && (
|
||||
<tr><td colSpan={7} className="py-2 px-3">
|
||||
<button onClick={addTax} className="flex items-center gap-1.5 text-teal-600 hover:text-teal-700 text-sm font-medium"><FaPlus size={10} /> Add Tax Row</button>
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="border-t border-gray-100 dark:border-gray-700 px-6 py-4">
|
||||
<div className="flex justify-end">
|
||||
<div className="w-full max-w-xs space-y-2 text-sm">
|
||||
<div className="flex justify-between text-gray-600 dark:text-gray-400">
|
||||
<span>Net Total ({displayTxnCurrency(form.currency)})</span>
|
||||
<span className="font-medium">{netTotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600 dark:text-gray-400">
|
||||
<span>Total Taxes and Charges ({displayTxnCurrency(form.currency)})</span>
|
||||
<span className="font-medium">{(doc?.total_taxes_and_charges ?? taxTotal).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-bold text-gray-900 dark:text-white border-t border-gray-200 dark:border-gray-700 pt-2 text-base">
|
||||
<span>Grand Total ({displayTxnCurrency(form.currency)})</span>
|
||||
<span>{grandTotal.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
{!isNew && doc && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700 px-6 py-4 grid grid-cols-3 gap-4 text-sm bg-gray-50 dark:bg-gray-900/20">
|
||||
<div><FL>Created By</FL><RV>{doc.owner}</RV></div>
|
||||
<div><FL>Created</FL><RV>{doc.creation ? new Date(doc.creation).toLocaleString() : '-'}</RV></div>
|
||||
<div><FL>Modified</FL><RV>{doc.modified ? new Date(doc.modified).toLocaleString() : '-'}</RV></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNew && (
|
||||
<ActivityLog
|
||||
doctype="Delivery Note"
|
||||
docname={doc?.name || dnName || ''}
|
||||
creationDate={doc?.creation}
|
||||
createdBy={doc?.owner}
|
||||
compact={false}
|
||||
initialVisible={5}
|
||||
collapsible
|
||||
startCollapsed
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeliveryNoteDetail;
|
||||
@ -1,265 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaTruck, FaPlus, FaSync, FaChevronDown, FaChevronUp, FaTimes, FaSearch, FaFileExport, FaEye, FaEdit, FaCopy, FaCheckSquare, FaSquare } from 'react-icons/fa';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import deliveryNoteService, { DeliveryNote } from '../services/deliveryNoteService';
|
||||
import DynamicExportModal from '../components/DynamicExportModal';
|
||||
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function buildDeliveryNoteExportFilters(f: { search: string; status: string }) {
|
||||
const filters: any[] = [];
|
||||
if (f.search) filters.push(['Delivery Note', 'name', 'like', `%${f.search}%`]);
|
||||
if (f.status === 'Draft') filters.push(['Delivery Note', 'docstatus', '=', 0]);
|
||||
if (f.status === 'Submitted') filters.push(['Delivery Note', 'docstatus', '=', 1]);
|
||||
if (f.status === 'Cancelled') filters.push(['Delivery Note', 'docstatus', '=', 2]);
|
||||
return filters;
|
||||
}
|
||||
|
||||
function getStatusStyle(dn: DeliveryNote) {
|
||||
if (dn.docstatus === 2) return 'bg-red-100 text-red-700';
|
||||
if (dn.docstatus === 1) return 'bg-green-100 text-green-700';
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
}
|
||||
function getStatusLabel(dn: DeliveryNote) {
|
||||
if (dn.docstatus === 2) return 'Cancelled';
|
||||
if (dn.docstatus === 1) return dn.status || 'Submitted';
|
||||
return 'Draft';
|
||||
}
|
||||
|
||||
const DeliveryNoteList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [notes, setNotes] = useState<DeliveryNote[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [applied, setApplied] = useState({ search: '', status: '' });
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const didInitUrlSync = useRef(false);
|
||||
const searchDebounceRef = useRef<number | null>(null);
|
||||
|
||||
const load = useCallback(async (off: number, f: typeof applied) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (f.search) filters.push(['Delivery Note', 'name', 'like', `%${f.search}%`]);
|
||||
if (f.status === 'Draft') filters.push(['Delivery Note', 'docstatus', '=', 0]);
|
||||
if (f.status === 'Submitted') filters.push(['Delivery Note', 'docstatus', '=', 1]);
|
||||
if (f.status === 'Cancelled') filters.push(['Delivery Note', 'docstatus', '=', 2]);
|
||||
const [rows, cnt] = await Promise.all([
|
||||
deliveryNoteService.getDeliveryNotes({ filters, limit_start: off, limit_page_length: PAGE_SIZE }),
|
||||
deliveryNoteService.getDeliveryNoteCount(filters),
|
||||
]);
|
||||
setNotes(rows); setTotal(cnt);
|
||||
} catch (e: any) { toast.error(e.message || 'Failed to load'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(0, applied); }, [load, applied]);
|
||||
|
||||
const selectionResetKey = useMemo(
|
||||
() => `${page}|${applied.search}|${applied.status}`,
|
||||
[page, applied.search, applied.status],
|
||||
);
|
||||
const {
|
||||
selectedRows,
|
||||
toggleRow,
|
||||
toggleAllOnPage,
|
||||
allOnPageSelected,
|
||||
someOnPageSelected,
|
||||
} = useListPageSelection(notes, selectionResetKey);
|
||||
|
||||
// Auto-apply filters
|
||||
useEffect(() => {
|
||||
if (!didInitUrlSync.current) {
|
||||
didInitUrlSync.current = true;
|
||||
return;
|
||||
}
|
||||
setApplied((prev) => ({ ...prev, status: statusFilter }));
|
||||
setPage(0);
|
||||
}, [statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didInitUrlSync.current) return;
|
||||
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||
searchDebounceRef.current = window.setTimeout(() => {
|
||||
setApplied((prev) => ({ ...prev, search: searchQuery }));
|
||||
setPage(0);
|
||||
}, 450);
|
||||
return () => {
|
||||
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
const clear = () => { setSearchQuery(''); setStatusFilter(''); setApplied({ search: '', status: '' }); setPage(0); };
|
||||
const hasActive = !!(applied.search || applied.status);
|
||||
const goPage = (p: number) => { setPage(p); load(p * PAGE_SIZE, applied); };
|
||||
const handleView = (name: string) => navigate(`/delivery-notes/${encodeURIComponent(name)}`);
|
||||
const handleEdit = (name: string) => navigate(`/delivery-notes/${encodeURIComponent(name)}?edit=1`);
|
||||
const handleDuplicate = (name: string) => navigate(`/delivery-notes/new?duplicate=${encodeURIComponent(name)}`);
|
||||
|
||||
const fetchAllForExport = useCallback(
|
||||
() =>
|
||||
fetchAllRowsForExport({
|
||||
doctype: 'Delivery Note',
|
||||
filters: buildDeliveryNoteExportFilters(applied),
|
||||
orderBy: 'modified desc',
|
||||
}),
|
||||
[applied],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ToastContainer position="top-right" autoClose={3000} />
|
||||
<div className="flex items-center justify-between mb-6 gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-teal-600 flex items-center justify-center"><FaTruck className="text-white text-base" /></div>
|
||||
<div><h1 className="text-xl font-bold text-gray-900 dark:text-white">Delivery Notes</h1><p className="text-xs text-gray-500">{total} total</p></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button onClick={() => load(page * PAGE_SIZE, applied)} className="p-2 text-gray-500 hover:text-indigo-600 border border-gray-200 rounded-lg"><FaSync size={13} /></button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||
disabled={total === 0 && selectedRows.size === 0}
|
||||
>
|
||||
<FaFileExport /> {t('listPages.export')}
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => navigate('/delivery-notes/new')} className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm font-medium"><FaPlus size={11} /> New Note</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DynamicExportModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
doctype="Delivery Note"
|
||||
selectedCount={selectedRows.size}
|
||||
pageCount={notes.length}
|
||||
totalCount={total}
|
||||
pageData={notes}
|
||||
selectedRows={selectedRows}
|
||||
rowKey="name"
|
||||
onFetchAll={fetchAllForExport}
|
||||
fileNamePrefix="delivery_notes"
|
||||
/>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl mb-5 overflow-hidden">
|
||||
<button onClick={() => setFiltersOpen(o => !o)} className="w-full flex items-center justify-between px-4 py-3 bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 text-white">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold"><FaSearch size={12} /> Filters {hasActive && <span className="bg-white/30 text-white text-xs px-2 py-0.5 rounded-full">Active</span>}</div>
|
||||
{filtersOpen ? <FaChevronUp size={11} /> : <FaChevronDown size={11} />}
|
||||
</button>
|
||||
{hasActive && (
|
||||
<div className="px-4 py-2 bg-blue-50 dark:bg-blue-900/20 flex flex-wrap gap-2 items-center border-b border-blue-100 dark:border-blue-900/30">
|
||||
{applied.search && <span className="flex items-center gap-1 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">ID: {applied.search}<button onClick={() => { setSearchQuery(''); setApplied(a => ({ ...a, search: '' })); }}><FaTimes size={9} /></button></span>}
|
||||
{applied.status && <span className="flex items-center gap-1 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">Status: {applied.status}<button onClick={() => { setStatusFilter(''); setApplied(a => ({ ...a, status: '' })); }}><FaTimes size={9} /></button></span>}
|
||||
<button onClick={clear} className="text-xs text-blue-600 hover:underline ml-auto">Clear All</button>
|
||||
</div>
|
||||
)}
|
||||
{filtersOpen && (
|
||||
<div className="px-4 py-3 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div><label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Note ID</label>
|
||||
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && e.preventDefault()} placeholder="Search…" className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-400" /></div>
|
||||
<div><label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Status</label>
|
||||
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||
<option value="">All</option><option value="Draft">Draft</option><option value="Submitted">Submitted</option><option value="Cancelled">Cancelled</option>
|
||||
</select></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||
<th className="w-10 px-4 py-3 text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllOnPage}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-teal-600 dark:hover:text-teal-400 transition-colors"
|
||||
title={allOnPageSelected ? 'Deselect all' : 'Select all'}
|
||||
aria-label="Select all on page"
|
||||
>
|
||||
{allOnPageSelected
|
||||
? <FaCheckSquare className="text-teal-600 dark:text-teal-400" size={18} />
|
||||
: someOnPageSelected
|
||||
? (
|
||||
<div className="relative inline-block">
|
||||
<FaSquare size={18} />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-2 h-0.5 bg-current" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: <FaSquare size={18} />}
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Note ID</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Customer</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Date</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Status</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Grand Total</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4 w-28"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{loading ? <tr><td colSpan={7} className="text-center py-10 text-gray-400">Loading…</td></tr>
|
||||
: notes.length === 0 ? <tr><td colSpan={7} className="text-center py-10 text-gray-400">No delivery notes found</td></tr>
|
||||
: notes.map(dn => (
|
||||
<tr key={dn.name} onClick={() => handleView(dn.name)} className={`cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${selectedRows.has(dn.name) ? 'bg-teal-50 dark:bg-teal-900/20' : ''}`}>
|
||||
<td className="w-10 px-4 py-3" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleRow(dn.name)}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-teal-600 dark:hover:text-teal-400 transition-colors"
|
||||
aria-label={`Select ${dn.name}`}
|
||||
>
|
||||
{selectedRows.has(dn.name)
|
||||
? <FaCheckSquare className="text-teal-600 dark:text-teal-400" size={18} />
|
||||
: <FaSquare size={18} />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-medium text-gray-900 dark:text-white">{dn.name}</td>
|
||||
<td className="py-3 px-4 text-gray-700 dark:text-gray-300">{dn.customer_name || dn.customer || '-'}</td>
|
||||
<td className="py-3 px-4 text-gray-500">{dn.posting_date || '-'}</td>
|
||||
<td className="py-3 px-4"><span className={`px-2 py-0.5 rounded text-xs font-semibold ${getStatusStyle(dn)}`}>{getStatusLabel(dn)}</span></td>
|
||||
<td className="py-3 px-4 text-right font-semibold text-gray-900 dark:text-white">{dn.currency || 'SAR'} {(dn.grand_total ?? 0).toFixed(2)}</td>
|
||||
<td className="py-2 px-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => handleView(dn.name)} className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors" title="View"><FaEye /></button>
|
||||
<button onClick={() => handleEdit(dn.name)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title="Edit"><FaEdit /></button>
|
||||
<button onClick={() => handleDuplicate(dn.name)} className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors" title="Duplicate"><FaCopy /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-xs text-gray-500">{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}</span>
|
||||
<div className="flex gap-2">
|
||||
<button disabled={page === 0} onClick={() => goPage(page - 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Prev</button>
|
||||
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => goPage(page + 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeliveryNoteList;
|
||||
@ -1,337 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
FaArrowLeft, FaSave, FaEdit, FaTimes,
|
||||
FaCheckCircle, FaTimesCircle, FaSpinner, FaUserTie,
|
||||
FaChevronDown, FaChevronRight,
|
||||
} from 'react-icons/fa';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import masterService, { Employee } from '../services/masterService';
|
||||
import LinkField from '../components/LinkField';
|
||||
import ActivityLog from '../components/ActivityLog';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
const FL: React.FC<{ children: React.ReactNode; required?: boolean }> = ({ children, required }) => (
|
||||
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||
{children}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
);
|
||||
const RV: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
||||
<div className="px-3 py-2 text-sm text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-800/60 rounded-lg min-h-[38px] flex items-center">
|
||||
{children || <span className="text-gray-400">-</span>}
|
||||
</div>
|
||||
);
|
||||
const inputCls = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-teal-400';
|
||||
|
||||
const CollapsibleSection: React.FC<{ title: string; children: React.ReactNode; defaultOpen?: boolean }> = ({ title, children, defaultOpen = true }) => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||
<button type="button" onClick={() => setOpen(o => !o)}
|
||||
className="w-full flex items-center justify-between px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">{title}</span>
|
||||
{open ? <FaChevronDown className="text-gray-400 text-xs" /> : <FaChevronRight className="text-gray-400 text-xs" />}
|
||||
</button>
|
||||
{open && <div className="px-6 pb-5">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const statusBadge = (s?: string) => {
|
||||
switch (s) {
|
||||
case 'Active': return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300';
|
||||
case 'Inactive': return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||
case 'Left': return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300';
|
||||
case 'On Leave': return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300';
|
||||
default: return 'bg-gray-100 text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
const EmployeeDetail: React.FC = () => {
|
||||
const { employeeName } = useParams<{ employeeName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isNew = employeeName === 'new';
|
||||
|
||||
const [employee, setEmployee] = useState<Employee | null>(null);
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(isNew);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const emptyForm: Partial<Employee> = {
|
||||
first_name: '', middle_name: '', last_name: '', salutation: '',
|
||||
gender: '', date_of_birth: '', date_of_joining: '', status: 'Active',
|
||||
company: '', designation: '', branch: '', department: '', reports_to: '', employee_number: '',
|
||||
};
|
||||
const [form, setForm] = useState<Partial<Employee>>(emptyForm);
|
||||
|
||||
const syncForm = useCallback((e: Employee) => {
|
||||
setForm({
|
||||
first_name: e.first_name || '',
|
||||
middle_name: e.middle_name || '',
|
||||
last_name: e.last_name || '',
|
||||
salutation: e.salutation || '',
|
||||
gender: e.gender || '',
|
||||
date_of_birth: e.date_of_birth || '',
|
||||
date_of_joining: e.date_of_joining || '',
|
||||
status: e.status || 'Active',
|
||||
company: e.company || '',
|
||||
designation: e.designation || '',
|
||||
branch: e.branch || '',
|
||||
department: e.department || '',
|
||||
reports_to: e.reports_to || '',
|
||||
employee_number: e.employee_number || '',
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNew && employeeName) {
|
||||
setLoading(true);
|
||||
masterService.getEmployee(employeeName)
|
||||
.then(e => { setEmployee(e); syncForm(e); })
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [employeeName, isNew, syncForm]);
|
||||
|
||||
const set = (k: keyof Employee, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.first_name?.trim()) { toast.error('First Name is required'); return; }
|
||||
if (!form.gender) { toast.error('Gender is required'); return; }
|
||||
if (!form.date_of_birth) { toast.error('Date of Birth is required'); return; }
|
||||
if (!form.date_of_joining) { toast.error('Date of Joining is required'); return; }
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isNew) {
|
||||
const created = await masterService.createEmployee(form);
|
||||
toast.success('Employee created', { icon: <FaCheckCircle /> });
|
||||
navigate(`/employees/${encodeURIComponent(created.name)}`);
|
||||
} else {
|
||||
const updated = await masterService.updateEmployee(employeeName!, form);
|
||||
toast.success('Employee updated', { icon: <FaCheckCircle /> });
|
||||
setEmployee(updated); syncForm(updated); setIsEditing(false);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Save failed', { icon: <FaTimesCircle /> });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => { if (employee) syncForm(employee); setIsEditing(false); };
|
||||
const editable = isNew || isEditing;
|
||||
|
||||
const displayName = employee?.employee_name
|
||||
|| [employee?.first_name, employee?.last_name].filter(Boolean).join(' ')
|
||||
|| employeeName;
|
||||
|
||||
if (loading) return <div className="flex justify-center items-center min-h-[400px]"><FaSpinner className="animate-spin text-teal-500 text-3xl" /></div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-6 py-6">
|
||||
<ToastContainer position="top-right" autoClose={3500} />
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm mb-6 text-gray-500 dark:text-gray-400">
|
||||
<button onClick={() => navigate('/projects')} className="hover:text-teal-600">Project Management</button>
|
||||
<span>/</span>
|
||||
<button onClick={() => navigate('/employees')} className="hover:text-teal-600">Employees</button>
|
||||
<span>/</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">{isNew ? 'New Employee' : displayName}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate('/employees')} className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"><FaArrowLeft /></button>
|
||||
<div className="w-10 h-10 rounded-xl bg-teal-600 flex items-center justify-center flex-shrink-0">
|
||||
<FaUserTie className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{isNew ? 'New Employee' : displayName}
|
||||
</h1>
|
||||
{!isNew && employee && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded font-medium ${statusBadge(employee.status)}`}>
|
||||
{employee.status || 'Active'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!isNew && !isEditing && (
|
||||
<button onClick={() => setIsEditing(true)} className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm">
|
||||
<FaEdit /> Edit
|
||||
</button>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50 text-sm font-medium">
|
||||
{saving ? <FaSpinner className="animate-spin" /> : <FaSave />}
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{!isNew && <button onClick={handleCancel} className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-600 dark:text-gray-400 text-sm"><FaTimes /></button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="mx-6 mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded text-red-700 dark:text-red-300 text-sm">{error}</div>}
|
||||
|
||||
{/* ── Overview ── */}
|
||||
<div className="p-6 grid grid-cols-1 sm:grid-cols-3 gap-x-6 gap-y-4 border-b border-gray-100 dark:border-gray-700">
|
||||
|
||||
{/* Series (read-only) */}
|
||||
<div>
|
||||
<FL>Series</FL>
|
||||
<RV>HR-EMP-</RV>
|
||||
</div>
|
||||
|
||||
{/* Gender */}
|
||||
<div>
|
||||
<FL required>Gender</FL>
|
||||
{editable
|
||||
? <select value={form.gender || ''} onChange={e => set('gender', e.target.value)} className={inputCls}>
|
||||
<option value="">Select gender</option>
|
||||
<option>Male</option><option>Female</option><option>Other</option><option>Prefer not to say</option>
|
||||
</select>
|
||||
: <RV>{form.gender}</RV>}
|
||||
</div>
|
||||
|
||||
{/* Date of Joining */}
|
||||
<div>
|
||||
<FL required>Date of Joining</FL>
|
||||
{editable
|
||||
? <input type="date" value={form.date_of_joining || ''} onChange={e => set('date_of_joining', e.target.value)} className={inputCls} />
|
||||
: <RV>{form.date_of_joining}</RV>}
|
||||
</div>
|
||||
|
||||
{/* First Name */}
|
||||
<div>
|
||||
<FL required>First Name</FL>
|
||||
{editable
|
||||
? <input value={form.first_name || ''} onChange={e => set('first_name', e.target.value)} className={inputCls} placeholder="First name" />
|
||||
: <RV>{form.first_name}</RV>}
|
||||
</div>
|
||||
|
||||
{/* Date of Birth */}
|
||||
<div>
|
||||
<FL required>Date of Birth</FL>
|
||||
{editable
|
||||
? <input type="date" value={form.date_of_birth || ''} onChange={e => set('date_of_birth', e.target.value)} className={inputCls} />
|
||||
: <RV>{form.date_of_birth}</RV>}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<FL>Status</FL>
|
||||
{editable
|
||||
? <select value={form.status || 'Active'} onChange={e => set('status', e.target.value)} className={inputCls}>
|
||||
<option>Active</option><option>Inactive</option><option>Left</option><option>On Leave</option>
|
||||
</select>
|
||||
: <RV>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusBadge(form.status)}`}>{form.status || 'Active'}</span>
|
||||
</RV>}
|
||||
</div>
|
||||
|
||||
{/* Middle Name */}
|
||||
<div>
|
||||
<FL>Middle Name</FL>
|
||||
{editable
|
||||
? <input value={form.middle_name || ''} onChange={e => set('middle_name', e.target.value)} className={inputCls} placeholder="Middle name" />
|
||||
: <RV>{form.middle_name}</RV>}
|
||||
</div>
|
||||
|
||||
{/* Salutation */}
|
||||
<div>
|
||||
<FL>Salutation</FL>
|
||||
{editable
|
||||
? <select value={form.salutation || ''} onChange={e => set('salutation', e.target.value)} className={inputCls}>
|
||||
<option value="">Select</option>
|
||||
<option>Mr.</option><option>Ms.</option><option>Mrs.</option><option>Dr.</option><option>Prof.</option>
|
||||
</select>
|
||||
: <RV>{form.salutation}</RV>}
|
||||
</div>
|
||||
|
||||
{/* Last Name */}
|
||||
<div>
|
||||
<FL>Last Name</FL>
|
||||
{editable
|
||||
? <input value={form.last_name || ''} onChange={e => set('last_name', e.target.value)} className={inputCls} placeholder="Last name" />
|
||||
: <RV>{form.last_name}</RV>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Company Details ── */}
|
||||
<CollapsibleSection title="Company Details">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-x-6 gap-y-4">
|
||||
|
||||
{/* Hospital (= company field) */}
|
||||
<div>
|
||||
<FL required>Hospital</FL>
|
||||
{editable
|
||||
? <LinkField label="Hospital" hideLabel doctype="Company" value={form.company || ''} onChange={v => set('company', v)} placeholder="Select hospital…" />
|
||||
: <RV>{form.company}</RV>}
|
||||
</div>
|
||||
|
||||
{/* Designation */}
|
||||
<div>
|
||||
<FL>Designation</FL>
|
||||
{editable
|
||||
? <LinkField label="Designation" hideLabel doctype="Designation" value={form.designation || ''} onChange={v => set('designation', v)} placeholder="Select designation…" />
|
||||
: <RV>{form.designation}</RV>}
|
||||
</div>
|
||||
|
||||
{/* Department */}
|
||||
<div>
|
||||
<FL>Department</FL>
|
||||
{editable
|
||||
? <LinkField label="Department" hideLabel doctype="Department" value={form.department || ''} onChange={v => set('department', v)} placeholder="Select department…" />
|
||||
: <RV>{form.department}</RV>}
|
||||
</div>
|
||||
|
||||
{/* Employee Number */}
|
||||
<div>
|
||||
<FL>Employee Number</FL>
|
||||
{editable
|
||||
? <input value={form.employee_number || ''} onChange={e => set('employee_number', e.target.value)} className={inputCls} placeholder="e.g. EMP-001" />
|
||||
: <RV>{form.employee_number}</RV>}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* ── Meta ── */}
|
||||
{!isNew && employee && (
|
||||
<div className="px-6 py-4 border-t border-gray-100 dark:border-gray-700 grid grid-cols-2 sm:grid-cols-3 gap-3 text-xs text-gray-400 dark:text-gray-500">
|
||||
<div><span className="font-medium block text-gray-500 dark:text-gray-400">Created By</span>{employee.owner}</div>
|
||||
<div><span className="font-medium block text-gray-500 dark:text-gray-400">Created</span>{employee.creation ? new Date(employee.creation).toLocaleString() : '-'}</div>
|
||||
<div><span className="font-medium block text-gray-500 dark:text-gray-400">Modified</span>{employee.modified ? new Date(employee.modified).toLocaleString() : '-'}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNew && (
|
||||
<div className="px-6 pb-6">
|
||||
<ActivityLog
|
||||
doctype="Employee"
|
||||
docname={employee?.name || employeeName || ''}
|
||||
creationDate={employee?.creation}
|
||||
createdBy={employee?.owner}
|
||||
compact={false}
|
||||
initialVisible={5}
|
||||
collapsible
|
||||
startCollapsed
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmployeeDetail;
|
||||
@ -1,242 +0,0 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaPlus, FaSearch, FaSpinner, FaUserTie, FaArrowLeft, FaFileExport } from 'react-icons/fa';
|
||||
import masterService, { type Employee } from '../services/masterService';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import DynamicExportModal from '../components/DynamicExportModal';
|
||||
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||
|
||||
const PAGE = 20;
|
||||
|
||||
const statusBadge = (status?: string) => {
|
||||
const s = (status || '').toLowerCase();
|
||||
if (s === 'active') return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300';
|
||||
if (s === 'inactive' || s === 'left') return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300';
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
|
||||
};
|
||||
|
||||
const EmployeeList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
const apiFilters = useMemo(() => {
|
||||
const f: Record<string, unknown> = {};
|
||||
if (search.trim()) {
|
||||
f.employee_name = ['like', `%${search.trim()}%`];
|
||||
}
|
||||
return f;
|
||||
}, [search]);
|
||||
|
||||
const load = useCallback(
|
||||
async (p = 0) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [{ data }, cnt] = await Promise.all([
|
||||
masterService.getEmployees({ limit_start: p * PAGE, limit_page_length: PAGE, filters: apiFilters }),
|
||||
masterService.getEmployeeCount(apiFilters),
|
||||
]);
|
||||
setEmployees(data);
|
||||
setTotal(cnt);
|
||||
setPage(p);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to load employees');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[apiFilters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load(0);
|
||||
}, [load]);
|
||||
|
||||
const selectionResetKey = useMemo(() => `${page}|${JSON.stringify(apiFilters)}`, [page, apiFilters]);
|
||||
const {
|
||||
selectedRows,
|
||||
toggleRow,
|
||||
toggleAllOnPage,
|
||||
allOnPageSelected,
|
||||
someOnPageSelected,
|
||||
} = useListPageSelection(employees, selectionResetKey);
|
||||
|
||||
const fetchAllForExport = useCallback(
|
||||
() => fetchAllRowsForExport({ doctype: 'Employee', filters: apiFilters, orderBy: 'modified desc' }),
|
||||
[apiFilters],
|
||||
);
|
||||
|
||||
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ToastContainer position="top-right" autoClose={3000} />
|
||||
|
||||
<div className="flex items-center gap-3 mb-6 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects')}
|
||||
className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
</button>
|
||||
<div className="w-10 h-10 rounded-xl bg-teal-600 flex items-center justify-center">
|
||||
<FaUserTie className="text-white text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Employees</h1>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{total} total</p>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap gap-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||
disabled={(total === 0 && selectedRows.size === 0) || loading}
|
||||
>
|
||||
<FaFileExport /> {t('listPages.export')}
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="relative">
|
||||
<FaSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={handleSearch}
|
||||
placeholder="Search employees…"
|
||||
className="pl-8 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white w-52 focus:outline-none focus:ring-2 focus:ring-teal-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/employees/new')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm font-medium"
|
||||
>
|
||||
<FaPlus size={11} /> New Employee
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DynamicExportModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
doctype="Employee"
|
||||
selectedCount={selectedRows.size}
|
||||
pageCount={employees.length}
|
||||
totalCount={total}
|
||||
pageData={employees}
|
||||
selectedRows={selectedRows}
|
||||
rowKey="name"
|
||||
onFetchAll={fetchAllForExport}
|
||||
fileNamePrefix="employees"
|
||||
/>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-16">
|
||||
<FaSpinner className="animate-spin text-teal-500 text-2xl" />
|
||||
</div>
|
||||
) : employees.length === 0 ? (
|
||||
<div className="py-16 text-center text-gray-400 dark:text-gray-500">
|
||||
<FaUserTie className="mx-auto text-4xl mb-3 opacity-30" />
|
||||
<p className="text-sm">No employees found</p>
|
||||
<button type="button" onClick={() => navigate('/employees/new')} className="mt-3 text-sm text-teal-600 hover:underline">
|
||||
+ Create first employee
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[2.5rem_1fr_1fr_1fr_1fr_120px] bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
<div className="px-2 py-3 flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-teal-600 focus:ring-teal-500"
|
||||
checked={allOnPageSelected}
|
||||
ref={el => {
|
||||
if (el) el.indeterminate = someOnPageSelected;
|
||||
}}
|
||||
onChange={toggleAllOnPage}
|
||||
aria-label="Select all on page"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 py-3">Name</div>
|
||||
<div className="px-4 py-3">ID</div>
|
||||
<div className="px-4 py-3">Department</div>
|
||||
<div className="px-4 py-3">Company</div>
|
||||
<div className="px-4 py-3">Status</div>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{employees.map((emp) => (
|
||||
<div
|
||||
key={emp.name}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/employees/${encodeURIComponent(emp.name)}`)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
navigate(`/employees/${encodeURIComponent(emp.name)}`);
|
||||
}
|
||||
}}
|
||||
className={`grid grid-cols-[2.5rem_1fr_1fr_1fr_1fr_120px] w-full text-left hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors cursor-pointer ${selectedRows.has(emp.name) ? 'bg-teal-50/80 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="px-2 py-3 flex items-center justify-center" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-teal-600 focus:ring-teal-500"
|
||||
checked={selectedRows.has(emp.name)}
|
||||
onChange={() => toggleRow(emp.name)}
|
||||
aria-label={`Select ${emp.name}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-sm font-medium text-teal-600 dark:text-teal-400">{emp.employee_name || emp.name}</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{emp.name}</div>
|
||||
<div className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{emp.department || '—'}</div>
|
||||
<div className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{emp.company || '—'}</div>
|
||||
<div className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusBadge(emp.status)}`}>
|
||||
{emp.status || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>
|
||||
Page {page + 1} · Showing {employees.length} of {total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => load(page - 1)} disabled={page === 0} className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => load(page + 1)}
|
||||
disabled={(page + 1) * PAGE >= total}
|
||||
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmployeeList;
|
||||
@ -230,11 +230,6 @@ const InspectionList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const setSearchParamsRef = useRef(setSearchParams);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParamsRef.current = setSearchParams;
|
||||
}, [setSearchParams]);
|
||||
|
||||
// ── Permission hook — same pattern as ModernDashboard ────────────────────
|
||||
// useUserPermissions('Issue Type') calls apiService.getPermissionFilters('Issue Type')
|
||||
@ -405,7 +400,7 @@ const InspectionList: React.FC = () => {
|
||||
filtersChangedOnce.current = true;
|
||||
return;
|
||||
}
|
||||
setSearchParamsRef.current((prev) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by');
|
||||
if (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
|
||||
@ -439,7 +434,7 @@ const InspectionList: React.FC = () => {
|
||||
setDateFilterBy(''); setDateStart(''); setDateEnd('');
|
||||
setSortBy('creation desc');
|
||||
setStatusFilter(''); setWorkflowStateFilter(''); setInspectionTypeFilter(''); setWorkOrderFilter(''); setDepartmentFilter('');
|
||||
setSearchParamsRef.current((prev) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end');
|
||||
next.delete('sort_by');
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useParams, useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIssueDetails, useIssueMutations } from '../hooks/useIssue';
|
||||
import {
|
||||
@ -10,26 +10,19 @@ import {
|
||||
FaCheckCircle,
|
||||
FaTimesCircle,
|
||||
FaExclamationTriangle,
|
||||
FaClock,
|
||||
FaUser,
|
||||
FaBuilding,
|
||||
FaEnvelope,
|
||||
FaCalendarAlt,
|
||||
FaTag,
|
||||
FaComment,
|
||||
FaClipboardList
|
||||
FaComment
|
||||
} from 'react-icons/fa';
|
||||
import { toast, ToastContainer, Bounce } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import LinkField from '../components/LinkField';
|
||||
import type { CreateIssueData } from '../services/issueService';
|
||||
import CommentSection from '../components/CommentSection';
|
||||
import WorkflowActions from '../components/WorkflowActions';
|
||||
import SupportPrecheckWizard from './SupportPrecheckWizard';
|
||||
import type { NewIssuePrecheckLocationState } from './supportPrecheckContent';
|
||||
import apiService from '../services/apiService';
|
||||
|
||||
const ROLES_CAN_CREATE_WO_FROM_ISSUE = ['Work Control', 'System Manager'];
|
||||
const ISSUE_STATUSES_ALLOW_WO = ['Open', 'Replied', 'On Hold'];
|
||||
|
||||
// Helper to get today's date in YYYY-MM-DD format
|
||||
const getTodayDate = (): string => {
|
||||
@ -63,14 +56,8 @@ const IssueDetail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { issueName } = useParams<{ issueName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
|
||||
const isNewIssue = issueName === 'new';
|
||||
const newIssuePrecheckState = (location.state || undefined) as NewIssuePrecheckLocationState | undefined;
|
||||
const skipNewIssuePrecheck =
|
||||
newIssuePrecheckState?.newIssuePrecheckDone === true ||
|
||||
searchParams.get('skip_precheck') === '1';
|
||||
|
||||
// Form data state
|
||||
const [formData, setFormData] = useState<CreateIssueData & {
|
||||
@ -103,85 +90,6 @@ const IssueDetail: React.FC = () => {
|
||||
|
||||
const [isEditing, setIsEditing] = useState(isNewIssue);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const appliedPrecheckReasonRef = useRef(false);
|
||||
|
||||
const [userRoles, setUserRoles] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const response = await apiService.apiCall<unknown>(
|
||||
'/api/method/asset_lite.api.user_roles.get_user_roles'
|
||||
);
|
||||
if (Array.isArray(response)) {
|
||||
setUserRoles(response as string[]);
|
||||
} else if (response && typeof response === 'object' && 'message' in response) {
|
||||
const msg = (response as { message?: string[] }).message;
|
||||
if (Array.isArray(msg)) setUserRoles(msg);
|
||||
}
|
||||
} catch {
|
||||
setUserRoles([]);
|
||||
}
|
||||
};
|
||||
void fetchRoles();
|
||||
}, []);
|
||||
|
||||
const canCreateWorkOrderFromIssue =
|
||||
!isNewIssue &&
|
||||
ISSUE_STATUSES_ALLOW_WO.includes((issue?.status || formData.status || '').trim()) &&
|
||||
ROLES_CAN_CREATE_WO_FROM_ISSUE.some((r) => userRoles.includes(r));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNewIssue || !skipNewIssuePrecheck) {
|
||||
return;
|
||||
}
|
||||
const reason = newIssuePrecheckState?.precheckCantCompleteReason?.trim();
|
||||
if (!reason || appliedPrecheckReasonRef.current) {
|
||||
return;
|
||||
}
|
||||
appliedPrecheckReasonRef.current = true;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
description: `${prev.description ? `${prev.description}\n\n` : ''}Could not complete self-service checks: ${reason}`,
|
||||
}));
|
||||
}, [isNewIssue, skipNewIssuePrecheck, newIssuePrecheckState?.precheckCantCompleteReason]);
|
||||
|
||||
/** Legacy Open issues: ensure workflow_state exists so Work Control sees actions */
|
||||
useEffect(() => {
|
||||
if (!issueName || isNewIssue || !issue) return;
|
||||
if (issue.workflow_state || issue.status !== 'Open') return;
|
||||
let cancelled = false;
|
||||
void apiService
|
||||
.apiCall('/api/method/asset_lite.api.issue_api.normalize_issue_workflow_state', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ issue_name: issueName }),
|
||||
})
|
||||
.then(() => {
|
||||
if (!cancelled) void refetch();
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [issueName, isNewIssue, issue?.name, issue?.workflow_state, issue?.status, refetch]);
|
||||
|
||||
const issueWorkflowDocData = useMemo(() => {
|
||||
if (!issue || isNewIssue) return undefined;
|
||||
return {
|
||||
status: formData.status || issue.status || '',
|
||||
subject: formData.subject ?? issue.subject ?? '',
|
||||
priority: formData.priority ?? issue.priority ?? '',
|
||||
company: formData.company ?? issue.company ?? '',
|
||||
};
|
||||
}, [
|
||||
issue,
|
||||
isNewIssue,
|
||||
formData.status,
|
||||
formData.subject,
|
||||
formData.priority,
|
||||
formData.company,
|
||||
]);
|
||||
|
||||
// Load issue data when fetched
|
||||
useEffect(() => {
|
||||
@ -226,17 +134,6 @@ const IssueDetail: React.FC = () => {
|
||||
try {
|
||||
if (isNewIssue) {
|
||||
const newIssue = await createIssue(formData);
|
||||
// Auto-assign the newly created Issue to Work Control for review
|
||||
try {
|
||||
await apiService.apiCall('/api/method/asset_lite.api.issue_api.assign_issue_to_work_control', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ issue_name: newIssue.name })
|
||||
});
|
||||
} catch (assignErr) {
|
||||
// Non-blocking: ticket is created even if assignment fails
|
||||
console.warn('Failed to auto-assign issue to Work Control:', assignErr);
|
||||
}
|
||||
toast.success('Issue created successfully!', {
|
||||
position: "top-right",
|
||||
autoClose: 3000,
|
||||
@ -297,10 +194,6 @@ const IssueDetail: React.FC = () => {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
};
|
||||
|
||||
if (isNewIssue && !skipNewIssuePrecheck) {
|
||||
return <SupportPrecheckWizard variant="newIssue" />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
|
||||
@ -373,19 +266,7 @@ const IssueDetail: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{!isNewIssue && canCreateWorkOrderFromIssue && issueName && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigate(`/work-orders/new?from_issue=${encodeURIComponent(issueName)}`)
|
||||
}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<FaClipboardList />
|
||||
{t('issues.createWorkOrderFromIssue')}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
{!isNewIssue && !isEditing && (
|
||||
<>
|
||||
<button
|
||||
@ -711,24 +592,6 @@ const IssueDetail: React.FC = () => {
|
||||
|
||||
{/* Sidebar - Right Column */}
|
||||
<div className="space-y-6">
|
||||
{!isNewIssue && issueName && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
{t('issues.workflowActions')}
|
||||
</h2>
|
||||
<WorkflowActions
|
||||
doctype="Issue"
|
||||
docname={issueName}
|
||||
workflowState={issue?.workflow_state || undefined}
|
||||
docData={issueWorkflowDocData}
|
||||
onStateChange={() => refetch()}
|
||||
documentLabel={t('issues.issueSingular')}
|
||||
stateHeading={t('workOrders.detail.currentState')}
|
||||
showFullAccessNote
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
|
||||
|
||||
@ -26,15 +26,10 @@ import {
|
||||
FaFileExport,
|
||||
FaFileExcel,
|
||||
FaFileCsv,
|
||||
FaDownload,
|
||||
FaClipboardList
|
||||
FaDownload
|
||||
} from 'react-icons/fa';
|
||||
import LinkField from '../components/LinkField';
|
||||
import { buildDateRangeFilters, toFrappeFilterArray } from '../utils/listFilterUtils';
|
||||
import apiService from '../services/apiService';
|
||||
|
||||
const ROLES_CAN_CREATE_WO_FROM_ISSUE = ['Work Control', 'System Manager'];
|
||||
const ISSUE_STATUSES_ALLOW_WO = ['Open', 'Replied', 'On Hold'];
|
||||
|
||||
// Export types
|
||||
type ExportFormat = 'csv' | 'excel';
|
||||
@ -222,11 +217,6 @@ const IssueList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const setSearchParamsRef = useRef(setSearchParams);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParamsRef.current = setSearchParams;
|
||||
}, [setSearchParams]);
|
||||
const currentPage = useMemo(() => {
|
||||
const p = parseInt(searchParams.get('page') || '1', 10);
|
||||
return Number.isNaN(p) || p < 1 ? 1 : p;
|
||||
@ -266,41 +256,11 @@ const IssueList: React.FC = () => {
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
|
||||
const [userRoles, setUserRoles] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const response = await apiService.apiCall<unknown>(
|
||||
'/api/method/asset_lite.api.user_roles.get_user_roles'
|
||||
);
|
||||
if (Array.isArray(response)) {
|
||||
setUserRoles(response as string[]);
|
||||
} else if (response && typeof response === 'object' && 'message' in response) {
|
||||
const msg = (response as { message?: string[] }).message;
|
||||
if (Array.isArray(msg)) setUserRoles(msg);
|
||||
}
|
||||
} catch {
|
||||
setUserRoles([]);
|
||||
}
|
||||
};
|
||||
void fetchRoles();
|
||||
}, []);
|
||||
|
||||
const canCreateWorkOrderFromIssue = useMemo(
|
||||
() => ROLES_CAN_CREATE_WO_FROM_ISSUE.some((r) => userRoles.includes(r)),
|
||||
[userRoles]
|
||||
);
|
||||
|
||||
const [dateFilterBy, setDateFilterBy] = useState<'' | 'creation' | 'modified'>(() => (searchParams.get('date_filter_by') as '' | 'creation' | 'modified') || '');
|
||||
const [dateStart, setDateStart] = useState<string>(() => searchParams.get('date_start') || '');
|
||||
const [dateEnd, setDateEnd] = useState<string>(() => searchParams.get('date_end') || '');
|
||||
// Default to Open so closed issues (e.g. after WO applied by WC) drop off the main queue
|
||||
const [statusFilter, setStatusFilter] = useState<string>(() => {
|
||||
const raw = searchParams.get('status');
|
||||
if (raw === null) return 'Open';
|
||||
return raw;
|
||||
});
|
||||
const [statusFilter, setStatusFilter] = useState<string>(() => searchParams.get('status') || '');
|
||||
const [priorityFilter, setPriorityFilter] = useState<string>(() => searchParams.get('priority') || '');
|
||||
const [companyFilter, setCompanyFilter] = useState<string>(() => searchParams.get('company') || '');
|
||||
const [issueIdFilter, setIssueIdFilter] = useState<string>(() => searchParams.get('issue_id') || '');
|
||||
@ -348,7 +308,7 @@ const IssueList: React.FC = () => {
|
||||
filtersChangedOnce.current = true;
|
||||
return;
|
||||
}
|
||||
setSearchParamsRef.current((prev) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by');
|
||||
if (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
|
||||
@ -371,7 +331,7 @@ const IssueList: React.FC = () => {
|
||||
setDateFilterBy(''); setDateStart(''); setDateEnd('');
|
||||
setSortBy('creation desc');
|
||||
setStatusFilter(''); setPriorityFilter(''); setCompanyFilter(''); setIssueIdFilter('');
|
||||
setSearchParamsRef.current((prev) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end');
|
||||
next.delete('sort_by');
|
||||
@ -527,14 +487,7 @@ const IssueList: React.FC = () => {
|
||||
<FaFileExport /><span className="font-medium">{t('listPages.export')}</span>
|
||||
{selectedRows.size > 0 && <span className="bg-white/20 px-1.5 py-0.5 rounded text-xs">{selectedRows.size}</span>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
navigate('/support/new?skip_precheck=1', {
|
||||
state: { newIssuePrecheckDone: true },
|
||||
})
|
||||
}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl"
|
||||
>
|
||||
<button onClick={() => navigate('/support/new')} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl">
|
||||
<FaPlus /><span className="font-medium">{t('issues.newIssue')}</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -735,11 +688,7 @@ const IssueList: React.FC = () => {
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() =>
|
||||
navigate('/support/new?skip_precheck=1', {
|
||||
state: { newIssuePrecheckDone: true },
|
||||
})
|
||||
}
|
||||
onClick={() => navigate('/support/new')}
|
||||
className="mt-4 text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{t('issues.createFirstIssue')}
|
||||
@ -779,19 +728,6 @@ const IssueList: React.FC = () => {
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => navigate(`/support/${issue.name}`)} className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors" title={t('issues.viewDetails')}><FaEye /></button>
|
||||
<button onClick={() => navigate(`/support/${issue.name}`)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title={t('issues.editIssue')}><FaEdit /></button>
|
||||
{canCreateWorkOrderFromIssue &&
|
||||
ISSUE_STATUSES_ALLOW_WO.includes((issue.status || '').trim()) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigate(`/work-orders/new?from_issue=${encodeURIComponent(issue.name)}`)
|
||||
}
|
||||
className="text-emerald-600 dark:text-emerald-400 hover:text-emerald-900 dark:hover:text-emerald-300 p-2 hover:bg-emerald-50 dark:hover:bg-emerald-900/30 rounded transition-colors"
|
||||
title={t('issues.createWorkOrderFromIssue')}
|
||||
>
|
||||
<FaClipboardList />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setDeleteConfirmOpen(issue.name)} className="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300 p-2 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors" title={t('issues.deleteIssue')}><FaTrash /></button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@ -318,11 +318,6 @@ const ItemList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const setSearchParamsRef = useRef(setSearchParams);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParamsRef.current = setSearchParams;
|
||||
}, [setSearchParams]);
|
||||
const pageFromUrl = useMemo(() => {
|
||||
const p = parseInt(searchParams.get('page') || '1', 10);
|
||||
return Math.max(0, Number.isNaN(p) ? 0 : p - 1);
|
||||
@ -471,7 +466,7 @@ const ItemList: React.FC = () => {
|
||||
filtersChangedOnce.current = true;
|
||||
return;
|
||||
}
|
||||
setSearchParamsRef.current((prev) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by');
|
||||
if (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
|
||||
|
||||
@ -1,170 +1,30 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { loadFrappeTranslations } from '../i18n';
|
||||
import { bootstrapFrappeUserFromSession } from '../utils/bootstrapFrappeUserFromSession';
|
||||
|
||||
interface LoginFormData {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const AFTER_PASSWORD_RESET_KEY = 'asm_show_after_password_reset';
|
||||
const TWO_FACTOR_TMP_ID_KEY = 'asm_login_tmp_id';
|
||||
|
||||
type LoginStep = 'credentials' | 'otp';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [formData, setFormData] = useState<LoginFormData>({
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checkingSession, setCheckingSession] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [forgotOpen, setForgotOpen] = useState(false);
|
||||
const [forgotEmail, setForgotEmail] = useState('');
|
||||
const [forgotLoading, setForgotLoading] = useState(false);
|
||||
const [forgotError, setForgotError] = useState<string | null>(null);
|
||||
const [forgotMessage, setForgotMessage] = useState(false);
|
||||
const [pwdResetBusy, setPwdResetBusy] = useState(false);
|
||||
const [postResetBanner, setPostResetBanner] = useState(false);
|
||||
const [loginStep, setLoginStep] = useState<LoginStep>('credentials');
|
||||
const [tmpId, setTmpId] = useState<string | null>(null);
|
||||
const [otpCode, setOtpCode] = useState('');
|
||||
const [verification, setVerification] = useState<{
|
||||
method: string;
|
||||
setup?: boolean;
|
||||
prompt?: string;
|
||||
} | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const manualLoginHandledRef = useRef(false);
|
||||
const forgotAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const closeForgotModal = useCallback(() => {
|
||||
forgotAbortRef.current?.abort();
|
||||
forgotAbortRef.current = null;
|
||||
setForgotOpen(false);
|
||||
setForgotLoading(false);
|
||||
setForgotError(null);
|
||||
setForgotMessage(false);
|
||||
}, []);
|
||||
|
||||
const openForgotModal = useCallback(() => {
|
||||
setForgotOpen(true);
|
||||
setForgotEmail(formData.email);
|
||||
setForgotError(null);
|
||||
setForgotMessage(false);
|
||||
setForgotLoading(false);
|
||||
setError(null);
|
||||
}, [formData.email]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!forgotOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeForgotModal();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [forgotOpen, closeForgotModal]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
if (
|
||||
typeof sessionStorage !== 'undefined' &&
|
||||
sessionStorage.getItem(AFTER_PASSWORD_RESET_KEY) === '1'
|
||||
) {
|
||||
sessionStorage.removeItem(AFTER_PASSWORD_RESET_KEY);
|
||||
if (!cancelled) setPostResetBanner(true);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(
|
||||
typeof window !== 'undefined' ? window.location.search : ''
|
||||
);
|
||||
if (params.get('manual_login') === '1' && !manualLoginHandledRef.current) {
|
||||
manualLoginHandledRef.current = true;
|
||||
if (!cancelled) setPwdResetBusy(true);
|
||||
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('frappe_session_id');
|
||||
|
||||
const baseURL = import.meta.env.VITE_FRAPPE_BASE_URL || '';
|
||||
let csrf =
|
||||
typeof window !== 'undefined'
|
||||
? (window as { csrf_token?: string }).csrf_token
|
||||
: undefined;
|
||||
if (!csrf) {
|
||||
try {
|
||||
const csrfRes = await fetch(`${baseURL}/api/method/frappe.sessions.get_csrf_token`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (csrfRes.ok) {
|
||||
const csrfData = (await csrfRes.json()) as { message?: string };
|
||||
if (typeof csrfData.message === 'string') csrf = csrfData.message;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (csrf) headers['X-Frappe-CSRF-Token'] = csrf;
|
||||
try {
|
||||
await fetch(`${baseURL}/api/method/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.setItem(AFTER_PASSWORD_RESET_KEY, '1');
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
setPwdResetBusy(false);
|
||||
navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (localStorage.getItem('user')) {
|
||||
if (!cancelled) {
|
||||
setCheckingSession(false);
|
||||
navigate('/dashboard', { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await bootstrapFrappeUserFromSession();
|
||||
if (cancelled) return;
|
||||
|
||||
if (result.ok) {
|
||||
try {
|
||||
await loadFrappeTranslations();
|
||||
} catch (err) {
|
||||
console.warn('Could not load translations after session bootstrap:', err);
|
||||
}
|
||||
if (!cancelled) navigate('/dashboard', { replace: true });
|
||||
}
|
||||
if (!cancelled) setCheckingSession(false);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [navigate, location.pathname, location.search]);
|
||||
|
||||
const { isRTL } = useLanguage();
|
||||
|
||||
// Get base URL for assets
|
||||
const baseUrl = import.meta.env.BASE_URL || '/';
|
||||
const logoVersion = import.meta.env.DEV
|
||||
? `?v=${Date.now()}`
|
||||
: `?v=1774269853`; // Auto-updated by build script
|
||||
: `?v=1768316563`; // Auto-updated by build script
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
@ -175,94 +35,48 @@ const Login: React.FC = () => {
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const completeLogin = useCallback(
|
||||
async (user: { full_name?: string; user_id?: string; sid?: string; email?: string }) => {
|
||||
const apiService = (await import('../services/apiService')).default;
|
||||
const userData = {
|
||||
...user,
|
||||
email: user.email || formData.email,
|
||||
};
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
if (user.sid) {
|
||||
apiService.setSessionId(user.sid);
|
||||
}
|
||||
sessionStorage.removeItem(TWO_FACTOR_TMP_ID_KEY);
|
||||
try {
|
||||
await loadFrappeTranslations();
|
||||
} catch (err) {
|
||||
console.warn('Could not load translations after login:', err);
|
||||
}
|
||||
navigate('/dashboard');
|
||||
},
|
||||
[formData.email, navigate]
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Dynamic import to catch any module loading errors
|
||||
const { useAuth } = await import('../hooks/useApi');
|
||||
const apiService = (await import('../services/apiService')).default;
|
||||
const result = await apiService.login(formData);
|
||||
|
||||
if (result.status === 'two_factor_required') {
|
||||
sessionStorage.setItem(TWO_FACTOR_TMP_ID_KEY, result.tmp_id);
|
||||
setTmpId(result.tmp_id);
|
||||
setVerification(result.verification);
|
||||
setLoginStep('otp');
|
||||
setOtpCode('');
|
||||
return;
|
||||
|
||||
const response = await apiService.login(formData);
|
||||
|
||||
if (response && response.message) {
|
||||
const userData = {
|
||||
...response.message,
|
||||
email: formData.email
|
||||
};
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
|
||||
if (response.message.sid) {
|
||||
apiService.setSessionId(response.message.sid);
|
||||
}
|
||||
|
||||
// Load translations from Frappe after successful login
|
||||
try {
|
||||
await loadFrappeTranslations();
|
||||
} catch (err) {
|
||||
console.warn('Could not load translations after login:', err);
|
||||
}
|
||||
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
setError(t('login.loginFailed'));
|
||||
}
|
||||
|
||||
await completeLogin(result.user);
|
||||
} catch (err: unknown) {
|
||||
} catch (err: any) {
|
||||
console.error('Login error:', err);
|
||||
const message = err instanceof Error ? err.message : t('login.loginFailed');
|
||||
setError(message);
|
||||
setError(err.message || t('login.loginFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const storedTmpId = tmpId || sessionStorage.getItem(TWO_FACTOR_TMP_ID_KEY);
|
||||
if (!storedTmpId) {
|
||||
setError(t('login.twoFactorSessionExpired'));
|
||||
setLoginStep('credentials');
|
||||
return;
|
||||
}
|
||||
if (!otpCode.trim()) {
|
||||
setError(t('login.twoFactorCodeRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const apiService = (await import('../services/apiService')).default;
|
||||
const result = await apiService.verifyLoginOtp(storedTmpId, otpCode);
|
||||
if (result.status === 'logged_in') {
|
||||
await completeLogin(result.user);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('login.twoFactorInvalid');
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const backToCredentials = () => {
|
||||
sessionStorage.removeItem(TWO_FACTOR_TMP_ID_KEY);
|
||||
setLoginStep('credentials');
|
||||
setTmpId(null);
|
||||
setVerification(null);
|
||||
setOtpCode('');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleDemoLogin = async () => {
|
||||
const demoUser = {
|
||||
full_name: 'Demo User',
|
||||
@ -283,70 +97,6 @@ const Login: React.FC = () => {
|
||||
navigate('/dashboard');
|
||||
};
|
||||
|
||||
const handleForgotPasswordSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const userVal = forgotEmail.trim();
|
||||
if (!userVal) {
|
||||
setForgotError(t('login.forgotPasswordUserRequired'));
|
||||
return;
|
||||
}
|
||||
forgotAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
forgotAbortRef.current = controller;
|
||||
|
||||
setForgotLoading(true);
|
||||
setForgotError(null);
|
||||
setForgotMessage(false);
|
||||
const forgotApi = await import('../services/apiService');
|
||||
try {
|
||||
await forgotApi.default.requestPasswordReset(userVal, controller.signal);
|
||||
setForgotMessage(true);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
setForgotError(t('login.forgotPasswordTimeout'));
|
||||
} else if (err instanceof forgotApi.ApiError) {
|
||||
if (err.code === 'USER_NOT_FOUND') setForgotError(t('login.forgotPasswordNotFound'));
|
||||
else if (err.code === 'RESET_NOT_ALLOWED') setForgotError(t('login.forgotPasswordCannotReset'));
|
||||
else if (err.code === 'FORBIDDEN') setForgotError(t('login.forgotPasswordFailed'));
|
||||
else if (err.code === 'EMPTY_EMAIL') setForgotError(t('login.forgotPasswordUserRequired'));
|
||||
else setForgotError(t('login.forgotPasswordFailed'));
|
||||
} else {
|
||||
setForgotError(t('login.forgotPasswordFailed'));
|
||||
}
|
||||
} finally {
|
||||
if (forgotAbortRef.current === controller) {
|
||||
forgotAbortRef.current = null;
|
||||
}
|
||||
setForgotLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (checkingSession || pwdResetBusy) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="flex flex-col items-center gap-3 text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
className="h-10 w-10 animate-spin text-indigo-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm">
|
||||
{pwdResetBusy ? t('login.finishingSignOut') : t('common.loading')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
@ -383,120 +133,51 @@ const Login: React.FC = () => {
|
||||
{t('login.subtitle')}
|
||||
</p>
|
||||
<p className="mt-1 text-center text-xs text-gray-600 dark:text-gray-400">
|
||||
{loginStep === 'otp' ? t('login.twoFactorTitle') : t('login.signIn')}
|
||||
{t('login.signIn')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-6">
|
||||
{postResetBanner && loginStep === 'credentials' && (
|
||||
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
|
||||
<p className="text-sm text-green-800 dark:text-green-300">
|
||||
{t('login.afterPasswordResetSignIn')}
|
||||
</p>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
{t('common.email')}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder={t('login.emailPlaceholder')}
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
{t('common.password')}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loginStep === 'otp' ? (
|
||||
<form className="space-y-6" onSubmit={handleOtpSubmit}>
|
||||
<div className="rounded-md bg-indigo-50 p-4 text-sm text-indigo-900 dark:bg-indigo-900/20 dark:text-indigo-200">
|
||||
{verification?.method === 'Email' && verification.prompt ? (
|
||||
<p>{verification.prompt}</p>
|
||||
) : verification?.method === 'OTP App' && verification.setup ? (
|
||||
<p>{t('login.twoFactorOtpAppEnter')}</p>
|
||||
) : verification?.method === 'OTP App' && !verification.setup ? (
|
||||
<p>{t('login.twoFactorOtpAppSetupIncomplete')}</p>
|
||||
) : (
|
||||
<p>{t('login.twoFactorOtpAppEnter')}</p>
|
||||
)}
|
||||
{verification?.method === 'Email' && (
|
||||
<p className="mt-2 text-xs text-indigo-800 dark:text-indigo-300">
|
||||
{t('login.twoFactorEmailQrHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="otp" className="sr-only">
|
||||
{t('login.twoFactorCodeLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="otp"
|
||||
name="otp"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
required
|
||||
autoFocus
|
||||
className="relative block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-center text-lg tracking-widest text-gray-900 placeholder-gray-500 focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
|
||||
placeholder={t('login.twoFactorCodePlaceholder')}
|
||||
value={otpCode}
|
||||
onChange={(ev) => {
|
||||
setOtpCode(ev.target.value.replace(/\D/g, '').slice(0, 6));
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
|
||||
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? t('common.loading') : t('login.twoFactorVerify')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={backToCredentials}
|
||||
className="w-full text-center text-sm font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400"
|
||||
>
|
||||
{t('login.twoFactorBackToLogin')}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
{t('common.email')}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder={t('login.emailPlaceholder')}
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
{t('common.password')}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
@ -514,22 +195,7 @@ const Login: React.FC = () => {
|
||||
t('common.login')
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{loginStep === 'credentials' && (
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openForgotModal}
|
||||
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300 focus:outline-none focus:underline"
|
||||
>
|
||||
{t('login.forgotPassword')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hidden space-y-3" aria-hidden="true">
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300 dark:border-gray-600" />
|
||||
@ -538,93 +204,17 @@ const Login: React.FC = () => {
|
||||
<span className="px-2 bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400">{t('login.or')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDemoLogin}
|
||||
tabIndex={-1}
|
||||
className="w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
🚀 {t('login.demoLogin')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{forgotOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={closeForgotModal}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-md rounded-lg border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="forgot-password-title"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForgotModal}
|
||||
className="absolute right-3 top-3 rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||
aria-label={t('login.forgotPasswordClose')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h3
|
||||
id="forgot-password-title"
|
||||
className="pr-8 text-lg font-semibold text-gray-900 dark:text-white"
|
||||
>
|
||||
{t('login.forgotPasswordTitle')}
|
||||
</h3>
|
||||
<p className="mt-2 text-xs leading-relaxed text-gray-600 dark:text-gray-400">
|
||||
{t('login.forgotPasswordHint')}
|
||||
</p>
|
||||
<form className="mt-4 space-y-3" onSubmit={handleForgotPasswordSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
name="reset-user"
|
||||
autoComplete="username"
|
||||
value={forgotEmail}
|
||||
onChange={(ev) => {
|
||||
setForgotEmail(ev.target.value);
|
||||
setForgotError(null);
|
||||
setForgotMessage(false);
|
||||
}}
|
||||
className="relative block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-500 focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-400"
|
||||
placeholder={t('login.forgotPasswordUserPlaceholder')}
|
||||
/>
|
||||
{forgotError && (
|
||||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
|
||||
{forgotError}
|
||||
</div>
|
||||
)}
|
||||
{forgotMessage && (
|
||||
<div className="rounded-md bg-green-50 p-3 text-sm text-green-800 dark:bg-green-900/20 dark:text-green-300">
|
||||
{t('login.forgotPasswordSentSuccess')}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForgotModal}
|
||||
className="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
{t('login.forgotPasswordClose')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={forgotLoading}
|
||||
className="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{forgotLoading ? t('common.loading') : t('login.forgotPasswordSubmit')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -180,11 +180,6 @@ const MaintenanceTeamList: React.FC = () => {
|
||||
];
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const setSearchParamsRef = useRef(setSearchParams);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParamsRef.current = setSearchParams;
|
||||
}, [setSearchParams]);
|
||||
const currentPage = useMemo(() => {
|
||||
const p = parseInt(searchParams.get('page') || '1', 10);
|
||||
return Number.isNaN(p) || p < 1 ? 1 : p;
|
||||
@ -251,7 +246,7 @@ const MaintenanceTeamList: React.FC = () => {
|
||||
filtersChangedOnce.current = true;
|
||||
return;
|
||||
}
|
||||
setSearchParamsRef.current((prev) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by');
|
||||
if (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
|
||||
@ -272,7 +267,7 @@ const MaintenanceTeamList: React.FC = () => {
|
||||
setDateFilterBy(''); setDateStart(''); setDateEnd('');
|
||||
setSortBy('creation desc');
|
||||
setCompanyFilter(''); setTeamNameFilter('');
|
||||
setSearchParamsRef.current((prev) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end');
|
||||
next.delete('sort_by');
|
||||
|
||||
@ -1,610 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
FaArrowLeft, FaSave, FaEdit, FaTimes, FaPlus, FaTrash,
|
||||
FaSpinner, FaBoxes, FaPaperPlane, FaChevronDown, FaChevronRight, FaPencilAlt, FaShoppingBag,
|
||||
} from 'react-icons/fa';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import materialRequestService, { MaterialRequest, MaterialRequestItem } from '../services/materialRequestService';
|
||||
import LinkField from '../components/LinkField';
|
||||
import ActivityLog from '../components/ActivityLog';
|
||||
import WorkflowActions from '../components/WorkflowActions';
|
||||
import workflowService from '../services/workflowService';
|
||||
import { DEFAULT_COMPANY } from '../constants/orgDefaults';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
const FL: React.FC<{ children: React.ReactNode; required?: boolean }> = ({ children, required }) => (
|
||||
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||
{children}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
);
|
||||
const RV: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
||||
<div className="px-3 py-2 text-sm text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-800/60 rounded-lg min-h-[38px] flex items-center">
|
||||
{children || <span className="text-gray-400">-</span>}
|
||||
</div>
|
||||
);
|
||||
const inputCls = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-orange-400';
|
||||
const inlineTxt = 'w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-orange-400';
|
||||
const inlineNum = 'w-full px-2 py-1 text-sm text-right border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-orange-400';
|
||||
const editorInput = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-orange-400';
|
||||
const editorNum = 'w-full px-3 py-2 text-sm text-right border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-orange-400';
|
||||
|
||||
// ── Collapsible section (matches Project/Task/Timesheet style) ─────────────────
|
||||
const CollapsibleSection: React.FC<{
|
||||
title: string; icon?: React.ReactNode; defaultOpen?: boolean; children: React.ReactNode;
|
||||
}> = ({ title, icon, defaultOpen = true, children }) => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden shadow-sm">
|
||||
<button type="button" onClick={() => setOpen(o => !o)}
|
||||
className="w-full flex items-center justify-between px-5 py-3.5 hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors text-left">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
{icon}<span>{title}</span>
|
||||
</div>
|
||||
{open ? <FaChevronDown className="text-gray-400 text-xs flex-shrink-0" /> : <FaChevronRight className="text-gray-400 text-xs flex-shrink-0" />}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-5 py-5 bg-white dark:bg-gray-800 border-t border-gray-100 dark:border-gray-700/50">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Collapsible group (inside editor) ─────────────────────────────────────────
|
||||
const RGroup: React.FC<{ label: string; children: React.ReactNode; defaultOpen?: boolean }> = ({ label, children, defaultOpen = false }) => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden mt-3">
|
||||
<button type="button" onClick={() => setOpen(o => !o)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-[11px] font-bold text-gray-500 uppercase tracking-wider bg-gray-50 dark:bg-gray-800/80 hover:bg-gray-100 transition-colors text-left">
|
||||
{open ? <FaChevronDown size={9} /> : <FaChevronRight size={9} />}{label}
|
||||
</button>
|
||||
{open && <div className="p-3 grid grid-cols-1 sm:grid-cols-2 gap-3 bg-white dark:bg-gray-800">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Create Dropdown ───────────────────────────────────────────────────────────
|
||||
const CreateDropdown: React.FC<{ items: { label: string; icon: React.ReactNode; onClick: () => void }[] }> = ({ items }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
document.addEventListener('mousedown', h);
|
||||
return () => document.removeEventListener('mousedown', h);
|
||||
}, []);
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button onClick={() => setOpen(o => !o)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 text-sm font-medium shadow-sm">
|
||||
Create <FaChevronDown size={10} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-1 w-52 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-50 py-1.5">
|
||||
<div className="px-3 py-1.5 text-[10px] font-bold text-gray-400 uppercase tracking-wider border-b border-gray-100 dark:border-gray-700 mb-1">Create from this request</div>
|
||||
{items.map(({ label, icon, onClick }) => (
|
||||
<button key={label} onClick={() => { onClick(); setOpen(false); }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-amber-50 dark:hover:bg-amber-900/20 hover:text-amber-700 transition-colors text-left">
|
||||
<span className="text-gray-400">{icon}</span>{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── MR Item Row Editor ────────────────────────────────────────────────────────
|
||||
const MRItemRowEditor: React.FC<{
|
||||
item: Partial<MaterialRequestItem>;
|
||||
rowNo: number;
|
||||
onChange: (k: string, v: any) => void;
|
||||
onClose: () => void;
|
||||
onDelete: () => void;
|
||||
onInsertBelow: () => void;
|
||||
}> = ({ item, rowNo, onChange, onClose, onDelete, onInsertBelow }) => {
|
||||
const set = (k: string, v: any) => onChange(k, v);
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-0">
|
||||
<div className="bg-amber-50/60 dark:bg-amber-900/10 border-b border-amber-200 dark:border-amber-800 px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-bold text-amber-700 dark:text-amber-300 uppercase tracking-wider">Editing Row #{rowNo}</span>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={onInsertBelow} className="px-2 py-1 text-[11px] bg-amber-600 text-white rounded hover:bg-amber-700">Insert Below</button>
|
||||
<button onClick={onDelete} className="px-2 py-1 text-[11px] bg-red-500 text-white rounded hover:bg-red-600">Delete</button>
|
||||
<button onClick={onClose} className="px-2 py-1 text-[11px] bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300">ESC</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div><FL required>Item Code</FL><LinkField label="Item Code" hideLabel doctype="Item" value={item.item_code || ''} onChange={v => set('item_code', v)} /></div>
|
||||
{/* schedule_date is the correct Frappe fieldname for "Required By" on MR items */}
|
||||
<div><FL required>Required By</FL><input type="date" value={(item as any).schedule_date || ''} onChange={e => set('schedule_date', e.target.value)} className={editorInput} /></div>
|
||||
<div><FL>Item Name</FL><input value={item.item_name || ''} onChange={e => set('item_name', e.target.value)} className={editorInput} /></div>
|
||||
</div>
|
||||
|
||||
<RGroup label="Description">
|
||||
<div className="sm:col-span-2">
|
||||
<FL>Description</FL>
|
||||
<textarea value={item.description || ''} onChange={e => set('description', e.target.value)} rows={2} className={editorInput + ' resize-none'} />
|
||||
</div>
|
||||
</RGroup>
|
||||
|
||||
<RGroup label="Quantity and Warehouse" defaultOpen>
|
||||
<div><FL required>Quantity</FL><input type="number" min={0} step="1" value={item.qty ?? 1} onChange={e => set('qty', parseFloat(e.target.value) || 0)} className={editorNum} /></div>
|
||||
<div><FL required>UOM</FL><LinkField label="UOM" hideLabel doctype="UOM" value={item.uom || ''} onChange={v => set('uom', v)} /></div>
|
||||
<div><FL>Stock UOM</FL><LinkField label="Stock UOM" hideLabel doctype="UOM" value={item.stock_uom || ''} onChange={v => set('stock_uom', v)} /></div>
|
||||
<div><FL>UOM Conversion Factor</FL><input type="number" min={0} step="0.0001" value={(item as any).conversion_factor ?? 1} onChange={e => set('conversion_factor', parseFloat(e.target.value) || 1)} className={editorNum} /></div>
|
||||
<div><FL>Target Warehouse</FL><LinkField label="Target Warehouse" hideLabel doctype="Warehouse" value={(item as any).warehouse || ''} onChange={v => set('warehouse', v)} /></div>
|
||||
</RGroup>
|
||||
|
||||
<RGroup label="Rate">
|
||||
<div><FL>Rate</FL><input type="number" min={0} step="0.01" value={(item as any).rate ?? 0} onChange={e => set('rate', parseFloat(e.target.value) || 0)} className={editorNum} /></div>
|
||||
</RGroup>
|
||||
|
||||
<RGroup label="Accounting Details">
|
||||
<div className="sm:col-span-2"><FL>Expense Account</FL><LinkField label="Expense Account" hideLabel doctype="Account" value={(item as any).expense_account || ''} onChange={v => set('expense_account', v)} /></div>
|
||||
</RGroup>
|
||||
|
||||
<RGroup label="Manufacturing">
|
||||
<div><FL>BOM No</FL><LinkField label="BOM No" hideLabel doctype="BOM" value={(item as any).bom_no || ''} onChange={v => set('bom_no', v)} /></div>
|
||||
</RGroup>
|
||||
|
||||
<RGroup label="Accounting Dimensions">
|
||||
<div><FL>Cost Center</FL><LinkField label="Cost Center" hideLabel doctype="Cost Center" value={(item as any).cost_center || ''} onChange={v => set('cost_center', v)} /></div>
|
||||
<div><FL>Project</FL><LinkField label="Project" hideLabel doctype="Project" value={(item as any).project || ''} onChange={v => set('project', v)} /></div>
|
||||
</RGroup>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Empty helper ──────────────────────────────────────────────────────────────
|
||||
const emptyItem = (today: string): Partial<MaterialRequestItem> => ({
|
||||
item_code: '', item_name: '', qty: 1, uom: '', schedule_date: today,
|
||||
});
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
const MaterialRequestDetail: React.FC = () => {
|
||||
const { mrName } = useParams<{ mrName: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const isNew = mrName === 'new';
|
||||
|
||||
const contextProject = searchParams.get('project') || '';
|
||||
const contextCustomer = searchParams.get('customer') || '';
|
||||
const contextCompany = searchParams.get('company') || DEFAULT_COMPANY;
|
||||
|
||||
const [doc, setDoc] = useState<MaterialRequest | null>(null);
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(isNew);
|
||||
const [expandedItem, setExpandedItem] = useState<number | null>(null);
|
||||
/** Workflow "Only Allow Edit For" — false until loaded for existing docs */
|
||||
const [wfCanEdit, setWfCanEdit] = useState(true);
|
||||
/** Whether Material Request has an active workflow in ERPNext (hide duplicate Submit) */
|
||||
const [mrHasWorkflow, setMrHasWorkflow] = useState<boolean | null>(null);
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const [form, setForm] = useState<Partial<MaterialRequest>>({
|
||||
material_request_type: 'Purchase',
|
||||
company: contextCompany, project: contextProject, customer: contextCustomer,
|
||||
transaction_date: today, schedule_date: today,
|
||||
items: [emptyItem(today)],
|
||||
});
|
||||
|
||||
const syncForm = useCallback((d: MaterialRequest) => {
|
||||
const items = (d.items || []).map((it: any) => ({
|
||||
...it,
|
||||
schedule_date: it.schedule_date || it.required_by || '',
|
||||
}));
|
||||
setForm({
|
||||
material_request_type: d.material_request_type || 'Purchase',
|
||||
company: (d as any).company || DEFAULT_COMPANY, project: (d as any).project || '', customer: (d as any).customer || '',
|
||||
transaction_date: d.transaction_date || today,
|
||||
schedule_date: (d as any).schedule_date || today,
|
||||
items,
|
||||
});
|
||||
}, [today]);
|
||||
|
||||
const fetchDoc = useCallback(() => {
|
||||
if (isNew || !mrName) return;
|
||||
setLoading(true);
|
||||
materialRequestService.getMaterialRequest(mrName)
|
||||
.then(d => { setDoc(d); syncForm(d); })
|
||||
.catch(e => toast.error(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [isNew, mrName, syncForm]);
|
||||
|
||||
useEffect(() => { fetchDoc(); }, [fetchDoc]);
|
||||
|
||||
useEffect(() => {
|
||||
workflowService.getWorkflowInfo('Material Request').then(w => setMrHasWorkflow(!!w)).catch(() => setMrHasWorkflow(false));
|
||||
}, []);
|
||||
|
||||
const onWorkflowMeta = useCallback((m: { canEdit: boolean }) => {
|
||||
setWfCanEdit(m.canEdit);
|
||||
}, []);
|
||||
|
||||
const set = (k: keyof MaterialRequest, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
const updateItem = (idx: number, k: string, v: any) =>
|
||||
setForm(f => {
|
||||
const items = [...(f.items || [])];
|
||||
items[idx] = { ...items[idx], [k]: v };
|
||||
return { ...f, items };
|
||||
});
|
||||
|
||||
const handleItemCode = async (idx: number, code: string) => {
|
||||
updateItem(idx, 'item_code', code);
|
||||
if (!code) return;
|
||||
try {
|
||||
const r = await fetch(`/api/resource/Item/${encodeURIComponent(code)}`, { credentials: 'include' });
|
||||
const body = await r.json();
|
||||
const d = body.data;
|
||||
if (!d) return;
|
||||
setForm(f => {
|
||||
const items = [...(f.items || [])];
|
||||
items[idx] = {
|
||||
...items[idx], item_code: code, item_name: d.item_name || code,
|
||||
stock_uom: d.stock_uom || '', uom: d.purchase_uom || d.stock_uom || '',
|
||||
description: d.description || '',
|
||||
};
|
||||
return { ...f, items };
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const addItem = (after?: number) => {
|
||||
setForm(f => {
|
||||
const items = [...(f.items || [])];
|
||||
const pos = after !== undefined ? after + 1 : items.length;
|
||||
items.splice(pos, 0, emptyItem(today));
|
||||
return { ...f, items };
|
||||
});
|
||||
};
|
||||
const removeItem = (idx: number) => {
|
||||
setForm(f => { const items = [...(f.items || [])]; items.splice(idx, 1); return { ...f, items }; });
|
||||
setExpandedItem(null);
|
||||
};
|
||||
|
||||
const buildPayload = (): Partial<MaterialRequest> => {
|
||||
// Compute doc-level schedule_date from the earliest item schedule_date or today
|
||||
const itemDates = (form.items || [])
|
||||
.map((it: any) => it.schedule_date || it.required_by || '')
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
const docScheduleDate = itemDates[0] || (form as any).schedule_date || today;
|
||||
|
||||
return {
|
||||
material_request_type: form.material_request_type || 'Purchase',
|
||||
company: (form as any).company || undefined,
|
||||
project: (form as any).project || undefined,
|
||||
customer: (form as any).customer || undefined,
|
||||
transaction_date: form.transaction_date,
|
||||
schedule_date: docScheduleDate, // required doc-level field
|
||||
items: (form.items || []).filter(it => it.item_code).map((it: any, i) => ({
|
||||
...(it.name ? { name: it.name } : {}),
|
||||
item_code: it.item_code,
|
||||
item_name: it.item_name || it.item_code,
|
||||
description: it.description || undefined,
|
||||
qty: it.qty ?? 1,
|
||||
uom: it.uom || undefined,
|
||||
stock_uom: it.stock_uom || undefined,
|
||||
conversion_factor: it.conversion_factor ?? 1,
|
||||
schedule_date: it.schedule_date || it.required_by || docScheduleDate,
|
||||
warehouse: it.warehouse || undefined,
|
||||
rate: it.rate ?? 0,
|
||||
expense_account: it.expense_account || undefined,
|
||||
cost_center: it.cost_center || (form as any).cost_center || undefined,
|
||||
project: it.project || (form as any).project || undefined,
|
||||
idx: i + 1,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
if (isNew) {
|
||||
const created = await materialRequestService.createMaterialRequest(buildPayload());
|
||||
toast.success('Material Request created');
|
||||
setIsEditing(false);
|
||||
navigate(`/material-requests/${created.name}`);
|
||||
} else {
|
||||
await materialRequestService.updateMaterialRequest(mrName!, buildPayload());
|
||||
toast.success('Material Request saved');
|
||||
setIsEditing(false);
|
||||
fetchDoc(); // refetch to preserve all fields (company, project, etc.)
|
||||
}
|
||||
} catch (e: any) { toast.error(e.message || 'Error saving'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!mrName || isNew) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const updated = await materialRequestService.submitMaterialRequest(mrName);
|
||||
setDoc(updated); syncForm(updated);
|
||||
toast.success('Material Request submitted');
|
||||
} catch (e: any) { toast.error(e.message || 'Error submitting'); }
|
||||
finally { setSubmitting(false); }
|
||||
};
|
||||
|
||||
const createPO = () => {
|
||||
const p = new URLSearchParams();
|
||||
p.set('mr', mrName!);
|
||||
if ((form as any).company) p.set('company', String((form as any).company));
|
||||
if ((form as any).project) p.set('project', String((form as any).project));
|
||||
navigate(`/purchase-orders/new?${p.toString()}`);
|
||||
};
|
||||
|
||||
const workflowState = (doc as unknown as { workflow_state?: string })?.workflow_state || '';
|
||||
const workflowDocData = useMemo(
|
||||
() => (doc ? ({ ...doc } as Record<string, unknown>) : undefined),
|
||||
[doc],
|
||||
);
|
||||
|
||||
const editable = isNew || (isEditing && wfCanEdit);
|
||||
const isSubmitted = !isNew && doc?.docstatus === 1;
|
||||
const showManualSubmit = !isNew && doc?.docstatus === 0 && mrHasWorkflow === false;
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center min-h-[400px]"><FaSpinner className="animate-spin text-orange-500 text-3xl" /></div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<ToastContainer position="top-right" autoClose={3500} />
|
||||
|
||||
{/* ── Sticky Header ─────────────────────────────────────────────────── */}
|
||||
<div className="sticky top-0 z-10 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="px-6 py-4">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400 mb-2.5">
|
||||
<button onClick={() => navigate('/projects')} className="hover:text-orange-500 transition-colors">Project Management</button>
|
||||
<span>/</span>
|
||||
<button onClick={() => navigate('/material-requests')} className="hover:text-orange-500 transition-colors">Material Requests</button>
|
||||
<span>/</span>
|
||||
<span className="text-gray-600 dark:text-gray-300 font-medium">{isNew ? 'New Material Request' : mrName}</span>
|
||||
</div>
|
||||
|
||||
{/* Title + Actions */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate('/material-requests')} className="p-1.5 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<FaArrowLeft size={14} />
|
||||
</button>
|
||||
<FaBoxes className="text-orange-500 text-xl" />
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{isNew ? 'New Material Request' : mrName}
|
||||
</h1>
|
||||
{!isNew && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-0.5">
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${(() => {
|
||||
const s = doc?.status || '';
|
||||
if (doc?.docstatus === 2 || s === 'Cancelled') return 'bg-red-100 text-red-700';
|
||||
if (!doc || doc.docstatus === 0) return 'bg-yellow-100 text-yellow-800';
|
||||
if (s === 'Transferred' || s === 'Issued') return 'bg-green-100 text-green-800';
|
||||
if (s === 'Partially Ordered' || s === 'Ordered') return 'bg-blue-100 text-blue-800';
|
||||
if (s === 'Pending') return 'bg-orange-100 text-orange-800';
|
||||
if (s === 'Stopped' || s === 'Closed') return 'bg-gray-100 text-gray-700';
|
||||
return 'bg-green-100 text-green-800';
|
||||
})()}`}>
|
||||
{doc?.docstatus === 2 ? 'Cancelled' : doc?.docstatus === 0 ? 'Draft' : (doc?.status || 'Submitted')}
|
||||
</span>
|
||||
{workflowState ? (
|
||||
<span className="inline-block px-2 py-0.5 rounded text-xs font-medium bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-200 border border-violet-200 dark:border-violet-700">
|
||||
{workflowState}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isSubmitted && (
|
||||
<CreateDropdown items={[
|
||||
{ label: 'Purchase Order', icon: <FaShoppingBag size={13} />, onClick: createPO },
|
||||
]} />
|
||||
)}
|
||||
{showManualSubmit && !isEditing && (
|
||||
<button onClick={handleSubmit} disabled={submitting}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm font-medium shadow-sm">
|
||||
{submitting ? <FaSpinner className="animate-spin" size={12} /> : <FaPaperPlane size={12} />} Submit
|
||||
</button>
|
||||
)}
|
||||
{!isNew && !isEditing && !isSubmitted && wfCanEdit && (
|
||||
<button onClick={() => setIsEditing(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-orange-500 text-orange-600 rounded-lg hover:bg-orange-50 text-sm font-medium">
|
||||
<FaEdit size={12} /> Edit
|
||||
</button>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
<button onClick={handleSave} disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 disabled:opacity-50 text-sm font-medium shadow-sm">
|
||||
{saving ? <FaSpinner className="animate-spin" size={12} /> : <FaSave size={12} />}
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{!isNew && (
|
||||
<button onClick={() => { if (doc) syncForm(doc); setIsEditing(false); }}
|
||||
className="p-2 border border-gray-300 rounded-lg text-gray-500 hover:bg-gray-100 text-sm">
|
||||
<FaTimes size={12} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Page Content: main + right sidebar (matches Work Order layout) ─ */}
|
||||
<div className="px-6 py-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
|
||||
<div className={`space-y-3 ${!isNew ? 'lg:col-span-3' : 'lg:col-span-4'}`}>
|
||||
{/* Details Card */}
|
||||
<CollapsibleSection title="Details" icon={<FaBoxes size={12} className="text-orange-500" />}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
|
||||
<div>
|
||||
<FL required>Purpose (Type)</FL>
|
||||
{editable
|
||||
? <select value={form.material_request_type || 'Purchase'} onChange={e => set('material_request_type', e.target.value)} className={inputCls}>
|
||||
<option value="Purchase">Purchase</option>
|
||||
<option value="Material Transfer">Material Transfer</option>
|
||||
<option value="Manufacture">Manufacture</option>
|
||||
<option value="Customer Provided">Customer Provided</option>
|
||||
<option value="Material Issue">Material Issue</option>
|
||||
</select>
|
||||
: <RV>{form.material_request_type}</RV>}
|
||||
</div>
|
||||
<div>
|
||||
<FL required>Transaction Date</FL>
|
||||
{editable
|
||||
? <input type="date" value={form.transaction_date || ''} onChange={e => set('transaction_date', e.target.value)} className={inputCls} />
|
||||
: <RV>{form.transaction_date}</RV>}
|
||||
</div>
|
||||
<div>
|
||||
<FL>Company</FL>
|
||||
{editable
|
||||
? <LinkField label="Company" hideLabel doctype="Company" value={(form as any).company || ''} onChange={v => set('company' as any, v)} placeholder="Select company…" />
|
||||
: <RV>{(form as any).company}</RV>}
|
||||
</div>
|
||||
<div>
|
||||
<FL>Project</FL>
|
||||
{editable
|
||||
? <LinkField label="Project" hideLabel doctype="Project" value={(form as any).project || ''} onChange={v => set('project' as any, v)} placeholder="Select project…" />
|
||||
: <RV>{(form as any).project}</RV>}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Items Card */}
|
||||
<CollapsibleSection title="Items" icon={<FaBoxes size={12} className="text-amber-500" />}>
|
||||
<div className="overflow-x-auto -mx-2">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-8">No.</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 min-w-[180px]">Item Code <span className="text-red-400">*</span></th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">UOM <span className="text-red-400">*</span></th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-24">Qty <span className="text-red-400">*</span></th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-32">Required By</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-36">Target Warehouse</th>
|
||||
{editable && <th className="w-16 py-2 px-2" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(form.items || []).map((it: any, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
<tr className={`border-b border-gray-100 dark:border-gray-700 align-middle ${expandedItem === idx ? 'bg-amber-50/60 dark:bg-amber-900/10' : ''}`}>
|
||||
<td className="py-1.5 px-3 text-gray-400 text-xs">{idx + 1}</td>
|
||||
<td className="py-1.5 px-2 min-w-[180px]">
|
||||
{editable
|
||||
? <LinkField label="Item" hideLabel doctype="Item" value={it.item_code || ''} onChange={v => handleItemCode(idx, v)} placeholder="Select item…" />
|
||||
: <span className="font-medium text-gray-800 dark:text-gray-200">{it.item_code || '-'}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 w-28">
|
||||
{editable
|
||||
? <LinkField label="UOM" hideLabel doctype="UOM" value={it.uom || ''} onChange={v => updateItem(idx, 'uom', v)} placeholder="UOM" />
|
||||
: <span className="text-gray-500 text-sm">{it.uom || '-'}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 w-24">
|
||||
{editable
|
||||
? <input type="number" min={0} step="1" value={it.qty ?? 1} onChange={e => updateItem(idx, 'qty', parseFloat(e.target.value) || 0)} className={inlineNum} />
|
||||
: <span className="block text-right text-sm pr-1">{it.qty ?? 0}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 w-32">
|
||||
{editable
|
||||
? <input type="date" value={it.schedule_date || it.required_by || ''} onChange={e => updateItem(idx, 'schedule_date', e.target.value)} className={inlineTxt} />
|
||||
: <span className="text-gray-500 text-sm">{it.schedule_date || it.required_by || '-'}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 w-36">
|
||||
{editable
|
||||
? <LinkField label="Warehouse" hideLabel doctype="Warehouse" value={it.warehouse || ''} onChange={v => updateItem(idx, 'warehouse', v)} placeholder="Warehouse" />
|
||||
: <span className="text-gray-500 text-sm truncate max-w-[120px] block">{it.warehouse || '-'}</span>}
|
||||
</td>
|
||||
{editable && (
|
||||
<td className="py-1.5 px-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setExpandedItem(expandedItem === idx ? null : idx)}
|
||||
className={`p-1.5 rounded text-xs ${expandedItem === idx ? 'bg-amber-600 text-white' : 'text-amber-600 hover:bg-amber-50'}`}>
|
||||
<FaPencilAlt size={11} />
|
||||
</button>
|
||||
<button onClick={() => removeItem(idx)} className="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded">
|
||||
<FaTrash size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{editable && expandedItem === idx && (
|
||||
<MRItemRowEditor
|
||||
item={it} rowNo={idx + 1}
|
||||
onChange={(k, v) => { if (k === 'item_code') handleItemCode(idx, v as string); else updateItem(idx, k, v); }}
|
||||
onClose={() => setExpandedItem(null)}
|
||||
onDelete={() => removeItem(idx)}
|
||||
onInsertBelow={() => addItem(idx)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{editable && (
|
||||
<tr><td colSpan={7} className="py-2 px-3">
|
||||
<button onClick={() => addItem()} className="flex items-center gap-1.5 text-orange-600 hover:text-orange-700 text-sm font-medium">
|
||||
<FaPlus size={10} /> Add Row
|
||||
</button>
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
{!isNew && mrName && (
|
||||
<aside className="lg:col-span-1 space-y-6 lg:sticky lg:top-28 lg:self-start">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-5 border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
Workflow Actions
|
||||
</h2>
|
||||
<WorkflowActions
|
||||
doctype="Material Request"
|
||||
docname={mrName}
|
||||
workflowState={workflowState}
|
||||
docData={workflowDocData as Record<string, any>}
|
||||
documentLabel="Material Request"
|
||||
onStateChange={fetchDoc}
|
||||
onWorkflowMeta={onWorkflowMeta}
|
||||
className="space-y-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ActivityLog
|
||||
doctype="Material Request"
|
||||
docname={mrName || ''}
|
||||
creationDate={doc?.creation}
|
||||
createdBy={doc?.owner}
|
||||
compact
|
||||
initialVisible={5}
|
||||
collapsible
|
||||
startCollapsed
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialRequestDetail;
|
||||
@ -1,230 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaBoxes, FaPlus, FaSync, FaChevronDown, FaChevronUp, FaTimes, FaSearch, FaFileExport } from 'react-icons/fa';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import materialRequestService, { MaterialRequest } from '../services/materialRequestService';
|
||||
import DynamicExportModal from '../components/DynamicExportModal';
|
||||
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function buildMaterialRequestExportFilters(f: { search: string; status: string; type: string }) {
|
||||
const filters: any[] = [];
|
||||
if (f.search) filters.push(['Material Request', 'name', 'like', `%${f.search}%`]);
|
||||
if (f.status === 'Draft') filters.push(['Material Request', 'docstatus', '=', 0]);
|
||||
if (f.status === 'Submitted') filters.push(['Material Request', 'docstatus', '=', 1]);
|
||||
if (f.status === 'Cancelled') filters.push(['Material Request', 'docstatus', '=', 2]);
|
||||
if (f.type) filters.push(['Material Request', 'material_request_type', '=', f.type]);
|
||||
return filters;
|
||||
}
|
||||
|
||||
function getStatusStyle(mr: MaterialRequest) {
|
||||
if (mr.docstatus === 2) return 'bg-red-100 text-red-700';
|
||||
if (mr.docstatus === 1) return 'bg-green-100 text-green-700';
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
}
|
||||
function getStatusLabel(mr: MaterialRequest) {
|
||||
if (mr.docstatus === 2) return 'Cancelled';
|
||||
if (mr.docstatus === 1) return mr.status || 'Submitted';
|
||||
return mr.status || 'Draft';
|
||||
}
|
||||
|
||||
const MaterialRequestList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [reqs, setReqs] = useState<MaterialRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [applied, setApplied] = useState({ search: '', status: '', type: '' });
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
const load = useCallback(async (off: number, f: typeof applied) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (f.search) filters.push(['Material Request', 'name', 'like', `%${f.search}%`]);
|
||||
if (f.status === 'Draft') filters.push(['Material Request', 'docstatus', '=', 0]);
|
||||
if (f.status === 'Submitted') filters.push(['Material Request', 'docstatus', '=', 1]);
|
||||
if (f.status === 'Cancelled') filters.push(['Material Request', 'docstatus', '=', 2]);
|
||||
if (f.type) filters.push(['Material Request', 'material_request_type', '=', f.type]);
|
||||
const [rows, cnt] = await Promise.all([
|
||||
materialRequestService.getMaterialRequests({ filters, limit_start: off, limit_page_length: PAGE_SIZE }),
|
||||
materialRequestService.getMaterialRequestCount(filters),
|
||||
]);
|
||||
setReqs(rows); setTotal(cnt);
|
||||
} catch (e: any) { toast.error(e.message || 'Failed to load'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(0, applied); }, [load, applied]);
|
||||
|
||||
const selectionResetKey = useMemo(
|
||||
() => `${page}|${applied.search}|${applied.status}|${applied.type}`,
|
||||
[page, applied.search, applied.status, applied.type],
|
||||
);
|
||||
const {
|
||||
selectedRows,
|
||||
toggleRow,
|
||||
toggleAllOnPage,
|
||||
allOnPageSelected,
|
||||
someOnPageSelected,
|
||||
} = useListPageSelection(reqs, selectionResetKey);
|
||||
|
||||
const apply = () => { const f = { search: searchQuery, status: statusFilter, type: typeFilter }; setApplied(f); setPage(0); };
|
||||
const clear = () => { setSearchQuery(''); setStatusFilter(''); setTypeFilter(''); setApplied({ search: '', status: '', type: '' }); setPage(0); };
|
||||
const hasActive = !!(applied.search || applied.status || applied.type);
|
||||
const goPage = (p: number) => { setPage(p); load(p * PAGE_SIZE, applied); };
|
||||
|
||||
const fetchAllForExport = useCallback(
|
||||
() =>
|
||||
fetchAllRowsForExport({
|
||||
doctype: 'Material Request',
|
||||
filters: buildMaterialRequestExportFilters(applied),
|
||||
orderBy: 'modified desc',
|
||||
}),
|
||||
[applied],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ToastContainer position="top-right" autoClose={3000} />
|
||||
<div className="flex items-center justify-between mb-6 gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-orange-500 flex items-center justify-center"><FaBoxes className="text-white text-base" /></div>
|
||||
<div><h1 className="text-xl font-bold text-gray-900 dark:text-white">Material Requests</h1><p className="text-xs text-gray-500">{total} total</p></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button onClick={() => load(page * PAGE_SIZE, applied)} className="p-2 text-gray-500 hover:text-indigo-600 border border-gray-200 rounded-lg"><FaSync size={13} /></button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||
disabled={total === 0 && selectedRows.size === 0}
|
||||
>
|
||||
<FaFileExport /> {t('listPages.export')}
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => navigate('/material-requests/new')} className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 text-sm font-medium"><FaPlus size={11} /> New Request</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DynamicExportModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
doctype="Material Request"
|
||||
selectedCount={selectedRows.size}
|
||||
pageCount={reqs.length}
|
||||
totalCount={total}
|
||||
pageData={reqs}
|
||||
selectedRows={selectedRows}
|
||||
rowKey="name"
|
||||
onFetchAll={fetchAllForExport}
|
||||
fileNamePrefix="material_requests"
|
||||
/>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl mb-5 overflow-hidden">
|
||||
<button onClick={() => setFiltersOpen(o => !o)} className="w-full flex items-center justify-between px-4 py-3 bg-gradient-to-r from-indigo-600 to-indigo-700 text-white">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold"><FaSearch size={12} /> Filters {hasActive && <span className="bg-white/30 text-white text-xs px-2 py-0.5 rounded-full">Active</span>}</div>
|
||||
{filtersOpen ? <FaChevronUp size={11} /> : <FaChevronDown size={11} />}
|
||||
</button>
|
||||
{hasActive && (
|
||||
<div className="px-4 py-2 bg-indigo-50 dark:bg-indigo-900/20 flex flex-wrap gap-2 items-center border-b border-indigo-100">
|
||||
{applied.search && <span className="flex items-center gap-1 text-xs bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full">ID: {applied.search}<button onClick={() => { setSearchQuery(''); setApplied(a => ({ ...a, search: '' })); }}><FaTimes size={9} /></button></span>}
|
||||
{applied.status && <span className="flex items-center gap-1 text-xs bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full">Status: {applied.status}<button onClick={() => { setStatusFilter(''); setApplied(a => ({ ...a, status: '' })); }}><FaTimes size={9} /></button></span>}
|
||||
{applied.type && <span className="flex items-center gap-1 text-xs bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full">Type: {applied.type}<button onClick={() => { setTypeFilter(''); setApplied(a => ({ ...a, type: '' })); }}><FaTimes size={9} /></button></span>}
|
||||
<button onClick={clear} className="text-xs text-indigo-600 hover:underline ml-auto">Clear All</button>
|
||||
</div>
|
||||
)}
|
||||
{filtersOpen && (
|
||||
<div className="px-4 py-3 grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||
<div><label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Request ID</label>
|
||||
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && apply()} placeholder="Search…" className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-400" /></div>
|
||||
<div><label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Type</label>
|
||||
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||
<option value="">All</option><option value="Purchase">Purchase</option><option value="Material Transfer">Material Transfer</option><option value="Manufacture">Manufacture</option>
|
||||
</select></div>
|
||||
<div><label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Status</label>
|
||||
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||
<option value="">All</option><option value="Draft">Draft</option><option value="Submitted">Submitted</option><option value="Cancelled">Cancelled</option>
|
||||
</select></div>
|
||||
<div className="flex items-end gap-2">
|
||||
<button onClick={apply} className="px-4 py-2 bg-indigo-600 text-white text-sm rounded hover:bg-indigo-700">Apply</button>
|
||||
<button onClick={clear} className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded hover:bg-gray-50">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||
<th className="w-10 px-2 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500"
|
||||
checked={allOnPageSelected}
|
||||
ref={el => {
|
||||
if (el) el.indeterminate = someOnPageSelected;
|
||||
}}
|
||||
onChange={toggleAllOnPage}
|
||||
aria-label="Select all on page"
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Request ID</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Type</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Date</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Company</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{loading ? <tr><td colSpan={6} className="text-center py-10 text-gray-400">Loading…</td></tr>
|
||||
: reqs.length === 0 ? <tr><td colSpan={6} className="text-center py-10 text-gray-400">No material requests found</td></tr>
|
||||
: reqs.map(mr => (
|
||||
<tr key={mr.name} onClick={() => navigate(`/material-requests/${mr.name}`)} className={`cursor-pointer hover:bg-orange-50 dark:hover:bg-orange-900/10 transition-colors ${selectedRows.has(mr.name) ? 'bg-orange-50/90 dark:bg-orange-900/20' : ''}`}>
|
||||
<td className="w-10 px-2 py-3" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500"
|
||||
checked={selectedRows.has(mr.name)}
|
||||
onChange={() => toggleRow(mr.name)}
|
||||
aria-label={`Select ${mr.name}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-medium text-orange-600">{mr.name}</td>
|
||||
<td className="py-3 px-4 text-gray-700 dark:text-gray-300">{mr.material_request_type || '-'}</td>
|
||||
<td className="py-3 px-4 text-gray-500">{mr.transaction_date || '-'}</td>
|
||||
<td className="py-3 px-4 text-gray-500">{mr.company || '-'}</td>
|
||||
<td className="py-3 px-4"><span className={`px-2 py-0.5 rounded text-xs font-semibold ${getStatusStyle(mr)}`}>{getStatusLabel(mr)}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-xs text-gray-500">{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}</span>
|
||||
<div className="flex gap-2">
|
||||
<button disabled={page === 0} onClick={() => goPage(page - 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Prev</button>
|
||||
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => goPage(page + 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialRequestList;
|
||||
@ -1981,9 +1981,7 @@ const PieChart: React.FC<{ data: any }> = ({ data }) => {
|
||||
}
|
||||
const total = values.reduce((sum: number, val: number) => sum + val, 0);
|
||||
const radius = 100;
|
||||
const pad = 22;
|
||||
const cx = radius + pad;
|
||||
const cy = radius + pad;
|
||||
const cx = radius + 10, cy = radius + 10;
|
||||
let cumulative = 0;
|
||||
const slices = values.map((value: number, i: number) => {
|
||||
const startAngle = (cumulative / total) * 2 * Math.PI - Math.PI / 2;
|
||||
|
||||
@ -559,12 +559,7 @@ const PPMPlannerList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const setSearchParamsRef = useRef(setSearchParams);
|
||||
const [page, setPage] = useState(0);
|
||||
useEffect(() => {
|
||||
setSearchParamsRef.current = setSearchParams;
|
||||
}, [setSearchParams]);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
|
||||
const [actionMenuOpen, setActionMenuOpen] = useState<string | null>(null);
|
||||
|
||||
@ -1,561 +0,0 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { FaMoneyBillWave, FaArrowLeft, FaSave, FaCheck, FaPlus, FaTrash } from 'react-icons/fa';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { paymentEntryService, PaymentEntry, PaymentEntryReference } from '../services/paymentEntryService';
|
||||
import LinkField from '../components/LinkField';
|
||||
import ActivityLog from '../components/ActivityLog';
|
||||
import { DEFAULT_COMPANY, DEFAULT_CURRENCY } from '../constants/orgDefaults';
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────────
|
||||
const FL: React.FC<{ children: React.ReactNode; required?: boolean }> = ({ children, required }) => (
|
||||
<label className="block text-[11px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||
{children}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
);
|
||||
const RV: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<p className="text-sm text-gray-800 dark:text-gray-200 min-h-[20px] py-0.5">{children || <span className="text-gray-400 italic">—</span>}</p>
|
||||
);
|
||||
|
||||
const inputCls = 'w-full border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-teal-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100';
|
||||
const numCls = inputCls + ' text-right';
|
||||
|
||||
const statusBadge = (pe: Partial<PaymentEntry>) => {
|
||||
const ds = pe.docstatus ?? 0;
|
||||
if (ds === 2) return <span className="px-2 py-1 rounded-full text-xs font-bold bg-red-100 text-red-700">Cancelled</span>;
|
||||
if (ds === 1) {
|
||||
const s = pe.status || 'Submitted';
|
||||
if (s === 'Paid') return <span className="px-2 py-1 rounded-full text-xs font-bold bg-green-100 text-green-700">Paid</span>;
|
||||
return <span className="px-2 py-1 rounded-full text-xs font-bold bg-blue-100 text-blue-700">{s}</span>;
|
||||
}
|
||||
return <span className="px-2 py-1 rounded-full text-xs font-bold bg-yellow-100 text-yellow-700">Draft</span>;
|
||||
};
|
||||
|
||||
const Section: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-4 py-2.5 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||
<h3 className="text-xs font-bold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{title}</h3>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const emptyRef = (): Partial<PaymentEntryReference> => ({
|
||||
reference_doctype: 'Sales Invoice',
|
||||
reference_name: '',
|
||||
total_amount: 0,
|
||||
outstanding_amount: 0,
|
||||
allocated_amount: 0,
|
||||
exchange_rate: 1,
|
||||
});
|
||||
|
||||
/** Default cash/bank account for Receive payments (company-specific; change if your COA differs). */
|
||||
const DEFAULT_ACCOUNT_PAID_TO = 'Cash - SA';
|
||||
|
||||
export default function PaymentEntryDetail() {
|
||||
const { peName } = useParams<{ peName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const isNew = peName === 'new';
|
||||
|
||||
const contextSI = searchParams.get('si') || '';
|
||||
const contextCustomer = searchParams.get('customer') || '';
|
||||
const contextCompany = searchParams.get('company') || DEFAULT_COMPANY;
|
||||
const contextProject = searchParams.get('project') || '';
|
||||
const contextAmount = parseFloat(searchParams.get('amount') || '0');
|
||||
const contextCurrency = searchParams.get('currency') || DEFAULT_CURRENCY;
|
||||
|
||||
const [doc, setDoc] = useState<PaymentEntry | null>(null);
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(isNew);
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const [form, setForm] = useState<Partial<PaymentEntry>>({
|
||||
payment_type: 'Receive',
|
||||
posting_date: today,
|
||||
company: contextCompany,
|
||||
party_type: 'Customer',
|
||||
party: contextCustomer || '',
|
||||
party_name: contextCustomer || '',
|
||||
mode_of_payment: 'Cash',
|
||||
paid_to: DEFAULT_ACCOUNT_PAID_TO,
|
||||
reference_date: today,
|
||||
reference_no: '',
|
||||
paid_amount: contextAmount || 0,
|
||||
received_amount: contextAmount || 0,
|
||||
source_exchange_rate: 1,
|
||||
target_exchange_rate: 1,
|
||||
project: contextProject || '',
|
||||
references: contextSI ? [{
|
||||
reference_doctype: 'Sales Invoice',
|
||||
reference_name: contextSI,
|
||||
total_amount: contextAmount,
|
||||
outstanding_amount: contextAmount,
|
||||
allocated_amount: contextAmount,
|
||||
exchange_rate: 1,
|
||||
}] : [],
|
||||
});
|
||||
|
||||
const syncForm = useCallback((d: PaymentEntry) => {
|
||||
setForm({
|
||||
payment_type: d.payment_type || 'Receive',
|
||||
posting_date: d.posting_date || today,
|
||||
company: d.company || DEFAULT_COMPANY,
|
||||
party_type: d.party_type || 'Customer',
|
||||
party: d.party || '',
|
||||
party_name: d.party_name || '',
|
||||
mode_of_payment: d.mode_of_payment || '',
|
||||
paid_from: d.paid_from || '',
|
||||
paid_to: d.paid_to || '',
|
||||
paid_from_account_currency: d.paid_from_account_currency || '',
|
||||
paid_to_account_currency: d.paid_to_account_currency || '',
|
||||
paid_amount: d.paid_amount || 0,
|
||||
received_amount: d.received_amount || 0,
|
||||
source_exchange_rate: d.source_exchange_rate || 1,
|
||||
target_exchange_rate: d.target_exchange_rate || 1,
|
||||
total_allocated_amount: d.total_allocated_amount || 0,
|
||||
unallocated_amount: d.unallocated_amount || 0,
|
||||
difference_amount: d.difference_amount || 0,
|
||||
project: d.project || '',
|
||||
cost_center: d.cost_center || '',
|
||||
remarks: d.remarks || '',
|
||||
reference_no: (d as any).reference_no || '',
|
||||
reference_date: (d as any).reference_date || '',
|
||||
references: d.references || [],
|
||||
} as any);
|
||||
}, [today]);
|
||||
|
||||
// Pre-fill from SI
|
||||
useEffect(() => {
|
||||
if (!isNew || !contextSI) return;
|
||||
fetch(`/api/resource/Sales Invoice/${encodeURIComponent(contextSI)}`, { credentials: 'include' })
|
||||
.then(r => r.json()).then(b => {
|
||||
const si = b.data;
|
||||
if (!si) return;
|
||||
const outstanding = si.outstanding_amount || si.grand_total || 0;
|
||||
setForm(f => ({
|
||||
...f,
|
||||
party: si.customer || f.party,
|
||||
party_name: si.customer_name || si.customer || f.party_name,
|
||||
company: si.company || f.company,
|
||||
project: si.project || f.project,
|
||||
paid_from: si.debit_to || '',
|
||||
paid_from_account_currency: si.currency || contextCurrency || '',
|
||||
paid_to_account_currency: si.currency || contextCurrency || '',
|
||||
paid_amount: outstanding,
|
||||
received_amount: outstanding,
|
||||
references: [{
|
||||
reference_doctype: 'Sales Invoice',
|
||||
reference_name: contextSI,
|
||||
total_amount: si.grand_total || 0,
|
||||
outstanding_amount: outstanding,
|
||||
allocated_amount: outstanding,
|
||||
exchange_rate: 1,
|
||||
}],
|
||||
remarks: `Amount ${si.currency || ''} ${outstanding} received from ${si.customer_name || si.customer}\nAmount ${si.currency || ''} ${outstanding} against Sales Invoice ${contextSI}`,
|
||||
} as any));
|
||||
}).catch(() => {});
|
||||
}, [isNew, contextSI]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) return;
|
||||
setLoading(true);
|
||||
paymentEntryService.getPaymentEntry(peName!)
|
||||
.then(d => { setDoc(d); syncForm(d); })
|
||||
.catch(e => toast.error(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [peName, isNew, syncForm]);
|
||||
|
||||
const set = (k: string, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
// References helpers
|
||||
const updateRef = (idx: number, k: string, v: any) => {
|
||||
setForm(f => {
|
||||
const refs = [...(f.references || [])];
|
||||
refs[idx] = { ...refs[idx], [k]: v };
|
||||
// auto-set allocated = outstanding if not changed
|
||||
if (k === 'outstanding_amount') refs[idx].allocated_amount = v;
|
||||
return { ...f, references: refs };
|
||||
});
|
||||
};
|
||||
|
||||
const addRef = () => setForm(f => ({ ...f, references: [...(f.references || []), emptyRef()] }));
|
||||
const removeRef = (idx: number) => setForm(f => ({ ...f, references: (f.references || []).filter((_, i) => i !== idx) }));
|
||||
|
||||
// Auto-update paid_amount from allocated refs
|
||||
const totalAllocated = (form.references || []).reduce((s, r) => s + (r.allocated_amount || 0), 0);
|
||||
|
||||
// Fetch reference outstanding amount when ref name is set
|
||||
const fetchRefDetails = async (idx: number, refDoctype: string, refName: string) => {
|
||||
if (!refDoctype || !refName) return;
|
||||
try {
|
||||
const r = await fetch(`/api/resource/${encodeURIComponent(refDoctype)}/${encodeURIComponent(refName)}`, { credentials: 'include' });
|
||||
const b = await r.json();
|
||||
const d = b.data;
|
||||
if (!d) return;
|
||||
setForm(f => {
|
||||
const refs = [...(f.references || [])];
|
||||
refs[idx] = {
|
||||
...refs[idx],
|
||||
total_amount: d.grand_total || d.outstanding_amount || 0,
|
||||
outstanding_amount: d.outstanding_amount || d.grand_total || 0,
|
||||
allocated_amount: d.outstanding_amount || d.grand_total || 0,
|
||||
};
|
||||
return { ...f, references: refs, paid_amount: d.outstanding_amount || d.grand_total || 0, received_amount: d.outstanding_amount || d.grand_total || 0 };
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const buildPayload = (): Partial<PaymentEntry> => ({
|
||||
payment_type: form.payment_type || 'Receive',
|
||||
posting_date: form.posting_date,
|
||||
company: (form as any).company || undefined,
|
||||
party_type: form.party_type || 'Customer',
|
||||
party: form.party,
|
||||
party_name: form.party_name || form.party,
|
||||
mode_of_payment: (form as any).mode_of_payment || undefined,
|
||||
paid_from: (form as any).paid_from || undefined,
|
||||
paid_to: (form as any).paid_to || undefined,
|
||||
paid_from_account_currency: (form as any).paid_from_account_currency || undefined,
|
||||
paid_to_account_currency: (form as any).paid_to_account_currency || undefined,
|
||||
paid_amount: form.paid_amount || totalAllocated,
|
||||
received_amount: form.received_amount || totalAllocated,
|
||||
source_exchange_rate: (form as any).source_exchange_rate || 1,
|
||||
target_exchange_rate: (form as any).target_exchange_rate || 1,
|
||||
project: (form as any).project || undefined,
|
||||
cost_center: (form as any).cost_center || undefined,
|
||||
remarks: (form as any).remarks || undefined,
|
||||
reference_no: (form as any).reference_no || undefined,
|
||||
reference_date: (form as any).reference_date || undefined,
|
||||
references: (form.references || []).filter(r => r.reference_name).map((r, i) => ({
|
||||
reference_doctype: r.reference_doctype || 'Sales Invoice',
|
||||
reference_name: r.reference_name,
|
||||
total_amount: r.total_amount || 0,
|
||||
outstanding_amount: r.outstanding_amount || 0,
|
||||
allocated_amount: r.allocated_amount || 0,
|
||||
exchange_rate: r.exchange_rate || 1,
|
||||
idx: i + 1,
|
||||
})),
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.party) { toast.error('Party is required'); return; }
|
||||
try {
|
||||
setSaving(true);
|
||||
if (isNew) {
|
||||
const created = await paymentEntryService.createPaymentEntry(buildPayload());
|
||||
toast.success('Payment Entry created');
|
||||
setIsEditing(false);
|
||||
navigate(`/payment-entries/${created.name}`);
|
||||
} else {
|
||||
const updated = await paymentEntryService.updatePaymentEntry(peName!, buildPayload());
|
||||
setDoc(updated); syncForm(updated);
|
||||
toast.success('Payment Entry saved');
|
||||
setIsEditing(false);
|
||||
}
|
||||
} catch (e: any) { toast.error(e.message || 'Error saving'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!peName || isNew) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const updated = await paymentEntryService.submitPaymentEntry(peName);
|
||||
setDoc(updated); syncForm(updated);
|
||||
toast.success('Payment Entry submitted');
|
||||
} catch (e: any) { toast.error(e.message || 'Error submitting'); }
|
||||
finally { setSubmitting(false); }
|
||||
};
|
||||
|
||||
const editable = isEditing && (doc?.docstatus ?? 0) < 1;
|
||||
const isSubmitted = (doc?.docstatus ?? 0) === 1;
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<><ToastContainer position="top-right" autoClose={3000} />
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-6 py-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate('/payment-entries')} className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<FaArrowLeft size={14} />
|
||||
</button>
|
||||
<div className="w-9 h-9 rounded-xl bg-teal-600 flex items-center justify-center shadow-sm">
|
||||
<FaMoneyBillWave className="text-white" size={15} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{isNew ? 'New Payment Entry' : (doc?.party_name || doc?.party || peName)}
|
||||
</h1>
|
||||
{!isNew && statusBadge(doc || {})}
|
||||
</div>
|
||||
{!isNew && <p className="text-xs text-gray-400">{peName}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{editable && (
|
||||
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white text-sm font-semibold rounded-lg shadow disabled:opacity-50 transition-colors">
|
||||
<FaSave size={13} />{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
{!isNew && !isEditing && !isSubmitted && (
|
||||
<button onClick={() => setIsEditing(true)} className="flex items-center gap-2 px-4 py-2 border border-teal-500 text-teal-600 hover:bg-teal-50 text-sm font-semibold rounded-lg transition-colors">
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{!isNew && !isSubmitted && !isEditing && (
|
||||
<button onClick={handleSubmit} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-semibold rounded-lg shadow disabled:opacity-50 transition-colors">
|
||||
<FaCheck size={12} />{submitting ? 'Submitting…' : 'Submit'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Type & Date */}
|
||||
<Section title="Payment Details">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FL required>Payment Type</FL>
|
||||
{editable
|
||||
? <select value={(form as any).payment_type || 'Receive'} onChange={e => set('payment_type', e.target.value)} className={inputCls}>
|
||||
<option value="Receive">Receive</option>
|
||||
<option value="Pay">Pay</option>
|
||||
<option value="Internal Transfer">Internal Transfer</option>
|
||||
</select>
|
||||
: <RV>{(form as any).payment_type}</RV>}
|
||||
</div>
|
||||
<div>
|
||||
<FL required>Posting Date</FL>
|
||||
{editable
|
||||
? <input type="date" value={(form as any).posting_date || ''} onChange={e => set('posting_date', e.target.value)} className={inputCls} />
|
||||
: <RV>{(form as any).posting_date}</RV>}
|
||||
</div>
|
||||
<div>
|
||||
<FL>Mode of Payment</FL>
|
||||
{editable
|
||||
? <LinkField label="Mode of Payment" hideLabel doctype="Mode of Payment" value={(form as any).mode_of_payment || ''} onChange={v => set('mode_of_payment', v)} placeholder="Cash, Bank…" />
|
||||
: <RV>{(form as any).mode_of_payment}</RV>}
|
||||
</div>
|
||||
<div>
|
||||
<FL>Company</FL>
|
||||
{editable
|
||||
? <LinkField label="Company" hideLabel doctype="Company" value={(form as any).company || ''} onChange={v => set('company', v)} placeholder="Company…" />
|
||||
: <RV>{(form as any).company}</RV>}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Party */}
|
||||
<Section title="Payment From / To">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FL>Party Type</FL>
|
||||
{editable
|
||||
? <select value={(form as any).party_type || 'Customer'} onChange={e => set('party_type', e.target.value)} className={inputCls}>
|
||||
<option value="Customer">Customer</option>
|
||||
<option value="Supplier">Supplier</option>
|
||||
<option value="Employee">Employee</option>
|
||||
</select>
|
||||
: <RV>{(form as any).party_type}</RV>}
|
||||
</div>
|
||||
<div>
|
||||
<FL required>Party</FL>
|
||||
{editable
|
||||
? <LinkField label="Party" hideLabel doctype={(form as any).party_type || 'Customer'} value={(form as any).party || ''} onChange={v => { set('party', v); set('party_name', v); }} placeholder="Select party…" />
|
||||
: <RV>{(form as any).party_name || (form as any).party}</RV>}
|
||||
</div>
|
||||
<div>
|
||||
<FL>Project</FL>
|
||||
{editable
|
||||
? <LinkField label="Project" hideLabel doctype="Project" value={(form as any).project || ''} onChange={v => set('project', v)} placeholder="Project…" />
|
||||
: <RV>{(form as any).project}</RV>}
|
||||
</div>
|
||||
<div>
|
||||
<FL>Cost Center</FL>
|
||||
{editable
|
||||
? <LinkField label="Cost Center" hideLabel doctype="Cost Center" value={(form as any).cost_center || ''} onChange={v => set('cost_center', v)} placeholder="Cost center…" />
|
||||
: <RV>{(form as any).cost_center}</RV>}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Accounts */}
|
||||
<Section title="Accounts">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FL>Account Paid From</FL>
|
||||
{editable
|
||||
? <LinkField label="Account Paid From" hideLabel doctype="Account" value={(form as any).paid_from || ''} onChange={v => set('paid_from', v)} placeholder="Debtors account…" />
|
||||
: <RV>{(form as any).paid_from}</RV>}
|
||||
</div>
|
||||
<div>
|
||||
<FL>Account Paid To</FL>
|
||||
{editable
|
||||
? <LinkField label="Account Paid To" hideLabel doctype="Account" value={(form as any).paid_to || ''} onChange={v => set('paid_to', v)} placeholder="Cash / Bank account…" />
|
||||
: <RV>{(form as any).paid_to}</RV>}
|
||||
</div>
|
||||
<div>
|
||||
<FL required>Paid Amount</FL>
|
||||
{editable
|
||||
? <input type="number" min={0} step="0.01" value={(form as any).paid_amount ?? 0} onChange={e => { const v = parseFloat(e.target.value) || 0; set('paid_amount', v); set('received_amount', v); }} className={numCls} />
|
||||
: <RV>{((form as any).paid_amount || 0).toFixed(2)}</RV>}
|
||||
</div>
|
||||
<div>
|
||||
<FL>Received Amount</FL>
|
||||
{editable
|
||||
? <input type="number" min={0} step="0.01" value={(form as any).received_amount ?? 0} onChange={e => set('received_amount', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||
: <RV>{((form as any).received_amount || 0).toFixed(2)}</RV>}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Transaction ID — ERPNext: reference_no, reference_date */}
|
||||
<Section title="Transaction ID">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FL>Cheque / Reference No</FL>
|
||||
{editable
|
||||
? <input type="text" value={(form as any).reference_no || ''} onChange={e => set('reference_no', e.target.value)} className={inputCls} placeholder="Reference number…" />
|
||||
: <RV>{(form as any).reference_no}</RV>}
|
||||
</div>
|
||||
<div>
|
||||
<FL>Cheque / Reference Date</FL>
|
||||
{editable
|
||||
? <input type="date" value={(form as any).reference_date || ''} onChange={e => set('reference_date', e.target.value)} className={inputCls} />
|
||||
: <RV>{(form as any).reference_date}</RV>}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* References */}
|
||||
<Section title="Payment References">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-8">No.</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3">Type</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3">Reference</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Total Amount</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Outstanding</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Allocated</th>
|
||||
{editable && <th className="w-10 py-2 px-2" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(form.references || []).map((ref, idx) => (
|
||||
<tr key={idx} className="border-b border-gray-100 dark:border-gray-700 align-middle">
|
||||
<td className="py-2 px-3 text-gray-400 text-xs">{idx + 1}</td>
|
||||
<td className="py-2 px-3 w-40">
|
||||
{editable
|
||||
? <select value={ref.reference_doctype || ''} onChange={e => updateRef(idx, 'reference_doctype', e.target.value)} className="w-full border border-gray-200 rounded px-2 py-1 text-xs bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100">
|
||||
<option value="Sales Invoice">Sales Invoice</option>
|
||||
<option value="Purchase Invoice">Purchase Invoice</option>
|
||||
<option value="Sales Order">Sales Order</option>
|
||||
<option value="Purchase Order">Purchase Order</option>
|
||||
<option value="Journal Entry">Journal Entry</option>
|
||||
</select>
|
||||
: <span className="text-gray-700 dark:text-gray-300 text-xs">{ref.reference_doctype}</span>}
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[180px]">
|
||||
{editable
|
||||
? <LinkField label="Reference" hideLabel
|
||||
doctype={ref.reference_doctype || 'Sales Invoice'}
|
||||
value={ref.reference_name || ''}
|
||||
onChange={v => { updateRef(idx, 'reference_name', v); if (v) fetchRefDetails(idx, ref.reference_doctype || 'Sales Invoice', v); }}
|
||||
placeholder="Select document…"
|
||||
/>
|
||||
: <span className="text-teal-600 font-medium text-xs">{ref.reference_name || '-'}</span>}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right text-gray-700 dark:text-gray-300 text-xs">
|
||||
{editable
|
||||
? <input type="number" min={0} step="0.01" value={ref.total_amount ?? 0} onChange={e => updateRef(idx, 'total_amount', parseFloat(e.target.value) || 0)} className="w-full border border-gray-200 rounded px-2 py-1 text-xs text-right bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100" />
|
||||
: (ref.total_amount || 0).toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right text-gray-700 dark:text-gray-300 text-xs">
|
||||
{(ref.outstanding_amount || 0).toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right font-semibold text-gray-900 dark:text-white text-xs">
|
||||
{editable
|
||||
? <input type="number" min={0} step="0.01" value={ref.allocated_amount ?? 0} onChange={e => updateRef(idx, 'allocated_amount', parseFloat(e.target.value) || 0)} className="w-full border border-gray-200 rounded px-2 py-1 text-xs text-right bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100" />
|
||||
: (ref.allocated_amount || 0).toFixed(2)}
|
||||
</td>
|
||||
{editable && (
|
||||
<td className="py-2 px-2">
|
||||
<button onClick={() => removeRef(idx)} className="p-1 text-red-400 hover:text-red-600 rounded"><FaTrash size={11} /></button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{editable && (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-2 px-3">
|
||||
<button onClick={addRef} className="flex items-center gap-1.5 text-teal-600 hover:text-teal-700 text-sm font-medium">
|
||||
<FaPlus size={10} /> Add Row
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex justify-end mt-3 pt-3 border-t border-gray-100 dark:border-gray-700 gap-6 text-sm">
|
||||
<span className="text-gray-500">Total Allocated: <strong className="text-gray-900 dark:text-white">{totalAllocated.toFixed(2)}</strong></span>
|
||||
<span className="text-gray-500">Unallocated: <strong className="text-gray-900 dark:text-white">{Math.max(0, ((form as any).paid_amount || 0) - totalAllocated).toFixed(2)}</strong></span>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Remarks */}
|
||||
<Section title="Remarks">
|
||||
<div>
|
||||
{editable
|
||||
? <textarea rows={3} value={(form as any).remarks || ''} onChange={e => set('remarks', e.target.value)} className={inputCls} placeholder="Remarks…" />
|
||||
: <RV>{(form as any).remarks}</RV>}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Totals */}
|
||||
{!isNew && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: 'Paid Amount', value: ((form as any).paid_amount || 0).toFixed(2) },
|
||||
{ label: 'Total Allocated', value: totalAllocated.toFixed(2) },
|
||||
{ label: 'Unallocated', value: Math.max(0, ((form as any).paid_amount || 0) - totalAllocated).toFixed(2) },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="text-center">
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase mb-1">{label}</p>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNew && doc && (
|
||||
<ActivityLog
|
||||
doctype="Payment Entry"
|
||||
docname={doc.name || peName || ''}
|
||||
creationDate={doc.creation}
|
||||
createdBy={doc.owner}
|
||||
compact={false}
|
||||
initialVisible={5}
|
||||
collapsible
|
||||
startCollapsed
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,267 +0,0 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaMoneyBillWave, FaPlus, FaSync, FaChevronDown, FaChevronUp, FaFilter, FaTimes, FaFileExport, FaEye, FaEdit, FaCopy, FaCheckSquare, FaSquare } from 'react-icons/fa';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { paymentEntryService } from '../services/paymentEntryService';
|
||||
import DynamicExportModal from '../components/DynamicExportModal';
|
||||
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const statusStyle = (status: string, docstatus: number) => {
|
||||
if (docstatus === 1) {
|
||||
if (status === 'Paid') return 'bg-green-100 text-green-700';
|
||||
if (status === 'Submitted') return 'bg-blue-100 text-blue-700';
|
||||
return 'bg-blue-100 text-blue-700';
|
||||
}
|
||||
if (docstatus === 2) return 'bg-red-100 text-red-700';
|
||||
return 'bg-yellow-100 text-yellow-700';
|
||||
};
|
||||
|
||||
const statusLabel = (status: string, docstatus: number) => {
|
||||
if (docstatus === 2) return 'Cancelled';
|
||||
if (docstatus === 1) return status || 'Submitted';
|
||||
return 'Draft';
|
||||
};
|
||||
|
||||
export default function PaymentEntryList() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [entries, setEntries] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filtersOpen, setFiltersOpen] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [partyFilter, setPartyFilter] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
const buildFilters = useCallback(() => {
|
||||
const f: any[] = [];
|
||||
if (searchQuery) f.push(['name', 'like', `%${searchQuery}%`]);
|
||||
if (partyFilter) f.push(['party', 'like', `%${partyFilter}%`]);
|
||||
if (typeFilter) f.push(['payment_type', '=', typeFilter]);
|
||||
return f;
|
||||
}, [searchQuery, partyFilter, typeFilter]);
|
||||
|
||||
const load = useCallback(async (pg = 0) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters = buildFilters();
|
||||
const [data, cnt] = await Promise.all([
|
||||
paymentEntryService.getPaymentEntries({ filters, limit_start: pg * PAGE_SIZE, limit_page_length: PAGE_SIZE }),
|
||||
paymentEntryService.getPaymentEntryCount(filters),
|
||||
]);
|
||||
setEntries(data); setTotal(cnt); setPage(pg);
|
||||
} catch (e: any) { toast.error(e.message || 'Error loading'); }
|
||||
finally { setLoading(false); }
|
||||
}, [buildFilters]);
|
||||
|
||||
useEffect(() => { load(0); }, [load]);
|
||||
|
||||
const selectionResetKey = useMemo(
|
||||
() => `${page}|${searchQuery}|${partyFilter}|${typeFilter}`,
|
||||
[page, searchQuery, partyFilter, typeFilter],
|
||||
);
|
||||
const {
|
||||
selectedRows,
|
||||
toggleRow,
|
||||
toggleAllOnPage,
|
||||
allOnPageSelected,
|
||||
someOnPageSelected,
|
||||
} = useListPageSelection(entries, selectionResetKey);
|
||||
|
||||
const fetchAllForExport = useCallback(
|
||||
() => fetchAllRowsForExport({ doctype: 'Payment Entry', filters: buildFilters(), orderBy: 'modified desc' }),
|
||||
[buildFilters],
|
||||
);
|
||||
|
||||
const goPage = (pg: number) => load(pg);
|
||||
|
||||
const activeFilterCount = [searchQuery, partyFilter, typeFilter].filter(Boolean).length;
|
||||
|
||||
const clearFilters = () => { setSearchQuery(''); setPartyFilter(''); setTypeFilter(''); };
|
||||
const handleView = (name: string) => navigate(`/payment-entries/${encodeURIComponent(name)}`);
|
||||
const handleEdit = (name: string) => navigate(`/payment-entries/${encodeURIComponent(name)}?edit=1`);
|
||||
const handleDuplicate = (name: string) => navigate(`/payment-entries/new?duplicate=${encodeURIComponent(name)}`);
|
||||
|
||||
return (
|
||||
<><ToastContainer position="top-right" autoClose={3000} />
|
||||
<div className="px-6 py-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-teal-600 flex items-center justify-center shadow">
|
||||
<FaMoneyBillWave className="text-white" size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Payment Entries</h1>
|
||||
<p className="text-xs text-gray-500">{total} total</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button onClick={() => load(page)} className="p-2 text-gray-500 hover:text-teal-600 hover:bg-teal-50 rounded-lg transition-colors"><FaSync size={14} /></button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||
disabled={total === 0 && selectedRows.size === 0}
|
||||
>
|
||||
<FaFileExport /> {t('listPages.export')}
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => navigate('/payment-entries/new')} className="flex items-center gap-2 px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white text-sm font-semibold rounded-lg shadow transition-colors">
|
||||
<FaPlus size={12} /> New Entry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DynamicExportModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
doctype="Payment Entry"
|
||||
selectedCount={selectedRows.size}
|
||||
pageCount={entries.length}
|
||||
totalCount={total}
|
||||
pageData={entries}
|
||||
selectedRows={selectedRows}
|
||||
rowKey="name"
|
||||
onFetchAll={fetchAllForExport}
|
||||
fileNamePrefix="payment_entries"
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="rounded-xl border border-blue-200 dark:border-blue-800 overflow-hidden shadow-sm">
|
||||
<button onClick={() => setFiltersOpen(o => !o)} className="w-full flex items-center justify-between px-4 py-3 bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 text-white text-sm font-semibold">
|
||||
<span className="flex items-center gap-2"><FaFilter size={12} /> Filters {activeFilterCount > 0 && <span className="bg-white/25 text-white text-[10px] px-1.5 py-0.5 rounded-full">{activeFilterCount}</span>}</span>
|
||||
{filtersOpen ? <FaChevronUp size={12} /> : <FaChevronDown size={12} />}
|
||||
</button>
|
||||
{filtersOpen && (
|
||||
<div className="p-4 bg-white dark:bg-gray-800 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-gray-500 uppercase mb-1">Payment ID</label>
|
||||
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Search by ID…" className="w-full border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-gray-500 uppercase mb-1">Party</label>
|
||||
<input value={partyFilter} onChange={e => setPartyFilter(e.target.value)} placeholder="Customer / Supplier…" className="w-full border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-gray-500 uppercase mb-1">Type</label>
|
||||
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)} className="w-full border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
|
||||
<option value="">All Types</option>
|
||||
<option value="Receive">Receive</option>
|
||||
<option value="Pay">Pay</option>
|
||||
<option value="Internal Transfer">Internal Transfer</option>
|
||||
</select>
|
||||
</div>
|
||||
{activeFilterCount > 0 && (
|
||||
<div className="sm:col-span-3 flex flex-wrap gap-2 items-center">
|
||||
{searchQuery && <span className="flex items-center gap-1 bg-blue-100 text-blue-700 text-xs px-2 py-1 rounded-full">ID: {searchQuery}<button onClick={() => setSearchQuery('')}><FaTimes size={9} /></button></span>}
|
||||
{partyFilter && <span className="flex items-center gap-1 bg-blue-100 text-blue-700 text-xs px-2 py-1 rounded-full">Party: {partyFilter}<button onClick={() => setPartyFilter('')}><FaTimes size={9} /></button></span>}
|
||||
{typeFilter && <span className="flex items-center gap-1 bg-blue-100 text-blue-700 text-xs px-2 py-1 rounded-full">Type: {typeFilter}<button onClick={() => setTypeFilter('')}><FaTimes size={9} /></button></span>}
|
||||
<button onClick={clearFilters} className="text-xs text-blue-600 hover:text-blue-800 font-medium ml-1">Clear all</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||
<th className="w-10 px-4 py-3 text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllOnPage}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-teal-600 dark:hover:text-teal-400 transition-colors"
|
||||
title={allOnPageSelected ? 'Deselect all' : 'Select all'}
|
||||
aria-label="Select all on page"
|
||||
>
|
||||
{allOnPageSelected
|
||||
? <FaCheckSquare className="text-teal-600 dark:text-teal-400" size={18} />
|
||||
: someOnPageSelected
|
||||
? (
|
||||
<div className="relative inline-block">
|
||||
<FaSquare size={18} />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-2 h-0.5 bg-current" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: <FaSquare size={18} />}
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Payment ID</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Type</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Date</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Party</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Amount</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Status</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4 w-28"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{loading
|
||||
? <tr><td colSpan={8} className="text-center py-10 text-gray-400">Loading…</td></tr>
|
||||
: entries.length === 0
|
||||
? <tr><td colSpan={8} className="text-center py-10 text-gray-400">No payment entries found</td></tr>
|
||||
: entries.map(pe => (
|
||||
<tr key={pe.name} onClick={() => handleView(pe.name)} className={`cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${selectedRows.has(pe.name) ? 'bg-teal-50 dark:bg-teal-900/20' : ''}`}>
|
||||
<td className="w-10 px-4 py-3" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleRow(pe.name)}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-teal-600 dark:hover:text-teal-400 transition-colors"
|
||||
aria-label={`Select ${pe.name}`}
|
||||
>
|
||||
{selectedRows.has(pe.name)
|
||||
? <FaCheckSquare className="text-teal-600 dark:text-teal-400" size={18} />
|
||||
: <FaSquare size={18} />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-medium text-gray-900 dark:text-white">{pe.name}</td>
|
||||
<td className="py-3 px-4 text-gray-700 dark:text-gray-300">{pe.payment_type || '-'}</td>
|
||||
<td className="py-3 px-4 text-gray-500">{pe.posting_date || '-'}</td>
|
||||
<td className="py-3 px-4 text-gray-700 dark:text-gray-300">{pe.party_name || pe.party || '-'}</td>
|
||||
<td className="py-3 px-4 text-right font-semibold text-gray-900 dark:text-white">{(pe.paid_amount || 0).toFixed(2)}</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-semibold ${statusStyle(pe.status, pe.docstatus)}`}>
|
||||
{statusLabel(pe.status, pe.docstatus)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => handleView(pe.name)} className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors" title="View"><FaEye /></button>
|
||||
<button onClick={() => handleEdit(pe.name)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title="Edit"><FaEdit /></button>
|
||||
<button onClick={() => handleDuplicate(pe.name)} className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors" title="Duplicate"><FaCopy /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-xs text-gray-500">{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}</span>
|
||||
<div className="flex gap-2">
|
||||
<button disabled={page === 0} onClick={() => goPage(page - 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Prev</button>
|
||||
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => goPage(page + 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,608 +0,0 @@
|
||||
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useProjectList } from '../hooks/useProject';
|
||||
import ListPagination from '../components/ListPagination';
|
||||
import { FaSearch, FaFilter, FaChevronDown, FaChevronUp, FaSync, FaEye, FaPlus, FaTimes, FaFileExport, FaEdit, FaCopy, FaCheckSquare, FaSquare, FaMicrophone } from 'react-icons/fa';
|
||||
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||
import { buildDateRangeFilters } from '../utils/listFilterUtils';
|
||||
import type { Project } from '../services/projectService';
|
||||
import DynamicExportModal from '../components/DynamicExportModal';
|
||||
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||
import VoiceStatusModal, { PROJECT_STATUS_OPTIONS } from '../components/VoiceStatusModal';
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'open':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||
case 'cancelled':
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityStyle = (priority: string) => {
|
||||
switch (priority?.toLowerCase()) {
|
||||
case 'high':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
||||
case 'medium':
|
||||
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
|
||||
case 'low':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const ProjectList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const setSearchParamsRef = useRef(setSearchParams);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParamsRef.current = setSearchParams;
|
||||
}, [setSearchParams]);
|
||||
|
||||
const currentPage = useMemo(() => {
|
||||
const p = parseInt(searchParams.get('page') || '1', 10);
|
||||
return Number.isNaN(p) || p < 1 ? 1 : p;
|
||||
}, [searchParams]);
|
||||
|
||||
const setCurrentPage = useCallback(
|
||||
(pageOrUpdater: number | ((p: number) => number)) => {
|
||||
const next = typeof pageOrUpdater === 'function' ? pageOrUpdater(currentPage) : pageOrUpdater;
|
||||
setSearchParams((prev) => {
|
||||
const nextParams = new URLSearchParams(prev);
|
||||
nextParams.set('page', String(next));
|
||||
return nextParams;
|
||||
});
|
||||
},
|
||||
[currentPage, setSearchParams]
|
||||
);
|
||||
|
||||
const pageSize = 20;
|
||||
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState<string>(() => searchParams.get('status') || '');
|
||||
const [priorityFilter, setPriorityFilter] = useState<string>(() => searchParams.get('priority') || '');
|
||||
const [searchQuery, setSearchQuery] = useState<string>(() => searchParams.get('q') || '');
|
||||
const [dateFilterBy, setDateFilterBy] = useState<'' | 'creation' | 'modified'>(
|
||||
() => (searchParams.get('date_filter_by') as '' | 'creation' | 'modified') || ''
|
||||
);
|
||||
const [dateStart, setDateStart] = useState<string>(() => searchParams.get('date_start') || '');
|
||||
const [dateEnd, setDateEnd] = useState<string>(() => searchParams.get('date_end') || '');
|
||||
const [sortBy, setSortBy] = useState<string>(() => searchParams.get('sort_by') || 'modified desc');
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
// ── Voice Command Assist ──────────────────────────────────────────
|
||||
const [showVoiceModal, setShowVoiceModal] = useState(false);
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const didInitUrlSync = useRef(false);
|
||||
const skipInitialSearchUrlSync = useRef(true);
|
||||
const searchDebounceRef = useRef<number | null>(null);
|
||||
|
||||
const apiFilters = useMemo(() => {
|
||||
const filters: Record<string, any> = {};
|
||||
if (statusFilter) filters['status'] = statusFilter;
|
||||
if (priorityFilter) filters['priority'] = priorityFilter;
|
||||
if (searchQuery) filters['project_name'] = ['like', `%${searchQuery}%`];
|
||||
Object.assign(filters, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd));
|
||||
return filters;
|
||||
}, [statusFilter, priorityFilter, searchQuery, dateFilterBy, dateStart, dateEnd]);
|
||||
|
||||
const orderBy = ['creation desc', 'creation asc', 'modified desc', 'modified asc', 'name asc', 'name desc'].includes(sortBy) ? sortBy : 'modified desc';
|
||||
|
||||
const { projects, loading, error, totalCount, refetch } = useProjectList({
|
||||
filters: apiFilters,
|
||||
limit_start: (currentPage - 1) * pageSize,
|
||||
limit_page_length: pageSize,
|
||||
order_by: orderBy,
|
||||
});
|
||||
|
||||
const selectionResetKey = useMemo(
|
||||
() => `${currentPage}|${sortBy}|${JSON.stringify(apiFilters)}`,
|
||||
[currentPage, sortBy, apiFilters],
|
||||
);
|
||||
const {
|
||||
selectedRows,
|
||||
toggleRow,
|
||||
toggleAllOnPage,
|
||||
allOnPageSelected,
|
||||
someOnPageSelected,
|
||||
} = useListPageSelection(projects, selectionResetKey);
|
||||
|
||||
const fetchAllForExport = useCallback(
|
||||
() => fetchAllRowsForExport({ doctype: 'Project', filters: apiFilters, orderBy }),
|
||||
[apiFilters, orderBy],
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const formatDate = (dateStr: string) =>
|
||||
dateStr ? new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-';
|
||||
|
||||
const clearFilters = () => {
|
||||
setStatusFilter('');
|
||||
setPriorityFilter('');
|
||||
setSearchQuery('');
|
||||
setDateFilterBy('');
|
||||
setDateStart('');
|
||||
setDateEnd('');
|
||||
setSortBy('modified desc');
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('status');
|
||||
next.delete('priority');
|
||||
next.delete('q');
|
||||
next.delete('date_filter_by');
|
||||
next.delete('date_start');
|
||||
next.delete('date_end');
|
||||
next.delete('sort_by');
|
||||
next.set('page', '1');
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const hasActiveFilters = !!statusFilter || !!priorityFilter || !!searchQuery || !!(dateFilterBy && (dateStart || dateEnd));
|
||||
|
||||
const syncFiltersToUrl = useCallback(() => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (statusFilter) next.set('status', statusFilter);
|
||||
else next.delete('status');
|
||||
if (priorityFilter) next.set('priority', priorityFilter);
|
||||
else next.delete('priority');
|
||||
if (searchQuery) next.set('q', searchQuery);
|
||||
else next.delete('q');
|
||||
if (dateFilterBy) next.set('date_filter_by', dateFilterBy);
|
||||
else next.delete('date_filter_by');
|
||||
if (dateStart) next.set('date_start', dateStart);
|
||||
else next.delete('date_start');
|
||||
if (dateEnd) next.set('date_end', dateEnd);
|
||||
else next.delete('date_end');
|
||||
if (sortBy !== 'modified desc') next.set('sort_by', sortBy);
|
||||
else next.delete('sort_by');
|
||||
next.set('page', '1');
|
||||
return next;
|
||||
});
|
||||
}, [statusFilter, priorityFilter, searchQuery, dateFilterBy, dateStart, dateEnd, sortBy, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didInitUrlSync.current) {
|
||||
didInitUrlSync.current = true;
|
||||
return;
|
||||
}
|
||||
setSearchParamsRef.current((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (statusFilter) next.set('status', statusFilter);
|
||||
else next.delete('status');
|
||||
if (priorityFilter) next.set('priority', priorityFilter);
|
||||
else next.delete('priority');
|
||||
if (dateFilterBy) next.set('date_filter_by', dateFilterBy);
|
||||
else next.delete('date_filter_by');
|
||||
if (dateStart) next.set('date_start', dateStart);
|
||||
else next.delete('date_start');
|
||||
if (dateEnd) next.set('date_end', dateEnd);
|
||||
else next.delete('date_end');
|
||||
if (sortBy !== 'modified desc') next.set('sort_by', sortBy);
|
||||
else next.delete('sort_by');
|
||||
next.set('page', '1');
|
||||
return next;
|
||||
});
|
||||
}, [statusFilter, priorityFilter, dateFilterBy, dateStart, dateEnd, sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didInitUrlSync.current) return;
|
||||
if (skipInitialSearchUrlSync.current) {
|
||||
skipInitialSearchUrlSync.current = false;
|
||||
return;
|
||||
}
|
||||
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||
searchDebounceRef.current = window.setTimeout(() => {
|
||||
setSearchParamsRef.current((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (searchQuery) next.set('q', searchQuery);
|
||||
else next.delete('q');
|
||||
next.set('page', '1');
|
||||
return next;
|
||||
});
|
||||
}, 450);
|
||||
return () => {
|
||||
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleEdit = (projectName: string) => navigate(`/projects/list/${encodeURIComponent(projectName)}?edit=1`);
|
||||
const handleDuplicate = (projectName: string) => navigate(`/projects/list/new?duplicate=${encodeURIComponent(projectName)}`);
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="flex items-center gap-2 text-sm mb-4">
|
||||
<button onClick={() => navigate('/projects')} className="text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400">{t('projects.moduleTitle')}</button>
|
||||
<span className="text-gray-400">/</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">{t('projects.projectsDoctype')}</span>
|
||||
</div>
|
||||
|
||||
{/* ── Page Header ──────────────────────────────────────────────── */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">{t('projects.title')}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{t('projects.listTotal')}
|
||||
{totalCount} {totalCount !== 1 ? t('projects.listProjects') : t('projects.listProject')}
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="ml-2 text-blue-600 dark:text-blue-400">
|
||||
• {selectedRows.size} {t('common.selected')}
|
||||
</span>
|
||||
)}
|
||||
{loading && (
|
||||
<span className="ml-2 inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
|
||||
<FaSync className="animate-spin h-3 w-3" />
|
||||
{t('common.updating')}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* ── Voice Command Assist ───────────────────────────────── */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowVoiceModal(true)}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all"
|
||||
title="Bulk-update project status by voice"
|
||||
>
|
||||
<FaMicrophone />
|
||||
<span className="font-medium">Voice Command Assist</span>
|
||||
</button>
|
||||
{/* ─────────────────────────────────────────────────────── */}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all disabled:opacity-50"
|
||||
disabled={totalCount === 0 && selectedRows.size === 0}
|
||||
>
|
||||
<FaFileExport />
|
||||
<span className="font-medium">{t('listPages.export')}</span>
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="bg-white/20 px-1.5 py-0.5 rounded text-xs">{selectedRows.size}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects/list/new')}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl"
|
||||
>
|
||||
<FaPlus />
|
||||
<span className="font-medium">{t('projects.newProject')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Export Modal ─────────────────────────────────────────────── */}
|
||||
<DynamicExportModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
doctype="Project"
|
||||
selectedCount={selectedRows.size}
|
||||
pageCount={projects.length}
|
||||
totalCount={totalCount}
|
||||
pageData={projects}
|
||||
selectedRows={selectedRows}
|
||||
rowKey="name"
|
||||
onFetchAll={fetchAllForExport}
|
||||
fileNamePrefix="projects"
|
||||
/>
|
||||
|
||||
{/* ── Voice Status Modal ───────────────────────────────────────── */}
|
||||
<VoiceStatusModal
|
||||
isOpen={showVoiceModal}
|
||||
onClose={() => setShowVoiceModal(false)}
|
||||
selectedRows={selectedRows}
|
||||
onUpdateSuccess={() => {
|
||||
refetch();
|
||||
}}
|
||||
doctype="Project"
|
||||
fieldname="status"
|
||||
statusOptions={PROJECT_STATUS_OPTIONS}
|
||||
widgetTitle="Voice Project Status Update"
|
||||
showLanguageToggle={true}
|
||||
noSelectionLabel="project"
|
||||
/>
|
||||
|
||||
{/* ── Filter Panel ─────────────────────────────────────────────── */}
|
||||
<div className="isolate bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-6">
|
||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 px-4 py-3 rounded-t-lg">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<button onClick={() => setIsFilterExpanded(v => !v)} className="text-white hover:bg-white/20 p-2 rounded-lg transition-all">
|
||||
{isFilterExpanded ? <FaChevronUp size={14} /> : <FaChevronDown size={14} />}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaFilter className="text-white" size={16} />
|
||||
<span className="text-white font-semibold text-sm">{t('listPages.filters')}</span>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">
|
||||
{[searchQuery, statusFilter, priorityFilter, dateFilterBy && dateStart].filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<div className="flex-1 overflow-x-auto mx-2">
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
{searchQuery && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||
<span className="font-semibold">Name:</span> {searchQuery}
|
||||
<button onClick={() => setSearchQuery('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||
</span>
|
||||
)}
|
||||
{statusFilter && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||
<span className="font-semibold">Status:</span> {statusFilter}
|
||||
<button onClick={() => setStatusFilter('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||
</span>
|
||||
)}
|
||||
{priorityFilter && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||
<span className="font-semibold">Priority:</span> {priorityFilter}
|
||||
<button onClick={() => setPriorityFilter('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||
</span>
|
||||
)}
|
||||
{dateFilterBy && dateStart && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||
<span className="font-semibold">{dateFilterBy === 'creation' ? 'Created' : 'Modified'}:</span> {dateStart}{dateEnd ? ` – ${dateEnd}` : ''}
|
||||
<button onClick={() => { setDateFilterBy(''); setDateStart(''); setDateEnd(''); }}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{hasActiveFilters && (
|
||||
<button onClick={clearFilters} className="text-white/80 hover:text-white text-xs underline whitespace-nowrap">Clear all</button>
|
||||
)}
|
||||
<button onClick={() => refetch()} className="text-white hover:bg-white/20 p-1.5 rounded-lg transition-all" title="Refresh">
|
||||
<FaSync size={12} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFilterExpanded && (
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Search</label>
|
||||
<div className="relative">
|
||||
<FaSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
||||
<input type="text" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && syncFiltersToUrl()} placeholder={t('projects.searchPlaceholder')}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-400 focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Status</label>
|
||||
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-400 focus:outline-none">
|
||||
<option value="">All Status</option>
|
||||
<option value="Open">Open</option>
|
||||
<option value="Completed">Completed</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Priority</label>
|
||||
<select value={priorityFilter} onChange={e => setPriorityFilter(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-400 focus:outline-none">
|
||||
<option value="">All Priority</option>
|
||||
<option value="High">High</option>
|
||||
<option value="Medium">Medium</option>
|
||||
<option value="Low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Date Filter By</label>
|
||||
<select value={dateFilterBy} onChange={e => setDateFilterBy(e.target.value as '' | 'creation' | 'modified')} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-400 focus:outline-none">
|
||||
<option value="">None</option>
|
||||
<option value="creation">Created</option>
|
||||
<option value="modified">Modified</option>
|
||||
</select>
|
||||
</div>
|
||||
{dateFilterBy && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">From</label>
|
||||
<input type="date" value={dateStart} onChange={e => setDateStart(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-400 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">To</label>
|
||||
<input type="date" value={dateEnd} onChange={e => setDateEnd(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-400 focus:outline-none" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Sort By</label>
|
||||
<select value={sortBy} onChange={e => setSortBy(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-400 focus:outline-none">
|
||||
<option value="modified desc">Modified (newest)</option>
|
||||
<option value="creation desc">Created (newest)</option>
|
||||
<option value="modified asc">Modified (oldest)</option>
|
||||
<option value="creation asc">Created (oldest)</option>
|
||||
<option value="name asc">Name A–Z</option>
|
||||
<option value="name desc">Name Z–A</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Table ────────────────────────────────────────────────────── */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden relative">
|
||||
{loading ? (
|
||||
<div className="p-12 text-center text-gray-500 dark:text-gray-400">{t('common.loading')}</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="p-12 text-center text-gray-500 dark:text-gray-400">{t('projects.noProjects')}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<tr>
|
||||
<th className="w-10 px-4 py-3 text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllOnPage}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
title={allOnPageSelected ? 'Deselect all' : 'Select all'}
|
||||
aria-label="Select all on page"
|
||||
>
|
||||
{allOnPageSelected
|
||||
? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} />
|
||||
: someOnPageSelected
|
||||
? (
|
||||
<div className="relative inline-block">
|
||||
<FaSquare size={18} />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-2 h-0.5 bg-current" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: <FaSquare size={18} />}
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t('projects.projectName')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('commonFields.status')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('commonFields.priority')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('projects.customer')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('projects.expectedEnd')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('projects.progress')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('common.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{projects.map((project: Project) => (
|
||||
<tr
|
||||
key={project.name}
|
||||
className={`hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer ${selectedRows.has(project.name) ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`}
|
||||
onClick={() => navigate(`/projects/list/${project.name}`)}
|
||||
>
|
||||
<td className="w-10 px-4 py-3" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleRow(project.name)}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
aria-label={`Select ${project.name}`}
|
||||
>
|
||||
{selectedRows.has(project.name)
|
||||
? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} />
|
||||
: <FaSquare size={18} />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="text-[15px] font-medium text-gray-900 dark:text-white hover:underline">
|
||||
{project.project_name || project.name}
|
||||
</span>
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400">{project.name}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getStatusStyle(project.status || '')}`}>
|
||||
{project.status || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getPriorityStyle(project.priority || '')}`}>
|
||||
{project.priority || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{project.customer || '-'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{formatDate(project.expected_end_date || '')}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden max-w-[80px]">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full"
|
||||
style={{ width: `${project.percent_complete ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">{project.percent_complete ?? 0}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/projects/list/${project.name}`)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors"
|
||||
title={t('common.view')}
|
||||
aria-label={t('common.view')}
|
||||
>
|
||||
<FaEye />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEdit(project.name)}
|
||||
className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors"
|
||||
title={t('common.edit', 'Edit')}
|
||||
aria-label={t('common.edit', 'Edit')}
|
||||
>
|
||||
<FaEdit />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDuplicate(project.name)}
|
||||
className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors"
|
||||
title={t('common.duplicate', 'Duplicate')}
|
||||
aria-label={t('common.duplicate', 'Duplicate')}
|
||||
>
|
||||
<FaCopy />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||
<ListPagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalCount={totalCount}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectList;
|
||||
@ -1,495 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
FaFolderOpen,
|
||||
FaTasks,
|
||||
FaClock,
|
||||
FaSpinner,
|
||||
FaPlus,
|
||||
FaSync,
|
||||
FaShoppingCart,
|
||||
FaTruck,
|
||||
FaFileInvoiceDollar,
|
||||
FaMoneyBillWave,
|
||||
FaClipboardList,
|
||||
FaFileContract,
|
||||
FaBoxOpen,
|
||||
FaUsers,
|
||||
FaUserFriends,
|
||||
FaClone,
|
||||
FaTags,
|
||||
} from 'react-icons/fa';
|
||||
import projectService from '../services/projectService';
|
||||
import salesOrderService from '../services/salesOrderService';
|
||||
import deliveryNoteService from '../services/deliveryNoteService';
|
||||
import salesInvoiceService from '../services/salesInvoiceService';
|
||||
import { paymentEntryService } from '../services/paymentEntryService';
|
||||
import materialRequestService from '../services/materialRequestService';
|
||||
import purchaseOrderService from '../services/purchaseOrderService';
|
||||
import purchaseReceiptService from '../services/purchaseReceiptService';
|
||||
import masterService from '../services/masterService';
|
||||
interface HubCounts {
|
||||
projects: number | null;
|
||||
tasks: number | null;
|
||||
timesheets: number | null;
|
||||
salesOrders: number | null;
|
||||
deliveryNotes: number | null;
|
||||
salesInvoices: number | null;
|
||||
paymentEntries: number | null;
|
||||
materialRequests: number | null;
|
||||
purchaseOrders: number | null;
|
||||
purchaseReceipts: number | null;
|
||||
customers: number | null;
|
||||
employees: number | null;
|
||||
projectTemplates: number | null;
|
||||
activityTypes: number | null;
|
||||
}
|
||||
|
||||
const emptyCounts = (): HubCounts => ({
|
||||
projects: null,
|
||||
tasks: null,
|
||||
timesheets: null,
|
||||
salesOrders: null,
|
||||
deliveryNotes: null,
|
||||
salesInvoices: null,
|
||||
paymentEntries: null,
|
||||
materialRequests: null,
|
||||
purchaseOrders: null,
|
||||
purchaseReceipts: null,
|
||||
customers: null,
|
||||
employees: null,
|
||||
projectTemplates: null,
|
||||
activityTypes: null,
|
||||
});
|
||||
|
||||
/** Card: tinted panel, themed icon */
|
||||
const ModuleCard: React.FC<{
|
||||
label: string;
|
||||
sub?: string;
|
||||
icon: React.ReactNode;
|
||||
iconClassName?: string;
|
||||
cardClassName?: string;
|
||||
count?: number | null;
|
||||
loading?: boolean;
|
||||
onClick: () => void;
|
||||
onNew?: () => void;
|
||||
secondaryNew?: { onClick: () => void; title: string; icon: React.ReactNode };
|
||||
}> = ({
|
||||
label, sub, icon, count, loading, onClick, onNew,
|
||||
iconClassName = 'bg-gradient-to-br from-blue-600 to-indigo-700',
|
||||
cardClassName = '',
|
||||
secondaryNew,
|
||||
}) => (
|
||||
<div
|
||||
className={`group rounded-lg border overflow-hidden hover:shadow-md transition-all cursor-pointer shadow-sm h-full min-w-0 flex flex-col ${cardClassName || 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-blue-300/80 dark:hover:border-blue-600'}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="p-2 flex-1 flex flex-col min-h-0">
|
||||
<div className="flex items-start justify-between mb-1 gap-1">
|
||||
<div className={`w-7 h-7 rounded-md ${iconClassName} flex items-center justify-center text-white shadow-sm shrink-0`}>
|
||||
<span className="text-xs">{icon}</span>
|
||||
</div>
|
||||
{(onNew || secondaryNew) && (
|
||||
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onNew && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNew();
|
||||
}}
|
||||
className="p-1 rounded-md bg-blue-600 text-white border border-blue-600 hover:bg-blue-700 shadow-sm"
|
||||
title={`New ${label}`}
|
||||
>
|
||||
<FaPlus size={9} />
|
||||
</button>
|
||||
)}
|
||||
{secondaryNew && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
secondaryNew.onClick();
|
||||
}}
|
||||
className="p-1 rounded-md border border-violet-200 dark:border-violet-700 bg-violet-50 dark:bg-violet-900/40 text-violet-700 dark:text-violet-200 hover:bg-violet-100 dark:hover:bg-violet-900/60"
|
||||
title={secondaryNew.title}
|
||||
>
|
||||
<span className="text-[10px] flex items-center gap-0.5 font-bold">
|
||||
<FaPlus size={7} />{secondaryNew.icon}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-semibold text-gray-900 dark:text-white text-xs leading-tight">{label}</p>
|
||||
{sub && <p className="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 leading-snug line-clamp-2">{sub}</p>}
|
||||
{count !== undefined && (
|
||||
<p className="text-base font-bold text-gray-900 dark:text-white mt-auto pt-1 leading-none tabular-nums">
|
||||
{loading && count === null ? <FaSpinner className="animate-spin text-base text-gray-400" /> : (count ?? 0)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Fills available width evenly — avoids empty space when few tiles sit in a half-width column */
|
||||
const fluidTileGridClass =
|
||||
'grid gap-1.5 w-full [grid-template-columns:repeat(auto-fit,minmax(6.75rem,1fr))]';
|
||||
|
||||
const SectionCard: React.FC<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: React.ReactNode;
|
||||
headerClassName?: string;
|
||||
bodyClassName?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({
|
||||
title, subtitle, icon, children,
|
||||
headerClassName = 'bg-gradient-to-r from-slate-600 to-slate-800 dark:from-slate-700 dark:to-slate-900',
|
||||
bodyClassName = '',
|
||||
className = '',
|
||||
}) => (
|
||||
<div
|
||||
className={`rounded-lg overflow-hidden shadow-sm border border-gray-200/70 dark:border-gray-700/80 ring-1 ring-black/[0.03] dark:ring-white/[0.06] flex flex-col min-h-0 h-full ${className}`}
|
||||
>
|
||||
<div className={`flex items-center gap-1.5 px-2.5 py-1.5 text-white shadow-inner shrink-0 ${headerClassName}`}>
|
||||
<span className="opacity-95 drop-shadow-sm shrink-0">{icon}</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xs font-semibold leading-tight tracking-tight">{title}</h2>
|
||||
{subtitle && <p className="text-[9px] text-white/85 leading-snug mt-0.5 line-clamp-2">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`p-2 flex-1 min-h-0 ${bodyClassName}`}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ProjectModulePage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [counts, setCounts] = useState<HubCounts>(emptyCounts);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setCounts(emptyCounts());
|
||||
|
||||
const settled = await Promise.allSettled([
|
||||
projectService.getModuleCounts(),
|
||||
salesOrderService.getSalesOrderCount([]),
|
||||
deliveryNoteService.getDeliveryNoteCount([]),
|
||||
salesInvoiceService.getSalesInvoiceCount({}),
|
||||
paymentEntryService.getPaymentEntryCount([]),
|
||||
materialRequestService.getMaterialRequestCount([]),
|
||||
purchaseOrderService.getPurchaseOrderCount([]),
|
||||
purchaseReceiptService.getPurchaseReceiptCount([]),
|
||||
masterService.getCustomerCount({}),
|
||||
masterService.getEmployeeCount({}),
|
||||
projectService.getProjectTemplateCount({}),
|
||||
projectService.getActivityTypeCount({}),
|
||||
]);
|
||||
|
||||
const num = (i: number): number => {
|
||||
const r = settled[i];
|
||||
return r.status === 'fulfilled' ? (r.value as number) : 0;
|
||||
};
|
||||
|
||||
const module =
|
||||
settled[0].status === 'fulfilled'
|
||||
? (settled[0].value as { projects: number; tasks: number; timesheets: number })
|
||||
: { projects: 0, tasks: 0, timesheets: 0 };
|
||||
|
||||
setCounts({
|
||||
projects: module.projects,
|
||||
tasks: module.tasks,
|
||||
timesheets: module.timesheets,
|
||||
salesOrders: num(1),
|
||||
deliveryNotes: num(2),
|
||||
salesInvoices: num(3),
|
||||
paymentEntries: num(4),
|
||||
materialRequests: num(5),
|
||||
purchaseOrders: num(6),
|
||||
purchaseReceipts: num(7),
|
||||
customers: num(8),
|
||||
employees: num(9),
|
||||
projectTemplates: num(10),
|
||||
activityTypes: num(11),
|
||||
});
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const flowLoading = loading;
|
||||
|
||||
return (
|
||||
<div className="p-3 min-h-screen bg-gray-50 dark:bg-gray-900 max-w-[1920px] mx-auto">
|
||||
<div className="flex flex-col gap-2 mb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-blue-600 to-indigo-700 flex items-center justify-center shadow-md ring-2 ring-white/50 dark:ring-gray-700/50">
|
||||
<FaFolderOpen className="text-white text-sm drop-shadow" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">
|
||||
{t('projects.moduleTitle', 'Project Management')}
|
||||
</h1>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
|
||||
{t('projects.hubSubtitle', 'Projects, tasks, timesheets, and linked other documents.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
className="p-2 text-gray-600 hover:text-blue-700 dark:text-gray-300 dark:hover:text-blue-400 border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 shadow-sm self-start sm:self-center"
|
||||
title={t('common.refresh', 'Refresh')}
|
||||
>
|
||||
<FaSync size={13} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary pills */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects/list')}
|
||||
className="flex items-center gap-2 text-xs font-semibold px-3 py-1.5 rounded-full border border-sky-200 dark:border-sky-700 bg-gradient-to-r from-sky-100 to-blue-100 dark:from-sky-900/40 dark:to-blue-900/30 text-sky-950 dark:text-sky-100 shadow-sm hover:shadow transition-shadow"
|
||||
>
|
||||
<span className="text-blue-800 dark:text-sky-300 tabular-nums font-bold">
|
||||
{loading && counts.projects === null ? '…' : (counts.projects ?? 0)}
|
||||
</span>
|
||||
Open projects
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects/tasks')}
|
||||
className="flex items-center gap-2 text-xs font-semibold px-3 py-1.5 rounded-full border border-indigo-200 dark:border-indigo-700 bg-gradient-to-r from-indigo-50 to-violet-100 dark:from-indigo-900/35 dark:to-violet-900/25 text-gray-800 dark:text-indigo-100 shadow-sm hover:shadow transition-shadow"
|
||||
>
|
||||
<span className="text-indigo-700 dark:text-indigo-300 tabular-nums font-bold">
|
||||
{loading && counts.tasks === null ? '…' : (counts.tasks ?? 0)}
|
||||
</span>
|
||||
Tasks
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects/timesheets')}
|
||||
className="flex items-center gap-2 text-xs font-semibold px-3 py-1.5 rounded-full border border-emerald-200 dark:border-emerald-700 bg-gradient-to-r from-emerald-50 to-teal-100 dark:from-emerald-900/35 dark:to-teal-900/25 text-gray-800 dark:text-emerald-100 shadow-sm hover:shadow transition-shadow"
|
||||
>
|
||||
<span className="text-emerald-700 dark:text-emerald-300 tabular-nums font-bold">
|
||||
{loading && counts.timesheets === null ? '…' : (counts.timesheets ?? 0)}
|
||||
</span>
|
||||
Timesheets
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Two columns on lg+: (Masters + Sales) | (Project & task + Buying) — halves screen, less scroll */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2 lg:gap-3 lg:items-stretch">
|
||||
<div className="flex flex-col gap-2 min-w-0">
|
||||
<SectionCard
|
||||
title="Masters"
|
||||
subtitle="Customers, employees, templates, and activity types."
|
||||
icon={<FaUsers size={14} />}
|
||||
headerClassName="bg-gradient-to-r from-purple-600 to-violet-700 dark:from-purple-700 dark:to-violet-800"
|
||||
bodyClassName="bg-white dark:bg-gray-900/40"
|
||||
>
|
||||
<div className={fluidTileGridClass}>
|
||||
<ModuleCard
|
||||
label="Customers"
|
||||
sub="Customer master"
|
||||
icon={<FaUsers className="text-white" />}
|
||||
iconClassName="bg-gradient-to-br from-violet-500 to-purple-700"
|
||||
cardClassName="bg-gradient-to-b from-white to-violet-50/40 dark:from-gray-800 dark:to-violet-950/20 border-violet-200/50 dark:border-violet-800/50"
|
||||
count={counts.customers}
|
||||
loading={loading}
|
||||
onClick={() => navigate('/customers')}
|
||||
onNew={() => navigate('/customers/new')}
|
||||
/>
|
||||
<ModuleCard
|
||||
label="Employees"
|
||||
sub="Employee master"
|
||||
icon={<FaUserFriends className="text-white" />}
|
||||
iconClassName="bg-gradient-to-br from-fuchsia-500 to-pink-700"
|
||||
cardClassName="bg-gradient-to-b from-white to-fuchsia-50/35 dark:from-gray-800 dark:to-fuchsia-950/15 border-fuchsia-200/50 dark:border-fuchsia-800/50"
|
||||
count={counts.employees}
|
||||
loading={loading}
|
||||
onClick={() => navigate('/employees')}
|
||||
onNew={() => navigate('/employees/new')}
|
||||
/>
|
||||
<ModuleCard
|
||||
label="Project template"
|
||||
sub="Reusable project templates"
|
||||
icon={<FaClone className="text-white" />}
|
||||
iconClassName="bg-gradient-to-br from-purple-600 to-indigo-800"
|
||||
cardClassName="bg-gradient-to-b from-white to-purple-50/35 dark:from-gray-800 dark:to-purple-950/20 border-purple-200/50 dark:border-purple-800/50"
|
||||
count={counts.projectTemplates}
|
||||
loading={loading}
|
||||
onClick={() => navigate('/projects/templates')}
|
||||
onNew={() => navigate('/projects/templates/new')}
|
||||
/>
|
||||
<ModuleCard
|
||||
label="Activity type"
|
||||
sub="Billing categories"
|
||||
icon={<FaTags className="text-white" />}
|
||||
iconClassName="bg-gradient-to-br from-cyan-500 to-blue-700"
|
||||
cardClassName="bg-gradient-to-b from-white to-cyan-50/30 dark:from-gray-800 dark:to-cyan-950/15 border-cyan-200/50 dark:border-cyan-800/50"
|
||||
count={counts.activityTypes}
|
||||
loading={loading}
|
||||
onClick={() => navigate('/projects/activity-types')}
|
||||
onNew={() => navigate('/projects/activity-types/new')}
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Sales flow"
|
||||
subtitle="Sales Order -> Delivery Note -> Sales Invoice -> Payment"
|
||||
icon={<FaShoppingCart size={14} />}
|
||||
headerClassName="bg-gradient-to-r from-green-600 to-emerald-700 dark:from-green-700 dark:to-emerald-800"
|
||||
bodyClassName="bg-white dark:bg-gray-900/40"
|
||||
>
|
||||
<div className={fluidTileGridClass}>
|
||||
<ModuleCard
|
||||
label="Sales Order"
|
||||
sub="Sales flow"
|
||||
icon={<FaShoppingCart className="text-white" />}
|
||||
iconClassName="bg-gradient-to-br from-emerald-600 to-teal-700"
|
||||
cardClassName="bg-gradient-to-b from-white to-emerald-50/45 dark:from-gray-800 dark:to-emerald-950/20 border-emerald-200/55 dark:border-emerald-800/50"
|
||||
count={counts.salesOrders}
|
||||
loading={flowLoading}
|
||||
onClick={() => navigate('/sales-orders')}
|
||||
/>
|
||||
<ModuleCard
|
||||
label="Delivery Note"
|
||||
sub="Sales flow"
|
||||
icon={<FaTruck className="text-white" />}
|
||||
iconClassName="bg-gradient-to-br from-cyan-600 to-blue-700"
|
||||
cardClassName="bg-gradient-to-b from-white to-cyan-50/40 dark:from-gray-800 dark:to-cyan-950/15 border-cyan-200/55 dark:border-cyan-800/50"
|
||||
count={counts.deliveryNotes}
|
||||
loading={flowLoading}
|
||||
onClick={() => navigate('/delivery-notes')}
|
||||
/>
|
||||
<ModuleCard
|
||||
label="Sales Invoice"
|
||||
sub="Sales flow"
|
||||
icon={<FaFileInvoiceDollar className="text-white" />}
|
||||
iconClassName="bg-gradient-to-br from-blue-600 to-indigo-700"
|
||||
cardClassName="bg-gradient-to-b from-white to-blue-50/40 dark:from-gray-800 dark:to-blue-950/20 border-blue-200/55 dark:border-blue-800/50"
|
||||
count={counts.salesInvoices}
|
||||
loading={flowLoading}
|
||||
onClick={() => navigate('/invoices')}
|
||||
/>
|
||||
<ModuleCard
|
||||
label="Payment Entry"
|
||||
sub="Sales flow"
|
||||
icon={<FaMoneyBillWave className="text-white" />}
|
||||
iconClassName="bg-gradient-to-br from-violet-600 to-purple-800"
|
||||
cardClassName="bg-gradient-to-b from-white to-violet-50/40 dark:from-gray-800 dark:to-violet-950/20 border-violet-200/55 dark:border-violet-800/50"
|
||||
count={counts.paymentEntries}
|
||||
loading={flowLoading}
|
||||
onClick={() => navigate('/payment-entries')}
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 min-w-0">
|
||||
<SectionCard
|
||||
title="Project & task management"
|
||||
subtitle="Core project monitoring and planning."
|
||||
icon={<FaFolderOpen size={14} />}
|
||||
headerClassName="bg-gradient-to-r from-blue-600 to-indigo-700 dark:from-blue-700 dark:to-indigo-800"
|
||||
bodyClassName="bg-white dark:bg-gray-900/40"
|
||||
>
|
||||
<div className={fluidTileGridClass}>
|
||||
<ModuleCard
|
||||
label="Projects"
|
||||
sub="Open projects"
|
||||
icon={<FaFolderOpen className="text-white" />}
|
||||
iconClassName="bg-gradient-to-br from-sky-500 to-blue-700"
|
||||
cardClassName="bg-gradient-to-b from-white to-sky-50/50 dark:from-gray-800 dark:to-sky-950/20 border-sky-200/60 dark:border-sky-800/50"
|
||||
count={counts.projects}
|
||||
loading={loading}
|
||||
onClick={() => navigate('/projects/list')}
|
||||
onNew={() => navigate('/projects/list/new')}
|
||||
/>
|
||||
<ModuleCard
|
||||
label="Tasks"
|
||||
sub="All tasks"
|
||||
icon={<FaTasks className="text-white" />}
|
||||
iconClassName="bg-gradient-to-br from-indigo-500 to-violet-700"
|
||||
cardClassName="bg-gradient-to-b from-white to-indigo-50/40 dark:from-gray-800 dark:to-indigo-950/20 border-indigo-200/50 dark:border-indigo-800/50"
|
||||
count={counts.tasks}
|
||||
loading={loading}
|
||||
onClick={() => navigate('/projects/tasks')}
|
||||
onNew={() => navigate('/projects/tasks/new')}
|
||||
/>
|
||||
<ModuleCard
|
||||
label="Timesheets"
|
||||
sub="Time logs"
|
||||
icon={<FaClock className="text-white" />}
|
||||
iconClassName="bg-gradient-to-br from-emerald-500 to-teal-700"
|
||||
cardClassName="bg-gradient-to-b from-white to-emerald-50/40 dark:from-gray-800 dark:to-emerald-950/20 border-emerald-200/50 dark:border-emerald-800/50"
|
||||
count={counts.timesheets}
|
||||
loading={loading}
|
||||
onClick={() => navigate('/projects/timesheets')}
|
||||
onNew={() => navigate('/projects/timesheets/new')}
|
||||
secondaryNew={{
|
||||
title: 'New Sales Order',
|
||||
icon: <FaShoppingCart size={10} />,
|
||||
onClick: () => navigate('/sales-orders/new'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Buying / material flow"
|
||||
subtitle="Material Request -> Purchase Order -> Purchase Receipt"
|
||||
icon={<FaBoxOpen size={14} />}
|
||||
headerClassName="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-600 dark:from-amber-600 dark:via-orange-600 dark:to-rose-700"
|
||||
bodyClassName="bg-white dark:bg-gray-900/40"
|
||||
>
|
||||
<div className={fluidTileGridClass}>
|
||||
<ModuleCard
|
||||
label="Material Request"
|
||||
sub="Buying flow"
|
||||
icon={<FaClipboardList className="text-white" />}
|
||||
iconClassName="bg-gradient-to-br from-amber-500 to-orange-600"
|
||||
cardClassName="bg-gradient-to-b from-white to-amber-50/45 dark:from-gray-800 dark:to-amber-950/20 border-amber-200/55 dark:border-amber-800/50"
|
||||
count={counts.materialRequests}
|
||||
loading={flowLoading}
|
||||
onClick={() => navigate('/material-requests')}
|
||||
/>
|
||||
<ModuleCard
|
||||
label="Purchase Order"
|
||||
sub="Buying flow"
|
||||
icon={<FaFileContract className="text-white" />}
|
||||
iconClassName="bg-gradient-to-br from-orange-600 to-red-700"
|
||||
cardClassName="bg-gradient-to-b from-white to-orange-50/40 dark:from-gray-800 dark:to-orange-950/20 border-orange-200/55 dark:border-orange-800/50"
|
||||
count={counts.purchaseOrders}
|
||||
loading={flowLoading}
|
||||
onClick={() => navigate('/purchase-orders')}
|
||||
/>
|
||||
<ModuleCard
|
||||
label="Purchase Receipt"
|
||||
sub="Buying flow"
|
||||
icon={<FaBoxOpen className="text-white" />}
|
||||
iconClassName="bg-gradient-to-br from-rose-600 to-pink-700"
|
||||
cardClassName="bg-gradient-to-b from-white to-rose-50/40 dark:from-gray-800 dark:to-rose-950/20 border-rose-200/55 dark:border-rose-800/50"
|
||||
count={counts.purchaseReceipts}
|
||||
loading={flowLoading}
|
||||
onClick={() => navigate('/purchase-receipts')}
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectModulePage;
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,302 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
FaArrowLeft, FaSave, FaEdit, FaTimes, FaPlus, FaTrash,
|
||||
FaCheckCircle, FaTimesCircle, FaSpinner, FaInfoCircle,
|
||||
} from 'react-icons/fa';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useProjectTemplateDetails, useProjectMutations } from '../hooks/useProject';
|
||||
import LinkField from '../components/LinkField';
|
||||
import ActivityLog from '../components/ActivityLog';
|
||||
import type { ProjectTemplate, ProjectTemplateTask } from '../services/projectService';
|
||||
|
||||
// Confirmed from Frappe desk: Project Template Task uses `task` (Link to Task, is_template=1)
|
||||
// and `subject` is auto-fetched from the linked task. `duration` is in days.
|
||||
|
||||
const emptyTask = (): ProjectTemplateTask => ({
|
||||
task: '',
|
||||
subject: '',
|
||||
duration: undefined,
|
||||
});
|
||||
|
||||
/** Only keep rows that have a task selected; strip empty/undefined fields */
|
||||
const sanitizeTemplateTasks = (tasks: ProjectTemplateTask[]): ProjectTemplateTask[] =>
|
||||
tasks
|
||||
.filter(t => t.task?.trim())
|
||||
.map(({ task, duration }) => ({
|
||||
task: task!.trim(),
|
||||
...(duration !== undefined ? { duration } : {}),
|
||||
}));
|
||||
|
||||
const ProjectTemplateDetail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { templateName } = useParams<{ templateName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isNew = templateName === 'new';
|
||||
|
||||
const { template, loading, error, refetch } = useProjectTemplateDetails(isNew ? null : (templateName || null));
|
||||
const { createProjectTemplate, updateProjectTemplate, loading: saving } = useProjectMutations();
|
||||
const [isEditing, setIsEditing] = useState(isNew);
|
||||
|
||||
const [form, setForm] = useState<Partial<ProjectTemplate>>({
|
||||
name: '',
|
||||
project_type: '',
|
||||
tasks: [emptyTask()],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (template && !isNew) {
|
||||
setForm({
|
||||
name: template.name || '',
|
||||
project_type: template.project_type || '',
|
||||
tasks: template.tasks?.length ? template.tasks : [],
|
||||
});
|
||||
}
|
||||
}, [template, isNew]);
|
||||
|
||||
const setField = (k: keyof ProjectTemplate, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
const addTask = () => setForm(f => ({
|
||||
...f,
|
||||
tasks: [...(f.tasks || []), emptyTask()],
|
||||
}));
|
||||
|
||||
const updateTask = (idx: number, k: keyof ProjectTemplateTask, v: any) => setForm(f => {
|
||||
const tasks = [...(f.tasks || [])];
|
||||
tasks[idx] = { ...tasks[idx], [k]: v };
|
||||
return { ...f, tasks };
|
||||
});
|
||||
|
||||
const removeTask = (idx: number) => setForm(f => {
|
||||
const tasks = [...(f.tasks || [])];
|
||||
tasks.splice(idx, 1);
|
||||
return { ...f, tasks };
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name?.trim()) { toast.error('Template name is required'); return; }
|
||||
|
||||
const cleanTasks = sanitizeTemplateTasks(form.tasks || []);
|
||||
const payload: Partial<ProjectTemplate> = {
|
||||
name: form.name.trim(),
|
||||
// Only send project_type if non-empty — empty string causes Link validation errors
|
||||
...(form.project_type?.trim() ? { project_type: form.project_type.trim() } : {}),
|
||||
tasks: cleanTasks,
|
||||
};
|
||||
|
||||
try {
|
||||
if (isNew) {
|
||||
const created = await createProjectTemplate(payload);
|
||||
toast.success(t('projects.templateCreated'), { icon: <FaCheckCircle /> });
|
||||
navigate(`/projects/templates/${encodeURIComponent(created.name)}`);
|
||||
} else {
|
||||
await updateProjectTemplate(templateName!, payload);
|
||||
toast.success(t('projects.templateUpdated'), { icon: <FaCheckCircle /> });
|
||||
setIsEditing(false);
|
||||
refetch();
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'), { icon: <FaTimesCircle /> });
|
||||
}
|
||||
};
|
||||
|
||||
const inputCls = (ed: boolean) =>
|
||||
`w-full px-3 py-2 text-sm border rounded-lg ${ed
|
||||
? 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-400'
|
||||
: 'border-transparent bg-gray-50 dark:bg-gray-800 text-gray-800 dark:text-gray-200 cursor-default'}`;
|
||||
|
||||
const editable = isNew || isEditing;
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center min-h-[400px]"><FaSpinner className="animate-spin text-purple-500 text-3xl" /></div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-6 py-6">
|
||||
<ToastContainer position="top-right" autoClose={3500} />
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm mb-6">
|
||||
<button onClick={() => navigate('/projects')} className="text-gray-500 hover:text-indigo-600 dark:text-gray-400">{t('projects.moduleTitle')}</button>
|
||||
<span className="text-gray-400">/</span>
|
||||
<button onClick={() => navigate('/projects/templates')} className="text-gray-500 hover:text-purple-600 dark:text-gray-400">{t('projects.projectTemplateDoctype')}</button>
|
||||
<span className="text-gray-400">/</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">{isNew ? t('projects.newProjectTemplate') : templateName}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate('/projects/templates')} className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"><FaArrowLeft /></button>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{isNew ? t('projects.newProjectTemplate') : (form.name || templateName)}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!isNew && !isEditing && (
|
||||
<button onClick={() => setIsEditing(true)} className="flex items-center gap-2 px-4 py-2 border border-purple-500 text-purple-600 dark:text-purple-400 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20 text-sm">
|
||||
<FaEdit /> {t('common.edit')}
|
||||
</button>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 text-sm">
|
||||
{saving ? <FaSpinner className="animate-spin" /> : <FaSave />}
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
{!isNew && (
|
||||
<button onClick={() => setIsEditing(false)} className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm"><FaTimes /></button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && !isNew && (
|
||||
<div className="mx-6 mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded text-red-700 dark:text-red-300 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Header fields */}
|
||||
<div className="p-6 grid grid-cols-1 sm:grid-cols-2 gap-4 border-b border-gray-100 dark:border-gray-700">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name || ''}
|
||||
onChange={e => setField('name', e.target.value)}
|
||||
disabled={!isNew}
|
||||
className={inputCls(isNew)}
|
||||
placeholder="Template name..."
|
||||
/>
|
||||
{!isNew && <p className="text-xs text-gray-400 mt-1">Name cannot be changed after creation</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Project Type</label>
|
||||
{editable
|
||||
? <LinkField label="Project Type" hideLabel value={form.project_type || ''} onChange={v => setField('project_type', v)} doctype="Project Type" placeholder="Select project type..." />
|
||||
: <div className="px-3 py-2 text-sm text-gray-800 dark:text-gray-200">{form.project_type || '-'}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks child table */}
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Tasks</h3>
|
||||
{editable && (
|
||||
<button onClick={addTask} className="flex items-center gap-1.5 px-3 py-1.5 bg-purple-600 text-white text-xs rounded-lg hover:bg-purple-700">
|
||||
<FaPlus /> Add Row
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info banner */}
|
||||
<div className="flex items-start gap-2 mb-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 text-xs text-blue-700 dark:text-blue-300">
|
||||
<FaInfoCircle className="mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
Each row links to a <strong>Task</strong> that has <strong>Is Template = Yes</strong>.
|
||||
To create a new template task, first go to{' '}
|
||||
<button onClick={() => navigate('/projects/tasks/new')} className="underline hover:text-blue-900 dark:hover:text-blue-100">Tasks → New Task</button>
|
||||
{' '}and check "Is Template", then come back and select it here.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(form.tasks || []).length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-400 dark:text-gray-500 text-sm bg-gray-50 dark:bg-gray-900/30 rounded-lg border border-dashed border-gray-300 dark:border-gray-600">
|
||||
{editable ? 'Click "Add Row" to link template tasks' : 'No tasks defined'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-10">No.</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase min-w-[280px]">Task * <span className="font-normal normal-case text-gray-400">(is_template=Yes)</span></th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Subject</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-28">Duration (days)</th>
|
||||
{editable && <th className="px-3 py-2 w-10" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{(form.tasks || []).map((task, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="px-3 py-2 text-gray-500 text-xs font-medium">{idx + 1}</td>
|
||||
|
||||
{/* task — Link to Task (is_template=1) */}
|
||||
<td className="px-2 py-1.5 min-w-[280px]">
|
||||
{editable
|
||||
? <LinkField
|
||||
label="Task"
|
||||
hideLabel
|
||||
value={task.task || ''}
|
||||
onChange={v => updateTask(idx, 'task', v)}
|
||||
doctype="Task"
|
||||
placeholder="Search template task..."
|
||||
compact
|
||||
/>
|
||||
: <span className="text-gray-800 dark:text-gray-200 font-medium">{task.task || '-'}</span>}
|
||||
</td>
|
||||
|
||||
{/* subject — auto-fetched from linked task, read-only */}
|
||||
<td className="px-3 py-2">
|
||||
<span className="text-gray-600 dark:text-gray-400 text-xs italic">
|
||||
{task.subject || '(from task)'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* duration in days */}
|
||||
<td className="px-2 py-1.5">
|
||||
{editable
|
||||
? <input
|
||||
type="number" min={0}
|
||||
value={task.duration ?? ''}
|
||||
onChange={e => updateTask(idx, 'duration', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
placeholder="0"
|
||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-1 focus:ring-purple-400"
|
||||
/>
|
||||
: <span className="text-gray-700 dark:text-gray-300 text-sm">{task.duration ?? '-'}</span>}
|
||||
</td>
|
||||
|
||||
{editable && (
|
||||
<td className="px-3 py-2">
|
||||
<button onClick={() => removeTask(idx)} className="text-red-500 hover:text-red-700 p-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20">
|
||||
<FaTrash size={12} />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meta */}
|
||||
{!isNew && template && (
|
||||
<div className="mt-6 pt-4 border-t border-gray-100 dark:border-gray-700 grid grid-cols-2 gap-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div><span className="font-medium block">Created</span>{template.creation ? new Date(template.creation).toLocaleString() : '-'}</div>
|
||||
<div><span className="font-medium block">Modified</span>{template.modified ? new Date(template.modified).toLocaleString() : '-'}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNew && (
|
||||
<div className="mt-4">
|
||||
<ActivityLog
|
||||
doctype="Project Template"
|
||||
docname={template?.name || templateName || ''}
|
||||
creationDate={template?.creation}
|
||||
createdBy={template?.owner}
|
||||
compact={false}
|
||||
initialVisible={5}
|
||||
collapsible
|
||||
startCollapsed
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectTemplateDetail;
|
||||
@ -1,224 +0,0 @@
|
||||
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaPlus, FaSearch, FaSync, FaClone, FaEye, FaFileExport } from 'react-icons/fa';
|
||||
import { useProjectTemplates } from '../hooks/useProject';
|
||||
import DynamicExportModal from '../components/DynamicExportModal';
|
||||
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const ProjectTemplateList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
const apiFilters = useMemo(() => {
|
||||
const f: Record<string, unknown> = {};
|
||||
if (search.trim()) f.name = ['like', `%${search.trim()}%`];
|
||||
return f;
|
||||
}, [search]);
|
||||
|
||||
const { templates, loading, totalCount, refetch } = useProjectTemplates({
|
||||
filters: apiFilters,
|
||||
limit_start: page * PAGE_SIZE,
|
||||
limit_page_length: PAGE_SIZE,
|
||||
order_by: 'name asc',
|
||||
});
|
||||
|
||||
// Ensure pagination always triggers data reload (some environments cache identical queries).
|
||||
useEffect(() => { refetch(); }, [page, apiFilters, refetch]);
|
||||
|
||||
const selectionResetKey = useMemo(() => `${page}|${JSON.stringify(apiFilters)}`, [page, apiFilters]);
|
||||
const {
|
||||
selectedRows,
|
||||
toggleRow,
|
||||
toggleAllOnPage,
|
||||
allOnPageSelected,
|
||||
someOnPageSelected,
|
||||
} = useListPageSelection(templates, selectionResetKey);
|
||||
|
||||
const fetchAllForExport = useCallback(
|
||||
() => fetchAllRowsForExport({ doctype: 'Project Template', filters: apiFilters, orderBy: 'name asc' }),
|
||||
[apiFilters],
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects')}
|
||||
className="text-sm text-gray-500 hover:text-violet-600 dark:text-gray-400 dark:hover:text-violet-400"
|
||||
>
|
||||
{t('projects.moduleTitle')}
|
||||
</button>
|
||||
<span className="text-gray-400">/</span>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<FaClone className="text-violet-500" /> {t('projects.projectTemplateDoctype')}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||
disabled={totalCount === 0 && selectedRows.size === 0}
|
||||
>
|
||||
<FaFileExport /> {t('listPages.export')}
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="p-2 text-gray-500 border border-gray-200 dark:border-gray-600 rounded-lg hover:text-violet-600"
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<FaSync size={14} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects/templates/new')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 text-sm font-medium"
|
||||
>
|
||||
<FaPlus size={12} /> New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-3 items-center">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<FaSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
placeholder="Search template…"
|
||||
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{totalCount} total</span>
|
||||
</div>
|
||||
|
||||
<DynamicExportModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
doctype="Project Template"
|
||||
selectedCount={selectedRows.size}
|
||||
pageCount={templates.length}
|
||||
totalCount={totalCount}
|
||||
pageData={templates}
|
||||
selectedRows={selectedRows}
|
||||
rowKey="name"
|
||||
onFetchAll={fetchAllForExport}
|
||||
fileNamePrefix="project_templates"
|
||||
/>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||
<th className="w-10 px-2 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-violet-600 focus:ring-violet-500"
|
||||
checked={allOnPageSelected}
|
||||
ref={el => {
|
||||
if (el) el.indeterminate = someOnPageSelected;
|
||||
}}
|
||||
onChange={toggleAllOnPage}
|
||||
aria-label="Select all on page"
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Name</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Project type</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Modified</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-3 px-4 w-24"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-10 text-gray-400">Loading…</td>
|
||||
</tr>
|
||||
) : templates.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-10 text-gray-400">No templates found</td>
|
||||
</tr>
|
||||
) : (
|
||||
templates.map((row) => (
|
||||
<tr
|
||||
key={row.name}
|
||||
className={`hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer ${selectedRows.has(row.name) ? 'bg-violet-50/80 dark:bg-violet-900/20' : ''}`}
|
||||
onClick={() => navigate(`/projects/templates/${encodeURIComponent(row.name)}`)}
|
||||
>
|
||||
<td className="w-10 px-2 py-3" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-violet-600 focus:ring-violet-500"
|
||||
checked={selectedRows.has(row.name)}
|
||||
onChange={() => toggleRow(row.name)}
|
||||
aria-label={`Select ${row.name}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-medium text-violet-600">{row.name}</td>
|
||||
<td className="py-3 px-4 text-gray-600 dark:text-gray-300">{row.project_type || '—'}</td>
|
||||
<td className="py-3 px-4 text-gray-500 text-xs">{row.modified ? new Date(row.modified).toLocaleDateString() : '—'}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/projects/templates/${encodeURIComponent(row.name)}`);
|
||||
}}
|
||||
className="text-violet-600 hover:text-violet-800 p-1"
|
||||
aria-label="View"
|
||||
>
|
||||
<FaEye />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalCount > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-xs text-gray-500">
|
||||
Page {page + 1} of {totalPages}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
className="px-3 py-1 text-xs border rounded disabled:opacity-40"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={(page + 1) * PAGE_SIZE >= totalCount}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="px-3 py-1 text-xs border rounded disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectTemplateList;
|
||||
@ -1,410 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
FaArrowLeft, FaSave, FaEdit, FaTimes, FaPlus, FaTrash,
|
||||
FaCheckCircle, FaTimesCircle, FaSpinner, FaBullhorn, FaPaperPlane,
|
||||
} from 'react-icons/fa';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import projectUpdateService, { type ProjectUpdate, type ProjectUpdateUserRow } from '../services/projectUpdateService';
|
||||
import apiService from '../services/apiService';
|
||||
import LinkField from '../components/LinkField';
|
||||
import ActivityLog from '../components/ActivityLog';
|
||||
|
||||
const FL: React.FC<{ children: React.ReactNode; required?: boolean }> = ({ children, required }) => (
|
||||
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||
{children}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
);
|
||||
const RV: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
||||
<div className="px-3 py-2 text-sm text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-800/60 rounded-lg min-h-[38px] flex items-center">
|
||||
{children || <span className="text-gray-400">—</span>}
|
||||
</div>
|
||||
);
|
||||
const inputCls = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-violet-400';
|
||||
|
||||
function ymdToday(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function timeNowShort(): string {
|
||||
const d = new Date();
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const m = String(d.getMinutes()).padStart(2, '0');
|
||||
const s = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
const ProjectUpdateDetail: React.FC = () => {
|
||||
const { updateName } = useParams<{ updateName: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const isNew = updateName === 'new';
|
||||
const prefProject = searchParams.get('project') || '';
|
||||
|
||||
const [doc, setDoc] = useState<ProjectUpdate | null>(null);
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(isNew);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const emptyForm: Partial<ProjectUpdate> & { users: ProjectUpdateUserRow[] } = useMemo(
|
||||
() => ({
|
||||
project: prefProject,
|
||||
sent: 0,
|
||||
date: ymdToday(),
|
||||
time: timeNowShort(),
|
||||
users: [],
|
||||
}),
|
||||
[prefProject],
|
||||
);
|
||||
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
|
||||
const syncForm = useCallback((u: ProjectUpdate) => {
|
||||
setForm({
|
||||
project: u.project || '',
|
||||
sent: u.sent ?? 0,
|
||||
date: (u.date || '').slice(0, 10) || ymdToday(),
|
||||
time: u.time ? String(u.time).slice(0, 8) : timeNowShort(),
|
||||
users: Array.isArray(u.users) ? u.users.map(r => ({ ...r })) : [],
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNew && updateName) {
|
||||
setLoading(true);
|
||||
projectUpdateService
|
||||
.getProjectUpdate(updateName)
|
||||
.then((u) => {
|
||||
setDoc(u);
|
||||
syncForm(u);
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : 'Load failed'))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [updateName, isNew, syncForm]);
|
||||
|
||||
const set = (k: keyof ProjectUpdate, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
const docLocked = !isNew && doc?.docstatus === 1;
|
||||
const editable = (isNew || isEditing) && !docLocked;
|
||||
|
||||
const addUserRow = () => {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
users: [...(f.users || []), { user: '', email: '', full_name: '' }],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeUserRow = (idx: number) => {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
users: (f.users || []).filter((_, i) => i !== idx),
|
||||
}));
|
||||
};
|
||||
|
||||
const setUserRow = (idx: number, patch: Partial<ProjectUpdateUserRow>) => {
|
||||
setForm(f => {
|
||||
const users = [...(f.users || [])];
|
||||
users[idx] = { ...users[idx], ...patch };
|
||||
return { ...f, users };
|
||||
});
|
||||
};
|
||||
|
||||
const hydrateUserRowFromUserDoc = useCallback(async (idx: number, userId: string) => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['email', 'full_name']));
|
||||
const res = await apiService.apiCall<{ name: string; email?: string; full_name?: string }>(
|
||||
`/api/resource/User/${encodeURIComponent(userId)}?${q.toString()}`,
|
||||
);
|
||||
// Frappe returns { data: {...} } when using /api/resource; apiCall unwraps message only,
|
||||
// so we also handle the common shape defensively.
|
||||
const data = (res as any)?.data ?? res;
|
||||
setUserRow(idx, {
|
||||
email: data?.email || userId,
|
||||
full_name: data?.full_name || '',
|
||||
});
|
||||
} catch {
|
||||
// Fallback: at least show the user id as email when it's an email-like login.
|
||||
setUserRow(idx, { email: userId });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const payloadFromForm = (): Partial<ProjectUpdate> => {
|
||||
const users = (form.users || [])
|
||||
.filter(r => (r.user || '').trim())
|
||||
.map((r, i) => ({
|
||||
doctype: 'Project User',
|
||||
...r,
|
||||
idx: i + 1,
|
||||
}));
|
||||
return {
|
||||
project: form.project?.trim() || undefined,
|
||||
sent: form.sent ? 1 : 0,
|
||||
date: form.date || undefined,
|
||||
time: form.time || undefined,
|
||||
users,
|
||||
};
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.project?.trim()) {
|
||||
toast.error('Project is required');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = payloadFromForm();
|
||||
if (isNew) {
|
||||
const created = await projectUpdateService.createProjectUpdate(body);
|
||||
toast.success('Project update created', { icon: <FaCheckCircle /> });
|
||||
navigate(`/projects/project-updates/${encodeURIComponent(created.name)}`, { replace: true });
|
||||
} else {
|
||||
const updated = await projectUpdateService.updateProjectUpdate(updateName!, body);
|
||||
toast.success('Saved', { icon: <FaCheckCircle /> });
|
||||
setDoc(updated);
|
||||
syncForm(updated);
|
||||
setIsEditing(false);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Save failed', { icon: <FaTimesCircle /> });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isNew || !updateName) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const submitted = await projectUpdateService.submitProjectUpdate(updateName);
|
||||
toast.success('Submitted', { icon: <FaCheckCircle /> });
|
||||
setDoc(submitted);
|
||||
syncForm(submitted);
|
||||
setIsEditing(false);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Submit failed', { icon: <FaTimesCircle /> });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (doc) syncForm(doc);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[400px]">
|
||||
<FaSpinner className="animate-spin text-violet-500 text-3xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNew && error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<button type="button" onClick={() => navigate('/projects/project-updates')} className="flex items-center gap-2 text-gray-600 mb-4">
|
||||
<FaArrowLeft /> Back
|
||||
</button>
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg text-red-700">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-6 py-6">
|
||||
<ToastContainer position="top-right" autoClose={3500} />
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400 mb-4">
|
||||
<button type="button" onClick={() => navigate('/projects')} className="hover:text-violet-600">Project Management</button>
|
||||
<span>/</span>
|
||||
<button type="button" onClick={() => navigate('/projects/project-updates')} className="hover:text-violet-600">Project updates</button>
|
||||
<span>/</span>
|
||||
<span className="text-gray-600 dark:text-gray-300">{isNew ? 'New' : updateName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects/project-updates')}
|
||||
className="mt-1 p-2 rounded-lg text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 border border-gray-200 dark:border-gray-600"
|
||||
>
|
||||
<FaArrowLeft size={14} />
|
||||
</button>
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-violet-500 to-purple-700 flex items-center justify-center shadow-md">
|
||||
<FaBullhorn className="text-white text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{isNew ? 'New project update' : (doc?.name || updateName)}
|
||||
</h1>
|
||||
{!isNew && doc && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<span className={`font-medium ${doc.docstatus === 1 ? 'text-green-600' : 'text-amber-600'}`}>
|
||||
{doc.docstatus === 1 ? 'Submitted' : 'Draft'}
|
||||
</span>
|
||||
{doc.project && (
|
||||
<>
|
||||
<span className="mx-2">·</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-violet-600 hover:underline font-mono"
|
||||
onClick={() => navigate(`/projects/list/${encodeURIComponent(doc.project!)}`)}
|
||||
>
|
||||
{doc.project}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!isNew && !docLocked && !isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium shadow"
|
||||
>
|
||||
<FaEdit size={12} /> Edit
|
||||
</button>
|
||||
)}
|
||||
{(isNew || isEditing) && !docLocked && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{saving ? <FaSpinner className="animate-spin" size={12} /> : <FaSave size={12} />}
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{!isNew && (
|
||||
<button type="button" onClick={handleCancel} className="flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-600 dark:text-gray-300">
|
||||
<FaTimes size={12} /> Cancel
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isNew && !docLocked && doc?.docstatus === 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{submitting ? <FaSpinner className="animate-spin" size={12} /> : <FaPaperPlane size={12} />}
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 max-w-4xl">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-4">Details</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="sm:col-span-2">
|
||||
<FL required>Project</FL>
|
||||
{editable
|
||||
? <LinkField label="Project" hideLabel doctype="Project" value={form.project || ''} onChange={v => set('project', v)} placeholder="Select project…" />
|
||||
: <RV>{form.project || '—'}</RV>}
|
||||
</div>
|
||||
<div>
|
||||
<FL>Date</FL>
|
||||
{editable
|
||||
? <input type="date" className={inputCls} value={form.date || ''} onChange={e => set('date', e.target.value)} />
|
||||
: <RV>{form.date || '—'}</RV>}
|
||||
</div>
|
||||
<div>
|
||||
<FL>Time</FL>
|
||||
{editable
|
||||
? <input type="time" step={1} className={inputCls} value={(form.time || '').slice(0, 8)} onChange={e => set('time', e.target.value)} />
|
||||
: <RV>{form.time || '—'}</RV>}
|
||||
</div>
|
||||
<div className="sm:col-span-2 flex items-center gap-3">
|
||||
<input
|
||||
id="sent"
|
||||
type="checkbox"
|
||||
className="w-4 h-4 text-violet-600 rounded border-gray-300"
|
||||
checked={!!form.sent}
|
||||
disabled={!editable}
|
||||
onChange={e => set('sent', e.target.checked ? 1 : 0)}
|
||||
/>
|
||||
<label htmlFor="sent" className={`text-sm font-medium text-gray-700 dark:text-gray-300 ${editable ? 'cursor-pointer' : ''}`}>
|
||||
Sent
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-200">Users</h2>
|
||||
{editable && (
|
||||
<button type="button" onClick={addUserRow} className="text-xs font-semibold text-violet-600 flex items-center gap-1 hover:underline">
|
||||
<FaPlus size={10} /> Add user
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(form.users || []).length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No users added.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{(form.users || []).map((row, idx) => (
|
||||
<div key={idx} className="flex flex-col sm:flex-row gap-2 sm:items-end p-3 rounded-lg border border-gray-100 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-900/20">
|
||||
<div className="flex-1 min-w-0">
|
||||
<FL>User</FL>
|
||||
{editable
|
||||
? (
|
||||
<LinkField
|
||||
label="User"
|
||||
hideLabel
|
||||
doctype="User"
|
||||
value={row.user || ''}
|
||||
onChange={(v) => {
|
||||
setUserRow(idx, { user: v, email: '', full_name: '' });
|
||||
hydrateUserRowFromUserDoc(idx, v);
|
||||
}}
|
||||
placeholder="Select user…"
|
||||
/>
|
||||
)
|
||||
: <RV><span className="font-mono text-xs">{row.user || '—'}</span></RV>}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<FL>Email</FL>
|
||||
<RV><span className="text-xs">{row.email || '—'}</span></RV>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<FL>Full name</FL>
|
||||
<RV><span className="text-xs">{row.full_name || '—'}</span></RV>
|
||||
</div>
|
||||
{editable && (
|
||||
<button type="button" onClick={() => removeUserRow(idx)} className="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg self-end sm:self-center" title="Remove row">
|
||||
<FaTrash size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isNew && updateName && (
|
||||
<ActivityLog doctype="Project Update" docname={updateName} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectUpdateDetail;
|
||||
@ -1,257 +0,0 @@
|
||||
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaPlus, FaSearch, FaSync, FaBullhorn, FaEye, FaFileExport } from 'react-icons/fa';
|
||||
import projectUpdateService, { type ProjectUpdate } from '../services/projectUpdateService';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import DynamicExportModal from '../components/DynamicExportModal';
|
||||
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const ProjectUpdateList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [rows, setRows] = useState<ProjectUpdate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
const apiFilters = useMemo(() => {
|
||||
const f: Record<string, unknown> = {};
|
||||
if (search.trim()) {
|
||||
const q = search.trim();
|
||||
f.name = ['like', `%${q}%`];
|
||||
}
|
||||
return f;
|
||||
}, [search]);
|
||||
|
||||
const load = useCallback(async (p = 0) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [data, cnt] = await Promise.all([
|
||||
projectUpdateService.getProjectUpdates({
|
||||
filters: apiFilters,
|
||||
limit_start: p * PAGE_SIZE,
|
||||
limit_page_length: PAGE_SIZE,
|
||||
order_by: 'creation desc',
|
||||
}),
|
||||
projectUpdateService.getProjectUpdateCount(apiFilters as Record<string, any>),
|
||||
]);
|
||||
setRows(data);
|
||||
setTotalCount(cnt);
|
||||
setPage(p);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to load project updates');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiFilters]);
|
||||
|
||||
useEffect(() => { load(0); }, [load]);
|
||||
|
||||
const selectionResetKey = useMemo(() => `${page}|${JSON.stringify(apiFilters)}`, [page, apiFilters]);
|
||||
const {
|
||||
selectedRows,
|
||||
toggleRow,
|
||||
toggleAllOnPage,
|
||||
allOnPageSelected,
|
||||
someOnPageSelected,
|
||||
} = useListPageSelection(rows, selectionResetKey);
|
||||
|
||||
const fetchAllForExport = useCallback(
|
||||
() => fetchAllRowsForExport({ doctype: 'Project Update', filters: apiFilters as Record<string, any>, orderBy: 'creation desc' }),
|
||||
[apiFilters],
|
||||
);
|
||||
|
||||
const docStatusLabel = (d?: number) => {
|
||||
if (d === 1) return 'Submitted';
|
||||
if (d === 2) return 'Cancelled';
|
||||
return 'Draft';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ToastContainer position="top-right" autoClose={3000} />
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects')}
|
||||
className="text-sm text-gray-500 hover:text-violet-600 dark:text-gray-400 dark:hover:text-violet-400"
|
||||
>
|
||||
{t('projects.moduleTitle', 'Project Management')}
|
||||
</button>
|
||||
<span className="text-gray-400">/</span>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<FaBullhorn className="text-violet-500" /> Project updates
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||
disabled={totalCount === 0 && selectedRows.size === 0}
|
||||
>
|
||||
<FaFileExport /> {t('listPages.export')}
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => load(page)}
|
||||
className="p-2 text-gray-500 border border-gray-200 dark:border-gray-600 rounded-lg hover:text-violet-600"
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<FaSync size={14} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects/project-updates/new')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 text-sm font-medium"
|
||||
>
|
||||
<FaPlus size={12} /> New project update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-3 items-center">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<FaSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
placeholder="Search by name or project…"
|
||||
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{totalCount} total</span>
|
||||
</div>
|
||||
|
||||
<DynamicExportModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
doctype="Project Update"
|
||||
selectedCount={selectedRows.size}
|
||||
pageCount={rows.length}
|
||||
totalCount={totalCount}
|
||||
pageData={rows}
|
||||
selectedRows={selectedRows}
|
||||
rowKey="name"
|
||||
onFetchAll={fetchAllForExport}
|
||||
fileNamePrefix="project_updates"
|
||||
/>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||
<th className="w-10 px-2 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-violet-600 focus:ring-violet-500"
|
||||
checked={allOnPageSelected}
|
||||
ref={el => {
|
||||
if (el) el.indeterminate = someOnPageSelected;
|
||||
}}
|
||||
onChange={toggleAllOnPage}
|
||||
aria-label="Select all on page"
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Name</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Project</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Date</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Time</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Sent</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Status</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-3 px-4 w-24"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-center py-10 text-gray-400">Loading…</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-center py-10 text-gray-400">No project updates</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((r) => (
|
||||
<tr
|
||||
key={r.name}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-900/30 cursor-pointer"
|
||||
onClick={() => navigate(`/projects/project-updates/${encodeURIComponent(r.name)}`)}
|
||||
title="Open"
|
||||
>
|
||||
<td className="px-2 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-violet-600"
|
||||
checked={selectedRows.has(r.name)}
|
||||
onChange={() => toggleRow(r.name)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Select ${r.name}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-800 dark:text-gray-200">{r.name}</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">{r.project || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono text-xs">{r.date || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono text-xs">{r.time || '—'}</td>
|
||||
<td className="px-4 py-3">{r.sent ? <span className="text-green-600 dark:text-green-400 font-medium">Yes</span> : <span className="text-gray-500">No</span>}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded ${r.docstatus === 1 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' : 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'}`}>
|
||||
{docStatusLabel(r.docstatus)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/projects/project-updates/${encodeURIComponent(r.name)}`); }}
|
||||
className="inline-flex items-center gap-1 text-violet-600 dark:text-violet-400 hover:underline text-xs font-semibold"
|
||||
>
|
||||
<FaEye size={11} /> View
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalCount > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500">
|
||||
<span>Page {page + 1} of {Math.max(1, Math.ceil(totalCount / PAGE_SIZE))}</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 0 || loading}
|
||||
onClick={() => load(page - 1)}
|
||||
className="px-3 py-1 rounded border border-gray-200 dark:border-gray-600 disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={(page + 1) * PAGE_SIZE >= totalCount || loading}
|
||||
onClick={() => load(page + 1)}
|
||||
className="px-3 py-1 rounded border border-gray-200 dark:border-gray-600 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectUpdateList;
|
||||
@ -1,900 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
FaArrowLeft, FaSave, FaEdit, FaTimes, FaPlus, FaTrash,
|
||||
FaSpinner, FaShoppingBag, FaPaperPlane,
|
||||
FaChevronDown, FaChevronRight, FaPencilAlt,
|
||||
FaFileInvoice,
|
||||
} from 'react-icons/fa';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import purchaseOrderService, { PurchaseOrder, PurchaseOrderItem, PurchaseTaxCharge } from '../services/purchaseOrderService';
|
||||
import LinkField from '../components/LinkField';
|
||||
import ActivityLog from '../components/ActivityLog';
|
||||
import { DEFAULT_COMPANY, DEFAULT_CURRENCY, displayTxnCurrency } from '../constants/orgDefaults';
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────────
|
||||
const FL: React.FC<{ children: React.ReactNode; required?: boolean }> = ({ children, required }) => (
|
||||
<label className="block text-[11px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||
{children}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
);
|
||||
const RV: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
||||
<div className="px-3 py-2 text-sm text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-800/60 rounded min-h-[34px] flex items-center">
|
||||
{children || <span className="text-gray-400">-</span>}
|
||||
</div>
|
||||
);
|
||||
const inputCls = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-amber-400';
|
||||
const numCls = inputCls + ' text-right';
|
||||
const inlineNum = 'w-full px-2 py-1 text-sm text-right border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-amber-400';
|
||||
const inlineTxt = 'w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-amber-400';
|
||||
|
||||
// ── Create Dropdown ───────────────────────────────────────────────────────────
|
||||
const CreateDropdown: React.FC<{
|
||||
items: { label: string; icon: React.ReactNode; onClick: () => void }[];
|
||||
}> = ({ items }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 text-sm font-medium shadow-sm"
|
||||
>
|
||||
Create
|
||||
<FaChevronDown size={10} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-1 w-52 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-50 py-1.5 overflow-hidden">
|
||||
<div className="px-3 py-1.5 text-[10px] font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider border-b border-gray-100 dark:border-gray-700 mb-1">
|
||||
Create from this order
|
||||
</div>
|
||||
{items.map(({ label, icon, onClick }) => (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => { onClick(); setOpen(false); }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-amber-50 dark:hover:bg-amber-900/20 hover:text-amber-700 dark:hover:text-amber-300 transition-colors text-left"
|
||||
>
|
||||
<span className="text-gray-400">{icon}</span>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Collapsible group inside row editor ───────────────────────────────────────
|
||||
const RGroup: React.FC<{ title: string; children: React.ReactNode; defaultOpen?: boolean }> = ({ title, children, defaultOpen = false }) => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="border-t border-gray-200 dark:border-gray-600 mt-3 pt-1">
|
||||
<button type="button" onClick={() => setOpen(o => !o)}
|
||||
className="flex items-center gap-2 py-1 text-xs font-semibold text-amber-600 dark:text-amber-400 hover:underline">
|
||||
{open ? <FaChevronDown size={9} /> : <FaChevronRight size={9} />}{title}
|
||||
</button>
|
||||
{open && <div className="mt-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Item Row Editor ───────────────────────────────────────────────────────────
|
||||
const POItemRowEditor: React.FC<{
|
||||
item: Partial<PurchaseOrderItem>; rowNo: number;
|
||||
onChange: (k: keyof PurchaseOrderItem, v: any) => void;
|
||||
onClose: () => void; onDelete: () => void; onInsertBelow: () => void;
|
||||
}> = ({ item, rowNo, onChange, onClose, onDelete, onInsertBelow }) => (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-0">
|
||||
<div className="border border-amber-300 dark:border-amber-600 rounded-lg mx-2 my-1 bg-white dark:bg-gray-800 shadow-md">
|
||||
{/* Editor header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-amber-50 dark:bg-amber-900/30 rounded-t-lg border-b border-amber-200 dark:border-amber-700">
|
||||
<span className="text-sm font-semibold text-amber-700 dark:text-amber-300">Editing Row #{rowNo}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onInsertBelow} className="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-600">Insert Below</button>
|
||||
<button onClick={onDelete} className="px-2 py-1 text-xs border border-red-300 rounded text-red-500 hover:bg-red-50">Delete</button>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600"><FaTimes size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Row 1: Item Code + Schedule Date */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><FL required>Item Code</FL>
|
||||
<LinkField label="Item" hideLabel doctype="Item" value={item.item_code || ''} onChange={v => onChange('item_code', v)} placeholder="Select item…" />
|
||||
</div>
|
||||
<div><FL>Schedule Date</FL>
|
||||
<input type="date" value={item.schedule_date || ''} onChange={e => onChange('schedule_date', e.target.value)} className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Item Name */}
|
||||
<div><FL required>Item Name</FL>
|
||||
<input value={item.item_name || ''} onChange={e => onChange('item_name', e.target.value)} className={inputCls} placeholder="Item name…" />
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<RGroup title="Description" defaultOpen={!!(item.description)}>
|
||||
<textarea rows={2} value={item.description || ''} onChange={e => onChange('description', e.target.value)} className={inputCls} placeholder="Description…" />
|
||||
</RGroup>
|
||||
|
||||
{/* Quantity and Rate */}
|
||||
<RGroup title="Quantity and Rate" defaultOpen>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><FL required>Quantity</FL>
|
||||
<input type="number" min={0} step="1" value={item.qty ?? 0} onChange={e => onChange('qty', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||
</div>
|
||||
<div><FL required>UOM</FL>
|
||||
<LinkField label="UOM" hideLabel doctype="UOM" value={item.uom || ''} onChange={v => onChange('uom', v)} placeholder="UOM…" />
|
||||
</div>
|
||||
<div><FL>Stock UOM</FL>
|
||||
<LinkField label="Stock UOM" hideLabel doctype="UOM" value={item.stock_uom || ''} onChange={v => onChange('stock_uom', v)} placeholder="Stock UOM…" />
|
||||
</div>
|
||||
<div><FL>UOM Conversion Factor</FL>
|
||||
<input type="number" min={0} step="0.001" value={item.conversion_factor ?? 1} onChange={e => onChange('conversion_factor', parseFloat(e.target.value) || 1)} className={numCls} />
|
||||
</div>
|
||||
<div><FL>Stock Qty (auto)</FL>
|
||||
<div className="px-3 py-2 text-sm text-gray-600 bg-gray-50 dark:bg-gray-700 rounded text-right">
|
||||
{((item.qty || 0) * (item.conversion_factor || 1)).toFixed(3)}
|
||||
</div>
|
||||
</div>
|
||||
<div><FL>Price List Rate</FL>
|
||||
<input type="number" min={0} step="0.01" value={item.price_list_rate ?? 0} onChange={e => onChange('price_list_rate', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||
</div>
|
||||
<div><FL>Last Purchase Rate</FL>
|
||||
<div className="px-3 py-2 text-sm text-right bg-gray-50 dark:bg-gray-700 rounded">{(item.last_purchase_rate ?? 0).toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</RGroup>
|
||||
|
||||
{/* Discount and Margin */}
|
||||
<RGroup title="Discount and Margin">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><FL required>Rate</FL>
|
||||
<input type="number" min={0} step="0.01" value={item.rate ?? 0} onChange={e => onChange('rate', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||
</div>
|
||||
<div><FL>Amount (auto)</FL>
|
||||
<div className="px-3 py-2 text-sm font-semibold text-right bg-gray-50 dark:bg-gray-700 rounded">
|
||||
{((item.qty || 0) * (item.rate || 0)).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-end gap-2 pb-1">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={!!(item.is_free_item)} onChange={e => onChange('is_free_item', e.target.checked ? 1 : 0)} className="rounded" />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Is Free Item</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</RGroup>
|
||||
|
||||
{/* Warehouse and Reference */}
|
||||
<RGroup title="Warehouse and Reference">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><FL>Warehouse</FL>
|
||||
<LinkField label="Warehouse" hideLabel doctype="Warehouse" value={item.warehouse || ''} onChange={v => onChange('warehouse', v)} placeholder="Warehouse…" />
|
||||
</div>
|
||||
<div className="flex items-end pb-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={!!(item.against_blanket_order)} onChange={e => onChange('against_blanket_order', e.target.checked ? 1 : 0)} className="rounded" />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Against Blanket Order</span>
|
||||
</label>
|
||||
</div>
|
||||
{item.material_request && (
|
||||
<div><FL>Material Request</FL>
|
||||
<RV>{item.material_request}</RV>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</RGroup>
|
||||
|
||||
{/* Available Quantity */}
|
||||
<RGroup title="Available Quantity">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><FL>Actual Qty (Warehouse)</FL>
|
||||
<div className="px-3 py-2 text-sm text-right bg-gray-50 dark:bg-gray-700 rounded">{item.actual_qty ?? 0}</div>
|
||||
</div>
|
||||
<div><FL>Company Total Stock</FL>
|
||||
<div className="px-3 py-2 text-sm text-right bg-gray-50 dark:bg-gray-700 rounded">{item.company_total_stock ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</RGroup>
|
||||
|
||||
{/* Accounting Details */}
|
||||
<RGroup title="Accounting Details">
|
||||
<div><FL>Expense Account</FL>
|
||||
<LinkField label="Account" hideLabel doctype="Account" value={item.expense_account || ''} onChange={v => onChange('expense_account', v)} placeholder="Expense account…" />
|
||||
</div>
|
||||
</RGroup>
|
||||
|
||||
{/* Accounting Dimensions */}
|
||||
<RGroup title="Accounting Dimensions">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><FL>Cost Center</FL>
|
||||
<LinkField label="Cost Center" hideLabel doctype="Cost Center" value={item.cost_center || ''} onChange={v => onChange('cost_center', v)} placeholder="Cost center…" />
|
||||
</div>
|
||||
<div><FL>Project</FL>
|
||||
<LinkField label="Project" hideLabel doctype="Project" value={item.project || ''} onChange={v => onChange('project', v)} placeholder="Project…" />
|
||||
</div>
|
||||
</div>
|
||||
</RGroup>
|
||||
|
||||
{/* Item Weight Details */}
|
||||
<RGroup title="Item Weight Details">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><FL>Weight Per Unit</FL>
|
||||
<input type="number" min={0} step="0.001" value={item.weight_per_unit ?? 0} onChange={e => onChange('weight_per_unit', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||
</div>
|
||||
<div><FL>Total Weight (auto)</FL>
|
||||
<div className="px-3 py-2 text-sm text-right bg-gray-50 dark:bg-gray-700 rounded">
|
||||
{((item.qty || 0) * (item.weight_per_unit || 0)).toFixed(3)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RGroup>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
// ── Tax Row Editor ────────────────────────────────────────────────────────────
|
||||
const POTaxRowEditor: React.FC<{
|
||||
tax: Partial<PurchaseTaxCharge>; rowNo: number;
|
||||
onChange: (k: keyof PurchaseTaxCharge, v: any) => void;
|
||||
onClose: () => void; onDelete: () => void; onInsertBelow: () => void;
|
||||
}> = ({ tax, rowNo, onChange, onClose, onDelete, onInsertBelow }) => (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-0">
|
||||
<div className="border border-amber-300 dark:border-amber-600 rounded-lg mx-2 my-1 bg-white dark:bg-gray-800 shadow-md">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-amber-50 dark:bg-amber-900/30 rounded-t-lg border-b border-amber-200">
|
||||
<span className="text-sm font-semibold text-amber-700 dark:text-amber-300">Editing Row #{rowNo}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onInsertBelow} className="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 text-gray-600">Insert Below</button>
|
||||
<button onClick={onDelete} className="px-2 py-1 text-xs border border-red-300 rounded text-red-500 hover:bg-red-50">Delete</button>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600"><FaTimes size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><FL required>Type</FL>
|
||||
<select value={tax.charge_type || ''} onChange={e => onChange('charge_type', e.target.value)} className={inputCls}>
|
||||
<option value="">Select type…</option>
|
||||
<option value="Actual">Actual</option>
|
||||
<option value="On Net Total">On Net Total</option>
|
||||
<option value="On Previous Row Amount">On Previous Row Amount</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><FL>Description</FL>
|
||||
<textarea rows={3} value={tax.description || ''} onChange={e => onChange('description', e.target.value)} className={inputCls} placeholder="Description…" />
|
||||
</div>
|
||||
</div>
|
||||
<div><FL required>Account Head</FL>
|
||||
<LinkField label="Account Head" hideLabel doctype="Account" value={tax.account_head || ''} onChange={v => onChange('account_head', v)} placeholder="Account…" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={!!(tax.included_in_print_rate)} onChange={e => onChange('included_in_print_rate', e.target.checked ? 1 : 0)} className="rounded" />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Is this Tax included in Basic Rate?</span>
|
||||
</div>
|
||||
<p className="text-xs text-amber-500 dark:text-amber-400">If checked, the tax amount will be considered as already included in the Print Rate / Print Amount</p>
|
||||
|
||||
<RGroup title="Accounting Dimensions" defaultOpen>
|
||||
<div><FL>Cost Center</FL>
|
||||
<LinkField label="Cost Center" hideLabel doctype="Cost Center" value={tax.cost_center || ''} onChange={v => onChange('cost_center', v)} placeholder="Cost center…" />
|
||||
</div>
|
||||
</RGroup>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><FL required>Tax Rate</FL>
|
||||
<input type="number" min={0} step="0.01" value={tax.rate ?? 0} onChange={e => onChange('rate', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||
</div>
|
||||
<div><FL>Account Currency</FL>
|
||||
<input value={tax.account_currency || DEFAULT_CURRENCY} onChange={e => onChange('account_currency', e.target.value)} className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
const emptyItem = (): Partial<PurchaseOrderItem> => ({ item_code: '', item_name: '', qty: 1, rate: 0, amount: 0, uom: '', conversion_factor: 1, is_free_item: 0 });
|
||||
const emptyTax = (): Partial<PurchaseTaxCharge> => ({ charge_type: '', account_head: '', rate: 0, account_currency: DEFAULT_CURRENCY, included_in_print_rate: 0 });
|
||||
|
||||
const PurchaseOrderDetail: React.FC = () => {
|
||||
const { poName } = useParams<{ poName: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const isNew = poName === 'new';
|
||||
|
||||
const contextProject = searchParams.get('project') || '';
|
||||
const contextSupplier = searchParams.get('supplier') || '';
|
||||
const contextCompany = searchParams.get('company') || DEFAULT_COMPANY;
|
||||
const contextMR = searchParams.get('mr') || '';
|
||||
|
||||
const [doc, setDoc] = useState<PurchaseOrder | null>(null);
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(isNew);
|
||||
const [expandedItem, setExpandedItem] = useState<number | null>(null);
|
||||
const [expandedTax, setExpandedTax] = useState<number | null>(null);
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const [form, setForm] = useState<Partial<PurchaseOrder>>({
|
||||
supplier: contextSupplier, supplier_name: contextSupplier,
|
||||
company: contextCompany, project: contextProject,
|
||||
transaction_date: today, schedule_date: today, currency: DEFAULT_CURRENCY,
|
||||
items: [], taxes: [],
|
||||
});
|
||||
|
||||
const syncForm = useCallback((d: PurchaseOrder) => {
|
||||
setForm({
|
||||
supplier: d.supplier || '', supplier_name: d.supplier_name || d.supplier || '',
|
||||
company: d.company || DEFAULT_COMPANY, project: d.project || '',
|
||||
transaction_date: d.transaction_date || today,
|
||||
schedule_date: d.schedule_date || '',
|
||||
currency: d.currency || DEFAULT_CURRENCY,
|
||||
cost_center: d.cost_center || '',
|
||||
set_warehouse: d.set_warehouse || '',
|
||||
tax_category: (d as any).tax_category || '',
|
||||
taxes_and_charges: (d as any).taxes_and_charges || '',
|
||||
items: d.items || [], taxes: d.taxes || [],
|
||||
});
|
||||
setExpandedItem(null); setExpandedTax(null);
|
||||
}, [today]);
|
||||
|
||||
// Pre-fill from MR when creating a new PO from Material Request context
|
||||
useEffect(() => {
|
||||
if (!isNew || !contextMR) return;
|
||||
fetch(`/api/resource/Material Request/${encodeURIComponent(contextMR)}`, { credentials: 'include' })
|
||||
.then(r => r.json()).then(body => {
|
||||
const mr = body.data;
|
||||
if (!mr) return;
|
||||
setForm(f => ({
|
||||
...f,
|
||||
company: mr.company || f.company,
|
||||
project: mr.project || f.project,
|
||||
schedule_date: mr.transaction_date || f.schedule_date,
|
||||
items: (mr.items || []).map((it: any) => ({
|
||||
item_code: it.item_code, item_name: it.item_name,
|
||||
description: it.description,
|
||||
qty: it.qty, uom: it.uom, stock_uom: it.stock_uom,
|
||||
rate: 0, amount: 0,
|
||||
schedule_date: it.required_by || f.schedule_date,
|
||||
material_request: contextMR,
|
||||
material_request_item: it.name,
|
||||
warehouse: it.warehouse,
|
||||
project: it.project || mr.project,
|
||||
cost_center: it.cost_center,
|
||||
})),
|
||||
}));
|
||||
}).catch(() => {});
|
||||
}, [isNew, contextMR]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) return;
|
||||
setLoading(true);
|
||||
purchaseOrderService.getPurchaseOrder(poName!)
|
||||
.then(d => { setDoc(d); syncForm(d); })
|
||||
.catch(e => toast.error(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [poName, isNew, syncForm]);
|
||||
|
||||
// Auto-fetch company default currency for new documents
|
||||
useEffect(() => {
|
||||
const company = form.company;
|
||||
if (!isNew || !company) return;
|
||||
fetch(`/api/resource/Company/${encodeURIComponent(company)}`, { credentials: 'include' })
|
||||
.then(r => r.json()).then(b => {
|
||||
if (b.data?.default_currency) {
|
||||
const cur = displayTxnCurrency(b.data.default_currency);
|
||||
setForm(f => ({ ...f, currency: cur,
|
||||
buying_price_list: (f as any).buying_price_list || 'Standard Buying',
|
||||
price_list_currency: (f as any).price_list_currency || cur,
|
||||
} as any));
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [form.company, isNew]);
|
||||
|
||||
const set = (k: keyof PurchaseOrder, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
// ── Item helpers ──────────────────────────────────────────────────────────
|
||||
const updateItem = (idx: number, k: keyof PurchaseOrderItem, v: any) =>
|
||||
setForm(f => {
|
||||
const items = [...(f.items || [])];
|
||||
const updated = { ...items[idx], [k]: v };
|
||||
if (k === 'qty' || k === 'rate') {
|
||||
const qty = parseFloat(String(k === 'qty' ? v : updated.qty)) || 0;
|
||||
const rate = parseFloat(String(k === 'rate' ? v : updated.rate)) || 0;
|
||||
updated.amount = parseFloat((qty * rate).toFixed(4));
|
||||
}
|
||||
if (k === 'qty' || k === 'conversion_factor') {
|
||||
const qty = parseFloat(String(k === 'qty' ? v : updated.qty)) || 0;
|
||||
const cf = parseFloat(String(k === 'conversion_factor' ? v : updated.conversion_factor)) || 1;
|
||||
updated.stock_qty = parseFloat((qty * cf).toFixed(4));
|
||||
}
|
||||
items[idx] = updated;
|
||||
return { ...f, items };
|
||||
});
|
||||
|
||||
const handleItemCode = async (idx: number, code: string) => {
|
||||
updateItem(idx, 'item_code', code);
|
||||
if (!code) return;
|
||||
try {
|
||||
const r = await fetch(`/api/resource/Item/${encodeURIComponent(code)}`, { credentials: 'include' });
|
||||
const body = await r.json(); const d = body.data; if (!d) return;
|
||||
setForm(f => {
|
||||
const items = [...(f.items || [])];
|
||||
items[idx] = {
|
||||
...items[idx],
|
||||
item_code: code,
|
||||
item_name: d.item_name || code,
|
||||
description: d.description || d.item_name || code,
|
||||
stock_uom: d.stock_uom || '',
|
||||
uom: d.purchase_uom || d.stock_uom || '',
|
||||
price_list_rate: d.standard_rate ?? 0,
|
||||
rate: items[idx].rate || d.standard_rate || 0,
|
||||
};
|
||||
return { ...f, items };
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const addItem = (afterIdx?: number) => {
|
||||
setForm(f => {
|
||||
const items = [...(f.items || [])];
|
||||
const newItem = emptyItem();
|
||||
let newIdx: number;
|
||||
if (afterIdx !== undefined) { items.splice(afterIdx + 1, 0, newItem); newIdx = afterIdx + 1; }
|
||||
else { items.push(newItem); newIdx = items.length - 1; }
|
||||
setTimeout(() => setExpandedItem(newIdx), 0);
|
||||
return { ...f, items };
|
||||
});
|
||||
};
|
||||
const removeItem = (idx: number) => { setForm(f => { const items = [...(f.items || [])]; items.splice(idx, 1); return { ...f, items }; }); setExpandedItem(null); };
|
||||
|
||||
// ── Tax helpers ───────────────────────────────────────────────────────────
|
||||
const updateTax = (idx: number, k: keyof PurchaseTaxCharge, v: any) =>
|
||||
setForm(f => { const taxes = [...(f.taxes || [])]; taxes[idx] = { ...taxes[idx], [k]: v }; return { ...f, taxes }; });
|
||||
|
||||
const addTax = (afterIdx?: number) => {
|
||||
setForm(f => {
|
||||
const taxes = [...(f.taxes || [])];
|
||||
const newTax = emptyTax();
|
||||
let newIdx: number;
|
||||
if (afterIdx !== undefined) { taxes.splice(afterIdx + 1, 0, newTax); newIdx = afterIdx + 1; }
|
||||
else { taxes.push(newTax); newIdx = taxes.length - 1; }
|
||||
setTimeout(() => setExpandedTax(newIdx), 0);
|
||||
return { ...f, taxes };
|
||||
});
|
||||
};
|
||||
const removeTax = (idx: number) => { setForm(f => { const taxes = [...(f.taxes || [])]; taxes.splice(idx, 1); return { ...f, taxes }; }); setExpandedTax(null); };
|
||||
|
||||
// ── Computed ──────────────────────────────────────────────────────────────
|
||||
const netTotal = (form.items || []).reduce((s, it) => s + ((it.qty || 0) * (it.rate || 0)), 0);
|
||||
const taxTotal = (form.taxes || []).reduce((s, tx) => {
|
||||
if (tx.charge_type === 'On Net Total') return s + ((tx.rate || 0) / 100) * netTotal;
|
||||
return s + (tx.tax_amount || 0);
|
||||
}, 0);
|
||||
const grandTotal = netTotal + taxTotal;
|
||||
|
||||
// ── Payload ───────────────────────────────────────────────────────────────
|
||||
const loadTaxTemplate = async (templateName: string) => {
|
||||
if (!templateName) return;
|
||||
try {
|
||||
const r = await fetch(`/api/resource/Purchase Taxes and Charges Template/${encodeURIComponent(templateName)}`, { credentials: 'include' });
|
||||
const body = await r.json();
|
||||
const tmpl = body.data;
|
||||
if (tmpl?.taxes?.length) {
|
||||
setForm(f => ({ ...f, taxes: tmpl.taxes.map((tx: any) => ({
|
||||
charge_type: tx.charge_type, account_head: tx.account_head,
|
||||
description: tx.description, rate: tx.rate,
|
||||
cost_center: tx.cost_center, account_currency: tx.account_currency,
|
||||
included_in_print_rate: tx.included_in_print_rate ?? 0,
|
||||
})) }));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const buildPayload = (): Partial<PurchaseOrder> => ({
|
||||
supplier: form.supplier, company: form.company || undefined,
|
||||
project: form.project || undefined, cost_center: form.cost_center || undefined,
|
||||
transaction_date: form.transaction_date,
|
||||
schedule_date: form.schedule_date || undefined,
|
||||
currency: form.currency || undefined,
|
||||
set_warehouse: form.set_warehouse || undefined,
|
||||
tax_category: (form as any).tax_category || undefined,
|
||||
taxes_and_charges: (form as any).taxes_and_charges || undefined,
|
||||
items: (form.items || []).filter(it => it.item_code).map((it, i) => ({
|
||||
item_code: it.item_code, item_name: it.item_name || it.item_code,
|
||||
description: it.description || it.item_name || it.item_code,
|
||||
qty: it.qty ?? 1, uom: it.uom || undefined, stock_uom: it.stock_uom || undefined,
|
||||
conversion_factor: it.conversion_factor ?? 1,
|
||||
rate: it.rate ?? 0, amount: (it.qty || 0) * (it.rate || 0),
|
||||
price_list_rate: it.price_list_rate ?? 0,
|
||||
schedule_date: it.schedule_date || form.schedule_date || undefined,
|
||||
is_free_item: it.is_free_item ?? 0,
|
||||
warehouse: it.warehouse || form.set_warehouse || undefined,
|
||||
expense_account: it.expense_account || undefined,
|
||||
against_blanket_order: it.against_blanket_order ?? 0,
|
||||
weight_per_unit: it.weight_per_unit || undefined,
|
||||
project: it.project || form.project || undefined,
|
||||
cost_center: it.cost_center || form.cost_center || undefined,
|
||||
idx: i + 1,
|
||||
})),
|
||||
taxes: (form.taxes || []).filter(tx => tx.charge_type).map((tx, i) => ({
|
||||
charge_type: tx.charge_type, account_head: tx.account_head || undefined,
|
||||
description: tx.description || undefined,
|
||||
included_in_print_rate: tx.included_in_print_rate ?? 0,
|
||||
cost_center: tx.cost_center || undefined, rate: tx.rate ?? 0,
|
||||
account_currency: tx.account_currency || DEFAULT_CURRENCY, idx: i + 1,
|
||||
})),
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.supplier) { toast.error('Supplier is required'); return; }
|
||||
try {
|
||||
setSaving(true);
|
||||
if (isNew) {
|
||||
const created = await purchaseOrderService.createPurchaseOrder(buildPayload());
|
||||
toast.success('Purchase Order created');
|
||||
setIsEditing(false);
|
||||
navigate(`/purchase-orders/${created.name}`);
|
||||
} else {
|
||||
const updated = await purchaseOrderService.updatePurchaseOrder(poName!, buildPayload());
|
||||
setDoc(updated); syncForm(updated);
|
||||
toast.success('Purchase Order saved');
|
||||
setIsEditing(false);
|
||||
}
|
||||
} catch (e: any) { toast.error(e.message || 'Error saving'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!poName || isNew) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const updated = await purchaseOrderService.submitPurchaseOrder(poName);
|
||||
setDoc(updated); syncForm(updated);
|
||||
toast.success('Purchase Order submitted');
|
||||
} catch (e: any) { toast.error(e.message || 'Error submitting'); }
|
||||
finally { setSubmitting(false); }
|
||||
};
|
||||
|
||||
const createPR = () => {
|
||||
const p = new URLSearchParams();
|
||||
p.set('po', poName!);
|
||||
if (form.supplier) p.set('supplier', form.supplier);
|
||||
if (form.company) p.set('company', String(form.company));
|
||||
if (form.project) p.set('project', String(form.project));
|
||||
navigate(`/purchase-receipts/new?${p.toString()}`);
|
||||
};
|
||||
|
||||
const editable = isNew || isEditing;
|
||||
const isSubmitted = !isNew && doc?.docstatus === 1;
|
||||
const title = isNew ? 'New Purchase Order' : (form.supplier_name || poName || '');
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center min-h-[400px]"><FaSpinner className="animate-spin text-amber-500 text-3xl" /></div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-6 py-6">
|
||||
<ToastContainer position="top-right" autoClose={3500} />
|
||||
|
||||
<div className="flex items-center gap-2 text-sm mb-6 text-gray-500">
|
||||
<button onClick={() => navigate('/purchase-orders')} className="hover:text-amber-600">Purchase Orders</button>
|
||||
<span>/</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">{isNew ? 'New Purchase Order' : poName}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate('/purchase-orders')} className="text-gray-400 hover:text-gray-700"><FaArrowLeft /></button>
|
||||
<FaShoppingBag className="text-amber-500" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">{title}</h1>
|
||||
{!isNew && poName && (
|
||||
<span className="text-xs text-gray-400 font-mono">{poName}</span>
|
||||
)}
|
||||
{!isNew && (
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-semibold ${(() => { const s = doc?.status || ''; if (doc?.docstatus === 2 || s === 'Cancelled') return 'bg-red-100 text-red-700'; if (!doc || doc.docstatus === 0) return 'bg-yellow-100 text-yellow-800'; if (s === 'Completed') return 'bg-green-100 text-green-800'; if (s === 'To Receive and Bill' || s === 'To Bill' || s === 'To Receive') return 'bg-blue-100 text-blue-800'; if (s === 'Closed') return 'bg-gray-100 text-gray-700'; return 'bg-green-100 text-green-800'; })()}`}>
|
||||
{doc?.docstatus === 2 ? 'Cancelled' : doc?.docstatus === 0 ? 'Draft' : (doc?.status || 'Submitted')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isSubmitted && (
|
||||
<CreateDropdown items={[
|
||||
{ label: 'Purchase Receipt', icon: <FaFileInvoice size={13} />, onClick: createPR },
|
||||
]} />
|
||||
)}
|
||||
{!isNew && !isEditing && doc?.docstatus === 0 && (
|
||||
<button onClick={handleSubmit} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm font-medium">
|
||||
{submitting ? <FaSpinner className="animate-spin" /> : <FaPaperPlane size={12} />} Submit
|
||||
</button>
|
||||
)}
|
||||
{!isNew && !isEditing && !isSubmitted && (
|
||||
<button onClick={() => setIsEditing(true)} className="flex items-center gap-2 px-4 py-2 border border-amber-500 text-amber-600 rounded-lg hover:bg-amber-50 text-sm">
|
||||
<FaEdit /> Edit
|
||||
</button>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 disabled:opacity-50 text-sm font-medium">
|
||||
{saving ? <FaSpinner className="animate-spin" /> : <FaSave />}{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{!isNew && <button onClick={() => { if (doc) syncForm(doc); setIsEditing(false); }} className="px-3 py-2 border border-gray-300 rounded-lg text-gray-600 text-sm"><FaTimes /></button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main fields */}
|
||||
<div className="px-6 pt-5 pb-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
|
||||
<div><FL required>Supplier</FL>
|
||||
{editable ? <LinkField label="Supplier" hideLabel doctype="Supplier" value={form.supplier || ''} onChange={v => { set('supplier', v); set('supplier_name', v); }} placeholder="Select supplier…" /> : <RV>{form.supplier_name || form.supplier}</RV>}
|
||||
</div>
|
||||
<div><FL required>Transaction Date</FL>
|
||||
{editable ? <input type="date" value={form.transaction_date || ''} onChange={e => set('transaction_date', e.target.value)} className={inputCls} /> : <RV>{form.transaction_date}</RV>}
|
||||
</div>
|
||||
<div><FL>Schedule Date</FL>
|
||||
{editable ? <input type="date" value={form.schedule_date || ''} onChange={e => set('schedule_date', e.target.value)} className={inputCls} /> : <RV>{form.schedule_date}</RV>}
|
||||
</div>
|
||||
<div><FL>Company</FL>
|
||||
{editable ? <LinkField label="Company" hideLabel doctype="Company" value={form.company || ''} onChange={v => set('company', v)} placeholder="Select company…" /> : <RV>{form.company}</RV>}
|
||||
</div>
|
||||
<div><FL>Project</FL>
|
||||
{editable ? <LinkField label="Project" hideLabel doctype="Project" value={form.project || ''} onChange={v => set('project', v)} placeholder="Select project…" /> : <RV>{form.project}</RV>}
|
||||
</div>
|
||||
<div><FL>Set Warehouse</FL>
|
||||
{editable ? <LinkField label="Warehouse" hideLabel doctype="Warehouse" value={form.set_warehouse || ''} onChange={v => set('set_warehouse', v)} placeholder="Set warehouse…" /> : <RV>{form.set_warehouse}</RV>}
|
||||
</div>
|
||||
<div><FL>Currency</FL>
|
||||
{editable
|
||||
? <select value={form.currency || DEFAULT_CURRENCY} onChange={e => set('currency', e.target.value)} className={inputCls}>
|
||||
<option value="SAR">SAR</option><option value="USD">USD</option><option value="EUR">EUR</option>
|
||||
</select>
|
||||
: <RV>{form.currency}</RV>}
|
||||
</div>
|
||||
<div><FL>Cost Center</FL>
|
||||
{editable ? <LinkField label="Cost Center" hideLabel doctype="Cost Center" value={form.cost_center || ''} onChange={v => set('cost_center', v)} placeholder="Cost center…" /> : <RV>{form.cost_center}</RV>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Items ── */}
|
||||
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="px-6 py-3 bg-gray-50 dark:bg-gray-900/20 border-b border-gray-100 dark:border-gray-700">
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Items</span>
|
||||
</div>
|
||||
<div className="px-6 pb-5">
|
||||
<div className="overflow-x-auto -mx-2 mt-3">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-8">No.</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 min-w-[180px]">Item Code <span className="text-red-400">*</span></th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-32">Schedule Date</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-24">Qty <span className="text-red-400">*</span></th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Rate ({form.currency || DEFAULT_CURRENCY})</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Amount ({form.currency || DEFAULT_CURRENCY})</th>
|
||||
{editable && <th className="w-16 py-2 px-2" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(form.items || []).map((it, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
<tr className={`border-b border-gray-100 dark:border-gray-700 align-middle ${expandedItem === idx ? 'bg-amber-50/60 dark:bg-amber-900/10' : ''}`}>
|
||||
<td className="py-1.5 px-3 text-gray-400 text-xs">{idx + 1}</td>
|
||||
<td className="py-1.5 px-2 min-w-[180px]">
|
||||
{editable
|
||||
? <LinkField label="Item" hideLabel doctype="Item" value={it.item_code || ''} onChange={v => handleItemCode(idx, v)} placeholder="Item Code" />
|
||||
: <span className="font-medium text-gray-800 dark:text-gray-200">{it.item_code || '-'}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 w-32">
|
||||
{editable
|
||||
? <input type="date" value={it.schedule_date || ''} onChange={e => updateItem(idx, 'schedule_date', e.target.value)} className={inlineTxt} />
|
||||
: <span className="text-gray-500 text-sm">{it.schedule_date || '-'}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 w-24">
|
||||
{editable ? <input type="number" min={0} step="1" value={it.qty ?? 0} onChange={e => updateItem(idx, 'qty', parseFloat(e.target.value) || 0)} className={inlineNum} /> : <span className="block text-right text-gray-700 dark:text-gray-300 text-sm pr-1">{it.qty ?? 0}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 w-28">
|
||||
{editable ? <input type="number" min={0} step="0.01" value={it.rate ?? 0} onChange={e => updateItem(idx, 'rate', parseFloat(e.target.value) || 0)} className={inlineNum} /> : <span className="block text-right text-gray-700 dark:text-gray-300 text-sm pr-1">{(it.rate ?? 0).toFixed(2)}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-right font-semibold text-gray-900 dark:text-white text-sm">{((it.qty || 0) * (it.rate || 0)).toFixed(2)}</td>
|
||||
{editable && (
|
||||
<td className="py-1.5 px-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setExpandedItem(expandedItem === idx ? null : idx)}
|
||||
className={`p-1.5 rounded text-xs ${expandedItem === idx ? 'bg-amber-500 text-white' : 'text-amber-600 hover:bg-amber-50'}`} title="More fields">
|
||||
<FaPencilAlt size={11} />
|
||||
</button>
|
||||
<button onClick={() => removeItem(idx)} className="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"><FaTrash size={11} /></button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{editable && expandedItem === idx && (
|
||||
<POItemRowEditor item={it} rowNo={idx + 1}
|
||||
onChange={(k, v) => { if (k === 'item_code') handleItemCode(idx, v as string); else updateItem(idx, k, v); }}
|
||||
onClose={() => setExpandedItem(null)} onDelete={() => removeItem(idx)} onInsertBelow={() => addItem(idx)} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{editable && (
|
||||
<tr><td colSpan={7} className="py-2 px-3">
|
||||
<button onClick={() => addItem()} className="flex items-center gap-1.5 text-amber-600 hover:text-amber-700 text-sm font-medium"><FaPlus size={10} /> Add Row</button>
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-between text-sm border-t border-gray-100 dark:border-gray-700 pt-3">
|
||||
<span className="text-gray-500">Total Qty: <strong>{(form.items || []).reduce((s, it) => s + (it.qty || 0), 0)}</strong></span>
|
||||
<span className="text-gray-500">Net Total: <strong className="text-gray-900 dark:text-white">{form.currency || DEFAULT_CURRENCY} {netTotal.toFixed(2)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Taxes ── */}
|
||||
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="px-6 py-3 bg-gray-50 dark:bg-gray-900/20 border-b border-gray-100 dark:border-gray-700">
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Taxes and Charges</span>
|
||||
</div>
|
||||
<div className="px-6 pt-4 pb-2 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
||||
<div><FL>Tax Category</FL>
|
||||
{editable ? <LinkField label="Tax Category" hideLabel doctype="Tax Category" value={(form as any).tax_category || ''} onChange={v => set('tax_category' as any, v)} placeholder="Select tax category…" /> : <RV>{(form as any).tax_category}</RV>}
|
||||
</div>
|
||||
<div><FL>Purchase Taxes and Charges Template</FL>
|
||||
{editable
|
||||
? <LinkField label="Purchase Taxes and Charges Template" hideLabel doctype="Purchase Taxes and Charges Template" value={(form as any).taxes_and_charges || ''} onChange={v => { set('taxes_and_charges' as any, v); loadTaxTemplate(v); }} placeholder="Select template…" />
|
||||
: <RV>{(form as any).taxes_and_charges}</RV>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-5">
|
||||
<div className="overflow-x-auto -mx-2 mt-3">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-8">No.</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-44">Type <span className="text-red-400">*</span></th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3">Account Head <span className="text-red-400">*</span></th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-24">Tax Rate</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Amount</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Total</th>
|
||||
{editable && <th className="w-16 py-2 px-2" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(form.taxes || []).map((tx, idx) => {
|
||||
const calcAmt = tx.charge_type === 'On Net Total' ? ((tx.rate || 0) / 100) * netTotal : (tx.charge_type === 'Actual' ? (tx.tax_amount || 0) : ((tx.rate || 0) / 100) * netTotal);
|
||||
const calcRunning = netTotal + (form.taxes || []).slice(0, idx + 1).reduce((s: number, t: any) => {
|
||||
const a = t.charge_type === 'On Net Total' ? ((t.rate || 0) / 100) * netTotal : (t.charge_type === 'Actual' ? (t.tax_amount || 0) : ((t.rate || 0) / 100) * netTotal);
|
||||
return s + a;
|
||||
}, 0);
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<tr className={`border-b border-gray-100 dark:border-gray-700 align-middle ${expandedTax === idx ? 'bg-amber-50/60 dark:bg-amber-900/10' : ''}`}>
|
||||
<td className="py-1.5 px-3 text-gray-400 text-xs">{idx + 1}</td>
|
||||
<td className="py-1.5 px-2 w-44">
|
||||
{editable
|
||||
? <select value={tx.charge_type || ''} onChange={e => updateTax(idx, 'charge_type', e.target.value)} className={inlineTxt}>
|
||||
<option value="">Select type…</option>
|
||||
<option value="Actual">Actual</option>
|
||||
<option value="On Net Total">On Net Total</option>
|
||||
<option value="On Previous Row Amount">On Previous Row Amount</option>
|
||||
</select>
|
||||
: <span className="text-gray-700 dark:text-gray-300">{tx.charge_type || '-'}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2">
|
||||
{editable
|
||||
? <LinkField label="Account Head" hideLabel doctype="Account" value={tx.account_head || ''} onChange={v => updateTax(idx, 'account_head', v)} placeholder="Account Head" />
|
||||
: <span className="text-gray-700 dark:text-gray-300">{tx.account_head || '-'}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 w-24">
|
||||
{editable
|
||||
? <input type="number" min={0} step="0.01" value={tx.rate ?? 0} onChange={e => updateTax(idx, 'rate', parseFloat(e.target.value) || 0)} className={inlineNum} />
|
||||
: <span className="block text-right text-gray-700 dark:text-gray-300 pr-1">{tx.rate ?? 0}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-right text-gray-700 dark:text-gray-300 text-sm">{calcAmt.toFixed(2)}</td>
|
||||
<td className="py-1.5 px-3 text-right font-semibold text-gray-900 dark:text-white text-sm">{calcRunning.toFixed(2)}</td>
|
||||
{editable && (
|
||||
<td className="py-1.5 px-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setExpandedTax(expandedTax === idx ? null : idx)}
|
||||
className={`p-1.5 rounded text-xs ${expandedTax === idx ? 'bg-amber-500 text-white' : 'text-amber-600 hover:bg-amber-50'}`} title="More fields">
|
||||
<FaPencilAlt size={11} />
|
||||
</button>
|
||||
<button onClick={() => removeTax(idx)} className="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"><FaTrash size={11} /></button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{editable && expandedTax === idx && (
|
||||
<POTaxRowEditor tax={tx} rowNo={idx + 1} onChange={(k, v) => updateTax(idx, k, v)}
|
||||
onClose={() => setExpandedTax(null)} onDelete={() => removeTax(idx)} onInsertBelow={() => addTax(idx)} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{editable && (
|
||||
<tr><td colSpan={7} className="py-2 px-3">
|
||||
<button onClick={() => addTax()} className="flex items-center gap-1.5 text-amber-600 hover:text-amber-700 text-sm font-medium"><FaPlus size={10} /> Add Row</button>
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{(form.taxes || []).length > 0 && (
|
||||
<div className="mt-2 flex justify-end text-sm text-gray-500 pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||
Total Taxes and Charges: <strong className="ml-2 text-gray-900 dark:text-white">{form.currency || DEFAULT_CURRENCY} {taxTotal.toFixed(2)}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Totals ── */}
|
||||
<div className="border-t border-gray-100 dark:border-gray-700 px-6 py-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Totals</h3>
|
||||
<div className="space-y-2 max-w-xs ml-auto">
|
||||
{[
|
||||
{ label: 'Net Total', value: (doc?.net_total ?? netTotal).toFixed(2) },
|
||||
{ label: 'Total Taxes', value: (doc?.total_taxes_and_charges ?? taxTotal).toFixed(2) },
|
||||
{ label: 'Grand Total', value: (doc?.grand_total ?? grandTotal).toFixed(2) },
|
||||
{ label: 'Rounded Total', value: (doc?.rounded_total ?? grandTotal).toFixed(2) },
|
||||
{ label: 'Advance Paid', value: (doc?.advance_paid ?? 0).toFixed(2) },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="flex justify-between text-sm border-b border-gray-100 dark:border-gray-700 pb-1.5 last:border-0">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{form.currency || DEFAULT_CURRENCY} {value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
{!isNew && doc && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700 px-6 py-4 grid grid-cols-3 gap-4 text-sm bg-gray-50 dark:bg-gray-900/20">
|
||||
<div><FL>Created By</FL><RV>{doc.owner}</RV></div>
|
||||
<div><FL>Created</FL><RV>{doc.creation ? new Date(doc.creation).toLocaleString() : '-'}</RV></div>
|
||||
<div><FL>Modified</FL><RV>{doc.modified ? new Date(doc.modified).toLocaleString() : '-'}</RV></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNew && (
|
||||
<ActivityLog
|
||||
doctype="Purchase Order"
|
||||
docname={doc?.name || poName || ''}
|
||||
creationDate={doc?.creation}
|
||||
createdBy={doc?.owner}
|
||||
compact={false}
|
||||
initialVisible={5}
|
||||
collapsible
|
||||
startCollapsed
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseOrderDetail;
|
||||
@ -1,263 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaShoppingBag, FaPlus, FaSync, FaChevronDown, FaChevronUp, FaTimes, FaSearch, FaFileExport } from 'react-icons/fa';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import purchaseOrderService, { PurchaseOrder } from '../services/purchaseOrderService';
|
||||
import DynamicExportModal from '../components/DynamicExportModal';
|
||||
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function getStatusStyle(po: PurchaseOrder) {
|
||||
if (po.docstatus === 2) return 'bg-red-100 text-red-700';
|
||||
if (po.docstatus === 1) {
|
||||
if (po.status === 'Completed') return 'bg-green-100 text-green-700';
|
||||
if (po.status === 'Stopped') return 'bg-red-100 text-red-700';
|
||||
return 'bg-green-100 text-green-700';
|
||||
}
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
}
|
||||
|
||||
function getStatusLabel(po: PurchaseOrder) {
|
||||
if (po.docstatus === 2) return 'Cancelled';
|
||||
if (po.docstatus === 1) return po.status || 'Submitted';
|
||||
return 'Draft';
|
||||
}
|
||||
|
||||
function buildPurchaseOrderExportFilters(f: { search: string; status: string }) {
|
||||
const filters: any[] = [];
|
||||
if (f.search) filters.push(['Purchase Order', 'name', 'like', `%${f.search}%`]);
|
||||
if (f.status === 'Draft') filters.push(['Purchase Order', 'docstatus', '=', 0]);
|
||||
if (f.status === 'Submitted') filters.push(['Purchase Order', 'docstatus', '=', 1]);
|
||||
if (f.status === 'Cancelled') filters.push(['Purchase Order', 'docstatus', '=', 2]);
|
||||
if (f.status === 'Completed') filters.push(['Purchase Order', 'status', '=', 'Completed']);
|
||||
return filters;
|
||||
}
|
||||
|
||||
const PurchaseOrderList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [orders, setOrders] = useState<PurchaseOrder[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [applied, setApplied] = useState({ search: '', status: '' });
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
const load = useCallback(async (off: number, f: typeof applied) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters = buildPurchaseOrderExportFilters(f);
|
||||
const [rows, cnt] = await Promise.all([
|
||||
purchaseOrderService.getPurchaseOrders({ filters, limit_start: off, limit_page_length: PAGE_SIZE }),
|
||||
purchaseOrderService.getPurchaseOrderCount(filters),
|
||||
]);
|
||||
setOrders(rows);
|
||||
setTotal(cnt);
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Failed to load');
|
||||
} finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(0, applied); }, [load, applied]);
|
||||
|
||||
const selectionResetKey = useMemo(
|
||||
() => `${page}|${applied.search}|${applied.status}`,
|
||||
[page, applied.search, applied.status],
|
||||
);
|
||||
const {
|
||||
selectedRows,
|
||||
toggleRow,
|
||||
toggleAllOnPage,
|
||||
allOnPageSelected,
|
||||
someOnPageSelected,
|
||||
} = useListPageSelection(orders, selectionResetKey);
|
||||
|
||||
const apply = () => {
|
||||
const f = { search: searchQuery, status: statusFilter };
|
||||
setApplied(f); setPage(0);
|
||||
};
|
||||
const clear = () => { setSearchQuery(''); setStatusFilter(''); setApplied({ search: '', status: '' }); setPage(0); };
|
||||
const hasActive = !!(applied.search || applied.status);
|
||||
|
||||
const goPage = (p: number) => { setPage(p); load(p * PAGE_SIZE, applied); };
|
||||
|
||||
const fetchAllForExport = useCallback(
|
||||
() =>
|
||||
fetchAllRowsForExport({
|
||||
doctype: 'Purchase Order',
|
||||
filters: buildPurchaseOrderExportFilters(applied),
|
||||
orderBy: 'modified desc',
|
||||
}),
|
||||
[applied],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ToastContainer position="top-right" autoClose={3000} />
|
||||
|
||||
<div className="flex items-center justify-between mb-6 gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500 flex items-center justify-center">
|
||||
<FaShoppingBag className="text-white text-base" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Purchase Orders</h1>
|
||||
<p className="text-xs text-gray-500">{total} total</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button onClick={() => load(page * PAGE_SIZE, applied)} className="p-2 text-gray-500 hover:text-amber-600 border border-gray-200 rounded-lg">
|
||||
<FaSync size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||
disabled={total === 0 && selectedRows.size === 0}
|
||||
>
|
||||
<FaFileExport /> {t('listPages.export')}
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => navigate('/purchase-orders/new')} className="flex items-center gap-2 px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 text-sm font-medium">
|
||||
<FaPlus size={11} /> New Purchase Order
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DynamicExportModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
doctype="Purchase Order"
|
||||
selectedCount={selectedRows.size}
|
||||
pageCount={orders.length}
|
||||
totalCount={total}
|
||||
pageData={orders}
|
||||
selectedRows={selectedRows}
|
||||
rowKey="name"
|
||||
onFetchAll={fetchAllForExport}
|
||||
fileNamePrefix="purchase_orders"
|
||||
/>
|
||||
|
||||
{/* Filter Panel */}
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl mb-5 overflow-hidden">
|
||||
<button onClick={() => setFiltersOpen(o => !o)} className="w-full flex items-center justify-between px-4 py-3 bg-gradient-to-r from-indigo-600 to-indigo-700 text-white">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<FaSearch size={12} /> Filters
|
||||
{hasActive && <span className="bg-white/30 text-white text-xs px-2 py-0.5 rounded-full">Active</span>}
|
||||
</div>
|
||||
{filtersOpen ? <FaChevronUp size={11} /> : <FaChevronDown size={11} />}
|
||||
</button>
|
||||
|
||||
{hasActive && (
|
||||
<div className="px-4 py-2 bg-indigo-50 dark:bg-indigo-900/20 flex flex-wrap gap-2 items-center border-b border-indigo-100 dark:border-indigo-800">
|
||||
{applied.search && <span className="flex items-center gap-1 text-xs bg-indigo-100 dark:bg-indigo-800 text-indigo-700 dark:text-indigo-300 px-2 py-1 rounded-full">ID: {applied.search}<button onClick={() => { setSearchQuery(''); setApplied(a => ({ ...a, search: '' })); }}><FaTimes size={9} /></button></span>}
|
||||
{applied.status && <span className="flex items-center gap-1 text-xs bg-indigo-100 dark:bg-indigo-800 text-indigo-700 dark:text-indigo-300 px-2 py-1 rounded-full">Status: {applied.status}<button onClick={() => { setStatusFilter(''); setApplied(a => ({ ...a, status: '' })); }}><FaTimes size={9} /></button></span>}
|
||||
<button onClick={clear} className="text-xs text-indigo-600 hover:underline ml-auto">Clear All</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtersOpen && (
|
||||
<div className="px-4 py-3 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Order ID</label>
|
||||
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && apply()} placeholder="Search…" className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Status</label>
|
||||
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||
<option value="">All</option>
|
||||
<option value="Draft">Draft</option>
|
||||
<option value="Submitted">Submitted</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
<option value="Completed">Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<button onClick={apply} className="px-4 py-2 bg-indigo-600 text-white text-sm rounded hover:bg-indigo-700">Apply</button>
|
||||
<button onClick={clear} className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded hover:bg-gray-50">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||
<th className="w-10 px-2 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500"
|
||||
checked={allOnPageSelected}
|
||||
ref={el => {
|
||||
if (el) el.indeterminate = someOnPageSelected;
|
||||
}}
|
||||
onChange={toggleAllOnPage}
|
||||
aria-label="Select all on page"
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">PO ID</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Supplier</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Transaction Date</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Schedule Date</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Status</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Grand Total</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Company</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{loading ? (
|
||||
<tr><td colSpan={8} className="text-center py-10 text-gray-400">Loading…</td></tr>
|
||||
) : orders.length === 0 ? (
|
||||
<tr><td colSpan={8} className="text-center py-10 text-gray-400">No purchase orders found</td></tr>
|
||||
) : orders.map(po => (
|
||||
<tr key={po.name} onClick={() => navigate(`/purchase-orders/${po.name}`)} className={`cursor-pointer hover:bg-amber-50 dark:hover:bg-amber-900/10 transition-colors ${selectedRows.has(po.name) ? 'bg-amber-50/90 dark:bg-amber-900/20' : ''}`}>
|
||||
<td className="w-10 px-2 py-3" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500"
|
||||
checked={selectedRows.has(po.name)}
|
||||
onChange={() => toggleRow(po.name)}
|
||||
aria-label={`Select ${po.name}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-medium text-amber-600">{po.name}</td>
|
||||
<td className="py-3 px-4 text-gray-700 dark:text-gray-300">{po.supplier_name || po.supplier || '-'}</td>
|
||||
<td className="py-3 px-4 text-gray-500">{po.transaction_date || '-'}</td>
|
||||
<td className="py-3 px-4 text-gray-500">{po.schedule_date || '-'}</td>
|
||||
<td className="py-3 px-4"><span className={`px-2 py-0.5 rounded text-xs font-semibold ${getStatusStyle(po)}`}>{getStatusLabel(po)}</span></td>
|
||||
<td className="py-3 px-4 text-right font-semibold text-gray-900 dark:text-white">{(po.grand_total ?? 0).toFixed(2)}</td>
|
||||
<td className="py-3 px-4 text-gray-500">{po.company || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-xs text-gray-500">{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}</span>
|
||||
<div className="flex gap-2">
|
||||
<button disabled={page === 0} onClick={() => goPage(page - 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Prev</button>
|
||||
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => goPage(page + 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseOrderList;
|
||||
@ -1,915 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
FaArrowLeft, FaSave, FaEdit, FaTimes, FaPlus, FaTrash,
|
||||
FaSpinner, FaPaperPlane, FaChevronDown, FaChevronRight, FaPencilAlt,
|
||||
FaClipboardCheck,
|
||||
} from 'react-icons/fa';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import purchaseReceiptService, { PurchaseReceipt, PurchaseReceiptItem, PurchaseTaxCharge } from '../services/purchaseReceiptService';
|
||||
import LinkField from '../components/LinkField';
|
||||
import ActivityLog from '../components/ActivityLog';
|
||||
import { DEFAULT_COMPANY, DEFAULT_CURRENCY, displayTxnCurrency } from '../constants/orgDefaults';
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
const FL: React.FC<{ children: React.ReactNode; required?: boolean }> = ({ children, required }) => (
|
||||
<label className="block text-[11px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||
{children}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
);
|
||||
const RV: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
||||
<div className="px-3 py-2 text-sm text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-800/60 rounded min-h-[34px] flex items-center">
|
||||
{children || <span className="text-gray-400">-</span>}
|
||||
</div>
|
||||
);
|
||||
const inputCls = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-green-400';
|
||||
const numCls = inputCls + ' text-right';
|
||||
const inlineNum = 'w-full px-2 py-1 text-sm text-right border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-green-400';
|
||||
const inlineTxt = 'w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-green-400';
|
||||
const roVal = 'px-3 py-2 text-sm text-right bg-gray-50 dark:bg-gray-700/60 rounded text-gray-600 dark:text-gray-300';
|
||||
|
||||
// ── Collapsible group inside row editor ───────────────────────────────────────
|
||||
const RGroup: React.FC<{ title: string; children: React.ReactNode; defaultOpen?: boolean }> = ({ title, children, defaultOpen = false }) => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="border-t border-gray-200 dark:border-gray-600 mt-3 pt-1">
|
||||
<button type="button" onClick={() => setOpen(o => !o)}
|
||||
className="flex items-center gap-2 py-1 text-xs font-semibold text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{open ? <FaChevronDown size={9} /> : <FaChevronRight size={9} />}{title}
|
||||
</button>
|
||||
{open && <div className="mt-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── PR Item Row Editor ─────────────────────────────────────────────────────────
|
||||
const PRItemRowEditor: React.FC<{
|
||||
item: Partial<PurchaseReceiptItem>; rowNo: number;
|
||||
onChange: (k: keyof PurchaseReceiptItem, v: any) => void;
|
||||
onClose: () => void; onDelete: () => void; onInsertBelow: () => void;
|
||||
}> = ({ item, rowNo, onChange, onClose, onDelete, onInsertBelow }) => (
|
||||
<tr>
|
||||
<td colSpan={8} className="p-0">
|
||||
<div className="border border-blue-300 dark:border-blue-600 rounded-lg mx-2 my-1 bg-white dark:bg-gray-800 shadow-md">
|
||||
{/* Editor header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-blue-50 dark:bg-blue-900/30 rounded-t-lg border-b border-blue-200 dark:border-blue-700">
|
||||
<span className="text-sm font-semibold text-blue-700 dark:text-blue-300">Editing Row #{rowNo}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onInsertBelow} className="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-600">Insert Below</button>
|
||||
<button onClick={onDelete} className="px-2 py-1 text-xs border border-red-300 rounded text-red-500 hover:bg-red-50">Delete</button>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600"><FaTimes size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Item Code + Item Name */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><FL required>Item Code</FL>
|
||||
<LinkField label="Item" hideLabel doctype="Item" value={item.item_code || ''} onChange={v => onChange('item_code', v)} placeholder="Select item…" />
|
||||
</div>
|
||||
<div><FL>Item Name</FL>
|
||||
<input value={item.item_name || ''} onChange={e => onChange('item_name', e.target.value)} className={inputCls} placeholder="Item name…" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<RGroup title="Description" defaultOpen={!!(item.description)}>
|
||||
<textarea rows={2} value={item.description || ''} onChange={e => onChange('description', e.target.value)} className={inputCls} placeholder="Description…" />
|
||||
</RGroup>
|
||||
|
||||
{/* Received and Accepted */}
|
||||
<RGroup title="Received and Accepted" defaultOpen>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><FL required>Received Qty</FL>
|
||||
<input type="number" min={0} step="1" value={item.received_qty ?? 0} onChange={e => onChange('received_qty', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||
</div>
|
||||
<div><FL required>UOM</FL>
|
||||
<LinkField label="UOM" hideLabel doctype="UOM" value={item.uom || ''} onChange={v => onChange('uom', v)} placeholder="UOM…" />
|
||||
</div>
|
||||
<div><FL required>Accepted Qty</FL>
|
||||
<input type="number" min={0} step="1" value={item.qty ?? item.received_qty ?? 0} onChange={e => onChange('qty', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-4">
|
||||
<input type="checkbox" checked={!!(item.retain_sample)} onChange={e => onChange('retain_sample', e.target.checked ? 1 : 0)} className="rounded" />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Retain Sample</span>
|
||||
</div>
|
||||
{item.retain_sample ? (
|
||||
<div><FL>Sample Quantity</FL>
|
||||
<input type="number" min={0} step="0.001" value={item.sample_quantity ?? 0} onChange={e => onChange('sample_quantity', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||
</div>
|
||||
) : null}
|
||||
<div><FL>Rejected Qty</FL>
|
||||
<input type="number" min={0} step="0.001" value={item.rejected_qty ?? 0} onChange={e => onChange('rejected_qty', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||
</div>
|
||||
</div>
|
||||
</RGroup>
|
||||
|
||||
{/* Rate and Amount */}
|
||||
<RGroup title="Rate and Amount" defaultOpen>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><FL>Price List Rate</FL>
|
||||
<div className={roVal}>{(item.price_list_rate ?? 0).toFixed(2)}</div>
|
||||
</div>
|
||||
<div><FL>Price List Rate (Company Currency)</FL>
|
||||
<div className={roVal}>{(item.price_list_rate ?? 0).toFixed(2)}</div>
|
||||
</div>
|
||||
<div><FL>Last Purchase Rate</FL>
|
||||
<div className={roVal}>{(item.valuation_rate ?? 0).toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</RGroup>
|
||||
|
||||
{/* Discount and Margin */}
|
||||
<RGroup title="Discount and Margin">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><FL required>Rate</FL>
|
||||
<input type="number" min={0} step="0.01" value={item.rate ?? 0} onChange={e => onChange('rate', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||
</div>
|
||||
<div><FL>Rate (Company Currency)</FL>
|
||||
<div className={roVal}>{(item.rate ?? 0).toFixed(2)}</div>
|
||||
</div>
|
||||
<div><FL>Amount</FL>
|
||||
<div className="px-3 py-2 text-sm font-semibold text-right bg-gray-50 dark:bg-gray-700/60 rounded">
|
||||
{((item.qty ?? item.received_qty ?? 0) * (item.rate || 0)).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div><FL>Amount (Company Currency)</FL>
|
||||
<div className={roVal}>{((item.qty ?? item.received_qty ?? 0) * (item.rate || 0)).toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-4">
|
||||
<input type="checkbox" checked={!!(item.is_free_item)} onChange={e => onChange('is_free_item', e.target.checked ? 1 : 0)} className="rounded" />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Is Free Item</span>
|
||||
</div>
|
||||
<div><FL>Net Rate</FL>
|
||||
<div className={roVal}>{(item.net_rate ?? item.rate ?? 0).toFixed(2)}</div>
|
||||
</div>
|
||||
<div><FL>Net Amount</FL>
|
||||
<div className={roVal}>{(item.net_amount ?? 0).toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</RGroup>
|
||||
|
||||
{/* Warehouse and Reference */}
|
||||
<RGroup title="Warehouse and Reference" defaultOpen>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><FL>Accepted Warehouse</FL>
|
||||
<LinkField label="Warehouse" hideLabel doctype="Warehouse" value={item.warehouse || ''} onChange={v => onChange('warehouse', v)} placeholder="Warehouse…" />
|
||||
</div>
|
||||
<div><FL>Rejected Warehouse</FL>
|
||||
<LinkField label="Warehouse" hideLabel doctype="Warehouse" value={item.rejected_warehouse || ''} onChange={v => onChange('rejected_warehouse', v)} placeholder="Rejected Warehouse…" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-4">
|
||||
<input type="checkbox" checked={!!(item.allow_zero_valuation_rate)} onChange={e => onChange('allow_zero_valuation_rate', e.target.checked ? 1 : 0)} className="rounded" />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Allow Zero Valuation Rate</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-4">
|
||||
<input type="checkbox" checked={!!(item.from_warehouse)} onChange={e => onChange('from_warehouse', e.target.checked ? 1 : 0)} className="rounded" />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Return Qty from Rejected Warehouse</span>
|
||||
</div>
|
||||
{item.purchase_order && (
|
||||
<div><FL>Purchase Order</FL>
|
||||
<div className={roVal + ' text-left'}>{item.purchase_order}</div>
|
||||
</div>
|
||||
)}
|
||||
<div><FL>Schedule Date</FL>
|
||||
<input type="date" value={item.schedule_date || ''} onChange={e => onChange('schedule_date', e.target.value)} className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
</RGroup>
|
||||
|
||||
{/* Serial and Batch No */}
|
||||
<RGroup title="Serial and Batch No">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={!!(item.use_serial_batch_fields)} onChange={e => onChange('use_serial_batch_fields', e.target.checked ? 1 : 0)} className="rounded" />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Use Serial / Batch Fields</span>
|
||||
</div>
|
||||
</RGroup>
|
||||
|
||||
{/* Item Weight Details */}
|
||||
<RGroup title="Item Weight Details">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><FL>Weight Per Unit</FL>
|
||||
<input type="number" min={0} step="0.001" value={item.weight_per_unit ?? 0} onChange={e => onChange('weight_per_unit', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||
</div>
|
||||
<div><FL>Total Weight</FL>
|
||||
<div className={roVal}>{(item.total_weight ?? 0).toFixed(3)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</RGroup>
|
||||
|
||||
{/* Accounting Details */}
|
||||
<RGroup title="Accounting Details">
|
||||
<div><FL>Expense Account</FL>
|
||||
<LinkField label="Expense Account" hideLabel doctype="Account" value={item.expense_account || ''} onChange={v => onChange('expense_account', v)} placeholder="Expense Account…" />
|
||||
</div>
|
||||
</RGroup>
|
||||
|
||||
{/* Accounting Dimensions */}
|
||||
<RGroup title="Accounting Dimensions">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><FL>Cost Center</FL>
|
||||
<LinkField label="Cost Center" hideLabel doctype="Cost Center" value={item.cost_center || ''} onChange={v => onChange('cost_center', v)} placeholder="Cost center…" />
|
||||
</div>
|
||||
<div><FL>Project</FL>
|
||||
<LinkField label="Project" hideLabel doctype="Project" value={item.project || ''} onChange={v => onChange('project', v)} placeholder="Project…" />
|
||||
</div>
|
||||
</div>
|
||||
</RGroup>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
// ── PR Tax Row Editor ──────────────────────────────────────────────────────────
|
||||
const PRTaxRowEditor: React.FC<{
|
||||
tax: Partial<PurchaseTaxCharge>; rowNo: number;
|
||||
onChange: (k: keyof PurchaseTaxCharge, v: any) => void;
|
||||
onClose: () => void; onDelete: () => void; onInsertBelow: () => void;
|
||||
}> = ({ tax, rowNo, onChange, onClose, onDelete, onInsertBelow }) => (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-0">
|
||||
<div className="border border-blue-300 dark:border-blue-600 rounded-lg mx-2 my-1 bg-white dark:bg-gray-800 shadow-md">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-blue-50 dark:bg-blue-900/30 rounded-t-lg border-b border-blue-200">
|
||||
<span className="text-sm font-semibold text-blue-700 dark:text-blue-300">Editing Row #{rowNo}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onInsertBelow} className="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 text-gray-600">Insert Below</button>
|
||||
<button onClick={onDelete} className="px-2 py-1 text-xs border border-red-300 rounded text-red-500 hover:bg-red-50">Delete</button>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600"><FaTimes size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><FL required>Type</FL>
|
||||
<select value={tax.charge_type || ''} onChange={e => onChange('charge_type', e.target.value)} className={inputCls}>
|
||||
<option value="">Select type…</option>
|
||||
<option value="Actual">Actual</option>
|
||||
<option value="On Net Total">On Net Total</option>
|
||||
<option value="On Previous Row Amount">On Previous Row Amount</option>
|
||||
<option value="On Previous Row Total">On Previous Row Total</option>
|
||||
<option value="On Item Quantity">On Item Quantity</option>
|
||||
<option value="Inter Company Transaction">Inter Company Transaction</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><FL>Description</FL>
|
||||
<textarea rows={3} value={tax.description || ''} onChange={e => onChange('description', e.target.value)} className={inputCls} placeholder="Description…" />
|
||||
</div>
|
||||
</div>
|
||||
<div><FL required>Account Head</FL>
|
||||
<LinkField label="Account Head" hideLabel doctype="Account" value={tax.account_head || ''} onChange={v => onChange('account_head', v)} placeholder="Account…" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={!!(tax.included_in_print_rate)} onChange={e => onChange('included_in_print_rate', e.target.checked ? 1 : 0)} className="rounded" />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Is this Tax included in Basic Rate?</span>
|
||||
</div>
|
||||
<p className="text-xs text-blue-500 dark:text-blue-400">If checked, the tax amount will be considered as already included in the Print Rate / Print Amount</p>
|
||||
|
||||
<RGroup title="Accounting Dimensions" defaultOpen>
|
||||
<div><FL>Cost Center</FL>
|
||||
<LinkField label="Cost Center" hideLabel doctype="Cost Center" value={tax.cost_center || ''} onChange={v => onChange('cost_center', v)} placeholder="Cost center…" />
|
||||
</div>
|
||||
</RGroup>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><FL required>Tax Rate</FL>
|
||||
<input type="number" min={0} step="0.01" value={tax.rate ?? 0} onChange={e => onChange('rate', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||
</div>
|
||||
<div><FL>Account Currency</FL>
|
||||
<input value={tax.account_currency || DEFAULT_CURRENCY} onChange={e => onChange('account_currency', e.target.value)} className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────────
|
||||
const emptyItem = (): Partial<PurchaseReceiptItem> => ({
|
||||
item_code: '', item_name: '', received_qty: 0, qty: 0, rejected_qty: 0,
|
||||
rate: 0, amount: 0, uom: '', conversion_factor: 1, is_free_item: 0, retain_sample: 0,
|
||||
});
|
||||
const emptyTax = (): Partial<PurchaseTaxCharge> => ({
|
||||
charge_type: '', account_head: '', rate: 0, account_currency: DEFAULT_CURRENCY, included_in_print_rate: 0,
|
||||
});
|
||||
|
||||
const PurchaseReceiptDetail: React.FC = () => {
|
||||
const { prName } = useParams<{ prName: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const isNew = prName === 'new';
|
||||
|
||||
const contextPO = searchParams.get('po') || '';
|
||||
const contextSupplier = searchParams.get('supplier') || '';
|
||||
const contextCompany = searchParams.get('company') || DEFAULT_COMPANY;
|
||||
const contextProject = searchParams.get('project') || '';
|
||||
|
||||
const [doc, setDoc] = useState<PurchaseReceipt | null>(null);
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(isNew);
|
||||
const [expandedItem, setExpandedItem] = useState<number | null>(null);
|
||||
const [expandedTax, setExpandedTax] = useState<number | null>(null);
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const [form, setForm] = useState<Partial<PurchaseReceipt>>({
|
||||
supplier: contextSupplier, supplier_name: contextSupplier,
|
||||
company: contextCompany, project: contextProject,
|
||||
posting_date: today, currency: DEFAULT_CURRENCY,
|
||||
items: [], taxes: [],
|
||||
});
|
||||
|
||||
const syncForm = useCallback((d: PurchaseReceipt) => {
|
||||
setForm({
|
||||
supplier: d.supplier || '', supplier_name: d.supplier_name || d.supplier || '',
|
||||
company: d.company || DEFAULT_COMPANY, project: d.project || '',
|
||||
posting_date: d.posting_date || today, currency: d.currency || DEFAULT_CURRENCY,
|
||||
set_warehouse: d.set_warehouse || '', cost_center: d.cost_center || '',
|
||||
tax_category: (d as any).tax_category || '',
|
||||
taxes_and_charges: (d as any).taxes_and_charges || '',
|
||||
items: d.items || [], taxes: d.taxes || [],
|
||||
});
|
||||
setExpandedItem(null); setExpandedTax(null);
|
||||
}, [today]);
|
||||
|
||||
// Fetch existing doc
|
||||
useEffect(() => {
|
||||
if (isNew) return;
|
||||
setLoading(true);
|
||||
purchaseReceiptService.getPurchaseReceipt(prName!)
|
||||
.then(d => { setDoc(d); syncForm(d); })
|
||||
.catch(e => toast.error(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [prName, isNew, syncForm]);
|
||||
|
||||
// Auto-fetch company default currency for new documents
|
||||
useEffect(() => {
|
||||
const company = form.company;
|
||||
if (!isNew || !company) return;
|
||||
fetch(`/api/resource/Company/${encodeURIComponent(company)}`, { credentials: 'include' })
|
||||
.then(r => r.json()).then(b => {
|
||||
if (b.data?.default_currency) {
|
||||
const cur = displayTxnCurrency(b.data.default_currency);
|
||||
setForm(f => ({ ...f, currency: cur,
|
||||
buying_price_list: (f as any).buying_price_list || 'Standard Buying',
|
||||
price_list_currency: (f as any).price_list_currency || cur,
|
||||
} as any));
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [form.company, isNew]);
|
||||
|
||||
// Pre-fill from PO when creating new
|
||||
useEffect(() => {
|
||||
if (!isNew || !contextPO) return;
|
||||
fetch(`/api/resource/Purchase Order/${encodeURIComponent(contextPO)}`, { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(body => {
|
||||
const po = body.data;
|
||||
if (!po) return;
|
||||
setForm(f => ({
|
||||
...f,
|
||||
supplier: po.supplier || f.supplier,
|
||||
supplier_name: po.supplier_name || po.supplier || f.supplier_name,
|
||||
company: po.company || f.company,
|
||||
project: po.project || f.project,
|
||||
items: (po.items || []).map((it: any) => ({
|
||||
item_code: it.item_code,
|
||||
item_name: it.item_name,
|
||||
uom: it.uom || it.stock_uom,
|
||||
stock_uom: it.stock_uom,
|
||||
conversion_factor: it.conversion_factor ?? 1,
|
||||
received_qty: it.qty ?? 0,
|
||||
qty: it.qty ?? 0,
|
||||
rejected_qty: 0,
|
||||
rate: it.rate ?? 0,
|
||||
amount: (it.qty ?? 0) * (it.rate ?? 0),
|
||||
warehouse: it.warehouse || '',
|
||||
purchase_order: contextPO,
|
||||
purchase_order_item: it.name,
|
||||
schedule_date: it.schedule_date || '',
|
||||
project: po.project || '',
|
||||
cost_center: it.cost_center || '',
|
||||
expense_account: it.expense_account || '',
|
||||
})),
|
||||
}));
|
||||
})
|
||||
.catch(() => { /* ignore */ });
|
||||
}, [isNew, contextPO]);
|
||||
|
||||
const set = (k: keyof PurchaseReceipt, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
// ── Item helpers ─────────────────────────────────────────────────────────────
|
||||
const updateItem = (idx: number, k: keyof PurchaseReceiptItem, v: any) =>
|
||||
setForm(f => {
|
||||
const items = [...(f.items || [])];
|
||||
const updated = { ...items[idx], [k]: v };
|
||||
if (k === 'qty' || k === 'received_qty' || k === 'rate') {
|
||||
const qty = parseFloat(String(k === 'qty' || k === 'received_qty' ? v : (updated.qty ?? updated.received_qty))) || 0;
|
||||
const rate = parseFloat(String(k === 'rate' ? v : updated.rate)) || 0;
|
||||
updated.amount = parseFloat((qty * rate).toFixed(4));
|
||||
}
|
||||
items[idx] = updated;
|
||||
return { ...f, items };
|
||||
});
|
||||
|
||||
const handleItemCode = async (idx: number, code: string) => {
|
||||
updateItem(idx, 'item_code', code);
|
||||
if (!code) return;
|
||||
try {
|
||||
const r = await fetch(`/api/resource/Item/${encodeURIComponent(code)}`, { credentials: 'include' });
|
||||
const body = await r.json(); const d = body.data; if (!d) return;
|
||||
setForm(f => {
|
||||
const items = [...(f.items || [])];
|
||||
items[idx] = {
|
||||
...items[idx],
|
||||
item_code: code,
|
||||
item_name: d.item_name || code,
|
||||
description: d.description || d.item_name || code,
|
||||
stock_uom: d.stock_uom || '',
|
||||
uom: d.purchase_uom || d.stock_uom || '',
|
||||
price_list_rate: d.standard_rate ?? 0,
|
||||
rate: items[idx].rate || d.standard_rate || 0,
|
||||
};
|
||||
return { ...f, items };
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const addItem = (afterIdx?: number) => {
|
||||
setForm(f => {
|
||||
const items = [...(f.items || [])];
|
||||
const newItem = emptyItem();
|
||||
let newIdx: number;
|
||||
if (afterIdx !== undefined) { items.splice(afterIdx + 1, 0, newItem); newIdx = afterIdx + 1; }
|
||||
else { items.push(newItem); newIdx = items.length - 1; }
|
||||
setTimeout(() => setExpandedItem(newIdx), 0);
|
||||
return { ...f, items };
|
||||
});
|
||||
};
|
||||
const removeItem = (idx: number) => {
|
||||
setForm(f => { const items = [...(f.items || [])]; items.splice(idx, 1); return { ...f, items }; });
|
||||
setExpandedItem(null);
|
||||
};
|
||||
|
||||
// ── Tax helpers ──────────────────────────────────────────────────────────────
|
||||
const updateTax = (idx: number, k: keyof PurchaseTaxCharge, v: any) =>
|
||||
setForm(f => { const taxes = [...(f.taxes || [])]; taxes[idx] = { ...taxes[idx], [k]: v }; return { ...f, taxes }; });
|
||||
|
||||
const addTax = (afterIdx?: number) => {
|
||||
setForm(f => {
|
||||
const taxes = [...(f.taxes || [])];
|
||||
const newTax = emptyTax();
|
||||
let newIdx: number;
|
||||
if (afterIdx !== undefined) { taxes.splice(afterIdx + 1, 0, newTax); newIdx = afterIdx + 1; }
|
||||
else { taxes.push(newTax); newIdx = taxes.length - 1; }
|
||||
setTimeout(() => setExpandedTax(newIdx), 0);
|
||||
return { ...f, taxes };
|
||||
});
|
||||
};
|
||||
const removeTax = (idx: number) => {
|
||||
setForm(f => { const taxes = [...(f.taxes || [])]; taxes.splice(idx, 1); return { ...f, taxes }; });
|
||||
setExpandedTax(null);
|
||||
};
|
||||
|
||||
// ── Computed ─────────────────────────────────────────────────────────────────
|
||||
const netTotal = (form.items || []).reduce((s, it) => s + ((it.qty ?? it.received_qty ?? 0) * (it.rate || 0)), 0);
|
||||
const taxTotal = (form.taxes || []).reduce((s, tx) => {
|
||||
if (tx.charge_type === 'On Net Total') return s + ((tx.rate || 0) / 100) * netTotal;
|
||||
if (tx.charge_type === 'Actual') return s + (tx.tax_amount || 0);
|
||||
return s + ((tx.rate || 0) / 100) * netTotal;
|
||||
}, 0);
|
||||
const grandTotal = netTotal + taxTotal;
|
||||
|
||||
// ── Payload ──────────────────────────────────────────────────────────────────
|
||||
const loadTaxTemplate = async (templateName: string) => {
|
||||
if (!templateName) return;
|
||||
try {
|
||||
const r = await fetch(`/api/resource/Purchase Taxes and Charges Template/${encodeURIComponent(templateName)}`, { credentials: 'include' });
|
||||
const body = await r.json();
|
||||
const tmpl = body.data;
|
||||
if (tmpl?.taxes?.length) {
|
||||
setForm(f => ({ ...f, taxes: tmpl.taxes.map((tx: any) => ({
|
||||
charge_type: tx.charge_type, account_head: tx.account_head,
|
||||
description: tx.description, rate: tx.rate,
|
||||
cost_center: tx.cost_center, account_currency: tx.account_currency,
|
||||
included_in_print_rate: tx.included_in_print_rate ?? 0,
|
||||
})) }));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const buildPayload = (): Partial<PurchaseReceipt> => ({
|
||||
supplier: form.supplier,
|
||||
company: form.company || undefined,
|
||||
project: form.project || undefined,
|
||||
posting_date: form.posting_date,
|
||||
currency: form.currency || undefined,
|
||||
set_warehouse: form.set_warehouse || undefined,
|
||||
cost_center: form.cost_center || undefined,
|
||||
tax_category: (form as any).tax_category || undefined,
|
||||
taxes_and_charges: (form as any).taxes_and_charges || undefined,
|
||||
items: (form.items || []).filter(it => it.item_code).map((it, i) => ({
|
||||
item_code: it.item_code,
|
||||
item_name: it.item_name || it.item_code,
|
||||
description: it.description || it.item_name || it.item_code,
|
||||
received_qty: it.received_qty ?? 0,
|
||||
qty: it.qty ?? it.received_qty ?? 0,
|
||||
rejected_qty: it.rejected_qty ?? 0,
|
||||
uom: it.uom || undefined,
|
||||
stock_uom: it.stock_uom || undefined,
|
||||
conversion_factor: it.conversion_factor ?? 1,
|
||||
rate: it.rate ?? 0,
|
||||
amount: (it.qty ?? it.received_qty ?? 0) * (it.rate ?? 0),
|
||||
price_list_rate: it.price_list_rate ?? 0,
|
||||
warehouse: it.warehouse || undefined,
|
||||
rejected_warehouse: it.rejected_warehouse || undefined,
|
||||
expense_account: it.expense_account || undefined,
|
||||
cost_center: it.cost_center || undefined,
|
||||
project: it.project || form.project || undefined,
|
||||
purchase_order: it.purchase_order || undefined,
|
||||
purchase_order_item: it.purchase_order_item || undefined,
|
||||
schedule_date: it.schedule_date || undefined,
|
||||
is_free_item: it.is_free_item ?? 0,
|
||||
retain_sample: it.retain_sample ?? 0,
|
||||
sample_quantity: it.sample_quantity || undefined,
|
||||
weight_per_unit: it.weight_per_unit || undefined,
|
||||
idx: i + 1,
|
||||
})),
|
||||
taxes: (form.taxes || []).filter(tx => tx.charge_type).map((tx, i) => ({
|
||||
charge_type: tx.charge_type,
|
||||
account_head: tx.account_head || undefined,
|
||||
description: tx.description || undefined,
|
||||
included_in_print_rate: tx.included_in_print_rate ?? 0,
|
||||
cost_center: tx.cost_center || undefined,
|
||||
rate: tx.rate ?? 0,
|
||||
account_currency: tx.account_currency || DEFAULT_CURRENCY,
|
||||
idx: i + 1,
|
||||
})),
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.supplier) { toast.error('Supplier is required'); return; }
|
||||
try {
|
||||
setSaving(true);
|
||||
if (isNew) {
|
||||
const created = await purchaseReceiptService.createPurchaseReceipt(buildPayload());
|
||||
toast.success('Purchase Receipt created');
|
||||
setIsEditing(false);
|
||||
navigate(`/purchase-receipts/${created.name}`);
|
||||
} else {
|
||||
const updated = await purchaseReceiptService.updatePurchaseReceipt(prName!, buildPayload());
|
||||
setDoc(updated); syncForm(updated);
|
||||
toast.success('Purchase Receipt saved');
|
||||
setIsEditing(false);
|
||||
}
|
||||
} catch (e: any) { toast.error(e.message || 'Error saving'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!prName || isNew) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const updated = await purchaseReceiptService.submitPurchaseReceipt(prName);
|
||||
setDoc(updated); syncForm(updated);
|
||||
toast.success('Purchase Receipt submitted');
|
||||
} catch (e: any) { toast.error(e.message || 'Error submitting'); }
|
||||
finally { setSubmitting(false); }
|
||||
};
|
||||
|
||||
const editable = isNew || isEditing;
|
||||
const isSubmitted = !isNew && doc?.docstatus === 1;
|
||||
const title = isNew ? 'New Purchase Receipt' : (form.supplier_name || prName || '');
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<FaSpinner className="animate-spin text-green-500 text-3xl" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-6 py-6">
|
||||
<ToastContainer position="top-right" autoClose={3500} />
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm mb-6 text-gray-500">
|
||||
<button onClick={() => navigate('/purchase-receipts')} className="hover:text-green-600">Purchase Receipts</button>
|
||||
<span>/</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">{isNew ? 'New Purchase Receipt' : prName}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate('/purchase-receipts')} className="text-gray-400 hover:text-gray-700"><FaArrowLeft /></button>
|
||||
<FaClipboardCheck className="text-green-500" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">{title}</h1>
|
||||
{!isNew && prName && (
|
||||
<span className="text-xs text-gray-400 font-mono">{prName}</span>
|
||||
)}
|
||||
{!isNew && (
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-semibold ${(() => { const s = doc?.status || ''; if (doc?.docstatus === 2 || s === 'Cancelled') return 'bg-red-100 text-red-700'; if (!doc || doc.docstatus === 0) return 'bg-yellow-100 text-yellow-800'; if (s === 'Completed') return 'bg-green-100 text-green-800'; if (s === 'Return Issued') return 'bg-orange-100 text-orange-800'; if (s === 'Closed') return 'bg-gray-100 text-gray-700'; return 'bg-green-100 text-green-800'; })()}`}>
|
||||
{doc?.docstatus === 2 ? 'Cancelled' : doc?.docstatus === 0 ? 'Draft' : (doc?.status || 'Submitted')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{!isNew && !isEditing && doc?.docstatus === 0 && (
|
||||
<button onClick={handleSubmit} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm font-medium">
|
||||
{submitting ? <FaSpinner className="animate-spin" /> : <FaPaperPlane size={12} />} Submit
|
||||
</button>
|
||||
)}
|
||||
{!isNew && !isEditing && !isSubmitted && (
|
||||
<button onClick={() => setIsEditing(true)} className="flex items-center gap-2 px-4 py-2 border border-green-500 text-green-600 rounded-lg hover:bg-green-50 text-sm">
|
||||
<FaEdit /> Edit
|
||||
</button>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 text-sm font-medium">
|
||||
{saving ? <FaSpinner className="animate-spin" /> : <FaSave />}{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{!isNew && <button onClick={() => { if (doc) syncForm(doc); setIsEditing(false); }} className="px-3 py-2 border border-gray-300 rounded-lg text-gray-600 text-sm"><FaTimes /></button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main fields */}
|
||||
<div className="px-6 pt-5 pb-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
|
||||
<div><FL required>Supplier</FL>
|
||||
{editable
|
||||
? <LinkField label="Supplier" hideLabel doctype="Supplier" value={form.supplier || ''} onChange={v => { set('supplier', v); set('supplier_name', v); }} placeholder="Select supplier…" />
|
||||
: <RV>{form.supplier_name || form.supplier}</RV>}
|
||||
</div>
|
||||
<div><FL required>Posting Date</FL>
|
||||
{editable
|
||||
? <input type="date" value={form.posting_date || ''} onChange={e => set('posting_date', e.target.value)} className={inputCls} />
|
||||
: <RV>{form.posting_date}</RV>}
|
||||
</div>
|
||||
<div><FL>Company</FL>
|
||||
{editable
|
||||
? <LinkField label="Company" hideLabel doctype="Company" value={form.company || ''} onChange={v => set('company', v)} placeholder="Select company…" />
|
||||
: <RV>{form.company}</RV>}
|
||||
</div>
|
||||
<div><FL>Project</FL>
|
||||
{editable
|
||||
? <LinkField label="Project" hideLabel doctype="Project" value={form.project || ''} onChange={v => set('project', v)} placeholder="Select project…" />
|
||||
: <RV>{form.project}</RV>}
|
||||
</div>
|
||||
<div><FL>Set Warehouse</FL>
|
||||
{editable
|
||||
? <LinkField label="Warehouse" hideLabel doctype="Warehouse" value={form.set_warehouse || ''} onChange={v => set('set_warehouse', v)} placeholder="Warehouse…" />
|
||||
: <RV>{form.set_warehouse}</RV>}
|
||||
</div>
|
||||
<div><FL>Currency</FL>
|
||||
{editable
|
||||
? <select value={form.currency || DEFAULT_CURRENCY} onChange={e => set('currency', e.target.value)} className={inputCls}>
|
||||
<option value="SAR">SAR</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
</select>
|
||||
: <RV>{form.currency}</RV>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Items ── */}
|
||||
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="px-6 py-3 bg-gray-50 dark:bg-gray-900/20 border-b border-gray-100 dark:border-gray-700">
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Items</span>
|
||||
</div>
|
||||
<div className="px-6 pb-5">
|
||||
<div className="overflow-x-auto -mx-2 mt-3">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-8">No.</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 min-w-[180px]">Item Code <span className="text-red-400">*</span></th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 min-w-[140px]">Item Name</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-24">Received Qty</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-24">Accepted Qty</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Rate</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Amount</th>
|
||||
{editable && <th className="w-16 py-2 px-2" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(form.items || []).map((it, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
<tr className={`border-b border-gray-100 dark:border-gray-700 align-middle ${expandedItem === idx ? 'bg-blue-50/60 dark:bg-blue-900/10' : ''}`}>
|
||||
<td className="py-1.5 px-3 text-gray-400 text-xs">{idx + 1}</td>
|
||||
<td className="py-1.5 px-2 min-w-[180px]">
|
||||
{editable
|
||||
? <LinkField label="Item" hideLabel doctype="Item" value={it.item_code || ''} onChange={v => handleItemCode(idx, v)} placeholder="Item Code" />
|
||||
: <span className="font-medium text-gray-800 dark:text-gray-200">{it.item_code || '-'}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 min-w-[140px]">
|
||||
<span className="text-gray-500 text-sm">{it.item_name || '-'}</span>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 w-24">
|
||||
{editable
|
||||
? <input type="number" min={0} step="1" value={it.received_qty ?? 0} onChange={e => updateItem(idx, 'received_qty', parseFloat(e.target.value) || 0)} className={inlineNum} />
|
||||
: <span className="block text-right text-gray-700 dark:text-gray-300 text-sm pr-1">{(it.received_qty ?? 0).toFixed(3)}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 w-24">
|
||||
{editable
|
||||
? <input type="number" min={0} step="1" value={it.qty ?? it.received_qty ?? 0} onChange={e => updateItem(idx, 'qty', parseFloat(e.target.value) || 0)} className={inlineNum} />
|
||||
: <span className="block text-right text-gray-700 dark:text-gray-300 text-sm pr-1">{(it.qty ?? 0).toFixed(3)}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 w-28">
|
||||
{editable
|
||||
? <input type="number" min={0} step="0.01" value={it.rate ?? 0} onChange={e => updateItem(idx, 'rate', parseFloat(e.target.value) || 0)} className={inlineNum} />
|
||||
: <span className="block text-right text-gray-700 dark:text-gray-300 text-sm pr-1">{(it.rate ?? 0).toFixed(2)}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-right font-semibold text-gray-900 dark:text-white text-sm">
|
||||
{((it.qty ?? it.received_qty ?? 0) * (it.rate || 0)).toFixed(2)}
|
||||
</td>
|
||||
{editable && (
|
||||
<td className="py-1.5 px-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setExpandedItem(expandedItem === idx ? null : idx)}
|
||||
className={`p-1.5 rounded text-xs ${expandedItem === idx ? 'bg-blue-600 text-white' : 'text-green-600 hover:bg-green-50'}`} title="More fields">
|
||||
<FaPencilAlt size={11} />
|
||||
</button>
|
||||
<button onClick={() => removeItem(idx)} className="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"><FaTrash size={11} /></button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{editable && expandedItem === idx && (
|
||||
<PRItemRowEditor item={it} rowNo={idx + 1}
|
||||
onChange={(k, v) => { if (k === 'item_code') handleItemCode(idx, v as string); else updateItem(idx, k, v); }}
|
||||
onClose={() => setExpandedItem(null)} onDelete={() => removeItem(idx)} onInsertBelow={() => addItem(idx)} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{editable && (
|
||||
<tr><td colSpan={8} className="py-2 px-3">
|
||||
<button onClick={() => addItem()} className="flex items-center gap-1.5 text-green-600 hover:text-green-700 text-sm font-medium"><FaPlus size={10} /> Add Row</button>
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-between text-sm border-t border-gray-100 dark:border-gray-700 pt-3">
|
||||
<span className="text-gray-500">Total Qty: <strong>{(form.items || []).reduce((s, it) => s + (it.qty ?? it.received_qty ?? 0), 0).toFixed(3)}</strong></span>
|
||||
<span className="text-gray-500">Total: <strong className="text-gray-900 dark:text-white">{form.currency || DEFAULT_CURRENCY} {netTotal.toFixed(2)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Taxes ── */}
|
||||
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="px-6 py-3 bg-gray-50 dark:bg-gray-900/20 border-b border-gray-100 dark:border-gray-700">
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Taxes and Charges</span>
|
||||
</div>
|
||||
<div className="px-6 pt-4 pb-2 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
||||
<div><FL>Tax Category</FL>
|
||||
{editable ? <LinkField label="Tax Category" hideLabel doctype="Tax Category" value={(form as any).tax_category || ''} onChange={v => set('tax_category' as any, v)} placeholder="Select tax category…" /> : <RV>{(form as any).tax_category}</RV>}
|
||||
</div>
|
||||
<div><FL>Purchase Taxes and Charges Template</FL>
|
||||
{editable
|
||||
? <LinkField label="Purchase Taxes and Charges Template" hideLabel doctype="Purchase Taxes and Charges Template" value={(form as any).taxes_and_charges || ''} onChange={v => { set('taxes_and_charges' as any, v); loadTaxTemplate(v); }} placeholder="Select template…" />
|
||||
: <RV>{(form as any).taxes_and_charges}</RV>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-5">
|
||||
<div className="overflow-x-auto -mx-2 mt-3">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-8">No.</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-44">Type <span className="text-red-400">*</span></th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3">Account Head <span className="text-red-400">*</span></th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-24">Tax Rate</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Amount</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Total</th>
|
||||
{editable && <th className="w-16 py-2 px-2" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(form.taxes || []).map((tx, idx) => {
|
||||
const calcAmt = tx.charge_type === 'On Net Total' ? ((tx.rate || 0) / 100) * netTotal : (tx.charge_type === 'Actual' ? (tx.tax_amount || 0) : ((tx.rate || 0) / 100) * netTotal);
|
||||
const calcRunning = netTotal + (form.taxes || []).slice(0, idx + 1).reduce((s: number, t: any) => {
|
||||
const a = t.charge_type === 'On Net Total' ? ((t.rate || 0) / 100) * netTotal : (t.charge_type === 'Actual' ? (t.tax_amount || 0) : ((t.rate || 0) / 100) * netTotal);
|
||||
return s + a;
|
||||
}, 0);
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<tr className={`border-b border-gray-100 dark:border-gray-700 align-middle ${expandedTax === idx ? 'bg-blue-50/60 dark:bg-blue-900/10' : ''}`}>
|
||||
<td className="py-1.5 px-3 text-gray-400 text-xs">{idx + 1}</td>
|
||||
<td className="py-1.5 px-2 w-44">
|
||||
{editable
|
||||
? <select value={tx.charge_type || ''} onChange={e => updateTax(idx, 'charge_type', e.target.value)} className={inlineTxt}>
|
||||
<option value="">Select type…</option>
|
||||
<option value="Actual">Actual</option>
|
||||
<option value="On Net Total">On Net Total</option>
|
||||
<option value="On Previous Row Amount">On Previous Row Amount</option>
|
||||
<option value="On Previous Row Total">On Previous Row Total</option>
|
||||
<option value="On Item Quantity">On Item Quantity</option>
|
||||
<option value="Inter Company Transaction">Inter Company Transaction</option>
|
||||
</select>
|
||||
: <span className="text-gray-700 dark:text-gray-300">{tx.charge_type || '-'}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2">
|
||||
{editable
|
||||
? <LinkField label="Account Head" hideLabel doctype="Account" value={tx.account_head || ''} onChange={v => updateTax(idx, 'account_head', v)} placeholder="Account Head" />
|
||||
: <span className="text-gray-700 dark:text-gray-300">{tx.account_head || '-'}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 w-24">
|
||||
{editable
|
||||
? <input type="number" min={0} step="0.01" value={tx.rate ?? 0} onChange={e => updateTax(idx, 'rate', parseFloat(e.target.value) || 0)} className={inlineNum} />
|
||||
: <span className="block text-right text-gray-700 dark:text-gray-300 pr-1">{tx.rate ?? 0}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-right text-gray-700 dark:text-gray-300 text-sm">{calcAmt.toFixed(2)}</td>
|
||||
<td className="py-1.5 px-3 text-right font-semibold text-gray-900 dark:text-white text-sm">{calcRunning.toFixed(2)}</td>
|
||||
{editable && (
|
||||
<td className="py-1.5 px-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setExpandedTax(expandedTax === idx ? null : idx)}
|
||||
className={`p-1.5 rounded text-xs ${expandedTax === idx ? 'bg-blue-600 text-white' : 'text-green-600 hover:bg-green-50'}`} title="More fields">
|
||||
<FaPencilAlt size={11} />
|
||||
</button>
|
||||
<button onClick={() => removeTax(idx)} className="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"><FaTrash size={11} /></button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{editable && expandedTax === idx && (
|
||||
<PRTaxRowEditor tax={tx} rowNo={idx + 1} onChange={(k, v) => updateTax(idx, k, v)}
|
||||
onClose={() => setExpandedTax(null)} onDelete={() => removeTax(idx)} onInsertBelow={() => addTax(idx)} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);})}
|
||||
{editable && (
|
||||
<tr><td colSpan={7} className="py-2 px-3">
|
||||
<button onClick={() => addTax()} className="flex items-center gap-1.5 text-green-600 hover:text-green-700 text-sm font-medium"><FaPlus size={10} /> Add Row</button>
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{(form.taxes || []).length > 0 && (
|
||||
<div className="mt-2 flex justify-end text-sm text-gray-500 pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||
Total Taxes and Charges: <strong className="ml-2 text-gray-900 dark:text-white">{form.currency || DEFAULT_CURRENCY} {taxTotal.toFixed(2)}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Totals ── */}
|
||||
<div className="border-t border-gray-100 dark:border-gray-700 px-6 py-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Totals</h3>
|
||||
<div className="space-y-2 max-w-xs ml-auto">
|
||||
{[
|
||||
{ label: 'Net Total', value: (doc?.net_total ?? netTotal).toFixed(2) },
|
||||
{ label: 'Total Taxes', value: (doc?.total_taxes_and_charges ?? taxTotal).toFixed(2) },
|
||||
{ label: 'Grand Total', value: (doc?.grand_total ?? grandTotal).toFixed(2) },
|
||||
{ label: 'Rounded Total', value: (doc?.rounded_total ?? grandTotal).toFixed(2) },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="flex justify-between text-sm border-b border-gray-100 dark:border-gray-700 pb-1.5 last:border-0">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{form.currency || DEFAULT_CURRENCY} {value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
{!isNew && doc && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700 px-6 py-4 grid grid-cols-3 gap-4 text-sm bg-gray-50 dark:bg-gray-900/20">
|
||||
<div><FL>Created By</FL><RV>{doc.owner}</RV></div>
|
||||
<div><FL>Created</FL><RV>{doc.creation ? new Date(doc.creation).toLocaleString() : '-'}</RV></div>
|
||||
<div><FL>Modified</FL><RV>{doc.modified ? new Date(doc.modified).toLocaleString() : '-'}</RV></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNew && (
|
||||
<ActivityLog
|
||||
doctype="Purchase Receipt"
|
||||
docname={doc?.name || prName || ''}
|
||||
creationDate={doc?.creation}
|
||||
createdBy={doc?.owner}
|
||||
compact={false}
|
||||
initialVisible={5}
|
||||
collapsible
|
||||
startCollapsed
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseReceiptDetail;
|
||||
@ -1,259 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaClipboardCheck, FaPlus, FaSync, FaChevronDown, FaChevronUp, FaTimes, FaSearch, FaFileExport } from 'react-icons/fa';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import purchaseReceiptService, { PurchaseReceipt } from '../services/purchaseReceiptService';
|
||||
import DynamicExportModal from '../components/DynamicExportModal';
|
||||
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function buildPurchaseReceiptExportFilters(f: { search: string; status: string }) {
|
||||
const filters: any[] = [];
|
||||
if (f.search) filters.push(['Purchase Receipt', 'name', 'like', `%${f.search}%`]);
|
||||
if (f.status === 'Draft') filters.push(['Purchase Receipt', 'docstatus', '=', 0]);
|
||||
if (f.status === 'Submitted') filters.push(['Purchase Receipt', 'docstatus', '=', 1]);
|
||||
if (f.status === 'Cancelled') filters.push(['Purchase Receipt', 'docstatus', '=', 2]);
|
||||
return filters;
|
||||
}
|
||||
|
||||
function getStatusStyle(pr: PurchaseReceipt) {
|
||||
if (pr.docstatus === 2) return 'bg-red-100 text-red-700';
|
||||
if (pr.docstatus === 1) return 'bg-green-100 text-green-700';
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
}
|
||||
|
||||
function getStatusLabel(pr: PurchaseReceipt) {
|
||||
if (pr.docstatus === 2) return 'Cancelled';
|
||||
if (pr.docstatus === 1) return pr.status || 'Submitted';
|
||||
return 'Draft';
|
||||
}
|
||||
|
||||
const PurchaseReceiptList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [receipts, setReceipts] = useState<PurchaseReceipt[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [applied, setApplied] = useState({ search: '', status: '' });
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
const load = useCallback(async (off: number, f: typeof applied) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (f.search) filters.push(['Purchase Receipt', 'name', 'like', `%${f.search}%`]);
|
||||
if (f.status === 'Draft') filters.push(['Purchase Receipt', 'docstatus', '=', 0]);
|
||||
if (f.status === 'Submitted') filters.push(['Purchase Receipt', 'docstatus', '=', 1]);
|
||||
if (f.status === 'Cancelled') filters.push(['Purchase Receipt', 'docstatus', '=', 2]);
|
||||
const [rows, cnt] = await Promise.all([
|
||||
purchaseReceiptService.getPurchaseReceipts({ filters, limit_start: off, limit_page_length: PAGE_SIZE }),
|
||||
purchaseReceiptService.getPurchaseReceiptCount(filters),
|
||||
]);
|
||||
setReceipts(rows);
|
||||
setTotal(cnt);
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Failed to load');
|
||||
} finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(0, applied); }, [load, applied]);
|
||||
|
||||
const selectionResetKey = useMemo(
|
||||
() => `${page}|${applied.search}|${applied.status}`,
|
||||
[page, applied.search, applied.status],
|
||||
);
|
||||
const {
|
||||
selectedRows,
|
||||
toggleRow,
|
||||
toggleAllOnPage,
|
||||
allOnPageSelected,
|
||||
someOnPageSelected,
|
||||
} = useListPageSelection(receipts, selectionResetKey);
|
||||
|
||||
const apply = () => {
|
||||
const f = { search: searchQuery, status: statusFilter };
|
||||
setApplied(f); setPage(0);
|
||||
};
|
||||
const clear = () => { setSearchQuery(''); setStatusFilter(''); setApplied({ search: '', status: '' }); setPage(0); };
|
||||
const hasActive = !!(applied.search || applied.status);
|
||||
|
||||
const goPage = (p: number) => { setPage(p); load(p * PAGE_SIZE, applied); };
|
||||
|
||||
const fetchAllForExport = useCallback(
|
||||
() =>
|
||||
fetchAllRowsForExport({
|
||||
doctype: 'Purchase Receipt',
|
||||
filters: buildPurchaseReceiptExportFilters(applied),
|
||||
orderBy: 'modified desc',
|
||||
}),
|
||||
[applied],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ToastContainer position="top-right" autoClose={3000} />
|
||||
|
||||
<div className="flex items-center justify-between mb-6 gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-green-500 flex items-center justify-center">
|
||||
<FaClipboardCheck className="text-white text-base" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Purchase Receipts</h1>
|
||||
<p className="text-xs text-gray-500">{total} total</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button onClick={() => load(page * PAGE_SIZE, applied)} className="p-2 text-gray-500 hover:text-green-600 border border-gray-200 rounded-lg">
|
||||
<FaSync size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||
disabled={total === 0 && selectedRows.size === 0}
|
||||
>
|
||||
<FaFileExport /> {t('listPages.export')}
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => navigate('/purchase-receipts/new')} className="flex items-center gap-2 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm font-medium">
|
||||
<FaPlus size={11} /> New Receipt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DynamicExportModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
doctype="Purchase Receipt"
|
||||
selectedCount={selectedRows.size}
|
||||
pageCount={receipts.length}
|
||||
totalCount={total}
|
||||
pageData={receipts}
|
||||
selectedRows={selectedRows}
|
||||
rowKey="name"
|
||||
onFetchAll={fetchAllForExport}
|
||||
fileNamePrefix="purchase_receipts"
|
||||
/>
|
||||
|
||||
{/* Filter Panel */}
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl mb-5 overflow-hidden">
|
||||
<button onClick={() => setFiltersOpen(o => !o)} className="w-full flex items-center justify-between px-4 py-3 bg-gradient-to-r from-green-500 to-green-600 text-white">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<FaSearch size={12} /> Filters
|
||||
{hasActive && <span className="bg-white/30 text-white text-xs px-2 py-0.5 rounded-full">Active</span>}
|
||||
</div>
|
||||
{filtersOpen ? <FaChevronUp size={11} /> : <FaChevronDown size={11} />}
|
||||
</button>
|
||||
|
||||
{hasActive && (
|
||||
<div className="px-4 py-2 bg-green-50 dark:bg-green-900/20 flex flex-wrap gap-2 items-center border-b border-green-100 dark:border-green-800">
|
||||
{applied.search && <span className="flex items-center gap-1 text-xs bg-green-100 dark:bg-green-800 text-green-700 dark:text-green-300 px-2 py-1 rounded-full">ID: {applied.search}<button onClick={() => { setSearchQuery(''); setApplied(a => ({ ...a, search: '' })); }}><FaTimes size={9} /></button></span>}
|
||||
{applied.status && <span className="flex items-center gap-1 text-xs bg-green-100 dark:bg-green-800 text-green-700 dark:text-green-300 px-2 py-1 rounded-full">Status: {applied.status}<button onClick={() => { setStatusFilter(''); setApplied(a => ({ ...a, status: '' })); }}><FaTimes size={9} /></button></span>}
|
||||
<button onClick={clear} className="text-xs text-green-600 hover:underline ml-auto">Clear All</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtersOpen && (
|
||||
<div className="px-4 py-3 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Receipt ID</label>
|
||||
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && apply()} placeholder="Search…" className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Status</label>
|
||||
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-green-400">
|
||||
<option value="">All</option>
|
||||
<option value="Draft">Draft</option>
|
||||
<option value="Submitted">Submitted</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<button onClick={apply} className="px-4 py-2 bg-green-500 text-white text-sm rounded hover:bg-green-600">Apply</button>
|
||||
<button onClick={clear} className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded hover:bg-gray-50">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||
<th className="w-10 px-2 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-green-600 focus:ring-green-500"
|
||||
checked={allOnPageSelected}
|
||||
ref={el => {
|
||||
if (el) el.indeterminate = someOnPageSelected;
|
||||
}}
|
||||
onChange={toggleAllOnPage}
|
||||
aria-label="Select all on page"
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">PR ID</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Supplier</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Posting Date</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Status</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Grand Total</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Company</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} className="text-center py-10 text-gray-400">Loading…</td></tr>
|
||||
) : receipts.length === 0 ? (
|
||||
<tr><td colSpan={7} className="text-center py-10 text-gray-400">No purchase receipts found</td></tr>
|
||||
) : receipts.map(pr => (
|
||||
<tr key={pr.name} onClick={() => navigate(`/purchase-receipts/${pr.name}`)} className={`cursor-pointer hover:bg-green-50 dark:hover:bg-green-900/10 transition-colors ${selectedRows.has(pr.name) ? 'bg-green-50/90 dark:bg-green-900/20' : ''}`}>
|
||||
<td className="w-10 px-2 py-3" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-green-600 focus:ring-green-500"
|
||||
checked={selectedRows.has(pr.name)}
|
||||
onChange={() => toggleRow(pr.name)}
|
||||
aria-label={`Select ${pr.name}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-medium text-green-600">{pr.name}</td>
|
||||
<td className="py-3 px-4 text-gray-700 dark:text-gray-300">{pr.supplier_name || pr.supplier || '-'}</td>
|
||||
<td className="py-3 px-4 text-gray-500">{pr.posting_date || '-'}</td>
|
||||
<td className="py-3 px-4"><span className={`px-2 py-0.5 rounded text-xs font-semibold ${getStatusStyle(pr)}`}>{getStatusLabel(pr)}</span></td>
|
||||
<td className="py-3 px-4 text-right font-semibold text-gray-900 dark:text-white">SAR {(pr.grand_total ?? 0).toFixed(2)}</td>
|
||||
<td className="py-3 px-4 text-gray-500">{pr.company || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-xs text-gray-500">{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}</span>
|
||||
<div className="flex gap-2">
|
||||
<button disabled={page === 0} onClick={() => goPage(page - 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Prev</button>
|
||||
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => goPage(page + 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseReceiptList;
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,367 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaFileInvoiceDollar, FaPlus, FaSearch, FaFilter, FaSync, FaEye, FaChevronDown, FaChevronUp, FaTimes, FaFileExport, FaEdit, FaCopy, FaCheckSquare, FaSquare } from 'react-icons/fa';
|
||||
import salesInvoiceService, { SalesInvoice } from '../services/salesInvoiceService';
|
||||
import ListPagination from '../components/ListPagination';
|
||||
import DynamicExportModal from '../components/DynamicExportModal';
|
||||
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||
|
||||
const getStatusStyle = (s: string, docstatus?: number) => {
|
||||
if (docstatus === 2) return 'bg-red-100 text-red-700';
|
||||
if (docstatus === 1 || s === 'Paid') return 'bg-green-100 text-green-800';
|
||||
if (s === 'Overdue') return 'bg-orange-100 text-orange-800';
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
};
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
const SalesInvoiceList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const setSearchParamsRef = useRef(setSearchParams);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParamsRef.current = setSearchParams;
|
||||
}, [setSearchParams]);
|
||||
|
||||
const currentPage = useMemo(() => {
|
||||
const p = parseInt(searchParams.get('page') || '1', 10);
|
||||
return Number.isNaN(p) || p < 1 ? 1 : p;
|
||||
}, [searchParams]);
|
||||
|
||||
const setCurrentPage = useCallback((v: number | ((p: number) => number)) => {
|
||||
const next = typeof v === 'function' ? v(currentPage) : v;
|
||||
setSearchParams(prev => { const n = new URLSearchParams(prev); n.set('page', String(next)); return n; });
|
||||
}, [currentPage, setSearchParams]);
|
||||
|
||||
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || '');
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('q') || '');
|
||||
const [sortBy, setSortBy] = useState(searchParams.get('sort_by') || 'creation desc');
|
||||
|
||||
const [invoices, setInvoices] = useState<SalesInvoice[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const didInitUrlSync = useRef(false);
|
||||
const skipInitialSearchUrlSync = useRef(true);
|
||||
const searchDebounceRef = useRef<number | null>(null);
|
||||
const fetchSeqRef = useRef(0);
|
||||
|
||||
const apiFilters = useMemo(() => {
|
||||
const f: Record<string, any> = {};
|
||||
if (statusFilter) f.status = statusFilter;
|
||||
if (searchQuery) f.name = ['like', `%${searchQuery}%`];
|
||||
return f;
|
||||
}, [statusFilter, searchQuery]);
|
||||
|
||||
const fetchInvoices = useCallback(async () => {
|
||||
const reqId = ++fetchSeqRef.current;
|
||||
try {
|
||||
setLoading(true); setError(null);
|
||||
const [res, count] = await Promise.all([
|
||||
salesInvoiceService.getSalesInvoices({
|
||||
filters: apiFilters,
|
||||
limit_start: (currentPage - 1) * pageSize,
|
||||
limit_page_length: pageSize,
|
||||
order_by: sortBy,
|
||||
}),
|
||||
salesInvoiceService.getSalesInvoiceCount(apiFilters),
|
||||
]);
|
||||
if (reqId !== fetchSeqRef.current) return;
|
||||
setInvoices(res.data);
|
||||
setTotalCount(count);
|
||||
} catch (err) {
|
||||
if (reqId !== fetchSeqRef.current) return;
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch invoices');
|
||||
} finally {
|
||||
if (reqId === fetchSeqRef.current) setLoading(false);
|
||||
}
|
||||
}, [apiFilters, currentPage, sortBy]);
|
||||
|
||||
useEffect(() => { fetchInvoices(); }, [fetchInvoices]);
|
||||
|
||||
const selectionResetKey = useMemo(
|
||||
() => `${currentPage}|${sortBy}|${JSON.stringify(apiFilters)}`,
|
||||
[currentPage, sortBy, apiFilters],
|
||||
);
|
||||
const {
|
||||
selectedRows,
|
||||
toggleRow,
|
||||
toggleAllOnPage,
|
||||
allOnPageSelected,
|
||||
someOnPageSelected,
|
||||
} = useListPageSelection(invoices, selectionResetKey);
|
||||
|
||||
const fetchAllForExport = useCallback(
|
||||
() => fetchAllRowsForExport({ doctype: 'Sales Invoice', filters: apiFilters, orderBy: sortBy }),
|
||||
[apiFilters, sortBy],
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
|
||||
const clearFilters = () => {
|
||||
setStatusFilter(''); setSearchQuery(''); setSortBy('creation desc');
|
||||
setSearchParams(prev => {
|
||||
const n = new URLSearchParams(prev);
|
||||
['status','q','sort_by'].forEach(k => n.delete(k));
|
||||
n.set('page', '1'); return n;
|
||||
});
|
||||
};
|
||||
|
||||
// Auto-apply filters (no Apply button)
|
||||
useEffect(() => {
|
||||
if (!didInitUrlSync.current) {
|
||||
didInitUrlSync.current = true;
|
||||
return;
|
||||
}
|
||||
setSearchParamsRef.current(prev => {
|
||||
const n = new URLSearchParams(prev);
|
||||
statusFilter ? n.set('status', statusFilter) : n.delete('status');
|
||||
sortBy !== 'creation desc' ? n.set('sort_by', sortBy) : n.delete('sort_by');
|
||||
n.set('page', '1');
|
||||
return n;
|
||||
});
|
||||
}, [statusFilter, sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didInitUrlSync.current) return;
|
||||
if (skipInitialSearchUrlSync.current) {
|
||||
skipInitialSearchUrlSync.current = false;
|
||||
return;
|
||||
}
|
||||
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||
searchDebounceRef.current = window.setTimeout(() => {
|
||||
setSearchParamsRef.current(prev => {
|
||||
const n = new URLSearchParams(prev);
|
||||
searchQuery ? n.set('q', searchQuery) : n.delete('q');
|
||||
n.set('page', '1');
|
||||
return n;
|
||||
});
|
||||
}, 450);
|
||||
return () => {
|
||||
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
const hasActiveFilters = !!(statusFilter || searchQuery);
|
||||
const handleView = (name: string) => navigate(`/invoices/${encodeURIComponent(name)}`);
|
||||
const handleEdit = (name: string) => navigate(`/invoices/${encodeURIComponent(name)}?edit=1`);
|
||||
const handleDuplicate = (name: string) => navigate(`/invoices/new?duplicate=${encodeURIComponent(name)}`);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate('/projects')} className="text-sm text-gray-500 hover:text-indigo-600">Project Management</button>
|
||||
<span className="text-gray-400">/</span>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<FaFileInvoiceDollar className="text-indigo-500" /> Sales Invoices
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all disabled:opacity-50"
|
||||
disabled={totalCount === 0 && selectedRows.size === 0}
|
||||
>
|
||||
<FaFileExport /> <span className="font-medium">{t('listPages.export')}</span>
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => navigate('/invoices/new')} className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700">
|
||||
<FaPlus /> New Invoice
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DynamicExportModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
doctype="Sales Invoice"
|
||||
selectedCount={selectedRows.size}
|
||||
pageCount={invoices.length}
|
||||
totalCount={totalCount}
|
||||
pageData={invoices}
|
||||
selectedRows={selectedRows}
|
||||
rowKey="name"
|
||||
onFetchAll={fetchAllForExport}
|
||||
fileNamePrefix="sales_invoices"
|
||||
/>
|
||||
|
||||
{/* Filter Panel */}
|
||||
<div className="isolate bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-6">
|
||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 px-4 py-2.5 rounded-t-lg">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<button onClick={() => setIsFilterExpanded(v => !v)} className="text-white hover:bg-white/20 p-1.5 rounded-lg">
|
||||
{isFilterExpanded ? <FaChevronUp size={12} /> : <FaChevronDown size={12} />}
|
||||
</button>
|
||||
<FaFilter className="text-white" size={13} />
|
||||
<span className="text-white font-semibold text-sm">Filters</span>
|
||||
{hasActiveFilters && (
|
||||
<span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">
|
||||
{[statusFilter, searchQuery].filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<div className="flex-1 overflow-x-auto mx-2">
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
{searchQuery && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||
<span className="font-semibold">ID:</span> {searchQuery}
|
||||
<button onClick={() => setSearchQuery('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||
</span>
|
||||
)}
|
||||
{statusFilter && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||
<span className="font-semibold">Status:</span> {statusFilter}
|
||||
<button onClick={() => setStatusFilter('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{hasActiveFilters && <button onClick={clearFilters} className="text-white/80 hover:text-white text-xs underline">Clear all</button>}
|
||||
<button onClick={fetchInvoices} className="text-white hover:bg-white/20 p-1.5 rounded-lg" title="Refresh">
|
||||
<FaSync size={12} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isFilterExpanded && (
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 uppercase tracking-wide mb-1">Invoice ID</label>
|
||||
<div className="relative">
|
||||
<FaSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
||||
<input type="text" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && e.preventDefault()} placeholder="Search by ID…"
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-400 focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 uppercase tracking-wide mb-1">Status</label>
|
||||
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-400 focus:outline-none">
|
||||
<option value="">All Status</option>
|
||||
<option value="Draft">Draft</option>
|
||||
<option value="Submitted">Submitted</option>
|
||||
<option value="Paid">Paid</option>
|
||||
<option value="Unpaid">Unpaid</option>
|
||||
<option value="Overdue">Overdue</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 uppercase tracking-wide mb-1">Sort By</label>
|
||||
<select value={sortBy} onChange={e => setSortBy(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-400 focus:outline-none">
|
||||
<option value="creation desc">Created (newest)</option>
|
||||
<option value="creation asc">Created (oldest)</option>
|
||||
<option value="posting_date desc">Date (newest)</option>
|
||||
<option value="grand_total desc">Amount (highest)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-12 text-center text-gray-500">Loading…</div>
|
||||
) : invoices.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<FaFileInvoiceDollar className="text-4xl text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500">No sales invoices yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<tr>
|
||||
<th className="w-10 px-4 py-3 text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllOnPage}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
title={allOnPageSelected ? 'Deselect all' : 'Select all'}
|
||||
aria-label="Select all on page"
|
||||
>
|
||||
{allOnPageSelected
|
||||
? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} />
|
||||
: someOnPageSelected
|
||||
? (
|
||||
<div className="relative inline-block">
|
||||
<FaSquare size={18} />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-2 h-0.5 bg-current" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: <FaSquare size={18} />}
|
||||
</button>
|
||||
</th>
|
||||
{['Invoice ID', 'Customer', 'Status', 'Date', 'Grand Total', 'Outstanding', ''].map(h => (
|
||||
<th key={h} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{invoices.map((inv) => (
|
||||
<tr key={inv.name} className={`hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer ${selectedRows.has(inv.name) ? 'bg-indigo-50 dark:bg-indigo-900/20' : ''}`} onClick={() => handleView(inv.name)}>
|
||||
<td className="w-10 px-4 py-3" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleRow(inv.name)}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
aria-label={`Select ${inv.name}`}
|
||||
>
|
||||
{selectedRows.has(inv.name)
|
||||
? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} />
|
||||
: <FaSquare size={18} />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 font-medium text-gray-900 dark:text-white text-sm">{inv.name}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">{inv.customer_name || inv.customer || '-'}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getStatusStyle(inv.status || '', inv.docstatus)}`}>
|
||||
{inv.status || 'Draft'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{inv.posting_date || '-'}</td>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">{inv.currency || ''} {(inv.grand_total ?? 0).toFixed(2)}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{(inv.outstanding_amount ?? 0).toFixed(2)}</td>
|
||||
<td className="px-6 py-3" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => handleView(inv.name)} className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300 p-2 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded transition-colors" title="View"><FaEye /></button>
|
||||
<button onClick={() => handleEdit(inv.name)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title="Edit"><FaEdit /></button>
|
||||
<button onClick={() => handleDuplicate(inv.name)} className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors" title="Duplicate"><FaCopy /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{totalPages > 1 && (
|
||||
<div className="border-t border-gray-200 px-4 py-3">
|
||||
<ListPagination currentPage={currentPage} totalPages={totalPages} totalCount={totalCount} pageSize={pageSize} onPageChange={setCurrentPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesInvoiceList;
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,339 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaShoppingCart, FaPlus, FaSync, FaChevronDown, FaChevronUp, FaTimes, FaSearch, FaFileExport, FaEye, FaEdit, FaCopy, FaCheckSquare, FaSquare } from 'react-icons/fa';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import salesOrderService, { SalesOrder } from '../services/salesOrderService';
|
||||
import DynamicExportModal from '../components/DynamicExportModal';
|
||||
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function buildSalesOrderExportFilters(f: { search: string; status: string; project: string }) {
|
||||
const filters: any[] = [];
|
||||
if (f.search) filters.push(['Sales Order', 'name', 'like', `%${f.search}%`]);
|
||||
if (f.project) filters.push(['Sales Order', 'project', '=', f.project]);
|
||||
if (f.status === 'Draft') filters.push(['Sales Order', 'docstatus', '=', 0]);
|
||||
if (f.status === 'Submitted') filters.push(['Sales Order', 'docstatus', '=', 1]);
|
||||
if (f.status === 'Cancelled') filters.push(['Sales Order', 'docstatus', '=', 2]);
|
||||
return filters;
|
||||
}
|
||||
|
||||
function getStatusStyle(so: SalesOrder) {
|
||||
if (so.docstatus === 2) return 'bg-red-100 text-red-700';
|
||||
if (so.docstatus === 1) {
|
||||
if (so.billing_status === 'Fully Billed') return 'bg-green-100 text-green-700';
|
||||
if (so.delivery_status === 'Fully Delivered') return 'bg-blue-100 text-blue-700';
|
||||
return 'bg-blue-100 text-blue-700';
|
||||
}
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
}
|
||||
|
||||
function getStatusLabel(so: SalesOrder) {
|
||||
if (so.docstatus === 2) return 'Cancelled';
|
||||
if (so.docstatus === 1) return so.status || 'Submitted';
|
||||
return 'Draft';
|
||||
}
|
||||
|
||||
const SalesOrderList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [orders, setOrders] = useState<SalesOrder[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const initialProject = searchParams.get('project')?.trim() || '';
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [projectFilter, setProjectFilter] = useState(initialProject);
|
||||
const [applied, setApplied] = useState({ search: '', status: '', project: initialProject });
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const didInitUrlSync = useRef(false);
|
||||
const searchDebounceRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const p = searchParams.get('project')?.trim() || '';
|
||||
setProjectFilter(p);
|
||||
setApplied(a => {
|
||||
if (a.project === p) return a;
|
||||
setPage(0);
|
||||
return { ...a, project: p };
|
||||
});
|
||||
}, [searchParams]);
|
||||
|
||||
const load = useCallback(async (off: number, f: typeof applied) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (f.search) filters.push(['Sales Order', 'name', 'like', `%${f.search}%`]);
|
||||
if (f.project) filters.push(['Sales Order', 'project', '=', f.project]);
|
||||
if (f.status === 'Draft') filters.push(['Sales Order', 'docstatus', '=', 0]);
|
||||
if (f.status === 'Submitted') filters.push(['Sales Order', 'docstatus', '=', 1]);
|
||||
if (f.status === 'Cancelled') filters.push(['Sales Order', 'docstatus', '=', 2]);
|
||||
const [rows, cnt] = await Promise.all([
|
||||
salesOrderService.getSalesOrders({ filters, limit_start: off, limit_page_length: PAGE_SIZE }),
|
||||
salesOrderService.getSalesOrderCount(filters),
|
||||
]);
|
||||
setOrders(rows);
|
||||
setTotal(cnt);
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Failed to load');
|
||||
} finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(0, applied); }, [load, applied]);
|
||||
|
||||
const selectionResetKey = useMemo(
|
||||
() => `${page}|${applied.search}|${applied.status}|${applied.project}`,
|
||||
[page, applied.search, applied.status, applied.project],
|
||||
);
|
||||
const {
|
||||
selectedRows,
|
||||
toggleRow,
|
||||
toggleAllOnPage,
|
||||
allOnPageSelected,
|
||||
someOnPageSelected,
|
||||
} = useListPageSelection(orders, selectionResetKey);
|
||||
|
||||
// Auto-apply filters (matches other lists; no explicit Apply button)
|
||||
useEffect(() => {
|
||||
if (!didInitUrlSync.current) {
|
||||
didInitUrlSync.current = true;
|
||||
return;
|
||||
}
|
||||
const nextApplied = { search: applied.search, status: statusFilter, project: projectFilter.trim() };
|
||||
setApplied(nextApplied);
|
||||
setPage(0);
|
||||
setSearchParams(prev => {
|
||||
const n = new URLSearchParams(prev);
|
||||
if (nextApplied.project) n.set('project', nextApplied.project); else n.delete('project');
|
||||
return n;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [statusFilter, projectFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didInitUrlSync.current) return;
|
||||
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||
searchDebounceRef.current = window.setTimeout(() => {
|
||||
const nextApplied = { search: searchQuery, status: statusFilter, project: projectFilter.trim() };
|
||||
setApplied(nextApplied);
|
||||
setPage(0);
|
||||
}, 450);
|
||||
return () => {
|
||||
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||
};
|
||||
}, [searchQuery, statusFilter, projectFilter]);
|
||||
const clear = () => {
|
||||
setSearchQuery(''); setStatusFilter(''); setProjectFilter('');
|
||||
setApplied({ search: '', status: '', project: '' }); setPage(0);
|
||||
setSearchParams(prev => { const n = new URLSearchParams(prev); n.delete('project'); return n; });
|
||||
};
|
||||
const hasActive = !!(applied.search || applied.status || applied.project);
|
||||
|
||||
const goPage = (p: number) => { setPage(p); load(p * PAGE_SIZE, applied); };
|
||||
const handleView = (name: string) => navigate(`/sales-orders/${encodeURIComponent(name)}`);
|
||||
const handleEdit = (name: string) => navigate(`/sales-orders/${encodeURIComponent(name)}?edit=1`);
|
||||
const handleDuplicate = (name: string) => navigate(`/sales-orders/new?duplicate=${encodeURIComponent(name)}`);
|
||||
|
||||
const fetchAllForExport = useCallback(
|
||||
() =>
|
||||
fetchAllRowsForExport({
|
||||
doctype: 'Sales Order',
|
||||
filters: buildSalesOrderExportFilters(applied),
|
||||
orderBy: 'modified desc',
|
||||
}),
|
||||
[applied],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ToastContainer position="top-right" autoClose={3000} />
|
||||
|
||||
<div className="flex items-center justify-between mb-6 gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center">
|
||||
<FaShoppingCart className="text-white text-base" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Sales Orders</h1>
|
||||
<p className="text-xs text-gray-500">{total} total</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button onClick={() => load(page * PAGE_SIZE, applied)} className="p-2 text-gray-500 hover:text-blue-600 border border-gray-200 rounded-lg">
|
||||
<FaSync size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||
disabled={total === 0 && selectedRows.size === 0}
|
||||
>
|
||||
<FaFileExport /> {t('listPages.export')}
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const qs = applied.project ? `?project=${encodeURIComponent(applied.project)}` : '';
|
||||
navigate(`/sales-orders/new${qs}`);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium"
|
||||
>
|
||||
<FaPlus size={11} /> New Order
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DynamicExportModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
doctype="Sales Order"
|
||||
selectedCount={selectedRows.size}
|
||||
pageCount={orders.length}
|
||||
totalCount={total}
|
||||
pageData={orders}
|
||||
selectedRows={selectedRows}
|
||||
rowKey="name"
|
||||
onFetchAll={fetchAllForExport}
|
||||
fileNamePrefix="sales_orders"
|
||||
/>
|
||||
|
||||
{/* Filter Panel */}
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl mb-5 overflow-hidden">
|
||||
<button onClick={() => setFiltersOpen(o => !o)} className="w-full flex items-center justify-between px-4 py-3 bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 text-white">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<FaSearch size={12} /> Filters
|
||||
{hasActive && <span className="bg-white/30 text-white text-xs px-2 py-0.5 rounded-full">Active</span>}
|
||||
</div>
|
||||
{filtersOpen ? <FaChevronUp size={11} /> : <FaChevronDown size={11} />}
|
||||
</button>
|
||||
|
||||
{hasActive && (
|
||||
<div className="px-4 py-2 bg-blue-50 dark:bg-blue-900/20 flex flex-wrap gap-2 items-center border-b border-blue-100 dark:border-blue-800">
|
||||
{applied.search && <span className="flex items-center gap-1 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300 px-2 py-1 rounded-full">ID: {applied.search}<button type="button" onClick={() => { setSearchQuery(''); setApplied(a => ({ ...a, search: '' })); }}><FaTimes size={9} /></button></span>}
|
||||
{applied.project && <span className="flex items-center gap-1 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300 px-2 py-1 rounded-full">Project: {applied.project}<button type="button" onClick={() => { setProjectFilter(''); setApplied(a => ({ ...a, project: '' })); setSearchParams(prev => { const n = new URLSearchParams(prev); n.delete('project'); return n; }); }}><FaTimes size={9} /></button></span>}
|
||||
{applied.status && <span className="flex items-center gap-1 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300 px-2 py-1 rounded-full">Status: {applied.status}<button type="button" onClick={() => { setStatusFilter(''); setApplied(a => ({ ...a, status: '' })); }}><FaTimes size={9} /></button></span>}
|
||||
<button type="button" onClick={clear} className="text-xs text-blue-600 hover:underline ml-auto">Clear All</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtersOpen && (
|
||||
<div className="px-4 py-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Order ID</label>
|
||||
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && e.preventDefault()} placeholder="Search…" className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Project</label>
|
||||
<input value={projectFilter} onChange={e => setProjectFilter(e.target.value)} onKeyDown={e => e.key === 'Enter' && e.preventDefault()} placeholder="Project name…" className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Status</label>
|
||||
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-400">
|
||||
<option value="">All</option>
|
||||
<option value="Draft">Draft</option>
|
||||
<option value="Submitted">Submitted</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||
<th className="w-10 px-4 py-3 text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllOnPage}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
title={allOnPageSelected ? 'Deselect all' : 'Select all'}
|
||||
aria-label="Select all on page"
|
||||
>
|
||||
{allOnPageSelected
|
||||
? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} />
|
||||
: someOnPageSelected
|
||||
? (
|
||||
<div className="relative inline-block">
|
||||
<FaSquare size={18} />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-2 h-0.5 bg-current" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: <FaSquare size={18} />}
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Order ID</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Customer</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Date</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Status</th>
|
||||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Grand Total</th>
|
||||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4 w-28"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} className="text-center py-10 text-gray-400">Loading…</td></tr>
|
||||
) : orders.length === 0 ? (
|
||||
<tr><td colSpan={7} className="text-center py-10 text-gray-400">No sales orders found</td></tr>
|
||||
) : orders.map(so => (
|
||||
<tr key={so.name} onClick={() => handleView(so.name)} className={`cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${selectedRows.has(so.name) ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`}>
|
||||
<td className="w-10 px-4 py-3" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleRow(so.name)}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
aria-label={`Select ${so.name}`}
|
||||
>
|
||||
{selectedRows.has(so.name)
|
||||
? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} />
|
||||
: <FaSquare size={18} />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-medium text-gray-900 dark:text-white">{so.name}</td>
|
||||
<td className="py-3 px-4 text-gray-700 dark:text-gray-300">{so.customer_name || so.customer || '-'}</td>
|
||||
<td className="py-3 px-4 text-gray-500">{so.transaction_date || '-'}</td>
|
||||
<td className="py-3 px-4"><span className={`px-2 py-0.5 rounded text-xs font-semibold ${getStatusStyle(so)}`}>{getStatusLabel(so)}</span></td>
|
||||
<td className="py-3 px-4 text-right font-semibold text-gray-900 dark:text-white">{so.currency || 'SAR'} {(so.grand_total ?? 0).toFixed(2)}</td>
|
||||
<td className="py-2 px-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => handleView(so.name)} className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors" title="View"><FaEye /></button>
|
||||
<button onClick={() => handleEdit(so.name)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title="Edit"><FaEdit /></button>
|
||||
<button onClick={() => handleDuplicate(so.name)} className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors" title="Duplicate"><FaCopy /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-xs text-gray-500">{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}</span>
|
||||
<div className="flex gap-2">
|
||||
<button disabled={page === 0} onClick={() => goPage(page - 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Prev</button>
|
||||
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => goPage(page + 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesOrderList;
|
||||
@ -1,394 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSfdaAssetMatches, useSfdaEntryDetails } from '../hooks/useSfdaEntries';
|
||||
import API_CONFIG from '../config/api';
|
||||
import {
|
||||
FaArrowLeft,
|
||||
FaFileMedical,
|
||||
FaFilePdf,
|
||||
FaExternalLinkAlt,
|
||||
FaCheckCircle,
|
||||
FaTimesCircle,
|
||||
FaTable,
|
||||
FaInfoCircle,
|
||||
FaUser,
|
||||
} from 'react-icons/fa';
|
||||
import type { SfdaDeviceEntry } from '../services/sfdaEntriesService';
|
||||
|
||||
type DeviceDataColumnKey = Exclude<
|
||||
keyof SfdaDeviceEntry,
|
||||
'name' | 'detail_pdf' | 'ade_detail_url' | 'idx'
|
||||
>;
|
||||
|
||||
const DEVICE_COLUMNS_BEFORE_ACTIONS: Array<{ key: DeviceDataColumnKey; labelKey: string }> = [
|
||||
{ key: 'ncmdr_ref', labelKey: 'sfdaEntries.columns.ncmdrRef' },
|
||||
{ key: 'manufacturer', labelKey: 'sfdaEntries.columns.manufacturer' },
|
||||
{ key: 'material', labelKey: 'sfdaEntries.deviceFields.material' },
|
||||
{ key: 'serial_no', labelKey: 'sfdaEntries.deviceFields.serialNo' },
|
||||
{ key: 'serial_no_matching', labelKey: 'sfdaEntries.deviceFields.matching' },
|
||||
];
|
||||
|
||||
const DEVICE_COLUMNS_AFTER_ACTIONS: Array<{ key: DeviceDataColumnKey; labelKey: string }> = [
|
||||
{ key: 'catalog_number', labelKey: 'sfdaEntries.deviceFields.catalogNumber' },
|
||||
{ key: 'udi', labelKey: 'sfdaEntries.deviceFields.udi' },
|
||||
{ key: 'gstn', labelKey: 'sfdaEntries.deviceFields.gtin' },
|
||||
{ key: 'material_description', labelKey: 'sfdaEntries.deviceFields.materialDescription' },
|
||||
{ key: 'batch', labelKey: 'sfdaEntries.deviceFields.batch' },
|
||||
];
|
||||
|
||||
const DEVICE_TABLE_COLUMN_COUNT =
|
||||
1 + DEVICE_COLUMNS_BEFORE_ACTIONS.length + 2 + DEVICE_COLUMNS_AFTER_ACTIONS.length;
|
||||
|
||||
function buildPdfUrl(detailPdf?: string): string | null {
|
||||
if (!detailPdf) return null;
|
||||
const baseUrl = API_CONFIG.BASE_URL || '';
|
||||
return detailPdf.startsWith('http') ? detailPdf : `${baseUrl}${detailPdf}`;
|
||||
}
|
||||
|
||||
function isSerialNoMatchingYes(value?: string): boolean {
|
||||
return (value || '').trim().toLowerCase() === 'yes';
|
||||
}
|
||||
|
||||
function formatMatchingValue(value?: string): string {
|
||||
const trimmed = (value || '').trim();
|
||||
return trimmed || '-';
|
||||
}
|
||||
|
||||
interface MatchedValueLinkProps {
|
||||
value?: string;
|
||||
isMatch: boolean;
|
||||
onNavigate: (path: string) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const MatchedValueLink: React.FC<MatchedValueLinkProps> = ({ value, isMatch, onNavigate, title }) => {
|
||||
const trimmed = (value || '').trim();
|
||||
if (!trimmed) {
|
||||
return <>-</>;
|
||||
}
|
||||
if (!isMatch) {
|
||||
return <span className="text-xs line-clamp-3 break-words">{trimmed}</span>;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={() => onNavigate(`/assets?serial_number=${encodeURIComponent(trimmed)}`)}
|
||||
className="text-red-600 dark:text-red-400 hover:underline font-medium text-left text-xs line-clamp-3 break-words"
|
||||
>
|
||||
{trimmed}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const SfdaEntriesDetail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { entryName } = useParams<{ entryName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { entry, loading, error } = useSfdaEntryDetails(entryName || null);
|
||||
|
||||
const formatDate = (dateStr?: string) =>
|
||||
dateStr
|
||||
? new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
: '-';
|
||||
|
||||
const formatDateTime = (dateStr?: string) =>
|
||||
dateStr
|
||||
? new Date(dateStr).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: '-';
|
||||
|
||||
const formatUsername = (email?: string) => {
|
||||
if (!email) return '-';
|
||||
const atIndex = email.indexOf('@');
|
||||
return atIndex === -1 ? email : email.substring(0, atIndex);
|
||||
};
|
||||
|
||||
const isPassed = entry?.passed === 1 || entry?.passed === true;
|
||||
const deviceList = entry?.device_list || [];
|
||||
const { sortedDeviceList, matchedSerialNumbers, matchesLoading } = useSfdaAssetMatches(deviceList);
|
||||
|
||||
const uniqueAlerts = useMemo(() => {
|
||||
const refs = new Set<string>();
|
||||
deviceList.forEach((row) => {
|
||||
if (row.ncmdr_ref) refs.add(row.ncmdr_ref);
|
||||
});
|
||||
return refs.size;
|
||||
}, [deviceList]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto" />
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">{t('sfdaEntries.detail.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !entry) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">{t('sfdaEntries.detail.errorLoading')}</h2>
|
||||
<p className="text-red-700 dark:text-red-400 mb-4">{error || t('sfdaEntries.detail.notFound')}</p>
|
||||
<button onClick={() => navigate('/sfda-entries')} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">
|
||||
{t('sfdaEntries.detail.backToList')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/sfda-entries')}
|
||||
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
>
|
||||
<FaArrowLeft size={20} />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white flex items-center gap-3 flex-wrap">
|
||||
<FaFileMedical className="text-blue-500" />
|
||||
{entry.title || entry.name}
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm font-medium ${
|
||||
isPassed
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 border border-green-200 dark:border-green-800'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{isPassed ? <FaCheckCircle size={12} /> : <FaTimesCircle size={12} />}
|
||||
{isPassed ? t('sfdaEntries.detail.passed') : t('sfdaEntries.detail.notPassed')}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{formatDate(entry.date)} · {uniqueAlerts} {t('sfdaEntries.detail.alerts')} · {deviceList.length}{' '}
|
||||
{t('sfdaEntries.detail.devices')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 mb-6">
|
||||
<div className="lg:col-span-3">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700 h-full">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
|
||||
<FaInfoCircle className="text-blue-500" />
|
||||
{t('sfdaEntries.detail.entryInformation')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field label={t('sfdaEntries.columns.title')} value={entry.title} highlight />
|
||||
<Field label={t('sfdaEntries.columns.date')} value={formatDate(entry.date)} />
|
||||
<Field label={t('sfdaEntries.detail.alertCount')} value={String(uniqueAlerts)} />
|
||||
<Field label={t('sfdaEntries.detail.passedDate')} value={formatDateTime(entry.passed_date)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-4 border border-gray-200 dark:border-gray-700 h-full">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-white mb-3 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
|
||||
<FaUser className="text-purple-500" size={14} />
|
||||
{t('sfdaEntries.detail.auditInfo')}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('commonFields.createdOn')}</p>
|
||||
<p className="text-xs font-medium text-gray-900 dark:text-white">{formatDateTime(entry.creation)}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('commonFields.createdBy')}</p>
|
||||
<p className="text-xs font-medium text-gray-900 dark:text-white">{formatUsername(entry.owner)}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('commonFields.modifiedOn')}</p>
|
||||
<p className="text-xs font-medium text-gray-900 dark:text-white">{formatDateTime(entry.modified)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white flex items-center gap-2">
|
||||
<FaTable className="text-orange-500" />
|
||||
{t('sfdaEntries.detail.deviceList')}
|
||||
</h2>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
||||
{sortedDeviceList.length} {t('sfdaEntries.detail.devices')}
|
||||
{matchesLoading && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
|
||||
<span className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500" />
|
||||
{t('sfdaEntries.detail.resolvingMatches')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-100 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-2 py-2 text-left text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
#
|
||||
</th>
|
||||
{DEVICE_COLUMNS_BEFORE_ACTIONS.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="px-2 py-2 text-left text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase whitespace-nowrap"
|
||||
>
|
||||
{t(col.labelKey)}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-2 py-2 text-left text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase whitespace-nowrap">
|
||||
{t('sfdaEntries.detail.adeDetailPdf')}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase whitespace-nowrap">
|
||||
{t('sfdaEntries.detail.adeDetailUrl')}
|
||||
</th>
|
||||
{DEVICE_COLUMNS_AFTER_ACTIONS.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="px-2 py-2 text-left text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase whitespace-nowrap"
|
||||
>
|
||||
{t(col.labelKey)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{sortedDeviceList.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={DEVICE_TABLE_COLUMN_COUNT}
|
||||
className="px-2 py-6 text-center text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{t('sfdaEntries.detail.noDevices')}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedDeviceList.map((row, index) => {
|
||||
const pdfUrl = buildPdfUrl(row.detail_pdf);
|
||||
const matchingYes = isSerialNoMatchingYes(row.serial_no_matching);
|
||||
const serialTrimmed = (row.serial_no || '').trim();
|
||||
const catalogTrimmed = (row.catalog_number || '').trim();
|
||||
const serialIsMatch =
|
||||
matchingYes && serialTrimmed !== '' && matchedSerialNumbers.has(serialTrimmed);
|
||||
const catalogIsMatch =
|
||||
matchingYes && catalogTrimmed !== '' && matchedSerialNumbers.has(catalogTrimmed);
|
||||
const assetLinkTitle = t('sfdaEntries.detail.openMatchingAsset');
|
||||
|
||||
const renderDataCell = (col: { key: DeviceDataColumnKey; labelKey: string }) => (
|
||||
<td key={col.key} className="px-2 py-2 text-xs text-gray-700 dark:text-gray-300 max-w-[10rem]">
|
||||
{col.key === 'serial_no_matching' ? (
|
||||
<span className="line-clamp-3 break-words">
|
||||
{formatMatchingValue(row.serial_no_matching)}
|
||||
</span>
|
||||
) : col.key === 'serial_no' ? (
|
||||
<MatchedValueLink
|
||||
value={row.serial_no}
|
||||
isMatch={serialIsMatch}
|
||||
onNavigate={navigate}
|
||||
title={assetLinkTitle}
|
||||
/>
|
||||
) : col.key === 'catalog_number' ? (
|
||||
<MatchedValueLink
|
||||
value={row.catalog_number}
|
||||
isMatch={catalogIsMatch}
|
||||
onNavigate={navigate}
|
||||
title={assetLinkTitle}
|
||||
/>
|
||||
) : (
|
||||
<span className="line-clamp-3 break-words">{row[col.key] || '-'}</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={row.name || index}
|
||||
className={
|
||||
matchingYes
|
||||
? 'bg-orange-50 dark:bg-orange-900/25 hover:bg-orange-100 dark:hover:bg-orange-900/35'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}
|
||||
>
|
||||
<td className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400">{index + 1}</td>
|
||||
{DEVICE_COLUMNS_BEFORE_ACTIONS.map(renderDataCell)}
|
||||
<td className="px-2 py-2 text-xs">
|
||||
{pdfUrl ? (
|
||||
<a
|
||||
href={pdfUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-red-600 dark:text-red-400 hover:underline inline-flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
<FaFilePdf size={10} />
|
||||
{t('sfdaEntries.detail.openPdfNewTab')}
|
||||
</a>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-xs max-w-[6rem]">
|
||||
{row.ade_detail_url ? (
|
||||
<a
|
||||
href={row.ade_detail_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline inline-flex items-center gap-1"
|
||||
title={t('sfdaEntries.detail.viewAde')}
|
||||
>
|
||||
<FaExternalLinkAlt size={9} />
|
||||
{/* {t('sfdaEntries.detail.viewAde')} */}
|
||||
</a>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
{DEVICE_COLUMNS_AFTER_ACTIONS.map(renderDataCell)}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
value?: React.ReactNode;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
const Field: React.FC<FieldProps> = ({ label, value, highlight }) => (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{label}</label>
|
||||
<div
|
||||
className={`px-3 py-2 rounded-md border text-sm ${
|
||||
highlight
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-300 font-medium'
|
||||
: 'bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white'
|
||||
}`}
|
||||
>
|
||||
{value || '-'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SfdaEntriesDetail;
|
||||
@ -1,572 +0,0 @@
|
||||
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSfdaEntryList } from '../hooks/useSfdaEntries';
|
||||
import ListPagination from '../components/ListPagination';
|
||||
import {
|
||||
FaFilter,
|
||||
FaSync,
|
||||
FaEye,
|
||||
FaTimes,
|
||||
FaSave,
|
||||
FaStar,
|
||||
FaTrash,
|
||||
FaFileMedical,
|
||||
FaCheckCircle,
|
||||
FaTimesCircle,
|
||||
} from 'react-icons/fa';
|
||||
|
||||
const SfdaEntriesList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const setSearchParamsRef = useRef(setSearchParams);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParamsRef.current = setSearchParams;
|
||||
}, [setSearchParams]);
|
||||
|
||||
const currentPage = useMemo(() => {
|
||||
const p = parseInt(searchParams.get('page') || '1', 10);
|
||||
return Number.isNaN(p) || p < 1 ? 1 : p;
|
||||
}, [searchParams]);
|
||||
|
||||
const setCurrentPage = useCallback(
|
||||
(pageOrUpdater: number | ((p: number) => number)) => {
|
||||
const next = typeof pageOrUpdater === 'function' ? pageOrUpdater(currentPage) : pageOrUpdater;
|
||||
setSearchParams((prev) => {
|
||||
const nextParams = new URLSearchParams(prev);
|
||||
nextParams.set('page', String(next));
|
||||
return nextParams;
|
||||
});
|
||||
},
|
||||
[currentPage, setSearchParams]
|
||||
);
|
||||
|
||||
const pageSize = 20;
|
||||
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||
const [isFilterExpanded, setIsFilterExpanded] = useState(true);
|
||||
const [activeFilterCount, setActiveFilterCount] = useState(0);
|
||||
const [savedFilters, setSavedFilters] = useState<Array<{ id: number; name: string; filters: Record<string, string> }>>([]);
|
||||
const [showSaveFilterModal, setShowSaveFilterModal] = useState(false);
|
||||
const [filterPresetName, setFilterPresetName] = useState('');
|
||||
|
||||
const [titleFilter, setTitleFilter] = useState(() => searchParams.get('title') || '');
|
||||
const [passedFilter, setPassedFilter] = useState(() => searchParams.get('passed') || '');
|
||||
const [dateStart, setDateStart] = useState(() => searchParams.get('date_start') || '');
|
||||
const [dateEnd, setDateEnd] = useState(() => searchParams.get('date_end') || '');
|
||||
const [sortBy, setSortBy] = useState(() => searchParams.get('sort_by') || 'modified desc');
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('sfdaEntriesFilterPresets');
|
||||
if (saved) setSavedFilters(JSON.parse(saved));
|
||||
}, []);
|
||||
|
||||
const hasDateFilter = !!(dateStart || dateEnd);
|
||||
|
||||
useEffect(() => {
|
||||
const count = [titleFilter, passedFilter].filter(Boolean).length + (hasDateFilter ? 1 : 0);
|
||||
setActiveFilterCount(count);
|
||||
}, [titleFilter, passedFilter, hasDateFilter]);
|
||||
|
||||
const apiFilters = useMemo(() => {
|
||||
const filters: Record<string, unknown> = {};
|
||||
if (titleFilter) filters.title = ['like', `%${titleFilter}%`];
|
||||
if (passedFilter === '1') filters.passed = 1;
|
||||
if (passedFilter === '0') filters.passed = 0;
|
||||
if (dateStart && dateEnd) filters.date = ['between', [dateStart, dateEnd]];
|
||||
else if (dateStart) filters.date = ['>=', dateStart];
|
||||
else if (dateEnd) filters.date = ['<=', dateEnd];
|
||||
return filters;
|
||||
}, [titleFilter, passedFilter, dateStart, dateEnd]);
|
||||
|
||||
const orderBy = ['creation desc', 'creation asc', 'modified desc', 'modified asc', 'name asc', 'name desc', 'date desc', 'date asc'].includes(sortBy)
|
||||
? sortBy
|
||||
: 'modified desc';
|
||||
|
||||
const { entries, loading, error, totalCount, refetch } = useSfdaEntryList({
|
||||
filters: apiFilters,
|
||||
limit_start: (currentPage - 1) * pageSize,
|
||||
limit_page_length: pageSize,
|
||||
order_by: orderBy,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !initialLoadComplete) setInitialLoadComplete(true);
|
||||
}, [loading, initialLoadComplete]);
|
||||
|
||||
const filtersChangedOnce = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!filtersChangedOnce.current) {
|
||||
filtersChangedOnce.current = true;
|
||||
return;
|
||||
}
|
||||
setSearchParamsRef.current((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (titleFilter) next.set('title', titleFilter);
|
||||
else next.delete('title');
|
||||
if (passedFilter) next.set('passed', passedFilter);
|
||||
else next.delete('passed');
|
||||
if (dateStart) next.set('date_start', dateStart);
|
||||
else next.delete('date_start');
|
||||
if (dateEnd) next.set('date_end', dateEnd);
|
||||
else next.delete('date_end');
|
||||
if (sortBy && sortBy !== 'modified desc') next.set('sort_by', sortBy);
|
||||
else next.delete('sort_by');
|
||||
next.set('page', '1');
|
||||
return next;
|
||||
});
|
||||
}, [titleFilter, passedFilter, dateStart, dateEnd, sortBy]);
|
||||
|
||||
const clearFilters = () => {
|
||||
setTitleFilter('');
|
||||
setPassedFilter('');
|
||||
setDateStart('');
|
||||
setDateEnd('');
|
||||
setSortBy('modified desc');
|
||||
setSearchParamsRef.current((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('title');
|
||||
next.delete('passed');
|
||||
next.delete('date_start');
|
||||
next.delete('date_end');
|
||||
next.delete('sort_by');
|
||||
next.set('page', '1');
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const hasActiveFilters = !!(titleFilter || passedFilter || hasDateFilter);
|
||||
|
||||
const handleSaveFilterPreset = () => {
|
||||
if (!filterPresetName.trim()) return;
|
||||
const preset = {
|
||||
id: Date.now(),
|
||||
name: filterPresetName,
|
||||
filters: { titleFilter, passedFilter, dateStart, dateEnd, sortBy },
|
||||
};
|
||||
const updated = [...savedFilters, preset];
|
||||
setSavedFilters(updated);
|
||||
setFilterPresetName('');
|
||||
setShowSaveFilterModal(false);
|
||||
localStorage.setItem('sfdaEntriesFilterPresets', JSON.stringify(updated));
|
||||
};
|
||||
|
||||
const handleLoadFilterPreset = (preset: { filters: Record<string, string> }) => {
|
||||
const f = preset.filters;
|
||||
setTitleFilter(f.titleFilter || '');
|
||||
setPassedFilter(f.passedFilter || '');
|
||||
setDateStart(f.dateStart || '');
|
||||
setDateEnd(f.dateEnd || '');
|
||||
setSortBy(f.sortBy || 'modified desc');
|
||||
};
|
||||
|
||||
const handleDeleteFilterPreset = (id: number) => {
|
||||
const updated = savedFilters.filter((f) => f.id !== id);
|
||||
setSavedFilters(updated);
|
||||
localStorage.setItem('sfdaEntriesFilterPresets', JSON.stringify(updated));
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) =>
|
||||
dateStr
|
||||
? new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
: '-';
|
||||
|
||||
const passedCount = entries.filter((e) => e.passed === 1 || e.passed === true).length;
|
||||
|
||||
if (loading && !initialLoadComplete) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto" />
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">{t('sfdaEntries.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">{t('sfdaEntries.errorLoading')}</h2>
|
||||
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
|
||||
<button onClick={refetch} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">
|
||||
{t('common.tryAgain')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<FaFileMedical className="text-3xl text-blue-600 dark:text-blue-400" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">{t('sfdaEntries.title')}</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('common.total')}: {totalCount}
|
||||
{loading && initialLoadComplete && (
|
||||
<span className="ml-2 inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
|
||||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500" />
|
||||
Updating...
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setIsFilterExpanded(!isFilterExpanded)}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${
|
||||
isFilterExpanded || hasActiveFilters
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<FaFilter />
|
||||
{t('listPages.filters')}
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="bg-blue-600 text-white text-xs px-1.5 py-0.5 rounded-full">{activeFilterCount}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={refetch}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<FaSync className={loading ? 'animate-spin' : ''} />
|
||||
{t('listPages.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('common.total')}</p>
|
||||
<p className="text-2xl font-bold text-gray-800 dark:text-white">{totalCount}</p>
|
||||
</div>
|
||||
<FaFileMedical className="text-3xl text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('sfdaEntries.stats.passed')}</p>
|
||||
<p className="text-2xl font-bold text-green-600">{passedCount}</p>
|
||||
</div>
|
||||
<FaCheckCircle className="text-3xl text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('sfdaEntries.stats.notPassed')}</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{entries.length - passedCount}</p>
|
||||
</div>
|
||||
<FaTimesCircle className="text-3xl text-orange-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFilterExpanded && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-4">
|
||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 px-4 py-3 rounded-t-lg">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<FaFilter className="text-white" size={16} />
|
||||
<h3 className="text-white font-semibold text-sm">{t('listPages.filters')}</h3>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">{activeFilterCount}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{activeFilterCount > 0 && (
|
||||
<button
|
||||
onClick={() => setShowSaveFilterModal(true)}
|
||||
className="px-3 py-1.5 bg-white text-blue-600 hover:bg-blue-50 rounded-md text-xs font-medium transition-all flex items-center gap-1.5"
|
||||
>
|
||||
<FaSave size={12} />
|
||||
<span className="hidden sm:inline">{t('common.save')}</span>
|
||||
</button>
|
||||
)}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded-md text-xs font-medium transition-all flex items-center gap-1.5"
|
||||
>
|
||||
<FaTimes size={12} />
|
||||
<span className="hidden sm:inline">{t('common.clearFilters')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{savedFilters.length > 0 && (
|
||||
<div className="mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
|
||||
<FaStar className="text-yellow-500" size={12} />
|
||||
{t('sfdaEntries.savedFilters')}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{savedFilters.map((preset) => (
|
||||
<div
|
||||
key={preset.id}
|
||||
className="group relative inline-flex items-center gap-2 px-3 py-1.5 bg-gradient-to-r from-blue-100 to-indigo-100 dark:from-blue-900/30 dark:to-indigo-900/30 border border-blue-200 dark:border-blue-700 rounded-lg hover:shadow-md transition-all"
|
||||
>
|
||||
<button
|
||||
onClick={() => handleLoadFilterPreset(preset)}
|
||||
className="text-xs font-medium text-blue-700 dark:text-blue-300"
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteFilterPreset(preset.id)}
|
||||
className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 transition-opacity"
|
||||
>
|
||||
<FaTrash size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 p-3 rounded-lg">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
|
||||
{t('filters.sortBy')}
|
||||
</label>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => {
|
||||
setSortBy(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="modified desc">{t('filters.sortModifiedNewest')}</option>
|
||||
<option value="modified asc">{t('filters.sortModifiedOldest')}</option>
|
||||
<option value="creation desc">{t('filters.sortCreationNewest')}</option>
|
||||
<option value="creation asc">{t('filters.sortCreationOldest')}</option>
|
||||
<option value="date desc">{t('sfdaEntries.filters.dateNewest')}</option>
|
||||
<option value="date asc">{t('sfdaEntries.filters.dateOldest')}</option>
|
||||
<option value="name asc">{t('filters.sortNameAsc')}</option>
|
||||
<option value="name desc">{t('filters.sortNameDesc')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
|
||||
{t('sfdaEntries.filters.title')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={titleFilter}
|
||||
onChange={(e) => {
|
||||
setTitleFilter(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder={t('sfdaEntries.filters.titlePlaceholder')}
|
||||
className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
|
||||
{t('sfdaEntries.filters.dateStart')}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateStart}
|
||||
onChange={(e) => {
|
||||
setDateStart(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
|
||||
{t('sfdaEntries.filters.dateEnd')}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateEnd}
|
||||
onChange={(e) => {
|
||||
setDateEnd(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
min={dateStart || undefined}
|
||||
className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
|
||||
{t('sfdaEntries.filters.passed')}
|
||||
</label>
|
||||
<select
|
||||
value={passedFilter}
|
||||
onChange={(e) => {
|
||||
setPassedFilter(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="">{t('listPages.all')}</option>
|
||||
<option value="1">{t('common.yes')}</option>
|
||||
<option value="0">{t('common.no')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSaveFilterModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6 animate-scale-in">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">{t('common.saveFilterPreset')}</h3>
|
||||
<input
|
||||
type="text"
|
||||
value={filterPresetName}
|
||||
onChange={(e) => setFilterPresetName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveFilterPreset();
|
||||
}}
|
||||
placeholder={t('common.enterFilterName')}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowSaveFilterModal(false);
|
||||
setFilterPresetName('');
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveFilterPreset}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors flex items-center gap-2"
|
||||
>
|
||||
<FaSave size={12} />
|
||||
{t('common.saveFilter')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden relative">
|
||||
{loading && initialLoadComplete && (
|
||||
<div className="absolute inset-0 bg-white/60 dark:bg-gray-800/60 flex items-center justify-center z-10 backdrop-blur-[1px]">
|
||||
<div className="flex items-center gap-3 bg-white dark:bg-gray-700 px-4 py-2 rounded-lg shadow-lg">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500" />
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">{t('common.filtering')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('sfdaEntries.columns.title')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('sfdaEntries.columns.date')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('sfdaEntries.columns.passed')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('listPages.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{entries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="flex flex-col items-center">
|
||||
<FaFileMedical className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
|
||||
<p>{t('sfdaEntries.noEntriesFound')}</p>
|
||||
{hasActiveFilters && (
|
||||
<button onClick={clearFilters} className="mt-4 text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{t('common.clearFilters')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<tr
|
||||
key={entry.name}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors"
|
||||
onClick={() => navigate(`/sfda-entries/${encodeURIComponent(entry.name)}`)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white line-clamp-2">{entry.title || '-'}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">{formatDate(entry.date)}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{entry.passed === 1 || entry.passed === true ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300">
|
||||
<FaCheckCircle size={10} />
|
||||
{t('common.yes')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||||
<FaTimesCircle size={10} />
|
||||
{t('common.no')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => navigate(`/sfda-entries/${encodeURIComponent(entry.name)}`)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors"
|
||||
title={t('sfdaEntries.viewDetails')}
|
||||
>
|
||||
<FaEye />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ListPagination
|
||||
currentPage={currentPage}
|
||||
totalCount={totalCount}
|
||||
pageSize={pageSize}
|
||||
itemLabel={t('sfdaEntries.paginationLabel')}
|
||||
onPageChange={(p) => setCurrentPage(p)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||
.animate-scale-in { animation: scale-in 0.2s ease-out; }
|
||||
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SfdaEntriesList;
|
||||
@ -174,11 +174,6 @@ const SupportPlanList: React.FC = () => {
|
||||
];
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const setSearchParamsRef = useRef(setSearchParams);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParamsRef.current = setSearchParams;
|
||||
}, [setSearchParams]);
|
||||
const currentPage = useMemo(() => {
|
||||
const p = parseInt(searchParams.get('page') || '1', 10);
|
||||
return Number.isNaN(p) || p < 1 ? 1 : p;
|
||||
@ -252,7 +247,7 @@ const SupportPlanList: React.FC = () => {
|
||||
filtersChangedOnce.current = true;
|
||||
return;
|
||||
}
|
||||
setSearchParamsRef.current((prev) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by');
|
||||
if (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
|
||||
@ -280,7 +275,7 @@ const SupportPlanList: React.FC = () => {
|
||||
setDateFilterBy(''); setDateStart(''); setDateEnd('');
|
||||
setContractWarrantyFilter(''); setFrequencyFilter(''); setWarStatusFilter('');
|
||||
setServiceContractStatusFilter(''); setVendorFilter(''); setAssetFilter('');
|
||||
setSearchParamsRef.current((prev) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end');
|
||||
next.delete('contract_warranty'); next.delete('frequency'); next.delete('war_status');
|
||||
|
||||
@ -1,301 +0,0 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FaArrowLeft, FaCheck, FaHeadset } from 'react-icons/fa';
|
||||
import { toast, ToastContainer, Bounce } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import {
|
||||
type CategoryId,
|
||||
CATEGORY_ORDER,
|
||||
STEPS_BY_CATEGORY,
|
||||
TOTAL_STEPS,
|
||||
} from './supportPrecheckContent';
|
||||
|
||||
const BTN_PRIMARY =
|
||||
'w-full min-h-[52px] sm:min-h-[48px] flex items-center justify-center gap-2 py-3.5 px-4 rounded-2xl text-white text-sm font-semibold shadow-lg transition-all bg-gradient-to-r from-violet-600 via-purple-600 to-fuchsia-600 hover:from-violet-700 hover:via-purple-700 hover:to-fuchsia-700 active:scale-[0.99] focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900';
|
||||
|
||||
type Variant = 'standalone' | 'newIssue';
|
||||
|
||||
interface SupportPrecheckWizardProps {
|
||||
variant: Variant;
|
||||
}
|
||||
|
||||
const SupportPrecheckWizard: React.FC<SupportPrecheckWizardProps> = ({ variant }) => {
|
||||
const navigate = useNavigate();
|
||||
const [category, setCategory] = useState<CategoryId>('power');
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [flowDone, setFlowDone] = useState(false);
|
||||
const [showReasonInput, setShowReasonInput] = useState(false);
|
||||
const [skipReason, setSkipReason] = useState('');
|
||||
|
||||
const steps = STEPS_BY_CATEGORY[category];
|
||||
const step = steps[Math.min(stepIndex, steps.length - 1)];
|
||||
const stepNumber = Math.min(stepIndex + 1, TOTAL_STEPS);
|
||||
|
||||
const goToNewIssueForm = useCallback(
|
||||
(state: { precheckCantCompleteReason?: string }) => {
|
||||
navigate('/support/new', {
|
||||
replace: true,
|
||||
state: {
|
||||
newIssuePrecheckDone: true,
|
||||
...state,
|
||||
},
|
||||
});
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const selectCategory = useCallback((id: CategoryId) => {
|
||||
setCategory(id);
|
||||
setStepIndex(0);
|
||||
setFlowDone(false);
|
||||
setShowReasonInput(false);
|
||||
setSkipReason('');
|
||||
}, []);
|
||||
|
||||
const onContinue = useCallback(() => {
|
||||
if (stepIndex >= TOTAL_STEPS - 1) {
|
||||
if (variant === 'newIssue') {
|
||||
goToNewIssueForm({});
|
||||
return;
|
||||
}
|
||||
setFlowDone(true);
|
||||
return;
|
||||
}
|
||||
setStepIndex((s) => Math.min(s + 1, TOTAL_STEPS - 1));
|
||||
}, [stepIndex, variant, goToNewIssueForm]);
|
||||
|
||||
const openReasonStep = useCallback(() => {
|
||||
setShowReasonInput(true);
|
||||
setSkipReason('');
|
||||
}, []);
|
||||
|
||||
const cancelReasonStep = useCallback(() => {
|
||||
setShowReasonInput(false);
|
||||
setSkipReason('');
|
||||
}, []);
|
||||
|
||||
const submitSkipReason = useCallback(() => {
|
||||
const trimmed = skipReason.trim();
|
||||
if (!trimmed) {
|
||||
toast.error("Please explain why you can't complete this step to continue.", {
|
||||
position: 'top-right',
|
||||
autoClose: 4000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
goToNewIssueForm({ precheckCantCompleteReason: trimmed });
|
||||
}, [skipReason, goToNewIssueForm]);
|
||||
|
||||
const progressDots = useMemo(
|
||||
() =>
|
||||
Array.from({ length: TOTAL_STEPS }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={
|
||||
i === stepIndex
|
||||
? 'h-2 w-10 sm:w-12 rounded-full bg-gradient-to-r from-violet-600 to-fuchsia-500 transition-all shadow-sm'
|
||||
: 'h-2 w-2 rounded-full bg-gray-300/90 dark:bg-gray-600 transition-all'
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
)),
|
||||
[stepIndex],
|
||||
);
|
||||
|
||||
const shellClass =
|
||||
'min-h-full w-full max-w-lg mx-auto px-4 sm:px-5 py-4 sm:py-6 pb-28 sm:pb-24 bg-gradient-to-br from-violet-100/90 via-white to-fuchsia-100/80 dark:from-gray-950 dark:via-gray-900 dark:to-violet-950/50';
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
autoClose={4000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme="colored"
|
||||
transition={Bounce}
|
||||
/>
|
||||
<div className={shellClass}>
|
||||
<div className="flex items-center gap-3 mb-6 sm:mb-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/support')}
|
||||
className="text-violet-700/70 dark:text-violet-300/80 hover:text-violet-900 dark:hover:text-white p-2 -ml-2 rounded-xl min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
aria-label="Back to support issues"
|
||||
>
|
||||
<FaArrowLeft className="text-lg" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{variant === 'newIssue' ? 'New issue' : 'Support'}
|
||||
</h1>
|
||||
<p className="text-xs text-violet-700/80 dark:text-violet-300/90">
|
||||
{variant === 'newIssue' ? 'Self-service checks before you create a ticket' : 'Self-service checks'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] sm:text-[11px] font-semibold tracking-wide text-gray-500 dark:text-gray-400 uppercase mb-3">
|
||||
What type of problem is it?
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mb-6 sm:mb-8">
|
||||
{CATEGORY_ORDER.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => selectCategory(id)}
|
||||
className={`px-3.5 sm:px-4 py-2.5 rounded-2xl text-xs sm:text-sm font-medium transition-all min-h-[44px] ${
|
||||
category === id
|
||||
? 'text-white shadow-md bg-gradient-to-r from-violet-600 to-fuchsia-600 ring-2 ring-violet-400/40'
|
||||
: 'bg-white/80 dark:bg-gray-800/90 text-gray-700 dark:text-gray-200 border border-gray-200/80 dark:border-gray-600 hover:border-violet-300 dark:hover:border-violet-500'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!flowDone ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<p className="text-[10px] sm:text-[11px] font-semibold tracking-wide text-gray-500 dark:text-gray-400 uppercase">
|
||||
Self-service checks
|
||||
</p>
|
||||
<p className="text-sm font-semibold bg-gradient-to-r from-violet-600 to-fuchsia-600 bg-clip-text text-transparent">
|
||||
Step {stepNumber} of {TOTAL_STEPS}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-center gap-2 mb-5 sm:mb-6">{progressDots}</div>
|
||||
|
||||
<div className="rounded-2xl border border-violet-200/60 dark:border-gray-700 bg-white/95 dark:bg-gray-800/95 shadow-lg shadow-violet-500/5 overflow-hidden backdrop-blur-sm">
|
||||
{step.imageSrc ? (
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50">
|
||||
<img
|
||||
src={step.imageSrc}
|
||||
alt={step.imageCaption ?? step.title}
|
||||
className="w-full max-h-48 sm:max-h-56 object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{step.imageCaption ? (
|
||||
<p className="text-center text-xs text-gray-500 dark:text-gray-400 py-2 px-4">
|
||||
{step.imageCaption}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="p-5 sm:p-6 space-y-4">
|
||||
<h2 className="text-lg sm:text-xl font-bold text-gray-900 dark:text-white leading-snug">{step.title}</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{step.intro}</p>
|
||||
|
||||
<div className="rounded-xl bg-violet-50/80 dark:bg-gray-900/60 divide-y divide-violet-100 dark:divide-gray-700 border border-violet-100/80 dark:border-gray-700">
|
||||
{step.bullets.map((text, idx) => (
|
||||
<p key={idx} className="text-sm text-gray-700 dark:text-gray-300 p-4 leading-relaxed">
|
||||
{text}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={onContinue} className={BTN_PRIMARY}>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full border-2 border-white/90 shrink-0">
|
||||
<FaCheck className="text-xs" />
|
||||
</span>
|
||||
Yes, I've tried this
|
||||
</button>
|
||||
|
||||
{showReasonInput ? (
|
||||
<div
|
||||
className="rounded-xl border border-violet-200/90 dark:border-violet-700/60 bg-gradient-to-br from-violet-50/95 via-fuchsia-50/50 to-violet-100/70 dark:from-violet-950/45 dark:via-fuchsia-950/25 dark:to-violet-900/35 p-4 sm:p-5 space-y-3 shadow-md shadow-violet-500/10"
|
||||
role="region"
|
||||
aria-label="Reason you cannot complete this step"
|
||||
>
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-violet-50 leading-snug">
|
||||
Why can't you complete this step? (required to continue)
|
||||
</p>
|
||||
<textarea
|
||||
value={skipReason}
|
||||
onChange={(e) => setSkipReason(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="e.g. No access to the panel, site policy, or missing tools…"
|
||||
className="w-full rounded-lg border border-violet-200/90 dark:border-violet-700/70 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-sm p-3 min-h-[100px] resize-y focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
<button type="button" onClick={submitSkipReason} className={BTN_PRIMARY}>
|
||||
Save explanation and continue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelReasonStep}
|
||||
className="w-full min-h-[44px] text-center text-sm font-medium text-violet-700 dark:text-violet-300 underline underline-offset-2 hover:text-violet-900 dark:hover:text-violet-200"
|
||||
>
|
||||
Back to checks
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openReasonStep}
|
||||
className="w-full min-h-[48px] text-center text-sm font-medium text-violet-700 dark:text-violet-300 underline underline-offset-2 hover:text-violet-900 dark:hover:text-violet-200"
|
||||
>
|
||||
I can't complete these checks
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-violet-200/60 dark:border-gray-700 bg-white/95 dark:bg-gray-800/95 shadow-lg p-6 text-center space-y-4">
|
||||
<p className="text-lg font-semibold text-gray-900 dark:text-white">Thanks for working through the checks</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
If the problem is still there, create a ticket from the support issues page with what you tried.
|
||||
</p>
|
||||
<button type="button" onClick={() => navigate('/support')} className={`${BTN_PRIMARY} max-w-xs mx-auto`}>
|
||||
Go to support issues
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{variant === 'standalone' && !showReasonInput ? (
|
||||
<div className="mt-8 rounded-2xl bg-white/70 dark:bg-gray-800/60 border border-violet-200/50 dark:border-gray-700 p-5 flex gap-3 shadow-sm">
|
||||
<FaHeadset className="text-violet-600 dark:text-violet-400 text-xl shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 dark:text-white text-sm">Still need help?</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1 leading-relaxed">
|
||||
To open a ticket without this checklist, use{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigate('/support/new', { state: { newIssuePrecheckDone: true } })
|
||||
}
|
||||
className="font-medium text-violet-700 dark:text-violet-300 underline underline-offset-2 hover:text-violet-900"
|
||||
>
|
||||
Create ticket directly
|
||||
</button>{' '}
|
||||
on the{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/support')}
|
||||
className="text-violet-600 dark:text-violet-400 underline underline-offset-2"
|
||||
>
|
||||
support issues
|
||||
</button>{' '}
|
||||
page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupportPrecheckWizard;
|
||||
@ -1,536 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { FaArrowLeft, FaCheck, FaCheckCircle, FaChevronLeft, FaClipboardList, FaHeadset } from 'react-icons/fa';
|
||||
import { toast, ToastContainer, Bounce } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { troubleshootStepImageUrl, type TroubleshootCategory } from '../data/troubleshootStepImages';
|
||||
import { useWorkOrderDetails, useWorkOrderMutations } from '../hooks/useWorkOrder';
|
||||
import workOrderService from '../services/workOrderService';
|
||||
import {
|
||||
TROUBLESHOOT_GUIDE_COMPLETED_MARKER,
|
||||
TROUBLESHOOT_TREE_COULD_NOT_COMPLETE_MARKER,
|
||||
} from '../utils/troubleshootGuideMarkers';
|
||||
import UltrasoundFlowchart from '../components/UltrasoundFlowchart';
|
||||
|
||||
const BTN_PRIMARY =
|
||||
'w-full min-h-[52px] sm:min-h-[48px] flex items-center justify-center gap-2 py-3.5 px-4 rounded-2xl text-white text-sm font-semibold shadow-lg transition-all bg-gradient-to-r from-violet-600 via-purple-600 to-fuchsia-600 hover:from-violet-700 hover:via-purple-700 hover:to-fuchsia-700 active:scale-[0.99] focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900 disabled:opacity-60 disabled:pointer-events-none';
|
||||
|
||||
const BTN_SECONDARY =
|
||||
'w-full min-h-[52px] sm:min-h-[48px] flex items-center justify-center gap-2 py-3.5 px-4 rounded-2xl text-sm font-semibold border-2 border-violet-300/90 dark:border-violet-600/80 text-violet-800 dark:text-violet-200 bg-white/90 dark:bg-gray-800/90 hover:bg-violet-50/90 dark:hover:bg-violet-950/50 active:scale-[0.99] focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900';
|
||||
|
||||
const SHELL_CLASS =
|
||||
'min-h-full w-full max-w-lg mx-auto px-4 sm:px-5 py-4 sm:py-6 pb-28 sm:pb-24 bg-gradient-to-br from-violet-100/90 via-white to-fuchsia-100/80 dark:from-gray-950 dark:via-gray-900 dark:to-violet-950/50';
|
||||
|
||||
type Cat = TroubleshootCategory;
|
||||
type Step = { heroCaption: string; title: string; bullets: string[] };
|
||||
|
||||
// ── All available categories ──────────────────────────────────────────────────
|
||||
const ALL_CATS: Cat[] = ['air_conditioning', 'power', 'building_systems', 'applications_account'];
|
||||
const ULTRASOUND_CAT: Cat = 'ultrasound';
|
||||
|
||||
function useSteps(cat: Cat): Step[] {
|
||||
const { t } = useTranslation();
|
||||
const raw = t(`workOrders.troubleshootTree.steps.${cat}`, { returnObjects: true });
|
||||
return Array.isArray(raw) ? (raw as Step[]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the linked asset is an ultrasound asset.
|
||||
* Checks custom_modality on the asset record linked to the work order.
|
||||
*/
|
||||
function isUltrasoundWorkOrder(workOrder: any): boolean {
|
||||
if (!workOrder) return false;
|
||||
const modality = (workOrder.custom_modality || '').toLowerCase();
|
||||
const assetName = (workOrder.asset_name || '').toLowerCase();
|
||||
// Check modality field first (most reliable)
|
||||
if (modality.includes('ultrasound') || modality.includes('us')) return true;
|
||||
// Fallback: check asset name contains ultrasound
|
||||
if (assetName.includes('ultrasound') || assetName.includes('ultra sound')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Self-service troubleshooting tree: work order route shows WO id; /support/troubleshoot keeps back link to support. */
|
||||
const SupportTroubleshoot: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { workOrderName } = useParams<{ workOrderName?: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { workOrder, loading: woLoading, refetch: refetchWorkOrder } = useWorkOrderDetails(workOrderName ?? null);
|
||||
const { updateWorkOrder, loading: saveLoading } = useWorkOrderMutations();
|
||||
|
||||
// ── Determine if this is an ultrasound WO and build the category list ────────
|
||||
const isUltrasound = useMemo(() => isUltrasoundWorkOrder(workOrder), [workOrder]);
|
||||
|
||||
const CATS: Cat[] = useMemo(
|
||||
() => (isUltrasound ? [ULTRASOUND_CAT] : ALL_CATS),
|
||||
[isUltrasound],
|
||||
);
|
||||
|
||||
// Default to ultrasound category when it's an ultrasound WO
|
||||
const [category, setCategory] = useState<Cat>('power');
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [showReasonInput, setShowReasonInput] = useState(false);
|
||||
const [skipReason, setSkipReason] = useState('');
|
||||
const [flowCompleted, setFlowCompleted] = useState(false);
|
||||
const [completingGuide, setCompletingGuide] = useState(false);
|
||||
|
||||
// Once work order loads and we know it's ultrasound, switch default category
|
||||
const hasSetDefaultCat = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!woLoading && workOrder && !hasSetDefaultCat.current) {
|
||||
hasSetDefaultCat.current = true;
|
||||
if (isUltrasoundWorkOrder(workOrder)) {
|
||||
setCategory(ULTRASOUND_CAT);
|
||||
}
|
||||
}
|
||||
}, [woLoading, workOrder]);
|
||||
|
||||
const redirectedForStateRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
redirectedForStateRef.current = false;
|
||||
}, [workOrderName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workOrderName || woLoading || !workOrder) return;
|
||||
if (workOrder.workflow_state === 'Repair InProgress') return;
|
||||
if (redirectedForStateRef.current) return;
|
||||
redirectedForStateRef.current = true;
|
||||
toast.info(t('workOrders.troubleshootTree.notAvailableUntilRepairInProgress'), {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
});
|
||||
navigate(`/work-orders/${encodeURIComponent(workOrderName)}`, { replace: true });
|
||||
}, [workOrderName, woLoading, workOrder, navigate, t]);
|
||||
|
||||
const steps = useSteps(category);
|
||||
const n = steps.length;
|
||||
const cur = n ? steps[Math.min(stepIndex, n - 1)] : null;
|
||||
const stepImageUrl = troubleshootStepImageUrl(category, stepIndex);
|
||||
|
||||
const progressDots = useMemo(
|
||||
() =>
|
||||
Array.from({ length: Math.max(n, 1) }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={
|
||||
flowCompleted || i === stepIndex
|
||||
? 'h-2 w-10 sm:w-12 rounded-full bg-gradient-to-r from-violet-600 to-fuchsia-500 transition-all shadow-sm'
|
||||
: 'h-2 w-2 rounded-full bg-gray-300/90 dark:bg-gray-600 transition-all'
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
)),
|
||||
[n, stepIndex, flowCompleted],
|
||||
);
|
||||
|
||||
const reset = useCallback((c: Cat) => {
|
||||
setCategory(c);
|
||||
setStepIndex(0);
|
||||
setShowReasonInput(false);
|
||||
setSkipReason('');
|
||||
setFlowCompleted(false);
|
||||
}, []);
|
||||
|
||||
const next = () => {
|
||||
setShowReasonInput(false);
|
||||
setSkipReason('');
|
||||
if (stepIndex + 1 < n) setStepIndex((i) => i + 1);
|
||||
else setFlowCompleted(true);
|
||||
};
|
||||
|
||||
const prev = () => {
|
||||
setShowReasonInput(false);
|
||||
setSkipReason('');
|
||||
if (stepIndex > 0) setStepIndex((i) => i - 1);
|
||||
};
|
||||
|
||||
const restartCategoryFromBeginning = () => {
|
||||
setFlowCompleted(false);
|
||||
setStepIndex(0);
|
||||
setShowReasonInput(false);
|
||||
setSkipReason('');
|
||||
};
|
||||
|
||||
const back = () => {
|
||||
if (workOrderName) navigate(`/work-orders/${encodeURIComponent(workOrderName)}`);
|
||||
else navigate('/support');
|
||||
};
|
||||
|
||||
const openReasonPanel = useCallback(() => {
|
||||
setShowReasonInput(true);
|
||||
setSkipReason('');
|
||||
}, []);
|
||||
|
||||
const cancelReasonPanel = useCallback(() => {
|
||||
setShowReasonInput(false);
|
||||
setSkipReason('');
|
||||
}, []);
|
||||
|
||||
const submitSkipReason = useCallback(async () => {
|
||||
const trimmed = skipReason.trim();
|
||||
if (!trimmed) {
|
||||
toast.error(t('workOrders.troubleshootTree.reasonRequiredToast'), {
|
||||
position: 'top-right',
|
||||
autoClose: 4000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (workOrderName) {
|
||||
if (woLoading) {
|
||||
toast.error(t('workOrders.troubleshootTree.workOrderStillLoading'), {
|
||||
position: 'top-right',
|
||||
autoClose: 4000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!workOrder) {
|
||||
toast.error(t('workOrders.troubleshootTree.saveExplanationFailed'), {
|
||||
position: 'top-right',
|
||||
autoClose: 4000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const categoryLabel = t(`workOrders.troubleshootTree.categories.${category}`);
|
||||
const stepTitle = cur?.title ?? '';
|
||||
const block = `\n\n${TROUBLESHOOT_TREE_COULD_NOT_COMPLETE_MARKER}\nWork order: ${workOrderName}\nCategory: ${categoryLabel}\nStep ${stepIndex + 1} of ${n}: ${stepTitle}\nReason: ${trimmed}\n`;
|
||||
const previousReport = (workOrder.actions_performed || '').trimEnd();
|
||||
const newReport = previousReport ? `${previousReport}${block}` : block.trim();
|
||||
try {
|
||||
await updateWorkOrder(workOrderName, { actions_performed: newReport });
|
||||
refetchWorkOrder();
|
||||
setShowReasonInput(false);
|
||||
setSkipReason('');
|
||||
toast.success(t('workOrders.troubleshootTree.explanationSavedStayOnGuide'), {
|
||||
position: 'top-right',
|
||||
autoClose: 4000,
|
||||
});
|
||||
} catch {
|
||||
toast.error(t('workOrders.troubleshootTree.saveExplanationFailed'), {
|
||||
position: 'top-right',
|
||||
autoClose: 4000,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/support/new', {
|
||||
replace: true,
|
||||
state: {
|
||||
newIssuePrecheckDone: true,
|
||||
precheckCantCompleteReason: trimmed,
|
||||
},
|
||||
});
|
||||
}, [
|
||||
skipReason,
|
||||
t,
|
||||
workOrderName,
|
||||
woLoading,
|
||||
workOrder,
|
||||
category,
|
||||
stepIndex,
|
||||
n,
|
||||
cur?.title,
|
||||
updateWorkOrder,
|
||||
navigate,
|
||||
refetchWorkOrder,
|
||||
]);
|
||||
|
||||
const completeGuideAndReturn = useCallback(async () => {
|
||||
if (!workOrderName) {
|
||||
navigate('/support');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCompletingGuide(true);
|
||||
const fresh = await workOrderService.getWorkOrderDetails(workOrderName);
|
||||
const previousReport = (fresh.actions_performed || '').trimEnd();
|
||||
const categoryLabel = t(`workOrders.troubleshootTree.categories.${category}`);
|
||||
const completionBlock = `\n\n${TROUBLESHOOT_GUIDE_COMPLETED_MARKER}\nWork order: ${workOrderName}\nCategory completed: ${categoryLabel}\nCompleted at: ${new Date().toISOString()}\n`;
|
||||
const newReport = previousReport ? `${previousReport}${completionBlock}` : completionBlock.trim();
|
||||
await updateWorkOrder(workOrderName, { actions_performed: newReport });
|
||||
navigate(`/work-orders/${encodeURIComponent(workOrderName)}`, {
|
||||
state: { troubleshootGuideJustCompleted: true },
|
||||
});
|
||||
} catch {
|
||||
toast.error(t('workOrders.troubleshootTree.saveExplanationFailed'), {
|
||||
position: 'top-right',
|
||||
autoClose: 4000,
|
||||
});
|
||||
} finally {
|
||||
setCompletingGuide(false);
|
||||
}
|
||||
}, [workOrderName, updateWorkOrder, navigate, t, category]);
|
||||
|
||||
// ── Gradient per category ────────────────────────────────────────────────────
|
||||
const grad =
|
||||
category === 'air_conditioning'
|
||||
? 'from-sky-400/90 to-cyan-700'
|
||||
: category === 'power'
|
||||
? 'from-violet-500/90 to-purple-900'
|
||||
: category === 'building_systems'
|
||||
? 'from-amber-400/90 to-stone-700'
|
||||
: category === 'ultrasound'
|
||||
? 'from-blue-500/90 to-teal-700'
|
||||
: 'from-indigo-400/90 to-slate-700';
|
||||
|
||||
// ── Page title: add ultrasound badge when relevant ───────────────────────────
|
||||
const pageSubtitle = useMemo(() => {
|
||||
if (!workOrderName) return t('workOrders.troubleshootTree.selfServiceTitle');
|
||||
if (isUltrasound) return `${t('workOrders.troubleshootTree.workOrderBadge', { id: workOrderName })} · ${t('workOrders.troubleshootTree.ultrasoundBadge', { defaultValue: 'Ultrasound diagnostic' })}`;
|
||||
return t('workOrders.troubleshootTree.workOrderBadge', { id: workOrderName });
|
||||
}, [workOrderName, isUltrasound, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
autoClose={4000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme="colored"
|
||||
transition={Bounce}
|
||||
/>
|
||||
<div className={SHELL_CLASS}>
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center gap-3 mb-6 sm:mb-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={back}
|
||||
className="text-violet-700/70 dark:text-violet-300/80 hover:text-violet-900 dark:hover:text-white p-2 -ml-2 rounded-xl min-h-[44px] min-w-[44px] flex items-center justify-center shrink-0"
|
||||
aria-label={t('workOrders.troubleshootTree.backToWorkOrder')}
|
||||
>
|
||||
<FaArrowLeft className="text-lg" />
|
||||
</button>
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 shadow-md ${isUltrasound ? 'bg-gradient-to-br from-blue-500 to-teal-600' : 'bg-gradient-to-br from-violet-600 to-fuchsia-600'}`}>
|
||||
<FaClipboardList className="text-white text-lg" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-gray-900 dark:text-white truncate">
|
||||
{isUltrasound
|
||||
? t('workOrders.troubleshootTree.ultrasoundBadge', { defaultValue: 'Ultrasound Diagnostic Guide' })
|
||||
: t('workOrders.troubleshootTree.pageTitle')}
|
||||
</h1>
|
||||
<p className="text-xs text-violet-700/80 dark:text-violet-300/90 truncate">
|
||||
{pageSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Ultrasound info banner ── */}
|
||||
{isUltrasound && (
|
||||
<div className="mb-5 p-3 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 flex items-start gap-2">
|
||||
<span className="text-blue-500 text-base shrink-0 mt-0.5">ℹ️</span>
|
||||
<p className="text-xs text-blue-800 dark:text-blue-300 leading-relaxed">
|
||||
This guide follows the official <strong>GE Voluson 730 Service Manual, Section 7-7</strong> troubleshooting trees. Start with the <strong>GE Voluson 730</strong> category for asset-specific steps.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Category tabs ── */}
|
||||
<p className="text-[10px] sm:text-[11px] font-semibold tracking-wide text-gray-500 dark:text-gray-400 uppercase mb-3">
|
||||
{t('workOrders.troubleshootTree.problemTypeTitle')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mb-6 sm:mb-8">
|
||||
{CATS.map((id) => {
|
||||
const isUltrasoundTab = id === ULTRASOUND_CAT;
|
||||
const isActive = category === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => reset(id)}
|
||||
className={`px-3.5 sm:px-4 py-2.5 rounded-2xl text-xs sm:text-sm font-medium transition-all min-h-[44px] ${
|
||||
isActive
|
||||
? isUltrasoundTab
|
||||
? 'text-white shadow-md bg-gradient-to-r from-blue-500 to-teal-600 ring-2 ring-blue-400/40'
|
||||
: 'text-white shadow-md bg-gradient-to-r from-violet-600 to-fuchsia-600 ring-2 ring-violet-400/40'
|
||||
: 'bg-white/80 dark:bg-gray-800/90 text-gray-700 dark:text-gray-200 border border-gray-200/80 dark:border-gray-600 hover:border-violet-300 dark:hover:border-violet-500'
|
||||
}`}
|
||||
>
|
||||
{isUltrasoundTab && '🔬 '}
|
||||
{t(`workOrders.troubleshootTree.categories.${id}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Step counter / flowchart ── */}
|
||||
{isUltrasound ? (
|
||||
<UltrasoundFlowchart />
|
||||
) : (
|
||||
<>
|
||||
{/* ── Step counter ── */}
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<p className="text-[10px] sm:text-[11px] font-semibold tracking-wide text-gray-500 dark:text-gray-400 uppercase">
|
||||
{t('workOrders.troubleshootTree.selfServiceTitle')}
|
||||
</p>
|
||||
{n > 0 ? (
|
||||
<p className={`text-sm font-semibold bg-clip-text text-transparent shrink-0 ${category === ULTRASOUND_CAT ? 'bg-gradient-to-r from-blue-500 to-teal-600' : 'bg-gradient-to-r from-violet-600 to-fuchsia-600'}`}>
|
||||
{flowCompleted
|
||||
? t('workOrders.troubleshootTree.guideCompleteBadge')
|
||||
: t('workOrders.troubleshootTree.stepOf', { current: stepIndex + 1, total: n })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex justify-center gap-2 mb-5 sm:mb-6">{progressDots}</div>
|
||||
|
||||
{/* ── Step card ── */}
|
||||
<div className="rounded-2xl border border-violet-200/60 dark:border-gray-700 bg-white/95 dark:bg-gray-800/95 shadow-lg shadow-violet-500/5 overflow-hidden backdrop-blur-sm">
|
||||
{flowCompleted && n > 0 ? (
|
||||
<div className="p-6 sm:p-8 space-y-5 text-center">
|
||||
<div className={`mx-auto flex h-16 w-16 items-center justify-center rounded-full shadow-lg ${category === ULTRASOUND_CAT ? 'bg-gradient-to-br from-blue-500 to-teal-600 shadow-blue-500/30' : 'bg-gradient-to-br from-violet-600 to-fuchsia-600 shadow-violet-500/30'}`}>
|
||||
<FaCheckCircle className="text-3xl text-white" aria-hidden />
|
||||
</div>
|
||||
<h2 className="text-lg sm:text-xl font-bold text-gray-900 dark:text-white leading-snug">
|
||||
{t('workOrders.troubleshootTree.allStepsCompleteTitle')}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed max-w-md mx-auto">
|
||||
{workOrderName
|
||||
? t('workOrders.troubleshootTree.allStepsCompleteBodyWo')
|
||||
: t('workOrders.troubleshootTree.allStepsCompleteBodyStandalone')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 pt-2 max-w-md mx-auto w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (workOrderName) void completeGuideAndReturn();
|
||||
else navigate('/support');
|
||||
}}
|
||||
className={BTN_PRIMARY}
|
||||
disabled={Boolean(workOrderName && completingGuide)}
|
||||
>
|
||||
{workOrderName
|
||||
? completingGuide
|
||||
? t('common.saving')
|
||||
: t('workOrders.troubleshootTree.continueToResolveComplaint')
|
||||
: t('workOrders.troubleshootTree.backToSupportFromGuide')}
|
||||
</button>
|
||||
<button type="button" onClick={restartCategoryFromBeginning} className={BTN_SECONDARY}>
|
||||
{t('workOrders.troubleshootTree.reviewCategoryAgain')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : cur ? (
|
||||
<>
|
||||
{stepImageUrl ? (
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50">
|
||||
<img
|
||||
src={stepImageUrl}
|
||||
alt={cur.heroCaption}
|
||||
className="w-full max-h-48 sm:max-h-56 object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
<p className="text-center text-xs text-gray-500 dark:text-gray-400 py-2 px-4">{cur.heroCaption}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={`aspect-[16/10] max-h-52 sm:max-h-60 flex items-end p-4 bg-gradient-to-br ${grad}`}>
|
||||
<span className="text-white/90 text-xs font-medium max-w-[85%]">{cur.heroCaption}</span>
|
||||
</div>
|
||||
<p className="text-center text-xs text-gray-500 dark:text-gray-400 py-2 px-4">{cur.heroCaption}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="p-5 sm:p-6 space-y-4">
|
||||
<h2 className="text-lg sm:text-xl font-bold text-gray-900 dark:text-white leading-snug">{cur.title}</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{t('workOrders.troubleshootTree.introHint')}</p>
|
||||
|
||||
<div className="rounded-xl bg-violet-50/80 dark:bg-gray-900/60 divide-y divide-violet-100 dark:divide-gray-700 border border-violet-100/80 dark:border-gray-700">
|
||||
{cur.bullets.map((b, i) => (
|
||||
<p key={i} className="text-sm text-gray-700 dark:text-gray-300 p-4 leading-relaxed">
|
||||
{b}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{stepIndex > 0 && !showReasonInput && (
|
||||
<button type="button" onClick={prev} className={BTN_SECONDARY}>
|
||||
<FaChevronLeft className="text-sm shrink-0" aria-hidden />
|
||||
{t('workOrders.troubleshootTree.previousStep')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={next} className={BTN_PRIMARY}>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full border-2 border-white/90 shrink-0">
|
||||
<FaCheck className="text-xs" />
|
||||
</span>
|
||||
{t('workOrders.troubleshootTree.confirmTried')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showReasonInput ? (
|
||||
<div
|
||||
className="rounded-xl border border-violet-200/90 dark:border-violet-700/60 bg-gradient-to-br from-violet-50/95 via-fuchsia-50/50 to-violet-100/70 dark:from-violet-950/45 dark:via-fuchsia-950/25 dark:to-violet-900/35 p-4 sm:p-5 space-y-3 shadow-md shadow-violet-500/10"
|
||||
role="region"
|
||||
aria-label={t('workOrders.troubleshootTree.cantCompleteStepPrompt')}
|
||||
>
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-violet-50 leading-snug">
|
||||
{t('workOrders.troubleshootTree.cantCompleteStepPrompt')}
|
||||
</p>
|
||||
<textarea
|
||||
value={skipReason}
|
||||
onChange={(e) => setSkipReason(e.target.value)}
|
||||
rows={4}
|
||||
placeholder={t('workOrders.troubleshootTree.cantCompletePlaceholder')}
|
||||
className="w-full rounded-lg border border-violet-200/90 dark:border-violet-700/70 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-sm p-3 min-h-[100px] resize-y focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
<button type="button" onClick={() => void submitSkipReason()} className={BTN_PRIMARY} disabled={saveLoading}>
|
||||
{t('workOrders.troubleshootTree.saveExplanationContinue')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelReasonPanel}
|
||||
className="w-full min-h-[48px] text-center text-sm font-medium text-violet-700 dark:text-violet-300 underline underline-offset-2 hover:text-violet-900 dark:hover:text-violet-200"
|
||||
>
|
||||
{t('workOrders.troubleshootTree.backToChecks')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openReasonPanel}
|
||||
className="w-full min-h-[48px] text-center text-sm font-medium text-violet-700 dark:text-violet-300 underline underline-offset-2 hover:text-violet-900 dark:hover:text-violet-200"
|
||||
>
|
||||
{t('workOrders.troubleshootTree.cantComplete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="p-8 text-center text-sm text-gray-500 dark:text-gray-400">{t('workOrders.troubleshootTree.noSteps')}</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="mt-8 rounded-2xl bg-white/70 dark:bg-gray-800/60 border border-violet-200/50 dark:border-gray-700 p-5 flex gap-3 shadow-sm">
|
||||
<FaHeadset className="text-violet-600 dark:text-violet-400 text-xl shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 dark:text-white text-sm">{t('workOrders.troubleshootTree.footerTitle')}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1 leading-relaxed">
|
||||
{isUltrasound
|
||||
? 'If none of the steps above resolve the issue, escalate to GE Healthcare / SAMAMA with the error codes and findings documented in your Technical Report.'
|
||||
: t('workOrders.troubleshootTree.footerHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupportTroubleshoot;
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,502 +0,0 @@
|
||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTaskList } from '../hooks/useProject';
|
||||
import ListPagination from '../components/ListPagination';
|
||||
import LinkField from '../components/LinkField';
|
||||
import { buildDateRangeFilters } from '../utils/listFilterUtils';
|
||||
import {
|
||||
FaTasks, FaPlus, FaSearch, FaFilter, FaSync, FaEye, FaChevronDown, FaChevronUp, FaTimes, FaFileExport, FaEdit, FaCopy, FaCheckSquare, FaSquare,
|
||||
} from 'react-icons/fa';
|
||||
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||
import type { Task } from '../services/projectService';
|
||||
import DynamicExportModal from '../components/DynamicExportModal';
|
||||
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||
|
||||
const getStatusStyle = (s: string) => {
|
||||
switch (s?.toLowerCase()) {
|
||||
case 'open': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
|
||||
case 'working': return 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300';
|
||||
case 'completed': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||
case 'cancelled': return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||
case 'overdue': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
||||
default: return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityStyle = (p: string) => {
|
||||
switch (p?.toLowerCase()) {
|
||||
case 'high': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
||||
case 'medium': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
|
||||
case 'low': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||
default: return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const TaskList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const pageSize = 20;
|
||||
|
||||
const currentPage = useMemo(() => {
|
||||
const p = parseInt(searchParams.get('page') || '1', 10);
|
||||
return Number.isNaN(p) || p < 1 ? 1 : p;
|
||||
}, [searchParams]);
|
||||
|
||||
const setCurrentPage = useCallback((v: number | ((p: number) => number)) => {
|
||||
const next = typeof v === 'function' ? v(currentPage) : v;
|
||||
setSearchParams(prev => { const n = new URLSearchParams(prev); n.set('page', String(next)); return n; });
|
||||
}, [currentPage, setSearchParams]);
|
||||
|
||||
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || '');
|
||||
const [priorityFilter, setPriorityFilter] = useState(searchParams.get('priority') || '');
|
||||
const [projectFilter, setProjectFilter] = useState(searchParams.get('project') || '');
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('q') || '');
|
||||
const [dateFilterBy, setDateFilterBy] = useState<'' | 'creation' | 'modified'>(
|
||||
(searchParams.get('date_filter_by') as '' | 'creation' | 'modified') || ''
|
||||
);
|
||||
const [dateStart, setDateStart] = useState(searchParams.get('date_start') || '');
|
||||
const [dateEnd, setDateEnd] = useState(searchParams.get('date_end') || '');
|
||||
const [sortBy, setSortBy] = useState(searchParams.get('sort_by') || 'creation desc');
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const didInitUrlSync = useRef(false);
|
||||
const skipInitialSearchUrlSync = useRef(true);
|
||||
const searchDebounceRef = useRef<number | null>(null);
|
||||
const setSearchParamsRef = useRef(setSearchParams);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParamsRef.current = setSearchParams;
|
||||
}, [setSearchParams]);
|
||||
|
||||
const apiFilters = useMemo(() => {
|
||||
const f: Record<string, any> = {};
|
||||
if (statusFilter) f.status = statusFilter;
|
||||
if (priorityFilter) f.priority = priorityFilter;
|
||||
if (projectFilter) f.project = projectFilter;
|
||||
if (searchQuery) f.subject = ['like', `%${searchQuery}%`];
|
||||
Object.assign(f, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd));
|
||||
return f;
|
||||
}, [statusFilter, priorityFilter, projectFilter, searchQuery, dateFilterBy, dateStart, dateEnd]);
|
||||
|
||||
const { tasks, loading, error, totalCount, refetch } = useTaskList({
|
||||
filters: apiFilters,
|
||||
limit_start: (currentPage - 1) * pageSize,
|
||||
limit_page_length: pageSize,
|
||||
order_by: sortBy,
|
||||
});
|
||||
|
||||
const selectionResetKey = useMemo(
|
||||
() => `${currentPage}|${sortBy}|${JSON.stringify(apiFilters)}`,
|
||||
[currentPage, sortBy, apiFilters],
|
||||
);
|
||||
const {
|
||||
selectedRows,
|
||||
toggleRow,
|
||||
toggleAllOnPage,
|
||||
allOnPageSelected,
|
||||
someOnPageSelected,
|
||||
} = useListPageSelection(tasks, selectionResetKey);
|
||||
|
||||
const fetchAllForExport = useCallback(
|
||||
() => fetchAllRowsForExport({ doctype: 'Task', filters: apiFilters, orderBy: sortBy }),
|
||||
[apiFilters, sortBy],
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-';
|
||||
|
||||
const clearFilters = () => {
|
||||
setStatusFilter(''); setPriorityFilter(''); setProjectFilter('');
|
||||
setSearchQuery(''); setDateFilterBy(''); setDateStart(''); setDateEnd('');
|
||||
setSortBy('creation desc');
|
||||
setSearchParams(prev => {
|
||||
const n = new URLSearchParams(prev);
|
||||
['status','priority','project','q','date_filter_by','date_start','date_end','sort_by'].forEach(k => n.delete(k));
|
||||
n.set('page','1'); return n;
|
||||
});
|
||||
};
|
||||
|
||||
// Auto-sync filters (no "Apply Filters" button for Project Management pages)
|
||||
useEffect(() => {
|
||||
if (!didInitUrlSync.current) {
|
||||
didInitUrlSync.current = true;
|
||||
return;
|
||||
}
|
||||
setSearchParamsRef.current(prev => {
|
||||
const n = new URLSearchParams(prev);
|
||||
statusFilter ? n.set('status', statusFilter) : n.delete('status');
|
||||
priorityFilter ? n.set('priority', priorityFilter) : n.delete('priority');
|
||||
projectFilter ? n.set('project', projectFilter) : n.delete('project');
|
||||
dateFilterBy ? n.set('date_filter_by', dateFilterBy) : n.delete('date_filter_by');
|
||||
dateStart ? n.set('date_start', dateStart) : n.delete('date_start');
|
||||
dateEnd ? n.set('date_end', dateEnd) : n.delete('date_end');
|
||||
sortBy !== 'creation desc' ? n.set('sort_by', sortBy) : n.delete('sort_by');
|
||||
n.set('page', '1');
|
||||
return n;
|
||||
});
|
||||
}, [statusFilter, priorityFilter, projectFilter, dateFilterBy, dateStart, dateEnd, sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didInitUrlSync.current) return;
|
||||
if (skipInitialSearchUrlSync.current) {
|
||||
skipInitialSearchUrlSync.current = false;
|
||||
return;
|
||||
}
|
||||
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||
searchDebounceRef.current = window.setTimeout(() => {
|
||||
setSearchParamsRef.current(prev => {
|
||||
const n = new URLSearchParams(prev);
|
||||
searchQuery ? n.set('q', searchQuery) : n.delete('q');
|
||||
n.set('page', '1');
|
||||
return n;
|
||||
});
|
||||
}, 450);
|
||||
return () => {
|
||||
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
const hasActiveFilters = !!(statusFilter || priorityFilter || projectFilter || searchQuery || (dateFilterBy && (dateStart || dateEnd)));
|
||||
const handleEdit = (taskName: string) => navigate(`/projects/tasks/${encodeURIComponent(taskName)}?edit=1`);
|
||||
const handleDuplicate = (taskName: string) => navigate(`/projects/tasks/new?duplicate=${encodeURIComponent(taskName)}`);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/projects')}
|
||||
className="text-sm text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
|
||||
>
|
||||
{t('projects.moduleTitle')}
|
||||
</button>
|
||||
<span className="text-gray-400">/</span>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<FaTasks className="text-indigo-500" /> {t('projects.tasksDoctype')}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all disabled:opacity-50"
|
||||
disabled={totalCount === 0 && selectedRows.size === 0}
|
||||
>
|
||||
<FaFileExport /> <span className="font-medium">{t('listPages.export')}</span>
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/projects/tasks/new')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
<FaPlus /> {t('projects.newTask')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DynamicExportModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
doctype="Task"
|
||||
selectedCount={selectedRows.size}
|
||||
pageCount={tasks.length}
|
||||
totalCount={totalCount}
|
||||
pageData={tasks}
|
||||
selectedRows={selectedRows}
|
||||
rowKey="name"
|
||||
onFetchAll={fetchAllForExport}
|
||||
fileNamePrefix="tasks"
|
||||
/>
|
||||
|
||||
{/* ── Filter Panel ── */}
|
||||
<div className="isolate bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-6">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 px-4 py-2.5 rounded-t-lg">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Left: toggle + title + count */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setIsFilterExpanded(v => !v)}
|
||||
className="text-white hover:bg-white/20 p-1.5 rounded-lg transition-all"
|
||||
>
|
||||
{isFilterExpanded ? <FaChevronUp size={12} /> : <FaChevronDown size={12} />}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaFilter className="text-white" size={13} />
|
||||
<span className="text-white font-semibold text-sm">Filters</span>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">
|
||||
{[searchQuery, statusFilter, priorityFilter, projectFilter, dateFilterBy && dateStart].filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center: active filter pills */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex-1 overflow-x-auto mx-2">
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
{searchQuery && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||
<span className="font-semibold">Subject:</span> {searchQuery}
|
||||
<button onClick={() => setSearchQuery('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||
</span>
|
||||
)}
|
||||
{statusFilter && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||
<span className="font-semibold">Status:</span> {statusFilter}
|
||||
<button onClick={() => setStatusFilter('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||
</span>
|
||||
)}
|
||||
{priorityFilter && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||
<span className="font-semibold">Priority:</span> {priorityFilter}
|
||||
<button onClick={() => setPriorityFilter('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||
</span>
|
||||
)}
|
||||
{projectFilter && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||
<span className="font-semibold">Project:</span> {projectFilter}
|
||||
<button onClick={() => setProjectFilter('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||
</span>
|
||||
)}
|
||||
{dateFilterBy && dateStart && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||
<span className="font-semibold">{dateFilterBy === 'creation' ? 'Created' : 'Modified'}:</span> {dateStart}{dateEnd ? ` – ${dateEnd}` : ''}
|
||||
<button onClick={() => { setDateFilterBy(''); setDateStart(''); setDateEnd(''); }}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right: clear + refresh */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{hasActiveFilters && (
|
||||
<button onClick={clearFilters} className="text-white/80 hover:text-white text-xs underline whitespace-nowrap">
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => refetch()} className="text-white hover:bg-white/20 p-1.5 rounded-lg transition-all" title="Refresh">
|
||||
<FaSync size={12} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded filter body */}
|
||||
{isFilterExpanded && (
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Search */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Search</label>
|
||||
<div className="relative">
|
||||
<FaSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
||||
<input
|
||||
type="text" value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && e.preventDefault()}
|
||||
placeholder="Search by task subject…"
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Status</label>
|
||||
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none">
|
||||
<option value="">All Status</option>
|
||||
<option value="Open">Open</option>
|
||||
<option value="Working">Working</option>
|
||||
<option value="Completed">Completed</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Priority</label>
|
||||
<select value={priorityFilter} onChange={e => setPriorityFilter(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none">
|
||||
<option value="">All Priority</option>
|
||||
<option value="High">High</option>
|
||||
<option value="Medium">Medium</option>
|
||||
<option value="Low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Project */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Project</label>
|
||||
<LinkField
|
||||
label="Project"
|
||||
hideLabel
|
||||
value={projectFilter}
|
||||
onChange={setProjectFilter}
|
||||
doctype="Project"
|
||||
placeholder="Filter by project…"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date filter by */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Date Filter By</label>
|
||||
<select value={dateFilterBy} onChange={e => setDateFilterBy(e.target.value as '' | 'creation' | 'modified')} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none">
|
||||
<option value="">None</option>
|
||||
<option value="creation">Created</option>
|
||||
<option value="modified">Modified</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{dateFilterBy && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">From</label>
|
||||
<input type="date" value={dateStart} onChange={e => setDateStart(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">To</label>
|
||||
<input type="date" value={dateEnd} onChange={e => setDateEnd(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sort by */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Sort By</label>
|
||||
<select value={sortBy} onChange={e => setSortBy(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none">
|
||||
<option value="creation desc">Created (newest)</option>
|
||||
<option value="creation asc">Created (oldest)</option>
|
||||
<option value="exp_end_date asc">Due date (soonest)</option>
|
||||
<option value="priority desc">Priority (high first)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-12 text-center text-gray-500 dark:text-gray-400">{t('common.loading')}</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<FaTasks className="text-4xl text-gray-300 dark:text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500 dark:text-gray-400">{t('projects.noTasks')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<tr>
|
||||
<th className="w-10 px-4 py-3 text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllOnPage}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
title={allOnPageSelected ? 'Deselect all' : 'Select all'}
|
||||
aria-label="Select all on page"
|
||||
>
|
||||
{allOnPageSelected
|
||||
? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} />
|
||||
: someOnPageSelected
|
||||
? (
|
||||
<div className="relative inline-block">
|
||||
<FaSquare size={18} />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-2 h-0.5 bg-current" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: <FaSquare size={18} />}
|
||||
</button>
|
||||
</th>
|
||||
{[t('projects.taskColumn'), t('projects.project'), t('commonFields.status'), t('commonFields.priority'), t('projects.assignedTo'), t('projects.dueDate'), 'Exp. Time', ''].map(h => (
|
||||
<th key={h} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{tasks.map((task: Task) => (
|
||||
<tr
|
||||
key={task.name}
|
||||
className={`hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer ${selectedRows.has(task.name) ? 'bg-indigo-50 dark:bg-indigo-900/20' : ''}`}
|
||||
onClick={() => navigate(`/projects/tasks/${task.name}`)}
|
||||
>
|
||||
<td className="w-10 px-4 py-3" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleRow(task.name)}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
aria-label={`Select ${task.name}`}
|
||||
>
|
||||
{selectedRows.has(task.name)
|
||||
? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} />
|
||||
: <FaSquare size={18} />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{task.subject || task.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{task.name}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{task.project ? (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); navigate(`/projects/list/${task.project}`); }}
|
||||
className="text-sm text-indigo-600 dark:text-indigo-400 hover:underline"
|
||||
>
|
||||
{task.project}
|
||||
</button>
|
||||
) : <span className="text-gray-400 text-sm">-</span>}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getStatusStyle(task.status || '')}`}>{task.status || '-'}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getPriorityStyle(task.priority || '')}`}>{task.priority || '-'}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
|
||||
{task._assign ? (JSON.parse(task._assign)[0] || '-') : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{formatDate(task.exp_end_date || '')}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{task.expected_time ? `${task.expected_time}h` : '-'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => navigate(`/projects/tasks/${task.name}`)} className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300 p-2 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded transition-colors" title="View">
|
||||
<FaEye />
|
||||
</button>
|
||||
<button onClick={() => handleEdit(task.name)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title="Edit">
|
||||
<FaEdit />
|
||||
</button>
|
||||
<button onClick={() => handleDuplicate(task.name)} className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors" title="Duplicate">
|
||||
<FaCopy />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||
<ListPagination currentPage={currentPage} totalPages={totalPages} totalCount={totalCount} pageSize={pageSize} onPageChange={setCurrentPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskList;
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,448 +0,0 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTimesheetList } from '../hooks/useProject';
|
||||
import ListPagination from '../components/ListPagination';
|
||||
import { buildDateRangeFilters, toFrappeFilterArray } from '../utils/listFilterUtils';
|
||||
import { FaClock, FaPlus, FaSearch, FaFilter, FaSync, FaEye, FaChevronDown, FaChevronUp, FaTimes, FaFileExport, FaEdit, FaCopy, FaCheckSquare, FaSquare } from 'react-icons/fa';
|
||||
import type { Timesheet } from '../services/projectService';
|
||||
import DynamicExportModal from '../components/DynamicExportModal';
|
||||
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||
|
||||
const getStatusStyle = (s: string) => {
|
||||
switch (s?.toLowerCase()) {
|
||||
case 'submitted': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||
case 'draft': return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||
case 'cancelled': return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300';
|
||||
default: return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const TimesheetList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const setSearchParamsRef = useRef(setSearchParams);
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParamsRef.current = setSearchParams;
|
||||
}, [setSearchParams]);
|
||||
|
||||
const currentPage = useMemo(() => {
|
||||
const p = parseInt(searchParams.get('page') || '1', 10);
|
||||
return Number.isNaN(p) || p < 1 ? 1 : p;
|
||||
}, [searchParams]);
|
||||
|
||||
const setCurrentPage = useCallback((v: number | ((p: number) => number)) => {
|
||||
const next = typeof v === 'function' ? v(currentPage) : v;
|
||||
setSearchParams(prev => { const n = new URLSearchParams(prev); n.set('page', String(next)); return n; });
|
||||
}, [currentPage, setSearchParams]);
|
||||
|
||||
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || '');
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('q') || '');
|
||||
const [dateFilterBy, setDateFilterBy] = useState<'' | 'creation' | 'modified'>(
|
||||
(searchParams.get('date_filter_by') as '' | 'creation' | 'modified') || ''
|
||||
);
|
||||
const [dateStart, setDateStart] = useState(searchParams.get('date_start') || '');
|
||||
const [dateEnd, setDateEnd] = useState(searchParams.get('date_end') || '');
|
||||
const [sortBy, setSortBy] = useState(searchParams.get('sort_by') || 'creation desc');
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const didInitUrlSync = useRef(false);
|
||||
const skipInitialSearchUrlSync = useRef(true);
|
||||
const searchDebounceRef = useRef<number | null>(null);
|
||||
|
||||
const projectFromUrl = useMemo(() => searchParams.get('project')?.trim() || '', [searchParams]);
|
||||
const [projectDraft, setProjectDraft] = useState(projectFromUrl);
|
||||
useEffect(() => { setProjectDraft(projectFromUrl); }, [projectFromUrl]);
|
||||
|
||||
const appendFilters = useMemo(
|
||||
() => (projectFromUrl ? [['Timesheet Detail', 'project', '=', projectFromUrl]] as any[] : []),
|
||||
[projectFromUrl],
|
||||
);
|
||||
|
||||
const apiFilters = useMemo(() => {
|
||||
const f: Record<string, any> = {};
|
||||
if (statusFilter) f.status = statusFilter;
|
||||
if (searchQuery) f.name = ['like', `%${searchQuery}%`];
|
||||
Object.assign(f, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd));
|
||||
return f;
|
||||
}, [statusFilter, searchQuery, dateFilterBy, dateStart, dateEnd]);
|
||||
|
||||
const { timesheets, loading, error, totalCount, refetch } = useTimesheetList({
|
||||
filters: apiFilters,
|
||||
appendFilters,
|
||||
limit_start: (currentPage - 1) * pageSize,
|
||||
limit_page_length: pageSize,
|
||||
order_by: sortBy,
|
||||
});
|
||||
|
||||
const selectionResetKey = useMemo(
|
||||
() => `${currentPage}|${sortBy}|${projectFromUrl}|${JSON.stringify(apiFilters)}|${JSON.stringify(appendFilters)}`,
|
||||
[currentPage, sortBy, projectFromUrl, apiFilters, appendFilters],
|
||||
);
|
||||
const {
|
||||
selectedRows,
|
||||
toggleRow,
|
||||
toggleAllOnPage,
|
||||
allOnPageSelected,
|
||||
someOnPageSelected,
|
||||
} = useListPageSelection(timesheets, selectionResetKey);
|
||||
|
||||
const timesheetExportFilters = useMemo(() => {
|
||||
let fa = toFrappeFilterArray(apiFilters);
|
||||
if (appendFilters.length) fa = [...fa, ...appendFilters];
|
||||
return fa.length > 0 ? fa : {};
|
||||
}, [apiFilters, appendFilters]);
|
||||
|
||||
const fetchAllForExport = useCallback(
|
||||
() => fetchAllRowsForExport({ doctype: 'Timesheet', filters: timesheetExportFilters, orderBy: sortBy }),
|
||||
[timesheetExportFilters, sortBy],
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-';
|
||||
|
||||
const clearFilters = () => {
|
||||
setStatusFilter(''); setSearchQuery('');
|
||||
setDateFilterBy(''); setDateStart(''); setDateEnd('');
|
||||
setSortBy('creation desc'); setProjectDraft('');
|
||||
setSearchParams(prev => {
|
||||
const n = new URLSearchParams(prev);
|
||||
['status','q','date_filter_by','date_start','date_end','sort_by','project'].forEach(k => n.delete(k));
|
||||
n.set('page','1'); return n;
|
||||
});
|
||||
};
|
||||
|
||||
// Auto-sync filters (no "Apply Filters" button for Project Management pages)
|
||||
useEffect(() => {
|
||||
if (!didInitUrlSync.current) {
|
||||
didInitUrlSync.current = true;
|
||||
return;
|
||||
}
|
||||
setSearchParamsRef.current(prev => {
|
||||
const n = new URLSearchParams(prev);
|
||||
statusFilter ? n.set('status', statusFilter) : n.delete('status');
|
||||
dateFilterBy ? n.set('date_filter_by', dateFilterBy) : n.delete('date_filter_by');
|
||||
dateStart ? n.set('date_start', dateStart) : n.delete('date_start');
|
||||
dateEnd ? n.set('date_end', dateEnd) : n.delete('date_end');
|
||||
sortBy !== 'creation desc' ? n.set('sort_by', sortBy) : n.delete('sort_by');
|
||||
const p = projectDraft.trim();
|
||||
p ? n.set('project', p) : n.delete('project');
|
||||
n.set('page', '1');
|
||||
return n;
|
||||
});
|
||||
}, [statusFilter, dateFilterBy, dateStart, dateEnd, sortBy, projectDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didInitUrlSync.current) return;
|
||||
if (skipInitialSearchUrlSync.current) {
|
||||
skipInitialSearchUrlSync.current = false;
|
||||
return;
|
||||
}
|
||||
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||
searchDebounceRef.current = window.setTimeout(() => {
|
||||
setSearchParamsRef.current(prev => {
|
||||
const n = new URLSearchParams(prev);
|
||||
searchQuery ? n.set('q', searchQuery) : n.delete('q');
|
||||
n.set('page', '1');
|
||||
return n;
|
||||
});
|
||||
}, 450);
|
||||
return () => {
|
||||
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
const hasActiveFilters = !!(statusFilter || searchQuery || projectFromUrl || (dateFilterBy && (dateStart || dateEnd)));
|
||||
const handleEdit = (timesheetName: string) => navigate(`/projects/timesheets/${encodeURIComponent(timesheetName)}?edit=1`);
|
||||
const handleDuplicate = (timesheetName: string) => navigate(`/projects/timesheets/new?duplicate=${encodeURIComponent(timesheetName)}`);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate('/projects')} className="text-sm text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400">{t('projects.moduleTitle')}</button>
|
||||
<span className="text-gray-400">/</span>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<FaClock className="text-indigo-500" /> {t('projects.timesheetDoctype')}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all disabled:opacity-50"
|
||||
disabled={totalCount === 0 && selectedRows.size === 0}
|
||||
>
|
||||
<FaFileExport /> <span className="font-medium">{t('listPages.export')}</span>
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const p = new URLSearchParams();
|
||||
if (projectFromUrl) p.set('project', projectFromUrl);
|
||||
const qs = p.toString();
|
||||
navigate(qs ? `/projects/timesheets/new?${qs}` : '/projects/timesheets/new');
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
<FaPlus /> {t('projects.newTimesheet')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DynamicExportModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
doctype="Timesheet"
|
||||
selectedCount={selectedRows.size}
|
||||
pageCount={timesheets.length}
|
||||
totalCount={totalCount}
|
||||
pageData={timesheets}
|
||||
selectedRows={selectedRows}
|
||||
rowKey="name"
|
||||
onFetchAll={fetchAllForExport}
|
||||
fileNamePrefix="timesheets"
|
||||
/>
|
||||
|
||||
{/* ── Filter Panel ── */}
|
||||
<div className="isolate bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-6">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 px-4 py-2.5 rounded-t-lg">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<button onClick={() => setIsFilterExpanded(v => !v)} className="text-white hover:bg-white/20 p-1.5 rounded-lg transition-all">
|
||||
{isFilterExpanded ? <FaChevronUp size={12} /> : <FaChevronDown size={12} />}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaFilter className="text-white" size={13} />
|
||||
<span className="text-white font-semibold text-sm">Filters</span>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">
|
||||
{[statusFilter, searchQuery, projectFromUrl, dateFilterBy && dateStart].filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<div className="flex-1 overflow-x-auto mx-2">
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
{searchQuery && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-gray-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||
<span className="font-semibold">ID:</span> {searchQuery}
|
||||
<button onClick={() => setSearchQuery('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||
</span>
|
||||
)}
|
||||
{statusFilter && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||
<span className="font-semibold">Status:</span> {statusFilter}
|
||||
<button onClick={() => setStatusFilter('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||
</span>
|
||||
)}
|
||||
{projectFromUrl && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||
<span className="font-semibold">Project:</span> {projectFromUrl}
|
||||
<button type="button" onClick={() => { setProjectDraft(''); setSearchParams(prev => { const n = new URLSearchParams(prev); n.delete('project'); n.set('page', '1'); return n; }); }}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||
</span>
|
||||
)}
|
||||
{dateFilterBy && dateStart && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||
<span className="font-semibold">{dateFilterBy === 'creation' ? 'Created' : 'Modified'}:</span> {dateStart}{dateEnd ? ` – ${dateEnd}` : ''}
|
||||
<button type="button" onClick={() => { setDateFilterBy(''); setDateStart(''); setDateEnd(''); }}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{hasActiveFilters && (
|
||||
<button onClick={clearFilters} className="text-white/80 hover:text-white text-xs underline whitespace-nowrap">Clear all</button>
|
||||
)}
|
||||
<button onClick={() => refetch()} className="text-white hover:bg-white/20 p-1.5 rounded-lg transition-all" title="Refresh">
|
||||
<FaSync size={12} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded body */}
|
||||
{isFilterExpanded && (
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Project</label>
|
||||
<input
|
||||
type="text"
|
||||
value={projectDraft}
|
||||
onChange={e => setProjectDraft(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && e.preventDefault()}
|
||||
placeholder="Filter by project…"
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Timesheet ID</label>
|
||||
<div className="relative">
|
||||
<FaSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
||||
<input type="text" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && e.preventDefault()} placeholder="Search by ID…"
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Status</label>
|
||||
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none">
|
||||
<option value="">All Status</option>
|
||||
<option value="Draft">Draft</option>
|
||||
<option value="Submitted">Submitted</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Date Filter By</label>
|
||||
<select value={dateFilterBy} onChange={e => setDateFilterBy(e.target.value as '' | 'creation' | 'modified')} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none">
|
||||
<option value="">None</option>
|
||||
<option value="creation">Created</option>
|
||||
<option value="modified">Modified</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Sort By</label>
|
||||
<select value={sortBy} onChange={e => setSortBy(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none">
|
||||
<option value="creation desc">Created (newest)</option>
|
||||
<option value="creation asc">Created (oldest)</option>
|
||||
<option value="modified desc">Modified (newest)</option>
|
||||
<option value="total_hours desc">Hours (highest)</option>
|
||||
<option value="total_hours asc">Hours (lowest)</option>
|
||||
</select>
|
||||
</div>
|
||||
{dateFilterBy && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">From</label>
|
||||
<input type="date" value={dateStart} onChange={e => setDateStart(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">To</label>
|
||||
<input type="date" value={dateEnd} onChange={e => setDateEnd(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-12 text-center text-gray-500 dark:text-gray-400">{t('common.loading')}</div>
|
||||
) : timesheets.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<FaClock className="text-4xl text-gray-300 dark:text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500 dark:text-gray-400">{t('projects.noTimesheets')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<tr>
|
||||
<th className="w-10 px-4 py-3 text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllOnPage}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
title={allOnPageSelected ? 'Deselect all' : 'Select all'}
|
||||
aria-label="Select all on page"
|
||||
>
|
||||
{allOnPageSelected
|
||||
? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} />
|
||||
: someOnPageSelected
|
||||
? (
|
||||
<div className="relative inline-block">
|
||||
<FaSquare size={18} />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-2 h-0.5 bg-current" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: <FaSquare size={18} />}
|
||||
</button>
|
||||
</th>
|
||||
{[t('projects.timesheetId'), t('commonFields.status'), t('projects.totalHours'), 'Billable Hrs', 'Billing Amt', 'Costing Amt', 'Created', ''].map(h => (
|
||||
<th key={h} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{timesheets.map((ts: Timesheet) => (
|
||||
<tr
|
||||
key={ts.name}
|
||||
className={`hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer ${selectedRows.has(ts.name) ? 'bg-indigo-50 dark:bg-indigo-900/20' : ''}`}
|
||||
onClick={() => navigate(`/projects/timesheets/${ts.name}`)}
|
||||
>
|
||||
<td className="w-10 px-4 py-3" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleRow(ts.name)}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
aria-label={`Select ${ts.name}`}
|
||||
>
|
||||
{selectedRows.has(ts.name)
|
||||
? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} />
|
||||
: <FaSquare size={18} />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{ts.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{ts.modified ? new Date(ts.modified).toLocaleDateString() : ''}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getStatusStyle(ts.status || '')}`}>{ts.status || 'Draft'}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">{ts.total_hours ?? 0} hrs</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{ts.total_billable_hours ?? 0} hrs</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{ts.total_billable_amount ?? '-'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{ts.total_costing_amount ?? '-'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{ts.creation ? new Date(ts.creation).toLocaleDateString() : '-'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => navigate(`/projects/timesheets/${ts.name}`)} className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300 p-2 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded transition-colors" title="View">
|
||||
<FaEye />
|
||||
</button>
|
||||
<button onClick={() => handleEdit(ts.name)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title="Edit">
|
||||
<FaEdit />
|
||||
</button>
|
||||
<button onClick={() => handleDuplicate(ts.name)} className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors" title="Duplicate">
|
||||
<FaCopy />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||
<ListPagination currentPage={currentPage} totalPages={totalPages} totalCount={totalCount} pageSize={pageSize} onPageChange={setCurrentPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimesheetList;
|
||||
@ -26,16 +26,9 @@ import {
|
||||
type UserProfile,
|
||||
type UpdateUserProfileData
|
||||
} from '../services/userProfileService';
|
||||
import {
|
||||
fetchTwoFactorStatus,
|
||||
resetOtpSecret,
|
||||
type TwoFactorStatus,
|
||||
} from '../services/twoFactorService';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const UserProfilePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// State
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -64,9 +57,6 @@ const UserProfilePage: React.FC = () => {
|
||||
const [showOldPassword, setShowOldPassword] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [twoFactorStatus, setTwoFactorStatus] = useState<TwoFactorStatus | null>(null);
|
||||
const [twoFactorLoading, setTwoFactorLoading] = useState(true);
|
||||
const [resettingOtp, setResettingOtp] = useState(false);
|
||||
|
||||
// Fetch user profile and check role on mount
|
||||
useEffect(() => {
|
||||
@ -107,15 +97,8 @@ const UserProfilePage: React.FC = () => {
|
||||
custom_user_id: data.custom_user_id || '',
|
||||
});
|
||||
|
||||
try {
|
||||
const tfa = await fetchTwoFactorStatus(data.name);
|
||||
setTwoFactorStatus(tfa);
|
||||
} catch (tfaErr) {
|
||||
console.warn('Could not load 2FA status:', tfaErr);
|
||||
setTwoFactorStatus(null);
|
||||
} finally {
|
||||
setTwoFactorLoading(false);
|
||||
}
|
||||
// Debug: Log formData after setting
|
||||
console.log('FormData role_profile_name:', data.role_profile_name || '');
|
||||
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to load profile';
|
||||
@ -179,30 +162,6 @@ const UserProfilePage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetOtp = async () => {
|
||||
if (!profile?.name) return;
|
||||
if (!window.confirm(t('profile.resetOtpConfirm'))) return;
|
||||
|
||||
try {
|
||||
setResettingOtp(true);
|
||||
await resetOtpSecret(profile.name);
|
||||
toast.success(t('profile.resetOtpSuccess'), {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
icon: <FaCheckCircle />,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : t('profile.resetOtpFailed');
|
||||
toast.error(errorMsg, {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
icon: <FaTimesCircle />,
|
||||
});
|
||||
} finally {
|
||||
setResettingOtp(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Change password
|
||||
const handleChangePassword = async () => {
|
||||
// Validation
|
||||
@ -304,12 +263,6 @@ const UserProfilePage: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showTwoFactor =
|
||||
!twoFactorLoading &&
|
||||
!!twoFactorStatus?.enabled_globally &&
|
||||
!!twoFactorStatus?.required_for_user &&
|
||||
!!twoFactorStatus?.otp_app;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
|
||||
@ -359,6 +312,7 @@ const UserProfilePage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Profile Card */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Profile Header */}
|
||||
@ -409,49 +363,32 @@ const UserProfilePage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{twoFactorLoading && (
|
||||
<div className="flex items-center gap-2 px-1 py-1 text-[11px] text-gray-400">
|
||||
<FaSpinner className="animate-spin shrink-0" size={12} />
|
||||
<span>{t('profile.twoFactorLoading')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTwoFactor && (
|
||||
<div className="rounded-lg border border-purple-200 bg-purple-50/80 p-3 dark:border-purple-800 dark:bg-purple-900/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<FaShieldAlt className="mt-0.5 shrink-0 text-purple-600 dark:text-purple-400" size={13} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold text-purple-900 dark:text-purple-200">
|
||||
{t('profile.twoFactorSidebarTitle')}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] leading-snug text-purple-800/90 dark:text-purple-300/90">
|
||||
{t('profile.twoFactorRequired')}
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] leading-snug text-gray-600 dark:text-gray-400">
|
||||
{t('profile.twoFactorOtpAppNoteShort')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetOtp}
|
||||
disabled={resettingOtp}
|
||||
className="mt-2 inline-flex w-full items-center justify-center gap-1.5 rounded-md border border-purple-300 bg-white px-2 py-1.5 text-[11px] font-medium text-purple-800 hover:bg-purple-50 disabled:opacity-50 dark:border-purple-600 dark:bg-purple-950/40 dark:text-purple-200 dark:hover:bg-purple-900/40"
|
||||
>
|
||||
{resettingOtp ? (
|
||||
<FaSpinner className="animate-spin" size={10} />
|
||||
) : (
|
||||
<FaShieldAlt size={10} />
|
||||
)}
|
||||
{t('profile.resetOtp')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Roles */}
|
||||
{/* {profile?.roles && profile.roles.length > 0 && (
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
Assigned Roles
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{profile.roles.map((role, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full text-xs font-medium"
|
||||
>
|
||||
{role.role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Right Column - Edit Forms */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Basic Information */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
|
||||
<FaUser className="text-blue-500" />
|
||||
@ -574,7 +511,8 @@ const UserProfilePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Password Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white flex items-center gap-2">
|
||||
@ -701,7 +639,7 @@ const UserProfilePage: React.FC = () => {
|
||||
<p className="text-sm">Click "Change Password" to update your password</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useWorkOrderDetails, useWorkOrderMutations } from '../hooks/useWorkOrder';
|
||||
import { useWorkflow } from '../hooks/useWorkflow';
|
||||
import { useFrappeFieldBehavior } from '../hooks/useFrappeFieldBehavior';
|
||||
import { setCurrentUser } from '../services/workflowService';
|
||||
import type { WorkflowTransition } from '../services/workflowService';
|
||||
import { FaArrowLeft, FaSave, FaEdit, FaLink, FaSearch, FaSpinner, FaExclamationTriangle, FaInfoCircle, FaPrint, FaPlus, FaTrash, FaCheckCircle, FaTimesCircle, FaUpload, FaFile, FaTimes, FaExternalLinkAlt, FaHistory, FaChevronDown, FaChevronUp, FaUser, FaClock, FaSync, FaBan, FaClipboardList } from 'react-icons/fa';
|
||||
import { FaArrowLeft, FaSave, FaEdit, FaLink, FaSearch, FaSpinner, FaExclamationTriangle, FaInfoCircle, FaPrint, FaPlus, FaTrash, FaCheckCircle, FaTimesCircle, FaUpload, FaFile, FaTimes, FaExternalLinkAlt, FaHistory, FaChevronDown, FaChevronUp, FaUser, FaClock, FaSync, FaBan } from 'react-icons/fa';
|
||||
import type { CreateWorkOrderData } from '../services/workOrderService';
|
||||
import { technicalReportHasGuideCompleted } from '../utils/troubleshootGuideMarkers';
|
||||
import { toast, ToastContainer, Bounce } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
@ -20,7 +19,6 @@ import CommentSection from '../components/CommentSection';
|
||||
|
||||
import DeleteRequestButton from '../components/DeleteRequestButton';
|
||||
import type { DeleteStatus } from '../services/deleteRequestService';
|
||||
import issueService from '../services/issueService';
|
||||
|
||||
// Print Format Configuration
|
||||
const PRINT_FORMAT_NAME = 'Service_Report'; // Change this if your print format has a different name
|
||||
@ -137,53 +135,12 @@ const addDays = (dateStr: string, days: number): string => {
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
/** Map Issue opening_date / opening_time to datetime-local value for failure_date. */
|
||||
const buildFailureDateFromIssueOpening = (
|
||||
openingDate?: string | null,
|
||||
openingTime?: string | null
|
||||
): string => {
|
||||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const fallback = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||
if (!openingDate?.trim()) return fallback;
|
||||
const datePart = openingDate.split(' ')[0].split('T')[0];
|
||||
let timePart = '00:00';
|
||||
if (openingTime && String(openingTime).trim().length >= 5) {
|
||||
timePart = String(openingTime).substring(0, 5);
|
||||
}
|
||||
return `${datePart}T${timePart}`;
|
||||
};
|
||||
|
||||
/** Work Order `custom_priority_` select options (Asset Lite custom field). */
|
||||
const WORK_ORDER_CUSTOM_PRIORITIES = ['Normal', 'Medium', 'Urgent'] as const;
|
||||
type WorkOrderCustomPriority = (typeof WORK_ORDER_CUSTOM_PRIORITIES)[number];
|
||||
|
||||
/**
|
||||
* Issue.priority links to "Issue Priority" (typically Low / Medium / High).
|
||||
* Work_Order.custom_priority_ only allows Normal / Medium / Urgent.
|
||||
*/
|
||||
const mapIssuePriorityToWorkOrderCustomPriority = (
|
||||
issuePriority: string | null | undefined
|
||||
): WorkOrderCustomPriority => {
|
||||
if (!issuePriority?.trim()) return 'Normal';
|
||||
const raw = issuePriority.trim();
|
||||
if ((WORK_ORDER_CUSTOM_PRIORITIES as readonly string[]).includes(raw)) {
|
||||
return raw as WorkOrderCustomPriority;
|
||||
}
|
||||
const norm = raw.toLowerCase();
|
||||
if (norm === 'low') return 'Normal';
|
||||
if (norm === 'medium') return 'Medium';
|
||||
if (norm === 'high' || norm === 'urgent' || norm === 'critical') return 'Urgent';
|
||||
return 'Normal';
|
||||
};
|
||||
|
||||
// ============== END DATE/TIME HELPER FUNCTIONS ==============
|
||||
|
||||
const WorkOrderDetail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { workOrderName } = useParams<{ workOrderName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const duplicateFromWorkOrder = searchParams.get('duplicate');
|
||||
|
||||
@ -192,11 +149,7 @@ const WorkOrderDetail: React.FC = () => {
|
||||
|
||||
const isNewWorkOrder = workOrderName === 'new';
|
||||
const isDuplicating = isNewWorkOrder && !!duplicateFromWorkOrder;
|
||||
const fromIssueName = searchParams.get('from_issue');
|
||||
const isCreatingFromIssue = isNewWorkOrder && !!fromIssueName && !isDuplicating;
|
||||
|
||||
const [issuePrefillResolved, setIssuePrefillResolved] = useState(!isCreatingFromIssue);
|
||||
|
||||
|
||||
/**
|
||||
* Open Service Report print format in a new window
|
||||
* Uses Frappe's built-in print view with trigger_print to auto-open print dialog
|
||||
@ -256,27 +209,7 @@ const WorkOrderDetail: React.FC = () => {
|
||||
isDuplicating ? duplicateFromWorkOrder : (isNewWorkOrder ? null : workOrderName || null)
|
||||
);
|
||||
const { createWorkOrder, updateWorkOrder, loading: saving } = useWorkOrderMutations();
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewWorkOrder || !workOrderName) return;
|
||||
const st = location.state as { troubleshootGuideJustCompleted?: boolean } | undefined;
|
||||
if (!st?.troubleshootGuideJustCompleted) return;
|
||||
void refetch();
|
||||
navigate(
|
||||
{ pathname: location.pathname, search: location.search, hash: location.hash },
|
||||
{ replace: true, state: {} },
|
||||
);
|
||||
}, [
|
||||
location.state,
|
||||
location.pathname,
|
||||
location.search,
|
||||
location.hash,
|
||||
navigate,
|
||||
refetch,
|
||||
isNewWorkOrder,
|
||||
workOrderName,
|
||||
]);
|
||||
|
||||
|
||||
const [isEditing, setIsEditing] = useState(isNewWorkOrder);
|
||||
const [isLoadingAsset, setIsLoadingAsset] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<{ action: string; nextState: string } | null>(null);
|
||||
@ -416,7 +349,6 @@ const WorkOrderDetail: React.FC = () => {
|
||||
custom_building?: string;
|
||||
custom_type?: string;
|
||||
custom_civil_work_category?: string;
|
||||
issue?: string;
|
||||
}>({
|
||||
company: 'King Fahad Specialist Hospital - Dammam',
|
||||
work_order_type: '',
|
||||
@ -475,7 +407,6 @@ const WorkOrderDetail: React.FC = () => {
|
||||
custom_technical_department: '',
|
||||
custom_room_no: '',
|
||||
custom_building: '',
|
||||
issue: '',
|
||||
// For Frappe field behavior evaluation
|
||||
__islocal: false,
|
||||
});
|
||||
@ -2758,57 +2689,6 @@ const fetchItemDefaultWarehouse = async (itemCode: string): Promise<string> => {
|
||||
}
|
||||
}, [isNewWorkOrder, isCreatingFromAsset, isDuplicating, searchParams]);
|
||||
|
||||
// Pre-populate from Support Issue (Work Control flow: /work-orders/new?from_issue=...)
|
||||
useEffect(() => {
|
||||
if (!isCreatingFromIssue || !fromIssueName) {
|
||||
setIssuePrefillResolved(true);
|
||||
return;
|
||||
}
|
||||
setIssuePrefillResolved(false);
|
||||
let cancelled = false;
|
||||
void issueService
|
||||
.getIssue(fromIssueName)
|
||||
.then((iss) => {
|
||||
if (cancelled) return;
|
||||
const subject = (iss.subject || '').trim();
|
||||
const body = (iss.description || '').trim();
|
||||
const descriptionFromIssue =
|
||||
subject && body
|
||||
? `Subject: ${subject}\n\n${body}`
|
||||
: subject || body || '';
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
issue: iss.name || fromIssueName,
|
||||
company: iss.company || prev.company,
|
||||
work_order_type: iss.issue_type || prev.work_order_type,
|
||||
custom_priority_: iss.priority
|
||||
? mapIssuePriorityToWorkOrderCustomPriority(iss.priority)
|
||||
: prev.custom_priority_,
|
||||
project: iss.project || prev.project,
|
||||
description: descriptionFromIssue || prev.description,
|
||||
failure_date: buildFailureDateFromIssueOpening(iss.opening_date, iss.opening_time),
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
toast.error(t('workOrders.detail.failedToLoadSupportIssue'), {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
icon: <FaExclamationTriangle />,
|
||||
});
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
issue: fromIssueName,
|
||||
}));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIssuePrefillResolved(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isCreatingFromIssue, fromIssueName, t]);
|
||||
|
||||
// Store initial values only on FIRST load (for "Back To Controller" and restore logic)
|
||||
useEffect(() => {
|
||||
if (workOrder && !hasLoadedInitialData && !isNewWorkOrder && !isDuplicating) {
|
||||
@ -2843,9 +2723,7 @@ const fetchItemDefaultWarehouse = async (itemCode: string): Promise<string> => {
|
||||
repair_status: isDuplicating ? 'Open' : (workOrder.repair_status || 'Open'),
|
||||
workflow_state: isDuplicating ? 'Draft' : (workOrder.workflow_state || 'Draft'),
|
||||
department: workOrder.department || '',
|
||||
custom_priority_: mapIssuePriorityToWorkOrderCustomPriority(
|
||||
workOrder.custom_priority_ || undefined
|
||||
),
|
||||
custom_priority_: workOrder.custom_priority_ || 'Normal',
|
||||
asset_type: workOrder.asset_type || 'Non Biomedical',
|
||||
custom_type: workOrder.custom_type || 'Corrective',
|
||||
manufacturer: workOrder.manufacturer || '',
|
||||
@ -2895,7 +2773,6 @@ const fetchItemDefaultWarehouse = async (itemCode: string): Promise<string> => {
|
||||
custom_room_no: workOrder.custom_room_no || '',
|
||||
custom_building: workOrder.custom_building || '',
|
||||
custom_civil_work_category: workOrder.custom_civil_work_category || '',
|
||||
issue: workOrder.issue || '',
|
||||
});
|
||||
}
|
||||
}, [workOrder, isDuplicating]);
|
||||
@ -3179,32 +3056,20 @@ const fetchItemDefaultWarehouse = async (itemCode: string): Promise<string> => {
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ Prepare data with proper datetime format for Frappe (omit client-only keys)
|
||||
const { __islocal: _omitIsLocal, ...formPayload } = formData as typeof formData & { __islocal?: boolean };
|
||||
// ✅ Prepare data with proper datetime format for Frappe
|
||||
const dataToSave = {
|
||||
...formPayload,
|
||||
custom_priority_: mapIssuePriorityToWorkOrderCustomPriority(formData.custom_priority_),
|
||||
...formData,
|
||||
failure_date: formatDateTimeForFrappe(formData.failure_date),
|
||||
first_responded_on: formatDateTimeForFrappe(formData.first_responded_on),
|
||||
completion_date: formatDateTimeForFrappe(formData.completion_date),
|
||||
};
|
||||
|
||||
// Always persist Support Issue link on create when the form has it (not only when URL has from_issue).
|
||||
if (isNewWorkOrder) {
|
||||
const linkedIssue = String(formData.issue || fromIssueName || '').trim();
|
||||
if (linkedIssue) {
|
||||
dataToSave.issue = linkedIssue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (isNewWorkOrder || isDuplicating) {
|
||||
const newWorkOrder = await createWorkOrder(dataToSave);
|
||||
const successMessage = isDuplicating
|
||||
? t('workOrders.detail.workOrderDuplicatedSuccessfully')
|
||||
: isCreatingFromIssue
|
||||
? t('workOrders.detail.workOrderCreatedFromIssueSuccessfully')
|
||||
: isCreatingFromAsset
|
||||
: isCreatingFromAsset
|
||||
? t('workOrders.detail.workOrderCreatedFromAssetSuccessfully')
|
||||
: t('workOrders.detail.workOrderCreatedSuccessfully');
|
||||
toast.success(successMessage, {
|
||||
@ -3504,21 +3369,12 @@ if (action === 'Send to Supervisor' && currentWfState === 'Sent to Engineer') {
|
||||
}
|
||||
};
|
||||
|
||||
const troubleshootGuideCompleted = useMemo(
|
||||
() => technicalReportHasGuideCompleted(workOrder?.actions_performed ?? formData.actions_performed),
|
||||
[workOrder?.actions_performed, formData.actions_performed],
|
||||
);
|
||||
|
||||
if (loading || (isCreatingFromIssue && !issuePrefillResolved)) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">
|
||||
{isCreatingFromIssue && !issuePrefillResolved
|
||||
? t('workOrders.detail.loadingSupportIssue')
|
||||
: t('workOrders.loadingDetails')}
|
||||
</p>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">{t('workOrders.loadingDetails')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -3542,7 +3398,6 @@ if (action === 'Send to Supervisor' && currentWfState === 'Sent to Engineer') {
|
||||
|
||||
const getPageTitle = () => {
|
||||
if (isDuplicating) return t('workOrders.detail.duplicateWorkOrder');
|
||||
if (isCreatingFromIssue) return t('workOrders.detail.createFromSupportIssue');
|
||||
if (isCreatingFromAsset) return t('workOrders.detail.createFromAsset');
|
||||
if (isNewWorkOrder) return t('workOrders.detail.newWorkOrder');
|
||||
return t('workOrders.detail.workOrderDetails');
|
||||
@ -4266,39 +4121,6 @@ if (action === 'Send to Supervisor' && currentWfState === 'Sent to Engineer') {
|
||||
{t('workOrders.detail.serviceReport')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isNewWorkOrder &&
|
||||
workOrderName &&
|
||||
currentWorkflowState === 'Repair InProgress' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/work-orders/${encodeURIComponent(workOrderName)}/troubleshoot`)}
|
||||
title={
|
||||
troubleshootGuideCompleted
|
||||
? t('workOrders.detail.troubleshootingGuideDoneTitle')
|
||||
: undefined
|
||||
}
|
||||
className={
|
||||
troubleshootGuideCompleted
|
||||
? 'bg-gray-500 hover:bg-gray-600 text-white px-6 py-2 rounded-lg flex items-center gap-2 shadow-sm ring-1 ring-gray-400/50 dark:ring-gray-500/50'
|
||||
: 'bg-violet-600 hover:bg-violet-700 text-white px-6 py-2 rounded-lg flex items-center gap-2'
|
||||
}
|
||||
>
|
||||
{troubleshootGuideCompleted ? (
|
||||
<FaCheckCircle className="shrink-0" aria-hidden />
|
||||
) : (
|
||||
<FaClipboardList className="shrink-0" aria-hidden />
|
||||
)}
|
||||
<span className="flex flex-col items-start leading-tight text-left">
|
||||
<span>{t('workOrders.detail.troubleshootingTree')}</span>
|
||||
{troubleshootGuideCompleted ? (
|
||||
<span className="text-[10px] font-medium opacity-90">
|
||||
{t('workOrders.detail.troubleshootingGuideDone')}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* ✅ Show Edit button only if not submitted and not cancelled */}
|
||||
{/* Hide for Contractor Supervisor unless they also have System Manager role */}
|
||||
@ -5267,15 +5089,6 @@ if (action === 'Send to Supervisor' && currentWfState === 'Sent to Engineer') {
|
||||
{t('workOrders.detail.description')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<LinkField
|
||||
label={t('workOrders.detail.supportIssue')}
|
||||
doctype="Issue"
|
||||
value={formData.issue || ''}
|
||||
onChange={(val) => setFormData((prev) => ({ ...prev, issue: val }))}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('workOrders.detail.natureOfComplaint')}
|
||||
|
||||
@ -20,8 +20,6 @@ import type { DeleteStatus } from '../services/deleteRequestService';
|
||||
import apiService from '../services/apiService';
|
||||
import API_CONFIG from '../config/api';
|
||||
import DynamicExportModal from '../components/DynamicExportModal';
|
||||
import VoiceStatusModal from '../components/VoiceStatusModal';
|
||||
import { FaMicrophone } from 'react-icons/fa';
|
||||
|
||||
type ExportFormat = 'csv' | 'excel';
|
||||
type ExportScope = 'selected' | 'all_on_page' | 'all_with_filters';
|
||||
@ -159,7 +157,6 @@ const WorkOrderList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const setSearchParamsRef = useRef(setSearchParams);
|
||||
|
||||
const page = useMemo(() => {
|
||||
const p = parseInt(searchParams.get('page') || '1', 10);
|
||||
@ -167,15 +164,11 @@ const WorkOrderList: React.FC = () => {
|
||||
}, [searchParams]);
|
||||
|
||||
const setPage = useCallback((zeroBasedPage: number) => {
|
||||
setSearchParamsRef.current((prev) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set('page', String(zeroBasedPage + 1));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParamsRef.current = setSearchParams;
|
||||
}, [setSearchParams]);
|
||||
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
|
||||
@ -193,7 +186,6 @@ const WorkOrderList: React.FC = () => {
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const [showReportModal, setShowReportModal] = useState(false);
|
||||
const [showCloseModal, setShowCloseModal] = useState(false);
|
||||
const [showVoiceModal, setShowVoiceModal] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [selectedWorkOrdersInfo, setSelectedWorkOrdersInfo] = useState<SelectedWorkOrderInfo[]>([]);
|
||||
const [isClusterManager, setIsClusterManager] = useState(false);
|
||||
@ -373,7 +365,7 @@ const WorkOrderList: React.FC = () => {
|
||||
const filtersChangedOnce = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!filtersChangedOnce.current) { filtersChangedOnce.current = true; return; }
|
||||
setSearchParamsRef.current((prev) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by');
|
||||
if (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
|
||||
@ -665,7 +657,7 @@ const WorkOrderList: React.FC = () => {
|
||||
setFilterSerialNumber(''); setTempSerialNumber('');
|
||||
setFilterManufacturer(''); setFilterSupplier('');
|
||||
setFilterDepartment(''); setFilterPriority(''); setFilterWorkflowState('');
|
||||
setSearchParamsRef.current((prev) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end');
|
||||
next.delete('work_order_id'); next.delete('asset'); next.delete('work_order_type');
|
||||
@ -810,13 +802,8 @@ const WorkOrderList: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{canViewWOReport && (
|
||||
<button
|
||||
onClick={() => setShowVoiceModal(true)}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all"
|
||||
title="Use this to bulk update work order status"
|
||||
>
|
||||
<FaMicrophone />
|
||||
<span className="font-medium">Voice Command Assist</span>
|
||||
<button onClick={() => setShowReportModal(true)} className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all">
|
||||
<FaTable /><span className="font-medium">WO Report</span>
|
||||
</button>
|
||||
)}
|
||||
{isClusterManager && selectedRows.size > 0 && (
|
||||
@ -1305,12 +1292,7 @@ const WorkOrderList: React.FC = () => {
|
||||
|
||||
{/* Work Order Report Modal */}
|
||||
<WorkOrderReportModal isOpen={showReportModal} onClose={() => setShowReportModal(false)} />
|
||||
<VoiceStatusModal
|
||||
isOpen={showVoiceModal}
|
||||
onClose={() => setShowVoiceModal(false)}
|
||||
selectedRows={selectedRows}
|
||||
onUpdateSuccess={() => { refetch(); setSelectedRows(new Set()); }}
|
||||
/>
|
||||
|
||||
<style>{`
|
||||
@keyframes scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||
.animate-scale-in { animation: scale-in 0.2s ease-out; }
|
||||
|
||||
@ -1,213 +0,0 @@
|
||||
export type CategoryId = 'hvac' | 'power' | 'building' | 'app';
|
||||
|
||||
const IMG = 'auto=format&fit=crop&w=960&q=80';
|
||||
|
||||
export type WizardStep = {
|
||||
imageSrc?: string;
|
||||
imageCaption?: string;
|
||||
title: string;
|
||||
intro: string;
|
||||
bullets: string[];
|
||||
};
|
||||
|
||||
export const CATEGORY_ORDER: { id: CategoryId; label: string }[] = [
|
||||
{ id: 'hvac', label: 'Air conditioning' },
|
||||
{ id: 'power', label: 'Power & electrical' },
|
||||
{ id: 'building', label: 'Building systems' },
|
||||
{ id: 'app', label: 'Application & account' },
|
||||
];
|
||||
|
||||
export const STEPS_BY_CATEGORY: Record<CategoryId, WizardStep[]> = {
|
||||
power: [
|
||||
{
|
||||
imageSrc: `https://images.unsplash.com/photo-1621905251918-48416bd8575a?${IMG}`,
|
||||
imageCaption: 'Panels, breakers, and protection devices',
|
||||
title: 'Breakers, RCDs, and panels',
|
||||
intro:
|
||||
'Read the steps below. When you have actually tried them, use the button below to continue.',
|
||||
bullets: [
|
||||
'Identify whether one circuit or a whole area is affected.',
|
||||
'Check the correct panel for that space; reset a tripped breaker only once if policy allows, and note which label matches the room or equipment.',
|
||||
'If an RCD/GFCI device has tripped, press reset after unplugging new or suspect equipment—if it trips again, leave it off and report.',
|
||||
],
|
||||
},
|
||||
{
|
||||
imageSrc: `https://images.unsplash.com/photo-1558494949-ef010cbdcc31?${IMG}`,
|
||||
imageCaption: 'UPS, server rooms, and facility power indicators',
|
||||
title: 'Power at the source',
|
||||
intro: 'Confirm whether the issue is local or upstream before opening a ticket.',
|
||||
bullets: [
|
||||
'Check if neighbouring rooms or equipment on the same feed still have power.',
|
||||
'Note any recent work, outages, or generator/UPS transfers communicated by facilities.',
|
||||
'Do not open energized panels beyond your training; photograph labels only if safe to do so.',
|
||||
],
|
||||
},
|
||||
{
|
||||
imageSrc: `https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?${IMG}`,
|
||||
imageCaption: 'Isolating equipment and electrical safety',
|
||||
title: 'Equipment and safety',
|
||||
intro: 'Reduce risk and capture details technicians will need.',
|
||||
bullets: [
|
||||
'Turn off or isolate affected equipment only if your procedure allows it.',
|
||||
'Unplug new or suspect devices from the affected circuit where policy permits.',
|
||||
'Record exact location, panel name, breaker label, and what changed just before the fault.',
|
||||
],
|
||||
},
|
||||
{
|
||||
imageSrc: `https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?${IMG}`,
|
||||
imageCaption: 'Notes, photos, and details for your ticket',
|
||||
title: 'Before you escalate',
|
||||
intro: 'If the problem is still there after the checks above, gather this for your ticket.',
|
||||
bullets: [
|
||||
'List what you tried and the outcome (including repeated trips or resets).',
|
||||
'Note any burning smell, heat, noise, or visible damage—stop and report immediately if unsafe.',
|
||||
'Use Support issues → New issue with photos and panel labels if your site allows attachments.',
|
||||
],
|
||||
},
|
||||
],
|
||||
hvac: [
|
||||
{
|
||||
imageSrc: `https://images.unsplash.com/photo-1770625467384-304e461ef1be?${IMG}`,
|
||||
imageCaption: 'Digital thermostat, mode, and setpoint',
|
||||
title: 'Quick HVAC checks',
|
||||
intro: 'Work through these before reporting poor cooling or airflow.',
|
||||
bullets: [
|
||||
'Confirm the thermostat mode (cool/heat/fan), setpoint, and that it is not in override or schedule lock.',
|
||||
'Check return air paths and vents—blocked grilles are a common cause of weak airflow.',
|
||||
'If there is a local isolator or shutoff for the unit, verify it is on per site policy only.',
|
||||
],
|
||||
},
|
||||
{
|
||||
imageSrc: `https://images.unsplash.com/photo-1585771724684-38269d6639fd?${IMG}`,
|
||||
imageCaption: 'Outdoor unit, lines, and coil area',
|
||||
title: 'Filters and coils',
|
||||
intro: 'Restricted airflow often traces to maintenance items.',
|
||||
bullets: [
|
||||
'Note the last filter change date; a clogged filter reduces airflow and can trip limits.',
|
||||
'Look for ice on lines or coils—if present, stop forcing the system and report.',
|
||||
'Do not remove safety covers unless you are authorized and trained.',
|
||||
],
|
||||
},
|
||||
{
|
||||
imageSrc: `https://images.unsplash.com/photo-1765850262030-1ae93e474473?${IMG}`,
|
||||
imageCaption: 'Ceiling units, alarms, and wet areas near equipment',
|
||||
title: 'Noise, water, and alarms',
|
||||
intro: 'Capture symptoms that help narrow the fault.',
|
||||
bullets: [
|
||||
'Record unusual sounds (grinding, squealing) and when they started.',
|
||||
'Check for water near the unit or drain pan overflow indicators.',
|
||||
'Copy any fault code or BMS alarm text shown on the panel or screen.',
|
||||
],
|
||||
},
|
||||
{
|
||||
imageSrc: `https://images.unsplash.com/photo-1497366216548-37526070297c?${IMG}`,
|
||||
imageCaption: 'Notes, readings, and what to send with your issue',
|
||||
title: 'Ready for a ticket',
|
||||
intro: 'Include this in your issue for faster routing.',
|
||||
bullets: [
|
||||
'Building, floor, room, and asset tag if available.',
|
||||
'Whether the space is occupied, sensitive (e.g. clinical), or time-critical.',
|
||||
'What you already tried and current thermostat readings if visible.',
|
||||
],
|
||||
},
|
||||
],
|
||||
building: [
|
||||
{
|
||||
imageSrc: `https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?${IMG}`,
|
||||
imageCaption: 'Doors, circulation, and access points',
|
||||
title: 'Access, doors, and security',
|
||||
intro: 'Many building tickets start with access or environmental comfort.',
|
||||
bullets: [
|
||||
'Confirm whether the problem is one door/zone or a wider access failure.',
|
||||
'Check for obstructions, broken glass, or propped doors that affect sensors.',
|
||||
'Note if card readers or intercoms show an error code or LED pattern.',
|
||||
],
|
||||
},
|
||||
{
|
||||
imageSrc: `https://images.unsplash.com/photo-1600585154526-990dced4db0d?${IMG}`,
|
||||
imageCaption: 'Fixtures, pipes, and visible water',
|
||||
title: 'Water and leaks',
|
||||
intro: 'Treat active leaks as urgent; safety comes first.',
|
||||
bullets: [
|
||||
'If water is near electrics, keep clear and report immediately per your emergency process.',
|
||||
'Identify the source if safe (ceiling, fixture, pipe chase) and contain with approved materials only.',
|
||||
'Photograph the area from a safe distance if your policy allows.',
|
||||
],
|
||||
},
|
||||
{
|
||||
imageSrc: `https://images.unsplash.com/photo-1558618666-fcd25c85cd64?${IMG}`,
|
||||
imageCaption: 'Lighting, outlets, and local circuits',
|
||||
title: 'Lighting and small power',
|
||||
intro: 'Local checks before logging a facilities issue.',
|
||||
bullets: [
|
||||
'Try adjacent switches; confirm the circuit is not isolated for maintenance.',
|
||||
'For recurring trips, note which device or circuit label is involved.',
|
||||
'Report flickering that affects a whole zone—it may indicate a loose neutral upstream.',
|
||||
],
|
||||
},
|
||||
{
|
||||
imageSrc: `https://images.unsplash.com/photo-1497366754035-f200968a6e72?${IMG}`,
|
||||
imageCaption: 'Location, timing, and operational impact',
|
||||
title: 'Escalation details',
|
||||
intro: 'Help the team respond on first visit.',
|
||||
bullets: [
|
||||
'Exact location, time noticed, and whether it is getting worse.',
|
||||
'Impact on operations, safety, or compliance.',
|
||||
'Any recent construction, cleaning, or vendor work nearby.',
|
||||
],
|
||||
},
|
||||
],
|
||||
app: [
|
||||
{
|
||||
imageSrc: `https://images.unsplash.com/photo-1460925895917-afdab827c52f?${IMG}`,
|
||||
imageCaption: 'Browser, session, and refresh',
|
||||
title: 'Browser and session',
|
||||
intro: 'Most application issues are fixed with a few quick steps.',
|
||||
bullets: [
|
||||
'Hard refresh the page (Ctrl+Shift+R or Cmd+Shift+R) or try another browser.',
|
||||
'Sign out and sign back in; clear site data only if your IT policy allows it.',
|
||||
'Disable conflicting extensions temporarily to rule out ad blockers or script blockers.',
|
||||
],
|
||||
},
|
||||
{
|
||||
imageSrc: `https://images.unsplash.com/photo-1544197150-b99a580bb7a8?${IMG}`,
|
||||
imageCaption: 'Wi‑Fi, mobile data, and VPN connectivity',
|
||||
title: 'Network and VPN',
|
||||
intro: 'Confirm connectivity before reporting an app outage.',
|
||||
bullets: [
|
||||
'Check other internal sites or the corporate intranet load correctly.',
|
||||
'If you use VPN, reconnect or try the flow off VPN if permitted.',
|
||||
'Note exact error text, URL, and approximate time of failure.',
|
||||
],
|
||||
},
|
||||
{
|
||||
imageSrc: `https://images.unsplash.com/photo-1563013544-824ae1b704d3?${IMG}`,
|
||||
imageCaption: 'Accounts, roles, and access control',
|
||||
title: 'Permissions and data',
|
||||
intro: 'Some errors are role or data related.',
|
||||
bullets: [
|
||||
'Confirm you are using the correct company/site profile if the app supports multiple.',
|
||||
'Try the same action on another device to see if it is account-specific.',
|
||||
'Screenshot the error banner or toast message (redact personal data).',
|
||||
],
|
||||
},
|
||||
{
|
||||
imageSrc: `https://images.unsplash.com/photo-1555066931-4365d14bab8c?${IMG}`,
|
||||
imageCaption: 'Steps, screenshots, and environment details',
|
||||
title: 'Handover to support',
|
||||
intro: 'Open a ticket with enough context to reproduce.',
|
||||
bullets: [
|
||||
'Steps you took, expected result, and actual result.',
|
||||
'Browser name and version, device type, and whether others are affected.',
|
||||
'Attach screenshots only if allowed by your data policy.',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const TOTAL_STEPS = 4;
|
||||
|
||||
export type NewIssuePrecheckLocationState = {
|
||||
newIssuePrecheckDone?: boolean;
|
||||
precheckCantCompleteReason?: string;
|
||||
};
|
||||
@ -96,29 +96,14 @@ interface KycDetailsResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export interface LoginUserMessage {
|
||||
full_name?: string;
|
||||
user_id?: string;
|
||||
email?: string;
|
||||
home_page?: string;
|
||||
sid?: string;
|
||||
interface LoginResponse {
|
||||
message: {
|
||||
full_name: string;
|
||||
user_id: string;
|
||||
sid: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TwoFactorVerification {
|
||||
method: string;
|
||||
setup?: boolean;
|
||||
prompt?: string;
|
||||
token_delivery?: boolean;
|
||||
}
|
||||
|
||||
export type LoginResult =
|
||||
| { status: 'logged_in'; user: LoginUserMessage }
|
||||
| {
|
||||
status: 'two_factor_required';
|
||||
tmp_id: string;
|
||||
verification: TwoFactorVerification;
|
||||
};
|
||||
|
||||
interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
@ -287,248 +272,95 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
private parseFrappeLoginError(data: Record<string, unknown>, status: number): string {
|
||||
if (typeof data.message === 'string' && data.message) return data.message;
|
||||
if (typeof data.exc === 'string' && data.exc) {
|
||||
const match = data.exc.match(/:\s*(.+)$/);
|
||||
return match ? match[1].trim() : data.exc;
|
||||
}
|
||||
if (data._server_messages) {
|
||||
try {
|
||||
const msgs = JSON.parse(String(data._server_messages)) as string[];
|
||||
const parsed = msgs.map((m) => JSON.parse(m) as { message?: string });
|
||||
const text = parsed.map((p) => p.message).filter(Boolean).join(' ');
|
||||
if (text) return text;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (status === 401) return 'Invalid credentials or verification code.';
|
||||
return 'Login failed. Please try again.';
|
||||
}
|
||||
|
||||
private parseLoggedInUser(data: Record<string, unknown>, fallbackEmail?: string): LoginUserMessage | null {
|
||||
if (typeof data.message === 'string' && data.message === 'Logged In') {
|
||||
return {
|
||||
full_name: data.full_name as string | undefined,
|
||||
user_id: (data.user as string) || fallbackEmail,
|
||||
home_page: data.home_page as string | undefined,
|
||||
sid: data.sid as string | undefined,
|
||||
email: fallbackEmail,
|
||||
};
|
||||
}
|
||||
if (data.message && typeof data.message === 'object') {
|
||||
return data.message as LoginUserMessage;
|
||||
}
|
||||
if (data.full_name || data.user) {
|
||||
return {
|
||||
full_name: data.full_name as string | undefined,
|
||||
user_id: (data.user as string) || fallbackEmail,
|
||||
home_page: data.home_page as string | undefined,
|
||||
sid: data.sid as string | undefined,
|
||||
email: fallbackEmail,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async postLoginRequest(formData: FormData): Promise<Record<string, unknown>> {
|
||||
const url = `${this.baseURL}${this.endpoints.LOGIN}`;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json' },
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const raw = await response.text();
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
data = raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
data = {};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = import.meta.env.DEV
|
||||
? this.parseFrappeLoginError(data, response.status)
|
||||
: response.status === 401
|
||||
? 'Invalid credentials or verification code.'
|
||||
: 'Login failed. Please try again.';
|
||||
throw new ApiError(errorMessage, response.status);
|
||||
}
|
||||
|
||||
return data;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
private mapLoginResponse(
|
||||
data: Record<string, unknown>,
|
||||
fallbackEmail?: string
|
||||
): LoginResult {
|
||||
const verification = data.verification as TwoFactorVerification | undefined;
|
||||
const tmpId = data.tmp_id as string | undefined;
|
||||
const message = data.message;
|
||||
|
||||
if (
|
||||
verification &&
|
||||
tmpId &&
|
||||
message !== 'Logged In'
|
||||
) {
|
||||
return {
|
||||
status: 'two_factor_required',
|
||||
tmp_id: tmpId,
|
||||
verification,
|
||||
};
|
||||
}
|
||||
|
||||
const user = this.parseLoggedInUser(data, fallbackEmail);
|
||||
if (user && (message === 'Logged In' || user.sid || user.user_id)) {
|
||||
return { status: 'logged_in', user };
|
||||
}
|
||||
|
||||
throw new ApiError('Unexpected login response.', 500, 'INVALID_RESPONSE');
|
||||
}
|
||||
|
||||
// Authentication Methods
|
||||
async login(credentials: LoginCredentials): Promise<LoginResult> {
|
||||
async login(credentials: LoginCredentials): Promise<LoginResponse> {
|
||||
// Only log in development mode (hide sensitive data in production)
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[API Service] Login attempt for:', credentials.email);
|
||||
}
|
||||
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('usr', credentials.email);
|
||||
formData.append('pwd', credentials.password);
|
||||
|
||||
const data = await this.postLoginRequest(formData);
|
||||
const result = this.mapLoginResponse(data, credentials.email);
|
||||
|
||||
if (import.meta.env.DEV && result.status === 'logged_in') {
|
||||
console.log('[API Service] Login successful');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Second step after password when Frappe requires OTP (OTP App). */
|
||||
async verifyLoginOtp(tmpId: string, otp: string): Promise<LoginResult> {
|
||||
const formData = new FormData();
|
||||
formData.append('otp', otp.trim());
|
||||
formData.append('tmp_id', tmpId);
|
||||
|
||||
const data = await this.postLoginRequest(formData);
|
||||
const result = this.mapLoginResponse(data);
|
||||
|
||||
if (result.status !== 'logged_in') {
|
||||
throw new ApiError('Verification failed. Please try again.', 401, 'OTP_FAILED');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest POST to Frappe — sends the same password-reset email as Desk "Forgot password".
|
||||
* The email link targets the Frappe site URL (update password on the server).
|
||||
*/
|
||||
async requestPasswordReset(userEmail: string, signal?: AbortSignal): Promise<void> {
|
||||
const trimmed = userEmail.trim();
|
||||
if (!trimmed) {
|
||||
throw new ApiError('Email is required', 400, 'EMPTY_EMAIL');
|
||||
}
|
||||
|
||||
const csrfToken = await this.getCSRFTokenForGuest();
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
};
|
||||
if (csrfToken) {
|
||||
headers['X-Frappe-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
|
||||
const url = `${this.baseURL}${this.endpoints.RESET_PASSWORD}`;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 45000);
|
||||
const combinedSignal = signal
|
||||
? AbortSignal.any([signal, controller.signal])
|
||||
: controller.signal;
|
||||
|
||||
|
||||
const url = `${this.baseURL}${this.endpoints.LOGIN}`;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: new URLSearchParams({ user: trimmed }).toString(),
|
||||
credentials: 'include',
|
||||
signal: combinedSignal,
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: formData,
|
||||
credentials: 'include', // Important: Include cookies
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
const raw = await response.text();
|
||||
let data: { message?: unknown } = {};
|
||||
try {
|
||||
data = raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
data = {};
|
||||
}
|
||||
|
||||
const msg =
|
||||
typeof data.message === 'string'
|
||||
? data.message
|
||||
: typeof data.message === 'object' &&
|
||||
data.message !== null &&
|
||||
'message' in (data.message as object)
|
||||
? String((data.message as { message?: string }).message)
|
||||
: '';
|
||||
|
||||
if (response.status === 404 || msg.toLowerCase().includes('not found')) {
|
||||
throw new ApiError('User not found', 404, 'USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
throw new ApiError('Could not send reset email', 403, 'FORBIDDEN');
|
||||
}
|
||||
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData: ApiResponse = await response.json().catch(() => ({}));
|
||||
// Hide detailed error messages in production
|
||||
const errorMessage = import.meta.env.DEV
|
||||
? (errorData.error || `HTTP error! status: ${response.status}`)
|
||||
: 'Invalid credentials. Please try again.';
|
||||
throw new ApiError(errorMessage, response.status);
|
||||
}
|
||||
|
||||
const data: any = await response.json();
|
||||
|
||||
// Handle Frappe API response format
|
||||
// Check if message is a string (like "Logged In")
|
||||
if (typeof data.message === 'string' && data.message === 'Logged In') {
|
||||
const userData = {
|
||||
full_name: data.full_name,
|
||||
user_id: data.user || data.email,
|
||||
home_page: data.home_page,
|
||||
sid: data.sid
|
||||
};
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[API Service] Login successful');
|
||||
}
|
||||
|
||||
return { message: userData } as LoginResponse;
|
||||
}
|
||||
|
||||
// If message contains user object
|
||||
if (data.message && typeof data.message === 'object') {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[API Service] Login successful');
|
||||
}
|
||||
return { message: data.message } as LoginResponse;
|
||||
}
|
||||
|
||||
// Sometimes Frappe returns full_name, user directly
|
||||
if (data.full_name || data.user) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[API Service] Login successful');
|
||||
}
|
||||
return { message: data } as LoginResponse;
|
||||
}
|
||||
|
||||
return { message: data } as LoginResponse;
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
// Only log detailed errors in development
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('[API Service] Login error:', error.message);
|
||||
}
|
||||
throw new ApiError(
|
||||
msg || `HTTP error! status: ${response.status}`,
|
||||
response.status,
|
||||
'REQUEST_FAILED'
|
||||
import.meta.env.DEV ? error.message : 'Login failed. Please try again.'
|
||||
);
|
||||
}
|
||||
|
||||
if (msg === 'disabled' || msg === 'not allowed') {
|
||||
throw new ApiError('Reset not allowed', 200, 'RESET_NOT_ALLOWED');
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** CSRF for guest flows (login page injects window.csrf_token). */
|
||||
async getCSRFTokenForGuest(): Promise<string | null> {
|
||||
const win = typeof window !== 'undefined' ? (window as { csrf_token?: string }) : undefined;
|
||||
if (win?.csrf_token) {
|
||||
return win.csrf_token;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${this.baseURL}${this.endpoints.CSRF_TOKEN}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'include',
|
||||
});
|
||||
if (response.ok) {
|
||||
const data: ApiResponse<string> = await response.json();
|
||||
return data.message || null;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// async login(credentials: LoginCredentials): Promise<LoginResponse> {
|
||||
// const formData = new FormData();
|
||||
// formData.append('usr', credentials.email);
|
||||
@ -734,4 +566,3 @@ class ApiError extends Error {
|
||||
const apiService = new ApiService();
|
||||
export default apiService;
|
||||
export { ApiError };
|
||||
export type { LoginCredentials };
|
||||
|
||||
@ -36,7 +36,6 @@ export interface Asset {
|
||||
workflow_state?: string;
|
||||
custom_delete_status?: string;
|
||||
custom_category?: string;
|
||||
custom_recalled?: string;
|
||||
|
||||
calculate_depreciation?: boolean;
|
||||
gross_purchase_amount?: number;
|
||||
@ -302,59 +301,6 @@ class AssetService {
|
||||
return apiService.apiCall<Asset[]>(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return which of the given values exist as Asset.custom_serial_number (exact match).
|
||||
*/
|
||||
async getExistingSerialNumbers(values: string[]): Promise<Set<string>> {
|
||||
const unique = Array.from(
|
||||
new Set(values.map((value) => value.trim()).filter(Boolean))
|
||||
);
|
||||
if (unique.length === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const matched = new Set<string>();
|
||||
const chunkSize = 500;
|
||||
|
||||
for (let offset = 0; offset < unique.length; offset += chunkSize) {
|
||||
const chunk = unique.slice(offset, offset + chunkSize);
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('fields', JSON.stringify(['custom_serial_number']));
|
||||
queryParams.append('filters', JSON.stringify([['custom_serial_number', 'in', chunk]]));
|
||||
queryParams.append('limit_page_length', '0');
|
||||
|
||||
const response = await fetch(
|
||||
`${API_CONFIG.BASE_URL}/api/resource/Asset?${queryParams.toString()}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(typeof window !== 'undefined' &&
|
||||
(window as Window & { csrf_token?: string }).csrf_token
|
||||
? { 'X-Frappe-CSRF-Token': (window as Window & { csrf_token?: string }).csrf_token! }
|
||||
: {}),
|
||||
},
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
for (const row of result.data || []) {
|
||||
const serial = (row.custom_serial_number || '').trim();
|
||||
if (serial) {
|
||||
matched.add(serial);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an asset document (changes docstatus from 0 to 1)
|
||||
*/
|
||||
|
||||
@ -1,102 +0,0 @@
|
||||
import { formatFrappeApiError } from '../utils/frappeErrorMessage';
|
||||
|
||||
export interface DeliveryNoteItem {
|
||||
name?: string; item_code?: string; item_name?: string; description?: string;
|
||||
qty?: number; uom?: string; stock_uom?: string; conversion_factor?: number;
|
||||
rate?: number; amount?: number; net_rate?: number; net_amount?: number;
|
||||
price_list_rate?: number; discount_percentage?: number;
|
||||
against_sales_order?: string; so_detail?: string;
|
||||
project?: string; cost_center?: string;
|
||||
is_free_item?: number; grant_commission?: number; idx?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface DeliveryNote {
|
||||
name: string; docstatus?: number; owner?: string; creation?: string; modified?: string;
|
||||
naming_series?: string; posting_date?: string; posting_time?: string; set_posting_time?: number;
|
||||
customer?: string; customer_name?: string; company?: string;
|
||||
project?: string; cost_center?: string; currency?: string;
|
||||
selling_price_list?: string; status?: string; is_return?: number;
|
||||
items?: DeliveryNoteItem[]; taxes?: any[];
|
||||
grand_total?: number; net_total?: number; total?: number;
|
||||
total_taxes_and_charges?: number; rounded_total?: number; [key: string]: any;
|
||||
}
|
||||
|
||||
class DeliveryNoteService {
|
||||
private async getCSRFToken(): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null;
|
||||
if ((window as any).csrf_token) return (window as any).csrf_token;
|
||||
if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token;
|
||||
try {
|
||||
const res = await fetch('/api/method/frappe.sessions.get_csrf_token', { credentials: 'include' });
|
||||
if (res.ok) { const json = await res.json(); if (json.message) { (window as any).csrf_token = json.message; return json.message; } }
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getHeaders(): Promise<Record<string, string>> {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
||||
const csrf = await this.getCSRFToken();
|
||||
if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
|
||||
return h;
|
||||
}
|
||||
|
||||
private async fetchJson(url: string, opts: RequestInit = {}): Promise<any> {
|
||||
const r = await fetch(url, { credentials: 'include', ...opts });
|
||||
const body = await r.json();
|
||||
if (!r.ok) {
|
||||
const msg = formatFrappeApiError(body) || body?.exc_type || `HTTP ${r.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async getDeliveryNotes(params: { filters?: any[]; limit_start?: number; limit_page_length?: number; order_by?: string } = {}): Promise<DeliveryNote[]> {
|
||||
const q = new URLSearchParams();
|
||||
const fields = ['name','customer','customer_name','posting_date','status','grand_total','currency','docstatus','creation'];
|
||||
q.set('fields', JSON.stringify(fields));
|
||||
if (params.filters?.length) q.set('filters', JSON.stringify(params.filters));
|
||||
q.set('limit_start', String(params.limit_start ?? 0));
|
||||
q.set('limit_page_length', String(params.limit_page_length ?? 20));
|
||||
q.set('order_by', params.order_by ?? 'creation desc');
|
||||
const body = await this.fetchJson(`/api/resource/Delivery Note?${q}`);
|
||||
return body.data || [];
|
||||
}
|
||||
|
||||
async getDeliveryNoteCount(filters: any[] = []): Promise<number> {
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||
if (filters.length) q.set('filters', JSON.stringify(filters));
|
||||
try {
|
||||
const body = await this.fetchJson(`/api/resource/Delivery Note?${q}`);
|
||||
return body.data?.[0]?.count ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async getDeliveryNote(name: string): Promise<DeliveryNote> {
|
||||
const body = await this.fetchJson(`/api/resource/Delivery Note/${encodeURIComponent(name)}`);
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async createDeliveryNote(data: Partial<DeliveryNote>): Promise<DeliveryNote> {
|
||||
const headers = await this.getHeaders();
|
||||
const body = await this.fetchJson('/api/resource/Delivery Note', { method: 'POST', headers, body: JSON.stringify({ ...data, doctype: 'Delivery Note' }) });
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async updateDeliveryNote(name: string, data: Partial<DeliveryNote>): Promise<DeliveryNote> {
|
||||
const headers = await this.getHeaders();
|
||||
const body = await this.fetchJson(`/api/resource/Delivery Note/${encodeURIComponent(name)}`, { method: 'PUT', headers, body: JSON.stringify(data) });
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async submitDeliveryNote(name: string): Promise<DeliveryNote> {
|
||||
const headers = await this.getHeaders();
|
||||
const body = await this.fetchJson(`/api/resource/Delivery Note/${encodeURIComponent(name)}`, { method: 'PUT', headers, body: JSON.stringify({ docstatus: 1 }) });
|
||||
return body.data;
|
||||
}
|
||||
}
|
||||
|
||||
export default new DeliveryNoteService();
|
||||
@ -16,8 +16,6 @@ export interface Issue {
|
||||
subject: string;
|
||||
raised_by: string;
|
||||
status: string;
|
||||
/** Support Issue workflow (Asset Lite), e.g. Sent to Work Control */
|
||||
workflow_state?: string;
|
||||
priority?: string;
|
||||
issue_type?: string;
|
||||
description?: string;
|
||||
|
||||
@ -1,189 +0,0 @@
|
||||
import API_CONFIG from '../config/api';
|
||||
import { toFrappeFilterArray } from '../utils/listFilterUtils';
|
||||
|
||||
// ─── Customer ─────────────────────────────────────────────────────────────────
|
||||
export interface Customer {
|
||||
name: string;
|
||||
naming_series?: string;
|
||||
customer_name?: string;
|
||||
customer_type?: string; // Company | Individual | Hospital
|
||||
customer_group?: string;
|
||||
territory?: string;
|
||||
is_internal_customer?: number;
|
||||
language?: string;
|
||||
default_commission_rate?: number;
|
||||
so_required?: number;
|
||||
dn_required?: number;
|
||||
is_frozen?: number;
|
||||
disabled?: number;
|
||||
owner?: string;
|
||||
creation?: string;
|
||||
modified?: string;
|
||||
docstatus?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// ─── Employee ─────────────────────────────────────────────────────────────────
|
||||
export interface Employee {
|
||||
name: string;
|
||||
naming_series?: string;
|
||||
employee_name?: string;
|
||||
first_name?: string;
|
||||
middle_name?: string;
|
||||
last_name?: string;
|
||||
salutation?: string;
|
||||
gender?: string;
|
||||
date_of_birth?: string;
|
||||
date_of_joining?: string;
|
||||
status?: string; // Active | Inactive | Left | On Leave
|
||||
// Company Details section
|
||||
company?: string; // Labeled "Hospital" in this system
|
||||
designation?: string;
|
||||
branch?: string;
|
||||
department?: string;
|
||||
reports_to?: string;
|
||||
employee_number?: string;
|
||||
user_id?: string;
|
||||
owner?: string;
|
||||
creation?: string;
|
||||
modified?: string;
|
||||
docstatus?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// ─── Service ──────────────────────────────────────────────────────────────────
|
||||
class MasterService {
|
||||
private readonly baseURL = API_CONFIG.BASE_URL;
|
||||
|
||||
private async getCSRFToken(): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null;
|
||||
if ((window as any).csrf_token) return (window as any).csrf_token;
|
||||
if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token;
|
||||
try {
|
||||
const res = await fetch(`${this.baseURL}/api/method/frappe.sessions.get_csrf_token`, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.message) { (window as any).csrf_token = json.message; return json.message; }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getHeaders(): Promise<Record<string, string>> {
|
||||
const h: Record<string, string> = { Accept: 'application/json', 'Content-Type': 'application/json' };
|
||||
const csrf = await this.getCSRFToken();
|
||||
if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
|
||||
return h;
|
||||
}
|
||||
|
||||
private async parseFrappeError(response: Response): Promise<Error> {
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (body._server_messages) {
|
||||
try {
|
||||
const msgs = JSON.parse(body._server_messages);
|
||||
const parsed = typeof msgs[0] === 'string' ? JSON.parse(msgs[0]) : msgs[0];
|
||||
if (parsed?.message) return new Error(parsed.message);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (body.message && typeof body.message === 'string') return new Error(body.message);
|
||||
if (body.exc_type) return new Error(`${body.exc_type}: ${body.exc?.split('\n').slice(-2).join(' ').trim() || response.statusText}`);
|
||||
} catch { /* ignore */ }
|
||||
const map: Record<number, string> = { 403: 'Permission denied.', 404: 'Record not found.', 417: 'Validation error.', 500: 'Server error.' };
|
||||
return new Error(map[response.status] || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
private async fetchJson(url: string, options?: RequestInit): Promise<any> {
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include', ...options,
|
||||
headers: { ...await this.getHeaders(), ...(options?.headers || {}) },
|
||||
});
|
||||
if (!response.ok) throw await this.parseFrappeError(response.clone());
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ─── Customer ───────────────────────────────────────────────────────────────
|
||||
async getCustomers(params: { limit_start?: number; limit_page_length?: number; filters?: Record<string, any> } = {}): Promise<{ data: Customer[] }> {
|
||||
const { limit_start = 0, limit_page_length = 20, filters = {} } = params;
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['name','customer_name','customer_type','customer_group','territory','disabled','creation','modified']));
|
||||
q.set('limit_start', String(limit_start));
|
||||
q.set('limit_page_length', String(limit_page_length));
|
||||
q.set('order_by', 'modified desc');
|
||||
if (Object.keys(filters).length > 0) {
|
||||
const fa = toFrappeFilterArray(filters);
|
||||
if (fa.length > 0) q.set('filters', JSON.stringify(fa));
|
||||
}
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Customer?${q}`);
|
||||
return { data: r.data || [] };
|
||||
}
|
||||
|
||||
async getCustomer(name: string): Promise<Customer> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Customer/${encodeURIComponent(name)}`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async createCustomer(data: Partial<Customer>): Promise<Customer> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Customer`, { method: 'POST', body: JSON.stringify(data) });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async updateCustomer(name: string, data: Partial<Customer>): Promise<Customer> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Customer/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async getCustomerCount(filters: Record<string, any> = {}): Promise<number> {
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||
if (Object.keys(filters).length > 0) {
|
||||
const fa = toFrappeFilterArray(filters);
|
||||
if (fa.length > 0) q.set('filters', JSON.stringify(fa));
|
||||
}
|
||||
try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Customer?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
|
||||
}
|
||||
|
||||
// ─── Employee ───────────────────────────────────────────────────────────────
|
||||
async getEmployees(params: { limit_start?: number; limit_page_length?: number; filters?: Record<string, any> } = {}): Promise<{ data: Employee[] }> {
|
||||
const { limit_start = 0, limit_page_length = 20, filters = {} } = params;
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['name','employee_name','first_name','last_name','gender','status','company','designation','department','date_of_joining','creation','modified']));
|
||||
q.set('limit_start', String(limit_start));
|
||||
q.set('limit_page_length', String(limit_page_length));
|
||||
q.set('order_by', 'modified desc');
|
||||
if (Object.keys(filters).length > 0) {
|
||||
const fa = toFrappeFilterArray(filters);
|
||||
if (fa.length > 0) q.set('filters', JSON.stringify(fa));
|
||||
}
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Employee?${q}`);
|
||||
return { data: r.data || [] };
|
||||
}
|
||||
|
||||
async getEmployee(name: string): Promise<Employee> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Employee/${encodeURIComponent(name)}`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async createEmployee(data: Partial<Employee>): Promise<Employee> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Employee`, { method: 'POST', body: JSON.stringify(data) });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async updateEmployee(name: string, data: Partial<Employee>): Promise<Employee> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Employee/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async getEmployeeCount(filters: Record<string, any> = {}): Promise<number> {
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||
if (Object.keys(filters).length > 0) {
|
||||
const fa = toFrappeFilterArray(filters);
|
||||
if (fa.length > 0) q.set('filters', JSON.stringify(fa));
|
||||
}
|
||||
try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Employee?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
|
||||
}
|
||||
}
|
||||
|
||||
const masterService = new MasterService();
|
||||
export default masterService;
|
||||
@ -1,127 +0,0 @@
|
||||
import { formatFrappeApiError } from '../utils/frappeErrorMessage';
|
||||
|
||||
const RESOURCE = 'Material%20Request';
|
||||
|
||||
export interface MaterialRequestItem {
|
||||
name?: string;
|
||||
item_code?: string;
|
||||
item_name?: string;
|
||||
description?: string;
|
||||
qty?: number;
|
||||
uom?: string;
|
||||
schedule_date?: string;
|
||||
idx?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface MaterialRequest {
|
||||
name: string;
|
||||
docstatus?: number;
|
||||
status?: string;
|
||||
material_request_type?: string;
|
||||
transaction_date?: string;
|
||||
schedule_date?: string;
|
||||
company?: string;
|
||||
set_warehouse?: string;
|
||||
items?: MaterialRequestItem[];
|
||||
owner?: string;
|
||||
creation?: string;
|
||||
modified?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
class MaterialRequestService {
|
||||
private async getCSRFToken(): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null;
|
||||
if ((window as any).csrf_token) return (window as any).csrf_token;
|
||||
if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token;
|
||||
try {
|
||||
const res = await fetch('/api/method/frappe.sessions.get_csrf_token', { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.message) {
|
||||
(window as any).csrf_token = json.message;
|
||||
return json.message;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getHeaders(): Promise<Record<string, string>> {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
||||
const csrf = await this.getCSRFToken();
|
||||
if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
|
||||
return h;
|
||||
}
|
||||
|
||||
private async fetchJson(url: string, opts: RequestInit = {}): Promise<any> {
|
||||
const headers = await this.getHeaders();
|
||||
const r = await fetch(url, { credentials: 'include', headers, ...opts });
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(formatFrappeApiError(body) || body?.message || `HTTP ${r.status}`);
|
||||
if (body.exc) throw new Error(formatFrappeApiError(body) || 'Request failed');
|
||||
return body;
|
||||
}
|
||||
|
||||
async getMaterialRequests(
|
||||
params: { filters?: any[]; limit_start?: number; limit_page_length?: number; order_by?: string } = {},
|
||||
): Promise<MaterialRequest[]> {
|
||||
const q = new URLSearchParams();
|
||||
const fields = [
|
||||
'name', 'material_request_type', 'transaction_date', 'status',
|
||||
'company', 'docstatus', 'creation', 'modified',
|
||||
];
|
||||
q.set('fields', JSON.stringify(fields));
|
||||
if (params.filters?.length) q.set('filters', JSON.stringify(params.filters));
|
||||
q.set('limit_start', String(params.limit_start ?? 0));
|
||||
q.set('limit_page_length', String(params.limit_page_length ?? 20));
|
||||
q.set('order_by', params.order_by ?? 'creation desc');
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}?${q}`);
|
||||
return body.data || [];
|
||||
}
|
||||
|
||||
async getMaterialRequestCount(filters: any[] = []): Promise<number> {
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||
if (filters.length) q.set('filters', JSON.stringify(filters));
|
||||
try {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}?${q}`);
|
||||
return body.data?.[0]?.count ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async getMaterialRequest(name: string): Promise<MaterialRequest> {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`);
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async createMaterialRequest(data: Partial<MaterialRequest>): Promise<MaterialRequest> {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async updateMaterialRequest(name: string, data: Partial<MaterialRequest>): Promise<MaterialRequest> {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async submitMaterialRequest(name: string): Promise<MaterialRequest> {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ docstatus: 1 }),
|
||||
});
|
||||
return body.data;
|
||||
}
|
||||
}
|
||||
|
||||
const materialRequestService = new MaterialRequestService();
|
||||
export default materialRequestService;
|
||||
@ -1,145 +0,0 @@
|
||||
export interface PaymentEntryReference {
|
||||
name?: string;
|
||||
reference_doctype?: string;
|
||||
reference_name?: string;
|
||||
due_date?: string;
|
||||
total_amount?: number;
|
||||
outstanding_amount?: number;
|
||||
allocated_amount?: number;
|
||||
exchange_rate?: number;
|
||||
exchange_gain_loss?: number;
|
||||
account?: string;
|
||||
payment_term_outstanding?: number;
|
||||
idx?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PaymentEntry {
|
||||
name?: string;
|
||||
payment_type?: string;
|
||||
posting_date?: string;
|
||||
company?: string;
|
||||
mode_of_payment?: string;
|
||||
party_type?: string;
|
||||
party?: string;
|
||||
party_name?: string;
|
||||
paid_from?: string;
|
||||
paid_from_account_type?: string;
|
||||
paid_from_account_currency?: string;
|
||||
paid_from_account_balance?: number;
|
||||
paid_to?: string;
|
||||
paid_to_account_type?: string;
|
||||
paid_to_account_currency?: string;
|
||||
paid_to_account_balance?: number;
|
||||
paid_amount?: number;
|
||||
received_amount?: number;
|
||||
source_exchange_rate?: number;
|
||||
target_exchange_rate?: number;
|
||||
base_paid_amount?: number;
|
||||
base_received_amount?: number;
|
||||
total_allocated_amount?: number;
|
||||
unallocated_amount?: number;
|
||||
difference_amount?: number;
|
||||
project?: string;
|
||||
cost_center?: string;
|
||||
status?: string;
|
||||
docstatus?: number;
|
||||
remarks?: string;
|
||||
/** Cheque / Reference No (Transaction ID) */
|
||||
reference_no?: string;
|
||||
/** Cheque / Reference Date */
|
||||
reference_date?: string;
|
||||
references?: PaymentEntryReference[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
class PaymentEntryService {
|
||||
private csrfToken: string | null = null;
|
||||
|
||||
private async getCSRFToken(): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null;
|
||||
if ((window as any).csrf_token) return (window as any).csrf_token;
|
||||
if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token;
|
||||
try {
|
||||
const res = await fetch('/api/method/frappe.sessions.get_csrf_token', { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.message) { (window as any).csrf_token = json.message; return json.message; }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getHeaders(): Promise<Record<string, string>> {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
||||
const csrf = await this.getCSRFToken();
|
||||
if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
|
||||
return h;
|
||||
}
|
||||
|
||||
private async fetchJson(url: string, opts: RequestInit = {}): Promise<any> {
|
||||
const r = await fetch(url, { credentials: 'include', ...opts });
|
||||
const body = await r.json();
|
||||
if (!r.ok) {
|
||||
let msg = body?.exc_type || body?.message || `HTTP ${r.status}`;
|
||||
if (body?._server_messages) {
|
||||
try {
|
||||
const msgs = JSON.parse(body._server_messages);
|
||||
const parsed = Array.isArray(msgs) ? msgs.map((m: string) => { try { return JSON.parse(m).message; } catch { return m; } }) : [];
|
||||
if (parsed.length) msg = parsed.join('\n');
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async getPaymentEntries(params: { filters?: any[]; limit_start?: number; limit_page_length?: number } = {}): Promise<PaymentEntry[]> {
|
||||
const q = new URLSearchParams();
|
||||
const fields = ['name', 'payment_type', 'posting_date', 'party', 'party_name', 'party_type', 'paid_amount', 'received_amount', 'status', 'mode_of_payment', 'company', 'docstatus', 'creation'];
|
||||
q.set('fields', JSON.stringify(fields));
|
||||
if (params.filters?.length) q.set('filters', JSON.stringify(params.filters));
|
||||
q.set('limit_start', String(params.limit_start ?? 0));
|
||||
q.set('limit_page_length', String(params.limit_page_length ?? 20));
|
||||
q.set('order_by', 'creation desc');
|
||||
const body = await this.fetchJson(`/api/resource/Payment Entry?${q}`);
|
||||
return body.data || [];
|
||||
}
|
||||
|
||||
async getPaymentEntryCount(filters: any[] = []): Promise<number> {
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||
if (filters.length) q.set('filters', JSON.stringify(filters));
|
||||
try {
|
||||
const body = await this.fetchJson(`/api/resource/Payment%20Entry?${q}`);
|
||||
return body.data?.[0]?.count ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async getPaymentEntry(name: string): Promise<PaymentEntry> {
|
||||
const body = await this.fetchJson(`/api/resource/Payment Entry/${encodeURIComponent(name)}`);
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async createPaymentEntry(data: Partial<PaymentEntry>): Promise<PaymentEntry> {
|
||||
const headers = await this.getHeaders();
|
||||
const body = await this.fetchJson('/api/resource/Payment Entry', { method: 'POST', headers, body: JSON.stringify({ ...data, doctype: 'Payment Entry' }) });
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async updatePaymentEntry(name: string, data: Partial<PaymentEntry>): Promise<PaymentEntry> {
|
||||
const headers = await this.getHeaders();
|
||||
const body = await this.fetchJson(`/api/resource/Payment Entry/${encodeURIComponent(name)}`, { method: 'PUT', headers, body: JSON.stringify(data) });
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async submitPaymentEntry(name: string): Promise<PaymentEntry> {
|
||||
const headers = await this.getHeaders();
|
||||
const body = await this.fetchJson(`/api/resource/Payment Entry/${encodeURIComponent(name)}`, { method: 'PUT', headers, body: JSON.stringify({ docstatus: 1 }) });
|
||||
return body.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const paymentEntryService = new PaymentEntryService();
|
||||
@ -1,629 +0,0 @@
|
||||
import API_CONFIG from '../config/api';
|
||||
import { toFrappeFilterArray } from '../utils/listFilterUtils';
|
||||
|
||||
// ─── Project ─────────────────────────────────────────────────────────────────
|
||||
export interface ProjectUser {
|
||||
user?: string; email?: string; full_name?: string; welcome_email_sent?: number;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
name: string;
|
||||
project_name?: string;
|
||||
status?: string;
|
||||
is_active?: string;
|
||||
priority?: string;
|
||||
company?: string;
|
||||
customer?: string;
|
||||
project_type?: string;
|
||||
custom_project_manager?: string;
|
||||
naming_series?: string;
|
||||
expected_start_date?: string;
|
||||
expected_end_date?: string;
|
||||
percent_complete_method?: string;
|
||||
percent_complete?: number;
|
||||
estimated_costing?: number;
|
||||
actual_time?: number;
|
||||
total_costing_amount?: number;
|
||||
total_purchase_cost?: number;
|
||||
total_sales_amount?: number;
|
||||
total_billable_amount?: number;
|
||||
total_billed_amount?: number;
|
||||
gross_margin?: number;
|
||||
per_gross_margin?: number;
|
||||
description?: string;
|
||||
notes?: string;
|
||||
users?: ProjectUser[];
|
||||
owner?: string;
|
||||
creation?: string;
|
||||
modified?: string;
|
||||
docstatus?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// ─── Task — exact fields present in actual Frappe system (verified from JSON) ─
|
||||
// NOTE: type, issue, parent_task are intentionally excluded — they do NOT exist
|
||||
// in this system's tabTask database table.
|
||||
export interface Task {
|
||||
name: string;
|
||||
subject?: string;
|
||||
project?: string;
|
||||
status?: string; // Open | Working | Completed | Cancelled | Overdue
|
||||
priority?: string; // Low | Medium | High | Urgent
|
||||
task_weight?: number;
|
||||
is_group?: number;
|
||||
is_template?: number;
|
||||
is_milestone?: number;
|
||||
exp_start_date?: string;
|
||||
exp_end_date?: string;
|
||||
expected_time?: number;
|
||||
actual_time?: number;
|
||||
progress?: number;
|
||||
completed_on?: string;
|
||||
custom_risk?: string;
|
||||
custom_action?: string;
|
||||
custom_task_obstacle?: string;
|
||||
custom_assign_to?: string;
|
||||
total_costing_amount?: number;
|
||||
total_billing_amount?: number;
|
||||
company?: string;
|
||||
description?: string;
|
||||
depends_on_tasks?: string;
|
||||
depends_on?: Array<{ task?: string; subject?: string }>;
|
||||
_assign?: string;
|
||||
owner?: string;
|
||||
creation?: string;
|
||||
modified?: string;
|
||||
docstatus?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// ─── Project Template ─────────────────────────────────────────────────────────
|
||||
// Frappe's "Project Template Task" child doctype fields (confirmed from Frappe desk):
|
||||
// task - Link to Task (mandatory, filtered by is_template=1)
|
||||
// subject - Data, auto-fetched from task.subject (read-only)
|
||||
// duration - Int (days, optional)
|
||||
// The user must select (or create) a Task with is_template=1 for each row.
|
||||
export interface ProjectTemplateTask {
|
||||
task?: string; // Link to Task doctype (is_template=1 tasks)
|
||||
subject?: string; // Auto-fetched from task.subject (read-only)
|
||||
duration?: number; // Duration in days
|
||||
}
|
||||
|
||||
export interface ProjectTemplate {
|
||||
name: string;
|
||||
project_type?: string;
|
||||
tasks?: ProjectTemplateTask[];
|
||||
owner?: string;
|
||||
creation?: string;
|
||||
modified?: string;
|
||||
}
|
||||
|
||||
// ─── Timesheet Detail child table — exact fields from Frappe system ───────────
|
||||
export interface TimesheetDetail {
|
||||
name?: string;
|
||||
activity_type?: string;
|
||||
from_time?: string;
|
||||
to_time?: string;
|
||||
hours?: number;
|
||||
billing_hours?: number;
|
||||
billing_rate?: number;
|
||||
billing_amount?: number;
|
||||
costing_rate?: number;
|
||||
costing_amount?: number;
|
||||
project?: string;
|
||||
task?: string;
|
||||
note?: string;
|
||||
is_billable?: number;
|
||||
completed?: number;
|
||||
idx?: number;
|
||||
}
|
||||
|
||||
// ─── Timesheet — exact fields from actual Frappe system data ─────────────────
|
||||
export interface Timesheet {
|
||||
name: string;
|
||||
naming_series?: string;
|
||||
status?: string; // Draft | Submitted | Cancelled
|
||||
currency?: string;
|
||||
exchange_rate?: number;
|
||||
// Header link fields (ERPNext: company + parent_project; optional custom aliases)
|
||||
employee?: string;
|
||||
customer?: string;
|
||||
parent_project?: string; // Frappe fieldname for header "Project"
|
||||
project?: string; // Legacy / display alias only — prefer parent_project for API
|
||||
company?: string; // Company (labeled Hospital in this app)
|
||||
hospital?: string; // Custom field on some sites — avoid relying on this for ERPNext core
|
||||
note?: string;
|
||||
// Totals (computed by Frappe)
|
||||
total_hours?: number;
|
||||
total_billable_hours?: number;
|
||||
total_billable_amount?: number;
|
||||
total_costing_amount?: number;
|
||||
time_logs?: TimesheetDetail[];
|
||||
owner?: string;
|
||||
creation?: string;
|
||||
modified?: string;
|
||||
docstatus?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// ─── Activity Type ────────────────────────────────────────────────────────────
|
||||
// Frappe Activity Type fields (from standard ERPNext Activity Type doctype):
|
||||
// activity_type - Data (mandatory) — autoname: Frappe sets name = activity_type
|
||||
// billing_rate - Currency (NOT default_billing_rate)
|
||||
// costing_rate - Currency (NOT default_costing_rate)
|
||||
// disabled - Check
|
||||
// autoname = "field:activity_type" → name column = activity_type value
|
||||
export interface ActivityType {
|
||||
name: string;
|
||||
activity_type?: string; // The display label (same as name due to autoname)
|
||||
billing_rate?: number; // Standard field name in Frappe
|
||||
costing_rate?: number; // Standard field name in Frappe
|
||||
disabled?: number;
|
||||
owner?: string;
|
||||
creation?: string;
|
||||
modified?: string;
|
||||
}
|
||||
|
||||
export interface ProjectListParams {
|
||||
filters?: Record<string, any>;
|
||||
/** Extra Frappe filter rows (e.g. child table: [['Timesheet Detail','project','=', name]]) */
|
||||
appendFilters?: any[][];
|
||||
fields?: string[];
|
||||
limit_start?: number;
|
||||
limit_page_length?: number;
|
||||
order_by?: string;
|
||||
}
|
||||
|
||||
/** Custom child tables on Task / Project (desk: `custom_task_updates`, `custom_project_updates`). */
|
||||
export const CUSTOM_TASK_UPDATES = 'custom_task_updates';
|
||||
export const CUSTOM_PROJECT_UPDATES = 'custom_project_updates';
|
||||
|
||||
export type ProgressUpdateChildRow = {
|
||||
name?: string;
|
||||
update_?: string;
|
||||
date?: string;
|
||||
task?: string;
|
||||
idx?: number;
|
||||
};
|
||||
|
||||
/** Build Frappe child-table payload; optional `fillTaskLink` sets hidden Task link on every row (Task form). */
|
||||
export function serializeProgressUpdateRows(
|
||||
rows: ProgressUpdateChildRow[],
|
||||
options?: { fillTaskLink?: string },
|
||||
): Record<string, unknown>[] {
|
||||
const fill = options?.fillTaskLink;
|
||||
return rows
|
||||
.filter(r => {
|
||||
const u = String(r.update_ || '').trim();
|
||||
const d = String(r.date || '').trim();
|
||||
const t = String(r.task || '').trim();
|
||||
return u || d || t;
|
||||
})
|
||||
.map((r, i) => {
|
||||
const row: Record<string, unknown> = { idx: i + 1, update_: r.update_ || '' };
|
||||
if (r.date) row.date = r.date;
|
||||
if (r.name) row.name = r.name;
|
||||
const taskVal = fill ?? r.task;
|
||||
if (taskVal) row.task = taskVal;
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Service class ────────────────────────────────────────────────────────────
|
||||
class ProjectService {
|
||||
private readonly baseURL = API_CONFIG.BASE_URL;
|
||||
|
||||
private clearCsrfTokenCache(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
delete (window as any).csrf_token;
|
||||
const fr = (window as any).frappe;
|
||||
if (fr && typeof fr === 'object' && 'csrf_token' in fr) {
|
||||
try {
|
||||
delete fr.csrf_token;
|
||||
} catch {
|
||||
fr.csrf_token = '';
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
private async getCSRFToken(): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null;
|
||||
// Frappe sets csrf_token on window directly OR via the frappe global object
|
||||
if ((window as any).csrf_token) return (window as any).csrf_token;
|
||||
if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token;
|
||||
// Fallback: fetch it from the API (happens when SPA loads before Frappe injects it)
|
||||
try {
|
||||
const res = await fetch(`${this.baseURL}/api/method/frappe.sessions.get_csrf_token`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.message) {
|
||||
(window as any).csrf_token = json.message;
|
||||
return json.message;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private mergeHeaders(base: Record<string, string>, extra?: HeadersInit): Record<string, string> {
|
||||
const out = { ...base };
|
||||
if (!extra) return out;
|
||||
if (extra instanceof Headers) {
|
||||
extra.forEach((v, k) => {
|
||||
out[k] = v;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
if (Array.isArray(extra)) {
|
||||
for (const [k, v] of extra) out[k] = v;
|
||||
return out;
|
||||
}
|
||||
return { ...out, ...extra };
|
||||
}
|
||||
|
||||
/** Detect CSRF failure so we can clear cache and retry once with a fresh token. */
|
||||
private isFrappeCsrfError(body: unknown): boolean {
|
||||
if (!body || typeof body !== 'object') return false;
|
||||
const b = body as Record<string, unknown>;
|
||||
if (b.exc_type === 'CSRFTokenError') return true;
|
||||
const msg = typeof b.message === 'string' ? b.message : '';
|
||||
if (/csrf/i.test(msg)) return true;
|
||||
if (b._server_messages && typeof b._server_messages === 'string') {
|
||||
try {
|
||||
const msgs = JSON.parse(b._server_messages as string);
|
||||
const first = msgs[0];
|
||||
const inner = typeof first === 'string' ? JSON.parse(first) : first;
|
||||
const m = typeof inner?.message === 'string' ? inner.message : '';
|
||||
if (/invalid request/i.test(m) && b.exc_type === 'CSRFTokenError') return true;
|
||||
} catch { /* ignore */ }
|
||||
if (/CSRFTokenError|csrf/i.test(b._server_messages as string)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async getHeaders(): Promise<Record<string, string>> {
|
||||
const h: Record<string, string> = { Accept: 'application/json', 'Content-Type': 'application/json' };
|
||||
const csrf = await this.getCSRFToken();
|
||||
if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Parse Frappe JSON error responses into a human-readable message. */
|
||||
private async parseFrappeError(response: Response): Promise<Error> {
|
||||
const statusMessages: Record<number, string> = {
|
||||
403: 'Permission denied. Your role does not have access to this DocType.',
|
||||
417: 'Validation error. Check field values or role permissions for this DocType.',
|
||||
404: 'Record not found.',
|
||||
500: 'Server error. Check that all field names are correct.',
|
||||
};
|
||||
try {
|
||||
const body = await response.json();
|
||||
// Frappe _server_messages is a double-serialized JSON array
|
||||
if (body._server_messages) {
|
||||
try {
|
||||
const msgs = JSON.parse(body._server_messages);
|
||||
const parsed = typeof msgs[0] === 'string' ? JSON.parse(msgs[0]) : msgs[0];
|
||||
if (parsed?.message) return new Error(parsed.message);
|
||||
} catch { /* ignore parse error */ }
|
||||
}
|
||||
// Direct message field
|
||||
if (body.message && typeof body.message === 'string' && body.message.trim()) {
|
||||
return new Error(body.message);
|
||||
}
|
||||
// exc_type with exception string (e.g. OperationalError, MandatoryError)
|
||||
if (body.exc_type) {
|
||||
const detail = body.exc ? body.exc.split('\n').slice(-2).join(' ').trim() : '';
|
||||
return new Error(`${body.exc_type}: ${detail || statusMessages[response.status] || response.statusText}`);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return new Error(statusMessages[response.status] || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
private async fetchJson(url: string, options?: RequestInit): Promise<any> {
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include', ...options,
|
||||
headers: { ...await this.getHeaders(), ...(options?.headers || {}) },
|
||||
});
|
||||
if (!response.ok) throw await this.parseFrappeError(response.clone());
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ─── Project ──────────────────────────────────────────────────────────────
|
||||
async getProjects(params: ProjectListParams = {}): Promise<{ data: Project[] }> {
|
||||
const { filters = {}, fields = ['name','project_name','status','priority','company','customer','expected_start_date','expected_end_date','percent_complete','actual_time','creation','modified','owner'], limit_start = 0, limit_page_length = 20, order_by = 'modified desc' } = params;
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(fields));
|
||||
q.set('limit_start', String(limit_start));
|
||||
q.set('limit_page_length', String(limit_page_length));
|
||||
q.set('order_by', order_by);
|
||||
if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); }
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Project?${q}`);
|
||||
return { data: r.data || [] };
|
||||
}
|
||||
|
||||
async getProject(name: string): Promise<Project> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Project/${encodeURIComponent(name)}`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async getProjectCount(filters: Record<string, any> = {}): Promise<number> {
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||
if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); }
|
||||
try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Project?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
|
||||
}
|
||||
|
||||
async createProject(data: Partial<Project>): Promise<Project> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Project`, { method: 'POST', body: JSON.stringify(data) });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async updateProject(name: string, data: Partial<Project>): Promise<Project> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Project/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
// ─── Project Template ─────────────────────────────────────────────────────
|
||||
async getProjectTemplates(params: ProjectListParams = {}): Promise<{ data: ProjectTemplate[] }> {
|
||||
const { filters = {}, limit_start = 0, limit_page_length = 50, order_by = 'name asc' } = params;
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['name', 'project_type', 'creation', 'modified']));
|
||||
q.set('limit_start', String(limit_start));
|
||||
q.set('limit_page_length', String(limit_page_length));
|
||||
q.set('order_by', order_by);
|
||||
if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); }
|
||||
try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Project%20Template?${q}`); return { data: r.data || [] }; }
|
||||
catch { return { data: [] }; }
|
||||
}
|
||||
|
||||
async getProjectTemplate(name: string): Promise<ProjectTemplate> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Project%20Template/${encodeURIComponent(name)}`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async createProjectTemplate(data: Partial<ProjectTemplate>): Promise<ProjectTemplate> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Project%20Template`, { method: 'POST', body: JSON.stringify(data) });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async updateProjectTemplate(name: string, data: Partial<ProjectTemplate>): Promise<ProjectTemplate> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Project%20Template/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async getProjectTemplateCount(filters: Record<string, any> = {}): Promise<number> {
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||
if (Object.keys(filters).length > 0) {
|
||||
const fa = toFrappeFilterArray(filters);
|
||||
if (fa.length > 0) q.set('filters', JSON.stringify(fa));
|
||||
}
|
||||
try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Project%20Template?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
|
||||
}
|
||||
|
||||
// ─── Tasks ────────────────────────────────────────────────────────────────
|
||||
// Only fields confirmed present in this system's tabTask table (from actual JSON).
|
||||
// type / issue / parent_task are deliberately omitted — they don't exist here.
|
||||
private readonly TASK_FIELDS = [
|
||||
'name', 'subject', 'project', 'status', 'priority',
|
||||
'task_weight', 'is_group', 'is_template', 'is_milestone',
|
||||
'expected_time', 'actual_time', 'progress',
|
||||
'exp_start_date', 'exp_end_date', 'parent_task',
|
||||
'completed_on',
|
||||
'custom_risk', 'custom_action', 'custom_task_obstacle', 'custom_assign_to',
|
||||
'total_costing_amount', 'total_billing_amount', 'company',
|
||||
'depends_on_tasks', '_assign', 'owner', 'creation', 'modified',
|
||||
];
|
||||
|
||||
async getTasks(params: ProjectListParams = {}): Promise<{ data: Task[] }> {
|
||||
const { filters = {}, fields = this.TASK_FIELDS, limit_start = 0, limit_page_length = 50, order_by = 'creation desc' } = params;
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(fields));
|
||||
q.set('limit_start', String(limit_start));
|
||||
q.set('limit_page_length', String(limit_page_length));
|
||||
q.set('order_by', order_by);
|
||||
if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); }
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Task?${q}`);
|
||||
return { data: r.data || [] };
|
||||
}
|
||||
|
||||
async getTasksForProject(projectName: string, params?: { limit_start?: number; limit_page_length?: number }): Promise<{ data: Task[] }> {
|
||||
return this.getTasks({ ...params, filters: { project: projectName }, limit_page_length: 100 });
|
||||
}
|
||||
|
||||
async getTask(name: string): Promise<Task> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Task/${encodeURIComponent(name)}`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async createTask(data: Partial<Task>): Promise<Task> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Task`, { method: 'POST', body: JSON.stringify(data) });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async updateTask(name: string, data: Partial<Task>): Promise<Task> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Task/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async getTaskCount(filters: Record<string, any> = {}): Promise<number> {
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||
if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); }
|
||||
try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Task?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
|
||||
}
|
||||
|
||||
// ─── Timesheets ──────────────────────────────────────────────────────────
|
||||
// Fields match the actual Frappe Timesheet doctype (verified against system JSON)
|
||||
// Note: employee/posting_date are NOT required in this system's configuration
|
||||
async getTimesheets(params: ProjectListParams = {}): Promise<{ data: Timesheet[] }> {
|
||||
const {
|
||||
filters = {},
|
||||
appendFilters = [],
|
||||
fields = [
|
||||
'name',
|
||||
'status',
|
||||
'docstatus',
|
||||
'currency',
|
||||
'employee',
|
||||
'customer',
|
||||
'company',
|
||||
'total_hours',
|
||||
'total_billable_hours',
|
||||
'total_billable_amount',
|
||||
'total_costing_amount',
|
||||
'creation',
|
||||
'modified',
|
||||
'owner',
|
||||
],
|
||||
limit_start = 0,
|
||||
limit_page_length = 20,
|
||||
order_by = 'creation desc',
|
||||
} = params;
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(fields));
|
||||
q.set('limit_start', String(limit_start));
|
||||
q.set('limit_page_length', String(limit_page_length));
|
||||
q.set('order_by', order_by);
|
||||
let fa = toFrappeFilterArray(filters);
|
||||
if (appendFilters.length) fa = [...fa, ...appendFilters];
|
||||
if (fa.length > 0) q.set('filters', JSON.stringify(fa));
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet?${q}`);
|
||||
return { data: r.data || [] };
|
||||
}
|
||||
|
||||
async getTimesheetsForProject(projectName: string, params?: { limit_start?: number; limit_page_length?: number }): Promise<{ data: Timesheet[] }> {
|
||||
const { limit_start = 0, limit_page_length = 50 } = params || {};
|
||||
const q = new URLSearchParams();
|
||||
q.set('filters', JSON.stringify([['Timesheet Detail', 'project', '=', projectName]]));
|
||||
q.set('fields', JSON.stringify(['name','status','docstatus','currency','total_hours','total_billable_hours','total_billable_amount','total_costing_amount','creation','modified']));
|
||||
q.set('limit_start', String(limit_start));
|
||||
q.set('limit_page_length', String(limit_page_length));
|
||||
q.set('order_by', 'creation desc');
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet?${q}`);
|
||||
return { data: r.data || [] };
|
||||
}
|
||||
|
||||
async getTimesheet(name: string): Promise<Timesheet> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet/${encodeURIComponent(name)}`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async createTimesheet(data: Partial<Timesheet>): Promise<Timesheet> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet`, { method: 'POST', body: JSON.stringify(data) });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async updateTimesheet(name: string, data: Partial<Timesheet>): Promise<Timesheet> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async submitTimesheet(name: string): Promise<Timesheet> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify({ docstatus: 1 }) });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async cancelTimesheet(name: string): Promise<Timesheet> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify({ docstatus: 2 }) });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async getTimesheetCount(filters: Record<string, any> = {}, appendFilters: any[] = []): Promise<number> {
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||
let fa = toFrappeFilterArray(filters);
|
||||
if (appendFilters.length) fa = [...fa, ...appendFilters];
|
||||
if (fa.length > 0) q.set('filters', JSON.stringify(fa));
|
||||
try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
|
||||
}
|
||||
|
||||
// ─── Activity Type ────────────────────────────────────────────────────────
|
||||
async getActivityTypes(params: ProjectListParams = {}): Promise<{ data: ActivityType[] }> {
|
||||
const { filters = {}, limit_start = 0, limit_page_length = 50, order_by = 'name asc' } = params;
|
||||
const q = new URLSearchParams();
|
||||
// Correct field names from standard ERPNext Activity Type doctype
|
||||
q.set('fields', JSON.stringify(['name', 'activity_type', 'billing_rate', 'costing_rate', 'disabled', 'creation', 'modified']));
|
||||
q.set('limit_start', String(limit_start));
|
||||
q.set('limit_page_length', String(limit_page_length));
|
||||
q.set('order_by', order_by);
|
||||
if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); }
|
||||
try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Activity%20Type?${q}`); return { data: r.data || [] }; }
|
||||
catch { return { data: [] }; }
|
||||
}
|
||||
|
||||
async getActivityType(name: string): Promise<ActivityType> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Activity%20Type/${encodeURIComponent(name)}`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async createActivityType(data: Partial<ActivityType>): Promise<ActivityType> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Activity%20Type`, { method: 'POST', body: JSON.stringify(data) });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async updateActivityType(name: string, data: Partial<ActivityType>): Promise<ActivityType> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Activity%20Type/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async getActivityTypeCount(filters: Record<string, any> = {}): Promise<number> {
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||
if (Object.keys(filters).length > 0) {
|
||||
const fa = toFrappeFilterArray(filters);
|
||||
if (fa.length > 0) q.set('filters', JSON.stringify(fa));
|
||||
}
|
||||
try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Activity%20Type?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
|
||||
}
|
||||
|
||||
// ─── Counts for module page ───────────────────────────────────────────────
|
||||
async getModuleCounts(): Promise<{ projects: number; tasks: number; timesheets: number }> {
|
||||
const [projects, tasks, timesheets] = await Promise.all([
|
||||
this.getProjectCount({ status: 'Open' }),
|
||||
this.getTaskCount({}),
|
||||
this.getTimesheetCount({}),
|
||||
]);
|
||||
return { projects, tasks, timesheets };
|
||||
}
|
||||
|
||||
/**
|
||||
* Project-level view: rows stored on Project plus rows from each Task’s `custom_task_updates`.
|
||||
* Capped task scan to avoid huge fan-out.
|
||||
*/
|
||||
async getMergedProjectProgressUpdates(
|
||||
projectName: string,
|
||||
opts?: { maxTasks?: number },
|
||||
): Promise<Array<ProgressUpdateChildRow & { _source: 'project' | 'task' }>> {
|
||||
const maxTasks = opts?.maxTasks ?? 120;
|
||||
const proj = await this.getProject(projectName);
|
||||
const { data: tasks } = await this.getTasksForProject(projectName, { limit_page_length: maxTasks });
|
||||
const fromProject: Array<ProgressUpdateChildRow & { _source: 'project' | 'task' }> = (
|
||||
(proj as any)[CUSTOM_PROJECT_UPDATES] || []
|
||||
).map((r: ProgressUpdateChildRow) => ({ ...r, _source: 'project' as const }));
|
||||
const names = tasks.map(t => t.name).filter(Boolean).slice(0, maxTasks);
|
||||
const taskDocs = await Promise.all(names.map(n => this.getTask(n).catch(() => null)));
|
||||
const fromTasks: Array<ProgressUpdateChildRow & { _source: 'project' | 'task' }> = [];
|
||||
for (const doc of taskDocs) {
|
||||
if (!doc) continue;
|
||||
const rows = (doc as any)[CUSTOM_TASK_UPDATES] || [];
|
||||
for (const r of rows) {
|
||||
fromTasks.push({
|
||||
...r,
|
||||
task: r.task || doc.name,
|
||||
_source: 'task',
|
||||
});
|
||||
}
|
||||
}
|
||||
const key = (r: ProgressUpdateChildRow) => String(r.date || '').slice(0, 10) || '';
|
||||
return [...fromTasks, ...fromProject].sort((a, b) => key(b).localeCompare(key(a)));
|
||||
}
|
||||
}
|
||||
|
||||
const projectService = new ProjectService();
|
||||
export default projectService;
|
||||
@ -1,141 +0,0 @@
|
||||
import { toFrappeFilterArray } from '../utils/listFilterUtils';
|
||||
import { formatFrappeApiError } from '../utils/frappeErrorMessage';
|
||||
|
||||
const RESOURCE = 'Project%20Update';
|
||||
|
||||
/** Child row on Project Update (Project User) */
|
||||
export interface ProjectUpdateUserRow {
|
||||
name?: string;
|
||||
user?: string;
|
||||
email?: string;
|
||||
full_name?: string;
|
||||
welcome_email_sent?: number;
|
||||
view_attachments?: number;
|
||||
hide_timesheets?: number;
|
||||
idx?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ProjectUpdate {
|
||||
name: string;
|
||||
project?: string;
|
||||
sent?: number;
|
||||
date?: string;
|
||||
time?: string;
|
||||
users?: ProjectUpdateUserRow[];
|
||||
owner?: string;
|
||||
creation?: string;
|
||||
modified?: string;
|
||||
modified_by?: string;
|
||||
docstatus?: number;
|
||||
naming_series?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
class ProjectUpdateService {
|
||||
private async getCSRFToken(): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null;
|
||||
if ((window as any).csrf_token) return (window as any).csrf_token;
|
||||
if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token;
|
||||
try {
|
||||
const res = await fetch('/api/method/frappe.sessions.get_csrf_token', { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.message) {
|
||||
(window as any).csrf_token = json.message;
|
||||
return json.message;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getHeaders(): Promise<Record<string, string>> {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
||||
const csrf = await this.getCSRFToken();
|
||||
if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
|
||||
return h;
|
||||
}
|
||||
|
||||
private async fetchJson(url: string, opts: RequestInit = {}): Promise<any> {
|
||||
const headers = await this.getHeaders();
|
||||
const r = await fetch(url, { credentials: 'include', headers, ...opts });
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(formatFrappeApiError(body) || body?.message || `HTTP ${r.status}`);
|
||||
if (body.exc) throw new Error(formatFrappeApiError(body) || 'Request failed');
|
||||
return body;
|
||||
}
|
||||
|
||||
async getProjectUpdates(params: {
|
||||
filters?: Record<string, any>;
|
||||
fields?: string[];
|
||||
limit_start?: number;
|
||||
limit_page_length?: number;
|
||||
order_by?: string;
|
||||
} = {}): Promise<ProjectUpdate[]> {
|
||||
const {
|
||||
filters = {},
|
||||
fields = ['name', 'project', 'sent', 'date', 'time', 'docstatus', 'owner', 'creation', 'modified'],
|
||||
limit_start = 0,
|
||||
limit_page_length = 20,
|
||||
order_by = 'creation desc',
|
||||
} = params;
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(fields));
|
||||
const fa = toFrappeFilterArray(filters);
|
||||
if (fa.length) q.set('filters', JSON.stringify(fa));
|
||||
q.set('limit_start', String(limit_start));
|
||||
q.set('limit_page_length', String(limit_page_length));
|
||||
q.set('order_by', order_by);
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}?${q}`);
|
||||
return body.data || [];
|
||||
}
|
||||
|
||||
async getProjectUpdateCount(filters: Record<string, any> = {}): Promise<number> {
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||
const fa = toFrappeFilterArray(filters);
|
||||
if (fa.length) q.set('filters', JSON.stringify(fa));
|
||||
try {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}?${q}`);
|
||||
return body.data?.[0]?.count ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async getProjectUpdate(name: string): Promise<ProjectUpdate> {
|
||||
// Request full document so child table fields (email/full_name/flags) are included.
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['*']));
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}?${q}`);
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async createProjectUpdate(data: Partial<ProjectUpdate>): Promise<ProjectUpdate> {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async updateProjectUpdate(name: string, data: Partial<ProjectUpdate>): Promise<ProjectUpdate> {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async submitProjectUpdate(name: string): Promise<ProjectUpdate> {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ docstatus: 1 }),
|
||||
});
|
||||
return body.data;
|
||||
}
|
||||
}
|
||||
|
||||
const projectUpdateService = new ProjectUpdateService();
|
||||
export default projectUpdateService;
|
||||
@ -1,143 +0,0 @@
|
||||
import { formatFrappeApiError } from '../utils/frappeErrorMessage';
|
||||
|
||||
const RESOURCE = 'Purchase%20Order';
|
||||
|
||||
export interface PurchaseOrderItem {
|
||||
name?: string;
|
||||
item_code?: string;
|
||||
item_name?: string;
|
||||
description?: string;
|
||||
qty?: number;
|
||||
rate?: number;
|
||||
amount?: number;
|
||||
uom?: string;
|
||||
idx?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PurchaseTaxCharge {
|
||||
name?: string;
|
||||
charge_type?: string;
|
||||
account_head?: string;
|
||||
tax_amount?: number;
|
||||
rate?: number;
|
||||
idx?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PurchaseOrder {
|
||||
name: string;
|
||||
docstatus?: number;
|
||||
status?: string;
|
||||
supplier?: string;
|
||||
supplier_name?: string;
|
||||
transaction_date?: string;
|
||||
schedule_date?: string;
|
||||
company?: string;
|
||||
currency?: string;
|
||||
grand_total?: number;
|
||||
net_total?: number;
|
||||
total_taxes_and_charges?: number;
|
||||
items?: PurchaseOrderItem[];
|
||||
taxes?: PurchaseTaxCharge[];
|
||||
owner?: string;
|
||||
creation?: string;
|
||||
modified?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
class PurchaseOrderService {
|
||||
private async getCSRFToken(): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null;
|
||||
if ((window as any).csrf_token) return (window as any).csrf_token;
|
||||
if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token;
|
||||
try {
|
||||
const res = await fetch('/api/method/frappe.sessions.get_csrf_token', { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.message) {
|
||||
(window as any).csrf_token = json.message;
|
||||
return json.message;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getHeaders(): Promise<Record<string, string>> {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
||||
const csrf = await this.getCSRFToken();
|
||||
if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
|
||||
return h;
|
||||
}
|
||||
|
||||
private async fetchJson(url: string, opts: RequestInit = {}): Promise<any> {
|
||||
const headers = await this.getHeaders();
|
||||
const r = await fetch(url, { credentials: 'include', headers, ...opts });
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(formatFrappeApiError(body) || body?.message || `HTTP ${r.status}`);
|
||||
if (body.exc) throw new Error(formatFrappeApiError(body) || 'Request failed');
|
||||
return body;
|
||||
}
|
||||
|
||||
async getPurchaseOrders(
|
||||
params: { filters?: any[]; limit_start?: number; limit_page_length?: number; order_by?: string } = {},
|
||||
): Promise<PurchaseOrder[]> {
|
||||
const q = new URLSearchParams();
|
||||
const fields = [
|
||||
'name', 'supplier', 'supplier_name', 'transaction_date', 'schedule_date',
|
||||
'status', 'grand_total', 'currency', 'docstatus', 'company', 'creation', 'modified',
|
||||
];
|
||||
q.set('fields', JSON.stringify(fields));
|
||||
if (params.filters?.length) q.set('filters', JSON.stringify(params.filters));
|
||||
q.set('limit_start', String(params.limit_start ?? 0));
|
||||
q.set('limit_page_length', String(params.limit_page_length ?? 20));
|
||||
q.set('order_by', params.order_by ?? 'creation desc');
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}?${q}`);
|
||||
return body.data || [];
|
||||
}
|
||||
|
||||
async getPurchaseOrderCount(filters: any[] = []): Promise<number> {
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||
if (filters.length) q.set('filters', JSON.stringify(filters));
|
||||
try {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}?${q}`);
|
||||
return body.data?.[0]?.count ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async getPurchaseOrder(name: string): Promise<PurchaseOrder> {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`);
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async createPurchaseOrder(data: Partial<PurchaseOrder>): Promise<PurchaseOrder> {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async updatePurchaseOrder(name: string, data: Partial<PurchaseOrder>): Promise<PurchaseOrder> {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async submitPurchaseOrder(name: string): Promise<PurchaseOrder> {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ docstatus: 1 }),
|
||||
});
|
||||
return body.data;
|
||||
}
|
||||
}
|
||||
|
||||
const purchaseOrderService = new PurchaseOrderService();
|
||||
export default purchaseOrderService;
|
||||
@ -1,142 +0,0 @@
|
||||
import { formatFrappeApiError } from '../utils/frappeErrorMessage';
|
||||
|
||||
const RESOURCE = 'Purchase%20Receipt';
|
||||
|
||||
export interface PurchaseReceiptItem {
|
||||
name?: string;
|
||||
item_code?: string;
|
||||
item_name?: string;
|
||||
description?: string;
|
||||
qty?: number;
|
||||
rate?: number;
|
||||
amount?: number;
|
||||
uom?: string;
|
||||
idx?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PurchaseTaxCharge {
|
||||
name?: string;
|
||||
charge_type?: string;
|
||||
account_head?: string;
|
||||
tax_amount?: number;
|
||||
rate?: number;
|
||||
idx?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PurchaseReceipt {
|
||||
name: string;
|
||||
docstatus?: number;
|
||||
status?: string;
|
||||
supplier?: string;
|
||||
supplier_name?: string;
|
||||
posting_date?: string;
|
||||
company?: string;
|
||||
currency?: string;
|
||||
grand_total?: number;
|
||||
net_total?: number;
|
||||
total_taxes_and_charges?: number;
|
||||
items?: PurchaseReceiptItem[];
|
||||
taxes?: PurchaseTaxCharge[];
|
||||
owner?: string;
|
||||
creation?: string;
|
||||
modified?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
class PurchaseReceiptService {
|
||||
private async getCSRFToken(): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null;
|
||||
if ((window as any).csrf_token) return (window as any).csrf_token;
|
||||
if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token;
|
||||
try {
|
||||
const res = await fetch('/api/method/frappe.sessions.get_csrf_token', { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.message) {
|
||||
(window as any).csrf_token = json.message;
|
||||
return json.message;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getHeaders(): Promise<Record<string, string>> {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
||||
const csrf = await this.getCSRFToken();
|
||||
if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
|
||||
return h;
|
||||
}
|
||||
|
||||
private async fetchJson(url: string, opts: RequestInit = {}): Promise<any> {
|
||||
const headers = await this.getHeaders();
|
||||
const r = await fetch(url, { credentials: 'include', headers, ...opts });
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(formatFrappeApiError(body) || body?.message || `HTTP ${r.status}`);
|
||||
if (body.exc) throw new Error(formatFrappeApiError(body) || 'Request failed');
|
||||
return body;
|
||||
}
|
||||
|
||||
async getPurchaseReceipts(
|
||||
params: { filters?: any[]; limit_start?: number; limit_page_length?: number; order_by?: string } = {},
|
||||
): Promise<PurchaseReceipt[]> {
|
||||
const q = new URLSearchParams();
|
||||
const fields = [
|
||||
'name', 'supplier', 'supplier_name', 'posting_date', 'status',
|
||||
'grand_total', 'currency', 'docstatus', 'company', 'creation', 'modified',
|
||||
];
|
||||
q.set('fields', JSON.stringify(fields));
|
||||
if (params.filters?.length) q.set('filters', JSON.stringify(params.filters));
|
||||
q.set('limit_start', String(params.limit_start ?? 0));
|
||||
q.set('limit_page_length', String(params.limit_page_length ?? 20));
|
||||
q.set('order_by', params.order_by ?? 'creation desc');
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}?${q}`);
|
||||
return body.data || [];
|
||||
}
|
||||
|
||||
async getPurchaseReceiptCount(filters: any[] = []): Promise<number> {
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||
if (filters.length) q.set('filters', JSON.stringify(filters));
|
||||
try {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}?${q}`);
|
||||
return body.data?.[0]?.count ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async getPurchaseReceipt(name: string): Promise<PurchaseReceipt> {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`);
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async createPurchaseReceipt(data: Partial<PurchaseReceipt>): Promise<PurchaseReceipt> {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async updatePurchaseReceipt(name: string, data: Partial<PurchaseReceipt>): Promise<PurchaseReceipt> {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async submitPurchaseReceipt(name: string): Promise<PurchaseReceipt> {
|
||||
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ docstatus: 1 }),
|
||||
});
|
||||
return body.data;
|
||||
}
|
||||
}
|
||||
|
||||
const purchaseReceiptService = new PurchaseReceiptService();
|
||||
export default purchaseReceiptService;
|
||||
@ -1,217 +0,0 @@
|
||||
/**
|
||||
* Run Frappe Script / Query reports via desk API.
|
||||
* Uses CSRF + JSON body; normalizes row shape (list of lists vs list of dicts).
|
||||
*/
|
||||
|
||||
import API_CONFIG from '../config/api';
|
||||
|
||||
export interface QueryReportColumn {
|
||||
fieldname: string;
|
||||
label?: string;
|
||||
fieldtype?: string;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export interface QueryReportMessage {
|
||||
columns: QueryReportColumn[];
|
||||
result: unknown[][];
|
||||
}
|
||||
|
||||
async function getCSRFToken(): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null;
|
||||
if ((window as unknown as { csrf_token?: string }).csrf_token) {
|
||||
return (window as unknown as { csrf_token: string }).csrf_token;
|
||||
}
|
||||
if ((window as unknown as { frappe?: { csrf_token?: string } }).frappe?.csrf_token) {
|
||||
return (window as unknown as { frappe: { csrf_token: string } }).frappe.csrf_token;
|
||||
}
|
||||
try {
|
||||
const base = API_CONFIG.BASE_URL || '';
|
||||
const res = await fetch(`${base}/api/method/frappe.sessions.get_csrf_token`, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.message) {
|
||||
(window as unknown as { csrf_token: string }).csrf_token = json.message;
|
||||
return json.message;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function buildPostHeaders(): Promise<Record<string, string>> {
|
||||
const h: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const csrf = await getCSRFToken();
|
||||
if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
|
||||
return h;
|
||||
}
|
||||
|
||||
function parseFrappeError(json: Record<string, unknown>, status: number): string {
|
||||
if (typeof json.message === 'string' && json.message.trim()) return json.message;
|
||||
if (json._server_messages) {
|
||||
try {
|
||||
const raw = json._server_messages;
|
||||
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
if (Array.isArray(arr) && arr[0]) {
|
||||
const o = typeof arr[0] === 'string' ? JSON.parse(arr[0]) : arr[0];
|
||||
if (o && typeof (o as { message?: string }).message === 'string') {
|
||||
return (o as { message: string }).message;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (typeof json.exc === 'string' && json.exc) {
|
||||
const lines = json.exc.trim().split('\n');
|
||||
return lines[lines.length - 1] || json.exc;
|
||||
}
|
||||
return `HTTP ${status}`;
|
||||
}
|
||||
|
||||
/** Convert API result to grid rows (array of arrays) using column fieldnames. */
|
||||
export function normalizeReportRows(
|
||||
columns: QueryReportColumn[],
|
||||
result: unknown,
|
||||
): unknown[][] {
|
||||
if (!Array.isArray(result) || result.length === 0) return [];
|
||||
const first = result[0] as unknown;
|
||||
if (Array.isArray(first)) return result as unknown[][];
|
||||
if (first !== null && typeof first === 'object') {
|
||||
const objs = result as Record<string, unknown>[];
|
||||
const keysFromCols = (columns || []).map(c => c.fieldname).filter(Boolean);
|
||||
const keys =
|
||||
keysFromCols.length > 0 ? keysFromCols : Object.keys((objs[0] || {}) as object);
|
||||
return objs.map(obj => keys.map(k => obj[k]));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function runQueryReport(
|
||||
reportName: string,
|
||||
filters: Record<string, unknown> = {},
|
||||
): Promise<QueryReportMessage> {
|
||||
const headers = await buildPostHeaders();
|
||||
const res = await fetch('/api/method/frappe.desk.query_report.run', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
report_name: reportName,
|
||||
filters: filters ?? {},
|
||||
ignore_prepared_report: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
let json: Record<string, unknown>;
|
||||
try {
|
||||
json = (await res.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error(`HTTP ${res.status}: unreadable response`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(parseFrappeError(json, res.status));
|
||||
}
|
||||
// Some Frappe versions still return HTTP 200 with `exc` populated on validation errors.
|
||||
if (json.exc) {
|
||||
throw new Error(parseFrappeError(json, res.status));
|
||||
}
|
||||
|
||||
const msg = json.message as QueryReportMessage | undefined;
|
||||
const columns = msg?.columns || [];
|
||||
const rawResult = msg?.result;
|
||||
let result = Array.isArray(rawResult) ? normalizeReportRows(columns, rawResult) : [];
|
||||
result = stripTrailingTotalRow(result);
|
||||
|
||||
return { columns, result };
|
||||
}
|
||||
|
||||
/** ERPNext query reports often append a final "Total" row. */
|
||||
function stripTrailingTotalRow(rows: unknown[][]): unknown[][] {
|
||||
if (rows.length < 2) return rows;
|
||||
const firstCell = rows[rows.length - 1]?.[0];
|
||||
if (typeof firstCell === 'string' && firstCell.trim().toLowerCase() === 'total') {
|
||||
return rows.slice(0, -1);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Build { name, value }[] for charts from report grid. */
|
||||
export function reportResultToChartPoints(
|
||||
columns: QueryReportColumn[],
|
||||
result: unknown[][],
|
||||
options: { maxPoints?: number } = {},
|
||||
): { name: string; value: number }[] {
|
||||
const maxPoints = options.maxPoints ?? 12;
|
||||
if (!columns?.length || !result?.length) return [];
|
||||
|
||||
const objects = result.map(row => {
|
||||
const o: Record<string, unknown> = {};
|
||||
columns.forEach((c, i) => {
|
||||
o[c.fieldname] = row[i];
|
||||
});
|
||||
return o;
|
||||
});
|
||||
|
||||
const valueIdx = columns.findIndex(c =>
|
||||
['Int', 'Float', 'Currency', 'Percent', 'Duration'].includes(String(c?.fieldtype || '')),
|
||||
);
|
||||
|
||||
const labelIdx = columns.findIndex(
|
||||
(c, i) =>
|
||||
i !== valueIdx &&
|
||||
(c.fieldtype === 'Link' ||
|
||||
/project|employee|task|subject|customer|item|warehouse|status|activity|date|delay/i.test(c.fieldname)),
|
||||
);
|
||||
const li = labelIdx >= 0 ? labelIdx : 0;
|
||||
|
||||
if (valueIdx < 0) {
|
||||
const labelField = columns[li]?.fieldname || columns[0].fieldname;
|
||||
const tally = new Map<string, number>();
|
||||
for (const o of objects) {
|
||||
const k = truncateLabel(String(o[labelField] ?? '—'), 28);
|
||||
tally.set(k, (tally.get(k) || 0) + 1);
|
||||
}
|
||||
return Array.from(tally.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, maxPoints)
|
||||
.map(([name, value]) => ({ name, value }));
|
||||
}
|
||||
|
||||
const labelField = columns[li]?.fieldname || columns[0].fieldname;
|
||||
const valueField = columns[valueIdx].fieldname;
|
||||
|
||||
return objects.slice(0, maxPoints).map(o => ({
|
||||
name: truncateLabel(String(o[labelField] ?? '—'), 24),
|
||||
value: coerceNumber(o[valueField]),
|
||||
}));
|
||||
}
|
||||
|
||||
function coerceNumber(v: unknown): number {
|
||||
if (typeof v === 'number' && !Number.isNaN(v)) return v;
|
||||
if (typeof v === 'string') {
|
||||
const n = parseFloat(v.replace(/[^0-9.-]/g, ''));
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function truncateLabel(s: string, max: number): string {
|
||||
const t = s.trim() || '—';
|
||||
return t.length <= max ? t : `${t.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
/** Default date range for report filters (YYYY-MM-DD). */
|
||||
export function reportDateRange(daysBack: number): { from_date: string; to_date: string } {
|
||||
const to = new Date();
|
||||
const from = new Date();
|
||||
from.setDate(from.getDate() - daysBack);
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
return { from_date: fmt(from), to_date: fmt(to) };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user