Compare commits

..

No commits in common. "4de2241f6eb689c502e355183a76cb99a76d7dad" and "2571aa699676ac8b47d4bb86aad9ca043f019fe3" have entirely different histories.

130 changed files with 3151 additions and 33998 deletions

View File

@ -2,8 +2,8 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" 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=1774269853" /> <link rel="apple-touch-icon" href="/seera-logo.png?v=1768316563" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Seera Arabia Asset Management System" /> <meta name="description" content="Seera Arabia Asset Management System" />
<title>Seera Arabia - Asset Management System</title> <title>Seera Arabia - Asset Management System</title>

View File

@ -12,7 +12,6 @@
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"axios": "^1.12.2", "axios": "^1.12.2",
"frappe-react-sdk": "^1.13.0", "frappe-react-sdk": "^1.13.0",
"html2canvas": "^1.4.1",
"i18next": "^25.7.2", "i18next": "^25.7.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
@ -24,7 +23,6 @@
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.4",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"recharts": "^3.8.1",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
@ -672,42 +670,6 @@
"react-dom": "^19.0.0" "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": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.47", "version": "1.0.0-beta.47",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
@ -749,18 +711,6 @@
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -806,69 +756,6 @@
"@babel/types": "^7.28.2" "@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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -952,12 +839,6 @@
"@types/react-router": "*" "@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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.48.1", "version": "8.48.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
@ -1423,15 +1304,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/baseline-browser-mapping": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.0.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.0.tgz",
@ -1745,15 +1617,6 @@
"node": ">= 8" "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": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -1773,127 +1636,6 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "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": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -2062,16 +1798,6 @@
"node": ">= 0.4" "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": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@ -2314,12 +2040,6 @@
"node": ">=0.10.0" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -2717,19 +2437,6 @@
"void-elements": "3.1.0" "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": { "node_modules/i18next": {
"version": "25.7.2", "version": "25.7.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.2.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.2.tgz",
@ -2778,16 +2485,6 @@
"node": ">= 4" "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": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -2815,15 +2512,6 @@
"node": ">=0.8.19" "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": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@ -3628,13 +3316,6 @@
"react": "*" "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": { "node_modules/react-leaflet": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
@ -3648,29 +3329,6 @@
"react-dom": "^19.0.0" "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": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@ -3767,57 +3425,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@ -4167,15 +3774,6 @@
"node": ">=14.0.0" "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": { "node_modules/thenify": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@ -4199,12 +3797,6 @@
"node": ">=0.8" "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": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -4370,37 +3962,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/vite": {
"version": "7.2.6", "version": "7.2.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz",

View File

@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "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 .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"copy-html-entry": "cp ../asm_ui_app/public/asm_app/index.html ../asm_ui_app/www/asm_app.html", "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", "@types/react-router-dom": "^5.3.3",
"axios": "^1.12.2", "axios": "^1.12.2",
"frappe-react-sdk": "^1.13.0", "frappe-react-sdk": "^1.13.0",
"html2canvas": "^1.4.1",
"i18next": "^25.7.2", "i18next": "^25.7.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
@ -28,7 +27,6 @@
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.4",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"recharts": "^3.8.1",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {

View File

@ -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()

View File

@ -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 { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { bootstrapFrappeUserFromSession } from './utils/bootstrapFrappeUserFromSession';
import Login from './pages/Login'; import Login from './pages/Login';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import ModernDashboard from './pages/ModernDashboard'; import ModernDashboard from './pages/ModernDashboard';
@ -27,7 +46,6 @@ import Sidebar from './components/Sidebar';
import Header from './components/Header'; import Header from './components/Header';
import IssueList from './pages/IssueList'; import IssueList from './pages/IssueList';
import IssueDetail from './pages/IssueDetail'; import IssueDetail from './pages/IssueDetail';
import SupportTroubleshoot from './pages/SupportTroubleshoot';
import MaintenanceTeamList from './pages/MaintenanceTeamList'; import MaintenanceTeamList from './pages/MaintenanceTeamList';
import MaintenanceTeamDetail from './pages/MaintenanceTeamDetail'; import MaintenanceTeamDetail from './pages/MaintenanceTeamDetail';
import InspectionList from './pages/InspectionList'; import InspectionList from './pages/InspectionList';
@ -35,182 +53,241 @@ import InspectionDetail from './pages/InspectionDetail';
import SupportPlanList from './pages/SupportPlanList'; import SupportPlanList from './pages/SupportPlanList';
import SupportPlanDetail from './pages/SupportPlanDetail'; import SupportPlanDetail from './pages/SupportPlanDetail';
import UserProfilePage from './pages/UserProfilePage'; 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 LayoutWithSidebar: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const user = localStorage.getItem('user'); const user = localStorage.getItem('user');
const userEmail = user ? JSON.parse(user).email : ''; const userEmail = user ? JSON.parse(user).email : '';
return ( return (
<SidebarLayoutProvider>
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-900"> <div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-900">
<Sidebar userEmail={userEmail} /> <Sidebar userEmail={userEmail} />
<div className="asm-app-main flex min-w-0 flex-1 flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
<Header userEmail={userEmail} /> <Header userEmail={userEmail} />
<div className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900">{children}</div> <div className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900">
{children}
</div>
</div> </div>
</div> </div>
</SidebarLayoutProvider>
); );
}; };
// Protected Route Component
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [status, setStatus] = useState<'loading' | 'authed' | 'guest'>('loading'); const user = localStorage.getItem('user');
return user ? <>{children}</> : <Navigate to="/login" replace />;
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') { const App: React.FC = () => {
return ( 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 App: React.FC = () => (
<Router basename="/asm_app"> <Router basename="/asm_app">
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <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="/dashboard" element={
<Route path="/assets/:assetName" element={<ProtectedRoute><LayoutWithSidebar><AssetDetail /></LayoutWithSidebar></ProtectedRoute>} /> <ProtectedRoute>
<Route path="/work-orders" element={<ProtectedRoute><LayoutWithSidebar><WorkOrderList /></LayoutWithSidebar></ProtectedRoute>} /> <LayoutWithSidebar><ModernDashboard /></LayoutWithSidebar>
<Route path="/work-orders/:workOrderName/troubleshoot" element={<ProtectedRoute><LayoutWithSidebar><SupportTroubleshoot /></LayoutWithSidebar></ProtectedRoute>} /> </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="/assets" element={
<Route path="/ppm" element={<ProtectedRoute><LayoutWithSidebar><PPMList /></LayoutWithSidebar></ProtectedRoute>} /> <ProtectedRoute>
<Route path="/ppm/:ppmName" element={<ProtectedRoute><LayoutWithSidebar><PPMDetail /></LayoutWithSidebar></ProtectedRoute>} /> <LayoutWithSidebar><AssetList /></LayoutWithSidebar>
<Route path="/ppm-planner" element={<ProtectedRoute><LayoutWithSidebar><PPMPlannerList /></LayoutWithSidebar></ProtectedRoute>} /> </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="/assets/:assetName" element={
<Route path="/maintenance-calendar/month-view" element={<ProtectedRoute><LayoutWithSidebar><MaintenanceCalendarPage /></LayoutWithSidebar></ProtectedRoute>} /> <ProtectedRoute>
<Route path="/yearly-ppm-planner" element={<ProtectedRoute><LayoutWithSidebar><YearlyPPMPlannerPage /></LayoutWithSidebar></ProtectedRoute>} /> <LayoutWithSidebar><AssetDetail /></LayoutWithSidebar>
<Route path="/active-map" element={<ProtectedRoute><LayoutWithSidebar><ActiveMap /></LayoutWithSidebar></ProtectedRoute>} /> </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="/work-orders" element={
<Route path="/events" element={<ProtectedRoute><LayoutWithSidebar><EventsList /></LayoutWithSidebar></ProtectedRoute>} /> <ProtectedRoute>
<Route path="/old-dashboard" element={<ProtectedRoute><LayoutWithSidebar><Dashboard /></LayoutWithSidebar></ProtectedRoute>} /> <LayoutWithSidebar><WorkOrderList /></LayoutWithSidebar>
<Route path="/maintenance-teams" element={<ProtectedRoute><LayoutWithSidebar><MaintenanceTeamList /></LayoutWithSidebar></ProtectedRoute>} /> </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="/work-orders/:workOrderName" element={
<Route path="/procurement" element={<ProtectedRoute><LayoutWithSidebar><ComingSoon title="Procurement" /></LayoutWithSidebar></ProtectedRoute>} /> <ProtectedRoute>
<Route path="/sla" element={<ProtectedRoute><LayoutWithSidebar><SupportPlanList /></LayoutWithSidebar></ProtectedRoute>} /> <LayoutWithSidebar><WorkOrderDetail /></LayoutWithSidebar>
<Route path="/sla/:slaName" element={<ProtectedRoute><LayoutWithSidebar><SupportPlanDetail /></LayoutWithSidebar></ProtectedRoute>} /> </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="/maintenance" element={
<Route path="/user-profile" element={<ProtectedRoute><LayoutWithSidebar><UserProfilePage /></LayoutWithSidebar></ProtectedRoute>} /> <ProtectedRoute>
<Route path="/projects" element={<ProtectedRoute><LayoutWithSidebar><ProjectModulePage /></LayoutWithSidebar></ProtectedRoute>} /> <LayoutWithSidebar><AssetMaintenanceList /></LayoutWithSidebar>
<Route path="/projects/reports" element={<ProtectedRoute><LayoutWithSidebar><ProjectReportsDashboard /></LayoutWithSidebar></ProtectedRoute>} /> </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="/maintenance/:logName" element={
<Route path="/projects/list/:projectName" element={<ProtectedRoute><LayoutWithSidebar><ProjectDetail /></LayoutWithSidebar></ProtectedRoute>} /> <ProtectedRoute>
<Route path="/projects/tasks" element={<ProtectedRoute><LayoutWithSidebar><TaskList /></LayoutWithSidebar></ProtectedRoute>} /> <LayoutWithSidebar><AssetMaintenanceDetail /></LayoutWithSidebar>
<Route path="/projects/tasks/:taskName" element={<ProtectedRoute><LayoutWithSidebar><TaskDetail /></LayoutWithSidebar></ProtectedRoute>} /> </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="/ppm" element={
<Route path="/projects/activity-types/:activityTypeName" element={<ProtectedRoute><LayoutWithSidebar><ActivityTypeDetail /></LayoutWithSidebar></ProtectedRoute>} /> <ProtectedRoute>
<Route path="/projects/templates" element={<ProtectedRoute><LayoutWithSidebar><ProjectTemplateList /></LayoutWithSidebar></ProtectedRoute>} /> <LayoutWithSidebar><PPMList /></LayoutWithSidebar>
<Route path="/projects/templates/:templateName" element={<ProtectedRoute><LayoutWithSidebar><ProjectTemplateDetail /></LayoutWithSidebar></ProtectedRoute>} /> </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="/ppm/:ppmName" element={
<Route path="/employees/:employeeName" element={<ProtectedRoute><LayoutWithSidebar><EmployeeDetail /></LayoutWithSidebar></ProtectedRoute>} /> <ProtectedRoute>
<Route path="/invoices" element={<ProtectedRoute><LayoutWithSidebar><SalesInvoiceList /></LayoutWithSidebar></ProtectedRoute>} /> <LayoutWithSidebar><PPMDetail /></LayoutWithSidebar>
<Route path="/invoices/:invoiceName" element={<ProtectedRoute><LayoutWithSidebar><SalesInvoiceDetail /></LayoutWithSidebar></ProtectedRoute>} /> </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="/ppm-planner" element={
<Route path="/purchase-orders/:poName" element={<ProtectedRoute><LayoutWithSidebar><PurchaseOrderDetail /></LayoutWithSidebar></ProtectedRoute>} /> <ProtectedRoute>
<Route path="/delivery-notes" element={<ProtectedRoute><LayoutWithSidebar><DeliveryNoteList /></LayoutWithSidebar></ProtectedRoute>} /> <LayoutWithSidebar><PPMPlannerList /></LayoutWithSidebar>
<Route path="/delivery-notes/:dnName" element={<ProtectedRoute><LayoutWithSidebar><DeliveryNoteDetail /></LayoutWithSidebar></ProtectedRoute>} /> </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="/ppm-planner/new" element={
<Route path="/purchase-receipts/:prName" element={<ProtectedRoute><LayoutWithSidebar><PurchaseReceiptDetail /></LayoutWithSidebar></ProtectedRoute>} /> <ProtectedRoute>
<Route path="/payment-entries" element={<ProtectedRoute><LayoutWithSidebar><PaymentEntryList /></LayoutWithSidebar></ProtectedRoute>} /> <LayoutWithSidebar><PPMPlanner /></LayoutWithSidebar>
<Route path="/payment-entries/:peName" element={<ProtectedRoute><LayoutWithSidebar><PaymentEntryDetail /></LayoutWithSidebar></ProtectedRoute>} /> </ProtectedRoute>
<Route path="/sfda-entries" element={<ProtectedRoute><LayoutWithSidebar><SfdaEntriesList /></LayoutWithSidebar></ProtectedRoute>} /> } />
<Route path="/sfda-entries/:entryName" element={<ProtectedRoute><LayoutWithSidebar><SfdaEntriesDetail /></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-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 />} />
<Route path="*" element={<Navigate to="/login" replace />} /> <Route path="*" element={<Navigate to="/login" replace />} />
</Routes> </Routes>
</Router> </Router>
); );
};
export default App; export default App;

View File

@ -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 { useTheme } from '../contexts/ThemeContext';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { useTranslation } from 'react-i18next'; 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 NotificationBell from './NotificationBell';
import { useSidebarLayout } from '../contexts/SidebarLayoutContext';
import { useNavigate } from 'react-router-dom';
interface HeaderProps { interface HeaderProps {
userEmail?: string; userEmail?: string;
} }
const Header: React.FC<HeaderProps> = () => { const Header: React.FC<HeaderProps> = ({ userEmail }) => {
const navigate = useNavigate();
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const { language, changeLanguage } = useLanguage(); const { language, changeLanguage } = useLanguage();
const { t } = useTranslation(); const { t } = useTranslation();
const { openMobileSidebar } = useSidebarLayout();
const navigate = useNavigate();
const [userFullName, setUserFullName] = useState<string>(''); // const handleLogout = () => {
const [showTooltip, setShowTooltip] = useState(false); // localStorage.removeItem('user');
// localStorage.removeItem('sid');
// Fetch full name — same two-call pattern as Sidebar // navigate('/login');
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 = async () => { const handleLogout = async () => {
localStorage.removeItem('user'); localStorage.removeItem('user');
@ -90,46 +55,13 @@ const Header: React.FC<HeaderProps> = () => {
}; };
return ( 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"> <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">
{/* Mobile menu button */} {/* User Email (optional, can be shown on hover or always) */}
<button {/* {userEmail && (
type="button" <div className="hidden md:block text-sm text-gray-600 dark:text-gray-400 mr-2">
onClick={openMobileSidebar} {userEmail}
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' })}
>
<Menu size={22} />
</button>
<div className="flex-1 lg:flex-none" aria-hidden="true" />
<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>
)} )} */}
</div>
)}
{/* Notification Bell */} {/* Notification Bell */}
<div className="relative"> <div className="relative">
@ -162,10 +94,10 @@ const Header: React.FC<HeaderProps> = () => {
> >
<LogOut size={20} /> <LogOut size={20} />
</button> </button>
</div>
</header> </header>
); );
}; };
export default Header; export default Header;

View File

@ -12,12 +12,9 @@ interface LinkFieldProps {
doctype: string; doctype: string;
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
/** When true, only the input is rendered (use an outer <label> / FL). */
hideLabel?: boolean;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
/** Frappe filter rows `[['DocType', 'field', 'op', value]]` or legacy dict form */ filters?: Record<string, any>;
filters?: any;
compact?: boolean; compact?: boolean;
usePortal?: boolean; usePortal?: boolean;
// New props for QuickCreate functionality // New props for QuickCreate functionality
@ -35,12 +32,11 @@ const LinkField: React.FC<LinkFieldProps> = ({
doctype, doctype,
value, value,
onChange, onChange,
hideLabel = false,
placeholder, placeholder,
disabled = false, disabled = false,
filters, filters,
compact = false, compact = false,
usePortal = true, usePortal = false,
// QuickCreate props with defaults // QuickCreate props with defaults
allowQuickCreate = false, // Default to false - must explicitly enable per field allowQuickCreate = false, // Default to false - must explicitly enable per field
onQuickCreateSuccess, onQuickCreateSuccess,
@ -404,15 +400,10 @@ const searchLink = useCallback(async (text: string = '', force: boolean = false)
return ( return (
<> <>
<div <div ref={containerRef} className={`relative w-full ${compact ? 'mb-2' : 'mb-4'}`}>
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 className={`block font-medium text-gray-700 dark:text-gray-300 ${compact ? 'text-[10px] mb-0.5' : 'text-sm mb-1'}`}>
{label} {label}
</label> </label>
)}
<div className="relative"> <div className="relative">
<input <input

View File

@ -95,14 +95,7 @@ const NotificationBell: React.FC = () => {
} else if (normalizedType === 'Inspection') { } else if (normalizedType === 'Inspection') {
console.log('[NotificationBell] Navigating to inspection:', `/inspections/${docName}`); console.log('[NotificationBell] Navigating to inspection:', `/inspections/${docName}`);
navigate(`/inspections/${docName}`); navigate(`/inspections/${docName}`);
} else if (normalizedType === 'Issue') { }else {
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 {
// Fallback: Try to open in Frappe if route not found // Fallback: Try to open in Frappe if route not found
console.warn(`[NotificationBell] Unknown document type: ${docType}, opening in Frappe`); console.warn(`[NotificationBell] Unknown document type: ${docType}, opening in Frappe`);
const frappeRoute = docType.toLowerCase().replace(/\s+/g, '-').replace(/_/g, '-'); const frappeRoute = docType.toLowerCase().replace(/\s+/g, '-').replace(/_/g, '-');

View File

@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { useSidebarLayout } from '../contexts/SidebarLayoutContext';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
LayoutDashboard, LayoutDashboard,
@ -18,13 +17,11 @@ import {
FileText, FileText,
HelpCircle, HelpCircle,
UserCircle, UserCircle,
Trash2, Trash2
FolderOpen,
Building2
} from 'lucide-react'; } from 'lucide-react';
import { FaChevronDown, FaChevronUp, FaClipboardCheck } from 'react-icons/fa'; import { FaClipboardCheck } from 'react-icons/fa';
interface SidebarLink { interface SidebarLink {
id: string; id: string;
@ -50,40 +47,13 @@ const ADMIN_ROLES = [
const TECHNICIAN_ROLE = 'Technician'; const TECHNICIAN_ROLE = 'Technician';
const END_USER_ROLE = 'End User'; 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 Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
const [isProjectsExpanded, setIsProjectsExpanded] = useState(true);
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const isLgUp = useLgUp();
const { isRTL } = useLanguage(); const { isRTL } = useLanguage();
const { t } = useTranslation(); 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 // ✅ Role-based state
const [userRoles, setUserRoles] = useState<{ 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 // Version is automatically updated by build script based on file modification time
const imageVersion = import.meta.env.DEV const imageVersion = import.meta.env.DEV
? `?v=${Date.now()}` ? `?v=${Date.now()}`
: `?v=1774269853`; // Auto-updated by build script : `?v=1768316563`; // Auto-updated by build script
const logoVersion = import.meta.env.DEV const logoVersion = import.meta.env.DEV
? `?v=${Date.now()}` ? `?v=${Date.now()}`
: `?v=1774269853`; // Auto-updated by build script : `?v=1768316563`; // Auto-updated by build script
const backgroundImageUrl = baseUrl.endsWith('/') const backgroundImageUrl = baseUrl.endsWith('/')
? `${baseUrl}sidebar-background.jpg${imageVersion}` ? `${baseUrl}sidebar-background.jpg${imageVersion}`
: `${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 // Define what each role can see
const endUserLinks = ['work-orders', 'support', 'assets', 'inventory', 'projects']; const endUserLinks = ['work-orders', 'support', 'assets', 'inventory'];
const technicianLinks = ['work-orders', 'inspections', 'procurement', 'support', 'active-map', 'assets', 'inventory', 'projects']; const technicianLinks = ['work-orders', 'inspections', 'procurement', 'support', 'active-map', 'assets', 'inventory'];
// Check visibility based on roles (union of permissions) // Check visibility based on roles (union of permissions)
let canSee = false; let canSee = false;
@ -317,13 +287,6 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
path: '/inventory', path: '/inventory',
visible: getVisibility('inventory') visible: getVisibility('inventory')
}, },
{
id: 'projects',
title: t('sidebar.projects'),
icon: <FolderOpen size={20} />,
path: '/projects',
visible: getVisibility('projects')
},
{ {
id: 'work-orders', id: 'work-orders',
title: t('common.workOrders'), title: t('common.workOrders'),
@ -338,13 +301,6 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
path: '/inspections', path: '/inspections',
visible: getVisibility('inspections') visible: getVisibility('inspections')
}, },
{
id: 'sfda-entries',
title: t('sidebar.sfdaEntries'),
icon: <FileText size={20} />,
path: '/sfda-entries',
visible: userRoles.isAdmin
},
// { // {
// id: 'maintenance', // id: 'maintenance',
// title: t('common.maintenance'), // title: t('common.maintenance'),
@ -387,13 +343,6 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
path: '/maintenance-teams', path: '/maintenance-teams',
visible: userRoles.isAdmin // Only admin visible: userRoles.isAdmin // Only admin
}, },
{
id: 'facility-management',
title: 'Facility Management',
icon: <Building2 size={20} />,
path: '/facility-management-external',
visible: userRoles.isAdmin
},
{ {
id: 'procurement', id: 'procurement',
title: t('sidebar.procurement'), title: t('sidebar.procurement'),
@ -468,93 +417,54 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
// } // }
]; ];
const projectsLink = links.find(l => l.id === 'projects'); const visibleLinks = links.filter(link => link.visible);
const visibleLinks = links.filter(link => link.visible && link.id !== 'projects');
const isActive = (path: string) => { const isActive = (path: string) => {
return location.pathname === path; return location.pathname === path;
}; };
const isProjectsActive = location.pathname === '/projects' || location.pathname.startsWith('/projects/');
useEffect(() => {
closeMobileSidebar();
}, [location.pathname, closeMobileSidebar]);
// ✅ Handle User Profile click // ✅ Handle User Profile click
const handleUserProfileClick = () => { const handleUserProfileClick = () => {
navigate('/user-profile'); 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 // ✅ Show loading state while fetching roles
if (userRoles.isLoading) { if (userRoles.isLoading) {
return ( 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 <div
className={` className={`
asm-app-sidebar relative
flex
h-screen h-screen
w-64 w-64
flex
flex-col flex-col
items-center items-center
justify-center justify-center
shadow-xl shadow-xl
${edgeBorderClass} border-r border-gray-200 dark:border-gray-700
${mobileDrawerShell}
`} `}
style={{ style={{
backgroundImage: `url(${backgroundImageUrl})`, backgroundImage: `url(${backgroundImageUrl})`,
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat'
}} }}
> >
<div className="absolute inset-0 z-0 bg-black/60 dark:bg-black/70" /> <div className="absolute inset-0 bg-black/60 dark:bg-black/70 z-0"></div>
<div className="relative z-10 text-white"> <div className="relative z-10 text-white">
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-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> <p className="mt-2 text-sm">{t('common.loading')}</p>
</div> </div>
</div> </div>
</>
); );
} }
return ( 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 <div
className={` className={`
asm-app-sidebar relative
h-screen h-screen
transition-all transition-all
duration-300 duration-300
@ -562,9 +472,8 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
flex flex
flex-col flex-col
shadow-xl shadow-xl
${edgeBorderClass} border-r border-gray-200 dark:border-gray-700
${isCollapsed ? 'w-16' : 'w-64'} ${isCollapsed ? 'w-16' : 'w-64'}
${mobileDrawerShell}
`} `}
style={{ style={{
backgroundImage: `url(${backgroundImageUrl})`, backgroundImage: `url(${backgroundImageUrl})`,
@ -622,57 +531,19 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
</div> </div>
)} )}
<button <button
type="button" onClick={() => setIsCollapsed(!isCollapsed)}
onClick={() => {
if (isLgUp) setIsCollapsed(!isCollapsed);
else closeMobileSidebar();
}}
className="text-white dark:text-white hover:bg-white/20 dark:hover:bg-white/20 p-2 rounded-lg transition-colors" 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> </button>
</div> </div>
{/* Navigation Links */} {/* Navigation Links */}
<nav className="flex-1 overflow-y-auto py-4"> <nav className="flex-1 overflow-y-auto py-4">
{visibleLinks.map((link) => ( {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 <Link
key={link.id}
to={link.path} to={link.path}
onClick={afterNav}
className={` className={`
flex flex
items-center items-center
@ -683,7 +554,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
hover:text-white dark:hover:text-white hover:text-white dark:hover:text-white
transition-all transition-all
duration-200 duration-200
${isActive(link.path) ? `bg-white/30 dark:bg-white/30 text-white dark:text-white ${activeEdgeBorderClass}` : ''} ${isActive(link.path) ? 'bg-white/30 dark:bg-white/30 text-white dark:text-white border-l-4 border-white' : ''}
${isCollapsed ? 'justify-center' : ''} ${isCollapsed ? 'justify-center' : ''}
`} `}
title={isCollapsed ? link.title : ''} title={isCollapsed ? link.title : ''}
@ -693,84 +564,20 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
<span className={`${isRTL ? 'mr-4' : 'ml-4'} font-medium`}>{link.title}</span> <span className={`${isRTL ? 'mr-4' : 'ml-4'} font-medium`}>{link.title}</span>
)} )}
</Link> </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>
)}
</div>
)}
</React.Fragment>
))} ))}
</nav> </nav>
{/* User Info & Version (Bottom) */} {/* 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-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 && userEmail && (
{/* {!isCollapsed && (userFullName || 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>
<div className="text-white/80 dark:text-white/80 text-xs truncate"> <div className="text-white/80 dark:text-white/80 text-xs truncate">
{t('sidebar.loggedInAs')} {t('sidebar.loggedInAs')}
@ -779,7 +586,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
{userFullName || userEmail} {userFullName || userEmail}
</div> </div>
{/* ✅ User Profile Button */}
<button <button
onClick={handleUserProfileClick} onClick={handleUserProfileClick}
className={` className={`
@ -798,10 +605,10 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
</button> </button>
</div> </div>
)} */} )}
{/* Collapsed state - just show icon button */} {/* Collapsed state - just show icon button */}
{/* {isCollapsed && ( {isCollapsed && (
<button <button
onClick={handleUserProfileClick} onClick={handleUserProfileClick}
className={` className={`
@ -817,19 +624,18 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
> >
<UserCircle size={20} /> <UserCircle size={20} />
</button> </button>
)} */} )}
{!isCollapsed && ( {!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')} {t('sidebar.version')}
</div> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
</>
); );
}; };

View File

@ -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;

View File

@ -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;

View File

@ -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; }

View File

@ -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}&nbsp;{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>
);
}

View File

@ -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;

View File

@ -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" },
};

View File

@ -1,5 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useWorkflow } from '../hooks/useWorkflow.ts'; import { useWorkflow } from '../hooks/useWorkflow.ts';
import type { WorkflowTransition } from '../services/workflowService'; import type { WorkflowTransition } from '../services/workflowService';
import { FaSpinner, FaExclamationTriangle, FaInfoCircle } from 'react-icons/fa'; import { FaSpinner, FaExclamationTriangle, FaInfoCircle } from 'react-icons/fa';
@ -8,21 +7,9 @@ interface WorkflowActionsProps {
doctype: string; doctype: string;
docname: string | null; docname: string | null;
workflowState?: string; workflowState?: string;
/** Merged doc + form for workflow condition evaluation */
docData?: Record<string, any>;
onActionComplete?: (action: string, success: boolean) => void; onActionComplete?: (action: string, success: boolean) => void;
onStateChange?: () => 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; 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; className?: string;
} }
@ -30,27 +17,17 @@ const WorkflowActions: React.FC<WorkflowActionsProps> = ({
doctype, doctype,
docname, docname,
workflowState, workflowState,
docData,
onActionComplete, onActionComplete,
onStateChange, onStateChange,
onWorkflowMeta,
documentLabel = 'document',
showStateInfo = true, showStateInfo = true,
stateHeading = 'Workflow State',
showFullAccessNote = false,
hideWhenNoWorkflow = false,
className = '', className = '',
}) => { }) => {
const { t } = useTranslation();
const { const {
transitions, transitions,
loading, loading,
actionLoading, actionLoading,
error, error,
applyAction, applyAction,
canEdit,
isSystemManager,
workflowInfo,
getStateStyle, getStateStyle,
getButtonStyle, getButtonStyle,
getIcon, getIcon,
@ -59,20 +36,14 @@ const WorkflowActions: React.FC<WorkflowActionsProps> = ({
docname, docname,
workflowState, workflowState,
enabled: !!docname, enabled: !!docname,
docData,
}); });
useEffect(() => {
if (!docname) return;
onWorkflowMeta?.({ canEdit });
}, [docname, canEdit, onWorkflowMeta]);
const [confirmAction, setConfirmAction] = useState<string | null>(null); const [confirmAction, setConfirmAction] = useState<string | null>(null);
// Actions that require confirmation // Actions that require confirmation
const actionsRequiringConfirmation = ['Reject', 'Cancel', 'Close']; const actionsRequiringConfirmation = ['Reject', 'Cancel', 'Close'];
const handleActionClick = async (action: string, nextState?: string) => { const handleActionClick = async (action: string) => {
// Check if action requires confirmation // Check if action requires confirmation
if (actionsRequiringConfirmation.includes(action) && confirmAction !== action) { if (actionsRequiringConfirmation.includes(action) && confirmAction !== action) {
setConfirmAction(action); setConfirmAction(action);
@ -81,7 +52,7 @@ const WorkflowActions: React.FC<WorkflowActionsProps> = ({
setConfirmAction(null); setConfirmAction(null);
const success = await applyAction(action, nextState); const success = await applyAction(action);
if (onActionComplete) { if (onActionComplete) {
onActionComplete(action, success); onActionComplete(action, success);
@ -100,10 +71,6 @@ const WorkflowActions: React.FC<WorkflowActionsProps> = ({
return null; return null;
} }
if (hideWhenNoWorkflow && !loading && !workflowInfo) {
return null;
}
const stateStyle = workflowState ? getStateStyle(workflowState) : getStateStyle('Draft'); const stateStyle = workflowState ? getStateStyle(workflowState) : getStateStyle('Draft');
return ( return (
@ -113,7 +80,7 @@ const WorkflowActions: React.FC<WorkflowActionsProps> = ({
<div className={`p-4 rounded-lg border ${stateStyle.bg} ${stateStyle.border}`}> <div className={`p-4 rounded-lg border ${stateStyle.bg} ${stateStyle.border}`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <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}`}> <p className={`text-lg font-semibold ${stateStyle.text}`}>
{workflowState} {workflowState}
</p> </p>
@ -151,16 +118,13 @@ const WorkflowActions: React.FC<WorkflowActionsProps> = ({
Confirm Action Confirm Action
</p> </p>
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-1"> <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> </p>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => { onClick={() => handleActionClick(confirmAction)}
const t = transitions.find(tr => tr.action === confirmAction);
handleActionClick(confirmAction, t?.next_state);
}}
disabled={actionLoading} disabled={actionLoading}
className="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-sm rounded-md disabled:opacity-50" 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 */} {/* Available Actions */}
{!loading && transitions.length > 0 && !confirmAction && ( {!loading && transitions.length > 0 && !confirmAction && (
<div className="space-y-2"> <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"> <p className="text-xs font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1">
<FaInfoCircle size={12} /> <FaInfoCircle size={12} />
Available Actions ({transitions.length}) Available Actions
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{transitions.map((transition: WorkflowTransition, index: number) => ( {transitions.map((transition: WorkflowTransition, index: number) => (
<button <button
key={`${transition.action}-${index}`} key={`${transition.action}-${index}`}
onClick={() => handleActionClick(transition.action, transition.next_state)} onClick={() => handleActionClick(transition.action)}
disabled={actionLoading} 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)}`} 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}`} title={`Move to: ${transition.next_state}`}
@ -218,19 +175,14 @@ const WorkflowActions: React.FC<WorkflowActionsProps> = ({
</div> </div>
{/* Show next states info */} {/* Show next states info */}
<div className="mt-3 pt-2 border-t border-gray-200 dark:border-gray-600"> <div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"> {transitions.map((t: WorkflowTransition, i: number) => (
{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"> <span key={i} className="inline-block mr-3">
{tr.action} <span className="font-medium">{tr.next_state}</span> {t.action} <span className="font-medium">{t.next_state}</span>
</span> </span>
))} ))}
</div> </div>
</div> </div>
</div>
)} )}
{/* No Actions Available */} {/* No Actions Available */}

View File

@ -7,9 +7,10 @@ interface ApiConfig {
} }
const API_CONFIG: ApiConfig = { const API_CONFIG: ApiConfig = {
// Same-origin relative URLs match logged-in Frappe session. Set VITE_FRAPPE_BASE_URL only when // Backend URL - Use proxy in development, direct URL in production
// the SPA is hosted on a different origin than the API (then rebuild). BASE_URL: import.meta.env.DEV
BASE_URL: import.meta.env.VITE_FRAPPE_BASE_URL || '', ? '' // Use relative URLs in development (goes through Vite proxy)
: import.meta.env.VITE_FRAPPE_BASE_URL || 'https://kfsh-dammam-asm.seeraarabia.com',
// API Endpoints // API Endpoints
ENDPOINTS: { ENDPOINTS: {
@ -75,10 +76,8 @@ const API_CONFIG: ApiConfig = {
// Authentication // Authentication
LOGIN: '/api/method/login', LOGIN: '/api/method/login',
RESET_PASSWORD: '/api/method/frappe.core.doctype.user.user.reset_password',
LOGOUT: '/api/method/logout', LOGOUT: '/api/method/logout',
CSRF_TOKEN: '/api/method/frappe.sessions.get_csrf_token', 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 // File Upload
UPLOAD_FILE: '/api/method/upload_file', UPLOAD_FILE: '/api/method/upload_file',

View 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 })}`;
}

View File

@ -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;
};

View File

@ -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)];
}

View File

@ -35,41 +35,17 @@ export function useInspectionList(params: InspectionListParams = {}): UseInspect
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
const paramsKey = JSON.stringify(params); const fetchInspections = useCallback(async () => {
useEffect(() => {
let cancelled = false;
(async () => {
try { try {
setLoading(true); setLoading(true);
setError(null); 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 () => { // Fetch inspections and count in parallel
try {
setLoading(true);
setError(null);
const [listResponse, count] = await Promise.all([ const [listResponse, count] = await Promise.all([
inspectionService.getInspections(params), inspectionService.getInspections(params),
inspectionService.getInspectionCount(params.filters || {}), inspectionService.getInspectionCount(params.filters || {})
]); ]);
setInspections(listResponse.data); setInspections(listResponse.data);
setTotalCount(count); setTotalCount(count);
} catch (err) { } catch (err) {
@ -79,14 +55,18 @@ export function useInspectionList(params: InspectionListParams = {}): UseInspect
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [paramsKey]); }, [JSON.stringify(params)]);
useEffect(() => {
fetchInspections();
}, [fetchInspections]);
return { return {
inspections, inspections,
loading, loading,
error, error,
totalCount, totalCount,
refetch, refetch: fetchInspections
}; };
} }

View File

@ -8,36 +8,14 @@ export const useIssueList = (params: IssueListParams = {}) => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
const paramsKey = JSON.stringify(params); const fetchIssues = useCallback(async () => {
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 () => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const response = await issueService.getIssues(params); const response = await issueService.getIssues(params);
setIssues(response.data); setIssues(response.data);
// Get total count for pagination
const count = await issueService.getIssueCount(params.filters); const count = await issueService.getIssueCount(params.filters);
setTotalCount(count); setTotalCount(count);
} catch (err) { } catch (err) {
@ -45,14 +23,18 @@ export const useIssueList = (params: IssueListParams = {}) => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [paramsKey]); }, [JSON.stringify(params)]);
useEffect(() => {
fetchIssues();
}, [fetchIssues]);
return { return {
issues, issues,
loading, loading,
error, error,
totalCount, totalCount,
refetch, refetch: fetchIssues,
}; };
}; };

View File

@ -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,
};
}

View File

@ -12,36 +12,14 @@ export const useMaintenanceTeamList = (params: MaintenanceTeamListParams = {}) =
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
const paramsKey = JSON.stringify(params); const fetchTeams = useCallback(async () => {
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 () => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const response = await maintenanceTeamService.getMaintenanceTeams(params); const response = await maintenanceTeamService.getMaintenanceTeams(params);
setTeams(response.data); setTeams(response.data);
// Get total count for pagination
const count = await maintenanceTeamService.getMaintenanceTeamCount(params.filters); const count = await maintenanceTeamService.getMaintenanceTeamCount(params.filters);
setTotalCount(count); setTotalCount(count);
} catch (err) { } catch (err) {
@ -49,14 +27,18 @@ export const useMaintenanceTeamList = (params: MaintenanceTeamListParams = {}) =
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [paramsKey]); }, [JSON.stringify(params)]);
useEffect(() => {
fetchTeams();
}, [fetchTeams]);
return { return {
teams, teams,
loading, loading,
error, error,
totalCount, totalCount,
refetch, refetch: fetchTeams,
}; };
}; };

View File

@ -12,9 +12,9 @@ export function useNotifications() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const data = await notificationService.getNotifications(); const data = await notificationService.getNotifications();
const filtered = data const filtered = data.filter(
.filter(Boolean) (n) => !n.subject?.startsWith('Failed to send email')
.filter((n) => !n.subject?.startsWith('Failed to send email')); );
setNotifications(filtered); setNotifications(filtered);
setUnreadCount(filtered.filter((n) => !n.read).length); setUnreadCount(filtered.filter((n) => !n.read).length);
// setNotifications(data); // setNotifications(data);

View File

@ -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,
};
};

View File

@ -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,
};
}

View File

@ -4,8 +4,7 @@ import workflowService, {
type WorkflowInfo, type WorkflowInfo,
getWorkflowStateStyle, getWorkflowStateStyle,
getActionButtonStyle, getActionButtonStyle,
getActionIcon, getActionIcon
hasWorkflowFullAccess,
} from '../services/workflowService'; } from '../services/workflowService';
interface UseWorkflowOptions { interface UseWorkflowOptions {
@ -77,17 +76,19 @@ export const useWorkflow = ({
const fetchUserInfo = async () => { const fetchUserInfo = async () => {
try { try {
const [roles, user, isSysManager, fullAccess] = await Promise.all([ const [roles, user, isSysManager] = await Promise.all([
workflowService.getCurrentUserRoles(), workflowService.getCurrentUserRoles(),
workflowService.getCurrentUser(), workflowService.getCurrentUser(),
workflowService.isSystemManager(), workflowService.isSystemManager(),
hasWorkflowFullAccess(),
]); ]);
setUserRoles(roles); setUserRoles(roles);
setCurrentUser(user); setCurrentUser(user);
setIsSystemManagerUser(isSysManager || fullAccess); setIsSystemManagerUser(isSysManager);
if (fullAccess) setCanEdit(true); // System Manager can always edit
if (isSysManager) {
setCanEdit(true);
}
} catch (err) { } catch (err) {
console.error('Error fetching user info:', err); console.error('Error fetching user info:', err);
} }

View File

@ -54,95 +54,18 @@
"sidebar": { "sidebar": {
"title": "أصول سيرا", "title": "أصول سيرا",
"loggedInAs": "تم تسجيل الدخول كـ:", "loggedInAs": "تم تسجيل الدخول كـ:",
"version": "أصول سيرا نظام إدارة الأصول الإصدار 2.26", "version": "أصول سيرا نظام إدارة الأصول الإصدار 1.0",
"inventory": "المخزون", "inventory": "المخزون",
"ppmPlanner": "مخطط الصيانة الوقائية", "ppmPlanner": "مخطط الصيانة الوقائية",
"maintenanceCalendar": "تقويم الصيانة", "maintenanceCalendar": "تقويم الصيانة",
"activeMap": "الخريطة النشطة", "activeMap": "الخريطة النشطة",
"maintenanceTeam": "فريق الصيانة", "maintenanceTeam": "فريق الصيانة",
"procurement": "المشتريات", "procurement": "المشتريات",
"projects": "إدارة المشاريع",
"sla": "اتفاقية مستوى الخدمة", "sla": "اتفاقية مستوى الخدمة",
"support": "الدعم", "support": "الدعم",
"inspection": "التفتيش", "inspection": "التفتيش",
"sfdaEntries": "يتذكر SFDA",
"userProfile": "الملف الشخصي" "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": { "login": {
"title": "أصول سيرا", "title": "أصول سيرا",
"subtitle": "نظام إدارة الأصول", "subtitle": "نظام إدارة الأصول",
@ -151,32 +74,7 @@
"passwordPlaceholder": "أدخل كلمة المرور", "passwordPlaceholder": "أدخل كلمة المرور",
"loginFailed": "فشل تسجيل الدخول. يرجى التحقق من بيانات الاعتماد الخاصة بك.", "loginFailed": "فشل تسجيل الدخول. يرجى التحقق من بيانات الاعتماد الخاصة بك.",
"demoLogin": "تسجيل دخول تجريبي", "demoLogin": "تسجيل دخول تجريبي",
"or": "أو", "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": "انتهت جلسة التحقق. يرجى تسجيل الدخول مرة أخرى."
}, },
"dashboard": { "dashboard": {
"title": "لوحة التحكم", "title": "لوحة التحكم",
@ -532,7 +430,6 @@
"department": "القسم", "department": "القسم",
"roomNumber": "رقم الغرفة", "roomNumber": "رقم الغرفة",
"location": "الموقع", "location": "الموقع",
"recalled": "مسترجع",
"selectStatus": "اختر الحالة", "selectStatus": "اختر الحالة",
"operational": "يعمل", "operational": "يعمل",
"underMaintenance": "قيد الصيانة", "underMaintenance": "قيد الصيانة",

File diff suppressed because it is too large Load Diff

View File

@ -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;

View File

@ -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;

View File

@ -75,7 +75,6 @@ const AssetDetail: React.FC = () => {
custom_total_spare_parts_amount: 0, custom_total_spare_parts_amount: 0,
custom_building: '', // Add if missing custom_building: '', // Add if missing
custom_room_number: '', custom_room_number: '',
custom_recalled: '',
is_existing_asset: true, is_existing_asset: true,
__islocal: false, __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_end_date: (asset as any).custom_end_date ? (asset as any).custom_end_date.split(' ')[0] : '',
custom_building: (asset as any).custom_building || '', custom_building: (asset as any).custom_building || '',
custom_room_number: (asset as any).custom_room_number || '', custom_room_number: (asset as any).custom_room_number || '',
custom_recalled: (asset as any).custom_recalled || '',
// Checkbox fields // Checkbox fields
custom_warranty: (asset as any).custom_warranty || false, custom_warranty: (asset as any).custom_warranty || false,
custom_extended_warranty: (asset as any).custom_extended_warranty || false, custom_extended_warranty: (asset as any).custom_extended_warranty || false,
@ -3266,25 +3264,6 @@ const handlePPMPlan = async () => {
)} )}
</div> </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>
</div> </div>

View File

@ -188,21 +188,16 @@ const AssetList: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const setSearchParamsRef = useRef(setSearchParams);
const page = useMemo(() => { const page = useMemo(() => {
const p = parseInt(searchParams.get('page') || '1', 10); const p = parseInt(searchParams.get('page') || '1', 10);
return Math.max(0, Number.isNaN(p) ? 0 : p - 1); return Math.max(0, Number.isNaN(p) ? 0 : p - 1);
}, [searchParams]); }, [searchParams]);
const setPage = useCallback((zeroBasedPage: number) => { const setPage = useCallback((zeroBasedPage: number) => {
setSearchParamsRef.current((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
next.set('page', String(zeroBasedPage + 1)); next.set('page', String(zeroBasedPage + 1));
return next; return next;
}); });
}, []);
useEffect(() => {
setSearchParamsRef.current = setSearchParams;
}, [setSearchParams]); }, [setSearchParams]);
const [searchTerm, setSearchTerm] = useState(() => searchParams.get('search') || ''); const [searchTerm, setSearchTerm] = useState(() => searchParams.get('search') || '');
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
@ -431,7 +426,7 @@ const AssetList: React.FC = () => {
filtersChangedOnce.current = true; filtersChangedOnce.current = true;
return; return;
} }
setSearchParamsRef.current((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by'); 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 (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
@ -1138,7 +1133,7 @@ const AssetList: React.FC = () => {
setSearchTerm(''); setSearchTerm('');
if (assetNameDebounceRef.current) clearTimeout(assetNameDebounceRef.current); if (assetNameDebounceRef.current) clearTimeout(assetNameDebounceRef.current);
if (serialNumberDebounceRef.current) clearTimeout(serialNumberDebounceRef.current); if (serialNumberDebounceRef.current) clearTimeout(serialNumberDebounceRef.current);
setSearchParamsRef.current((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end'); next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end');
next.delete('sort_by'); next.delete('sort_by');

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -230,11 +230,6 @@ const InspectionList: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const setSearchParamsRef = useRef(setSearchParams);
useEffect(() => {
setSearchParamsRef.current = setSearchParams;
}, [setSearchParams]);
// ── Permission hook — same pattern as ModernDashboard ──────────────────── // ── Permission hook — same pattern as ModernDashboard ────────────────────
// useUserPermissions('Issue Type') calls apiService.getPermissionFilters('Issue Type') // useUserPermissions('Issue Type') calls apiService.getPermissionFilters('Issue Type')
@ -405,7 +400,7 @@ const InspectionList: React.FC = () => {
filtersChangedOnce.current = true; filtersChangedOnce.current = true;
return; return;
} }
setSearchParamsRef.current((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by'); 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 (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
@ -439,7 +434,7 @@ const InspectionList: React.FC = () => {
setDateFilterBy(''); setDateStart(''); setDateEnd(''); setDateFilterBy(''); setDateStart(''); setDateEnd('');
setSortBy('creation desc'); setSortBy('creation desc');
setStatusFilter(''); setWorkflowStateFilter(''); setInspectionTypeFilter(''); setWorkOrderFilter(''); setDepartmentFilter(''); setStatusFilter(''); setWorkflowStateFilter(''); setInspectionTypeFilter(''); setWorkOrderFilter(''); setDepartmentFilter('');
setSearchParamsRef.current((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end'); next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end');
next.delete('sort_by'); next.delete('sort_by');

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useIssueDetails, useIssueMutations } from '../hooks/useIssue'; import { useIssueDetails, useIssueMutations } from '../hooks/useIssue';
import { import {
@ -10,26 +10,19 @@ import {
FaCheckCircle, FaCheckCircle,
FaTimesCircle, FaTimesCircle,
FaExclamationTriangle, FaExclamationTriangle,
FaClock,
FaUser, FaUser,
FaBuilding, FaBuilding,
FaEnvelope, FaEnvelope,
FaCalendarAlt, FaCalendarAlt,
FaTag, FaTag,
FaComment, FaComment
FaClipboardList
} from 'react-icons/fa'; } from 'react-icons/fa';
import { toast, ToastContainer, Bounce } from 'react-toastify'; import { toast, ToastContainer, Bounce } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
import LinkField from '../components/LinkField'; import LinkField from '../components/LinkField';
import type { CreateIssueData } from '../services/issueService'; import type { CreateIssueData } from '../services/issueService';
import CommentSection from '../components/CommentSection'; 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 // Helper to get today's date in YYYY-MM-DD format
const getTodayDate = (): string => { const getTodayDate = (): string => {
@ -63,14 +56,8 @@ const IssueDetail: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { issueName } = useParams<{ issueName: string }>(); const { issueName } = useParams<{ issueName: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const isNewIssue = issueName === 'new'; 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 // Form data state
const [formData, setFormData] = useState<CreateIssueData & { const [formData, setFormData] = useState<CreateIssueData & {
@ -103,85 +90,6 @@ const IssueDetail: React.FC = () => {
const [isEditing, setIsEditing] = useState(isNewIssue); const [isEditing, setIsEditing] = useState(isNewIssue);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); 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 // Load issue data when fetched
useEffect(() => { useEffect(() => {
@ -226,17 +134,6 @@ const IssueDetail: React.FC = () => {
try { try {
if (isNewIssue) { if (isNewIssue) {
const newIssue = await createIssue(formData); 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!', { toast.success('Issue created successfully!', {
position: "top-right", position: "top-right",
autoClose: 3000, autoClose: 3000,
@ -297,10 +194,6 @@ const IssueDetail: React.FC = () => {
return new Date(dateStr).toLocaleString(); return new Date(dateStr).toLocaleString();
}; };
if (isNewIssue && !skipNewIssuePrecheck) {
return <SupportPrecheckWizard variant="newIssue" />;
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900"> <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> </div>
<div className="flex gap-3 flex-wrap"> <div className="flex gap-3">
{!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>
)}
{!isNewIssue && !isEditing && ( {!isNewIssue && !isEditing && (
<> <>
<button <button
@ -711,24 +592,6 @@ const IssueDetail: React.FC = () => {
{/* Sidebar - Right Column */} {/* Sidebar - Right Column */}
<div className="space-y-6"> <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 */} {/* Status Card */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700"> <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"> <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">

View File

@ -26,15 +26,10 @@ import {
FaFileExport, FaFileExport,
FaFileExcel, FaFileExcel,
FaFileCsv, FaFileCsv,
FaDownload, FaDownload
FaClipboardList
} from 'react-icons/fa'; } from 'react-icons/fa';
import LinkField from '../components/LinkField'; import LinkField from '../components/LinkField';
import { buildDateRangeFilters, toFrappeFilterArray } from '../utils/listFilterUtils'; 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 // Export types
type ExportFormat = 'csv' | 'excel'; type ExportFormat = 'csv' | 'excel';
@ -222,11 +217,6 @@ const IssueList: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const setSearchParamsRef = useRef(setSearchParams);
useEffect(() => {
setSearchParamsRef.current = setSearchParams;
}, [setSearchParams]);
const currentPage = useMemo(() => { const currentPage = useMemo(() => {
const p = parseInt(searchParams.get('page') || '1', 10); const p = parseInt(searchParams.get('page') || '1', 10);
return Number.isNaN(p) || p < 1 ? 1 : p; return Number.isNaN(p) || p < 1 ? 1 : p;
@ -266,41 +256,11 @@ const IssueList: React.FC = () => {
const [showExportModal, setShowExportModal] = useState(false); const [showExportModal, setShowExportModal] = useState(false);
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null); 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 [dateFilterBy, setDateFilterBy] = useState<'' | 'creation' | 'modified'>(() => (searchParams.get('date_filter_by') as '' | 'creation' | 'modified') || '');
const [dateStart, setDateStart] = useState<string>(() => searchParams.get('date_start') || ''); const [dateStart, setDateStart] = useState<string>(() => searchParams.get('date_start') || '');
const [dateEnd, setDateEnd] = useState<string>(() => searchParams.get('date_end') || ''); 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>(() => searchParams.get('status') || '');
const [statusFilter, setStatusFilter] = useState<string>(() => {
const raw = searchParams.get('status');
if (raw === null) return 'Open';
return raw;
});
const [priorityFilter, setPriorityFilter] = useState<string>(() => searchParams.get('priority') || ''); const [priorityFilter, setPriorityFilter] = useState<string>(() => searchParams.get('priority') || '');
const [companyFilter, setCompanyFilter] = useState<string>(() => searchParams.get('company') || ''); const [companyFilter, setCompanyFilter] = useState<string>(() => searchParams.get('company') || '');
const [issueIdFilter, setIssueIdFilter] = useState<string>(() => searchParams.get('issue_id') || ''); const [issueIdFilter, setIssueIdFilter] = useState<string>(() => searchParams.get('issue_id') || '');
@ -348,7 +308,7 @@ const IssueList: React.FC = () => {
filtersChangedOnce.current = true; filtersChangedOnce.current = true;
return; return;
} }
setSearchParamsRef.current((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by'); 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 (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
@ -371,7 +331,7 @@ const IssueList: React.FC = () => {
setDateFilterBy(''); setDateStart(''); setDateEnd(''); setDateFilterBy(''); setDateStart(''); setDateEnd('');
setSortBy('creation desc'); setSortBy('creation desc');
setStatusFilter(''); setPriorityFilter(''); setCompanyFilter(''); setIssueIdFilter(''); setStatusFilter(''); setPriorityFilter(''); setCompanyFilter(''); setIssueIdFilter('');
setSearchParamsRef.current((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end'); next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end');
next.delete('sort_by'); next.delete('sort_by');
@ -527,14 +487,7 @@ const IssueList: React.FC = () => {
<FaFileExport /><span className="font-medium">{t('listPages.export')}</span> <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>} {selectedRows.size > 0 && <span className="bg-white/20 px-1.5 py-0.5 rounded text-xs">{selectedRows.size}</span>}
</button> </button>
<button <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">
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"
>
<FaPlus /><span className="font-medium">{t('issues.newIssue')}</span> <FaPlus /><span className="font-medium">{t('issues.newIssue')}</span>
</button> </button>
</div> </div>
@ -735,11 +688,7 @@ const IssueList: React.FC = () => {
</button> </button>
) : ( ) : (
<button <button
onClick={() => onClick={() => navigate('/support/new')}
navigate('/support/new?skip_precheck=1', {
state: { newIssuePrecheckDone: true },
})
}
className="mt-4 text-blue-600 dark:text-blue-400 hover:underline" className="mt-4 text-blue-600 dark:text-blue-400 hover:underline"
> >
{t('issues.createFirstIssue')} {t('issues.createFirstIssue')}
@ -779,19 +728,6 @@ const IssueList: React.FC = () => {
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}> <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-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> <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> <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> </div>
</td> </td>

View File

@ -318,11 +318,6 @@ const ItemList: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const setSearchParamsRef = useRef(setSearchParams);
useEffect(() => {
setSearchParamsRef.current = setSearchParams;
}, [setSearchParams]);
const pageFromUrl = useMemo(() => { const pageFromUrl = useMemo(() => {
const p = parseInt(searchParams.get('page') || '1', 10); const p = parseInt(searchParams.get('page') || '1', 10);
return Math.max(0, Number.isNaN(p) ? 0 : p - 1); return Math.max(0, Number.isNaN(p) ? 0 : p - 1);
@ -471,7 +466,7 @@ const ItemList: React.FC = () => {
filtersChangedOnce.current = true; filtersChangedOnce.current = true;
return; return;
} }
setSearchParamsRef.current((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by'); 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 (dateStart) next.set('date_start', dateStart); else next.delete('date_start');

View File

@ -1,170 +1,30 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLanguage } from '../contexts/LanguageContext';
import { loadFrappeTranslations } from '../i18n'; import { loadFrappeTranslations } from '../i18n';
import { bootstrapFrappeUserFromSession } from '../utils/bootstrapFrappeUserFromSession';
interface LoginFormData { interface LoginFormData {
email: string; email: string;
password: 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 Login: React.FC = () => {
const [formData, setFormData] = useState<LoginFormData>({ const [formData, setFormData] = useState<LoginFormData>({
email: '', email: '',
password: '', password: '',
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [checkingSession, setCheckingSession] = useState(true);
const [error, setError] = useState<string | null>(null); 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 navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
const manualLoginHandledRef = useRef(false); const { isRTL } = useLanguage();
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]);
// Get base URL for assets
const baseUrl = import.meta.env.BASE_URL || '/'; const baseUrl = import.meta.env.BASE_URL || '/';
const logoVersion = import.meta.env.DEV const logoVersion = import.meta.env.DEV
? `?v=${Date.now()}` ? `?v=${Date.now()}`
: `?v=1774269853`; // Auto-updated by build script : `?v=1768316563`; // Auto-updated by build script
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target; const { name, value } = e.target;
@ -175,94 +35,48 @@ const Login: React.FC = () => {
setError(null); 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
// Dynamic import to catch any module loading errors
const { useAuth } = await import('../hooks/useApi');
const apiService = (await import('../services/apiService')).default; const apiService = (await import('../services/apiService')).default;
const result = await apiService.login(formData);
if (result.status === 'two_factor_required') { const response = await apiService.login(formData);
sessionStorage.setItem(TWO_FACTOR_TMP_ID_KEY, result.tmp_id);
setTmpId(result.tmp_id);
setVerification(result.verification);
setLoginStep('otp');
setOtpCode('');
return;
}
await completeLogin(result.user); if (response && response.message) {
} catch (err: unknown) { const userData = {
console.error('Login error:', err); ...response.message,
const message = err instanceof Error ? err.message : t('login.loginFailed'); email: formData.email
setError(message);
} finally {
setLoading(false);
}
}; };
localStorage.setItem('user', JSON.stringify(userData));
const handleOtpSubmit = async (e: React.FormEvent) => { if (response.message.sid) {
e.preventDefault(); apiService.setSessionId(response.message.sid);
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); // Load translations from Frappe after successful login
setError(null);
try { try {
const apiService = (await import('../services/apiService')).default; await loadFrappeTranslations();
const result = await apiService.verifyLoginOtp(storedTmpId, otpCode); } catch (err) {
if (result.status === 'logged_in') { console.warn('Could not load translations after login:', err);
await completeLogin(result.user);
} }
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('login.twoFactorInvalid'); navigate('/dashboard');
setError(message); } else {
setError(t('login.loginFailed'));
}
} catch (err: any) {
console.error('Login error:', err);
setError(err.message || t('login.loginFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const backToCredentials = () => {
sessionStorage.removeItem(TWO_FACTOR_TMP_ID_KEY);
setLoginStep('credentials');
setTmpId(null);
setVerification(null);
setOtpCode('');
setError(null);
};
const handleDemoLogin = async () => { const handleDemoLogin = async () => {
const demoUser = { const demoUser = {
full_name: 'Demo User', full_name: 'Demo User',
@ -283,70 +97,6 @@ const Login: React.FC = () => {
navigate('/dashboard'); 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 ( 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="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"> <div className="max-w-md w-full space-y-8">
@ -383,81 +133,11 @@ const Login: React.FC = () => {
{t('login.subtitle')} {t('login.subtitle')}
</p> </p>
<p className="mt-1 text-center text-xs text-gray-600 dark:text-gray-400"> <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> </p>
</div> </div>
<div className="mt-8 space-y-6"> <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{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>
</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 className="rounded-md shadow-sm -space-y-px">
<div> <div>
<label htmlFor="email" className="sr-only"> <label htmlFor="email" className="sr-only">
@ -497,6 +177,7 @@ const Login: React.FC = () => {
</div> </div>
)} )}
<div className="space-y-3">
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
@ -514,22 +195,7 @@ const Login: React.FC = () => {
t('common.login') t('common.login')
)} )}
</button> </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="relative">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300 dark:border-gray-600" /> <div className="w-full border-t border-gray-300 dark:border-gray-600" />
@ -542,90 +208,14 @@ const Login: React.FC = () => {
<button <button
type="button" type="button"
onClick={handleDemoLogin} 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" 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')} 🚀 {t('login.demoLogin')}
</button> </button>
</div> </div>
</div>
</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> </form>
</div> </div>
</div> </div>
)}
</div>
); );
}; };

View File

@ -180,11 +180,6 @@ const MaintenanceTeamList: React.FC = () => {
]; ];
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const setSearchParamsRef = useRef(setSearchParams);
useEffect(() => {
setSearchParamsRef.current = setSearchParams;
}, [setSearchParams]);
const currentPage = useMemo(() => { const currentPage = useMemo(() => {
const p = parseInt(searchParams.get('page') || '1', 10); const p = parseInt(searchParams.get('page') || '1', 10);
return Number.isNaN(p) || p < 1 ? 1 : p; return Number.isNaN(p) || p < 1 ? 1 : p;
@ -251,7 +246,7 @@ const MaintenanceTeamList: React.FC = () => {
filtersChangedOnce.current = true; filtersChangedOnce.current = true;
return; return;
} }
setSearchParamsRef.current((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by'); 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 (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
@ -272,7 +267,7 @@ const MaintenanceTeamList: React.FC = () => {
setDateFilterBy(''); setDateStart(''); setDateEnd(''); setDateFilterBy(''); setDateStart(''); setDateEnd('');
setSortBy('creation desc'); setSortBy('creation desc');
setCompanyFilter(''); setTeamNameFilter(''); setCompanyFilter(''); setTeamNameFilter('');
setSearchParamsRef.current((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end'); next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end');
next.delete('sort_by'); next.delete('sort_by');

View File

@ -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;

View File

@ -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;

View File

@ -1981,9 +1981,7 @@ const PieChart: React.FC<{ data: any }> = ({ data }) => {
} }
const total = values.reduce((sum: number, val: number) => sum + val, 0); const total = values.reduce((sum: number, val: number) => sum + val, 0);
const radius = 100; const radius = 100;
const pad = 22; const cx = radius + 10, cy = radius + 10;
const cx = radius + pad;
const cy = radius + pad;
let cumulative = 0; let cumulative = 0;
const slices = values.map((value: number, i: number) => { const slices = values.map((value: number, i: number) => {
const startAngle = (cumulative / total) * 2 * Math.PI - Math.PI / 2; const startAngle = (cumulative / total) * 2 * Math.PI - Math.PI / 2;

View File

@ -559,12 +559,7 @@ const PPMPlannerList: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const setSearchParamsRef = useRef(setSearchParams);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
useEffect(() => {
setSearchParamsRef.current = setSearchParams;
}, [setSearchParams]);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
const [actionMenuOpen, setActionMenuOpen] = useState<string | null>(null); const [actionMenuOpen, setActionMenuOpen] = useState<string | null>(null);

View File

@ -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>
</>
);
}

View File

@ -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

View File

@ -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 AZ</option>
<option value="name desc">Name ZA</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;

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -174,11 +174,6 @@ const SupportPlanList: React.FC = () => {
]; ];
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const setSearchParamsRef = useRef(setSearchParams);
useEffect(() => {
setSearchParamsRef.current = setSearchParams;
}, [setSearchParams]);
const currentPage = useMemo(() => { const currentPage = useMemo(() => {
const p = parseInt(searchParams.get('page') || '1', 10); const p = parseInt(searchParams.get('page') || '1', 10);
return Number.isNaN(p) || p < 1 ? 1 : p; return Number.isNaN(p) || p < 1 ? 1 : p;
@ -252,7 +247,7 @@ const SupportPlanList: React.FC = () => {
filtersChangedOnce.current = true; filtersChangedOnce.current = true;
return; return;
} }
setSearchParamsRef.current((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by'); 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 (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
@ -280,7 +275,7 @@ const SupportPlanList: React.FC = () => {
setDateFilterBy(''); setDateStart(''); setDateEnd(''); setDateFilterBy(''); setDateStart(''); setDateEnd('');
setContractWarrantyFilter(''); setFrequencyFilter(''); setWarStatusFilter(''); setContractWarrantyFilter(''); setFrequencyFilter(''); setWarStatusFilter('');
setServiceContractStatusFilter(''); setVendorFilter(''); setAssetFilter(''); setServiceContractStatusFilter(''); setVendorFilter(''); setAssetFilter('');
setSearchParamsRef.current((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end'); next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end');
next.delete('contract_warranty'); next.delete('frequency'); next.delete('war_status'); next.delete('contract_warranty'); next.delete('frequency'); next.delete('war_status');

View File

@ -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&apos;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&apos;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&apos;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;

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -26,16 +26,9 @@ import {
type UserProfile, type UserProfile,
type UpdateUserProfileData type UpdateUserProfileData
} from '../services/userProfileService'; } from '../services/userProfileService';
import {
fetchTwoFactorStatus,
resetOtpSecret,
type TwoFactorStatus,
} from '../services/twoFactorService';
import { useTranslation } from 'react-i18next';
const UserProfilePage: React.FC = () => { const UserProfilePage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
// State // State
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -64,9 +57,6 @@ const UserProfilePage: React.FC = () => {
const [showOldPassword, setShowOldPassword] = useState(false); const [showOldPassword, setShowOldPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = 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 // Fetch user profile and check role on mount
useEffect(() => { useEffect(() => {
@ -107,15 +97,8 @@ const UserProfilePage: React.FC = () => {
custom_user_id: data.custom_user_id || '', custom_user_id: data.custom_user_id || '',
}); });
try { // Debug: Log formData after setting
const tfa = await fetchTwoFactorStatus(data.name); console.log('FormData role_profile_name:', data.role_profile_name || '');
setTwoFactorStatus(tfa);
} catch (tfaErr) {
console.warn('Could not load 2FA status:', tfaErr);
setTwoFactorStatus(null);
} finally {
setTwoFactorLoading(false);
}
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to load profile'; 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 // Change password
const handleChangePassword = async () => { const handleChangePassword = async () => {
// Validation // Validation
@ -305,12 +264,6 @@ const UserProfilePage: React.FC = () => {
); );
} }
const showTwoFactor =
!twoFactorLoading &&
!!twoFactorStatus?.enabled_globally &&
!!twoFactorStatus?.required_for_user &&
!!twoFactorStatus?.otp_app;
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
<ToastContainer <ToastContainer
@ -359,6 +312,7 @@ const UserProfilePage: React.FC = () => {
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Profile Card */}
<div className="lg:col-span-1"> <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"> <div className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Profile Header */} {/* Profile Header */}
@ -409,49 +363,32 @@ const UserProfilePage: React.FC = () => {
</p> </p>
</div> </div>
</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> </div>
)}
{showTwoFactor && ( {/* User Roles */}
<div className="rounded-lg border border-purple-200 bg-purple-50/80 p-3 dark:border-purple-800 dark:bg-purple-900/20"> {/* {profile?.roles && profile.roles.length > 0 && (
<div className="flex items-start gap-2"> <div className="p-4 border-t border-gray-200 dark:border-gray-700">
<FaShieldAlt className="mt-0.5 shrink-0 text-purple-600 dark:text-purple-400" size={13} /> <h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
<div className="min-w-0 flex-1"> Assigned Roles
<p className="text-xs font-semibold text-purple-900 dark:text-purple-200"> </h3>
{t('profile.twoFactorSidebarTitle')} <div className="flex flex-wrap gap-2">
</p> {profile.roles.map((role, index) => (
<p className="mt-1 text-[11px] leading-snug text-purple-800/90 dark:text-purple-300/90"> <span
{t('profile.twoFactorRequired')} key={index}
</p> 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"
<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 ? ( {role.role}
<FaSpinner className="animate-spin" size={10} /> </span>
) : ( ))}
<FaShieldAlt size={10} />
)}
{t('profile.resetOtp')}
</button>
</div> </div>
</div> </div>
</div> )} */}
)}
</div>
</div> </div>
</div> </div>
{/* Right Column - Edit Forms */}
<div className="lg:col-span-2 space-y-6"> <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"> <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"> <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" /> <FaUser className="text-blue-500" />
@ -575,6 +512,7 @@ const UserProfilePage: React.FC = () => {
</div> </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="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"> <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"> <h2 className="text-lg font-semibold text-gray-800 dark:text-white flex items-center gap-2">

View File

@ -1,14 +1,13 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; 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 { useWorkOrderDetails, useWorkOrderMutations } from '../hooks/useWorkOrder';
import { useWorkflow } from '../hooks/useWorkflow'; import { useWorkflow } from '../hooks/useWorkflow';
import { useFrappeFieldBehavior } from '../hooks/useFrappeFieldBehavior'; import { useFrappeFieldBehavior } from '../hooks/useFrappeFieldBehavior';
import { setCurrentUser } from '../services/workflowService'; import { setCurrentUser } from '../services/workflowService';
import type { WorkflowTransition } 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 type { CreateWorkOrderData } from '../services/workOrderService';
import { technicalReportHasGuideCompleted } from '../utils/troubleshootGuideMarkers';
import { toast, ToastContainer, Bounce } from 'react-toastify'; import { toast, ToastContainer, Bounce } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
@ -20,7 +19,6 @@ import CommentSection from '../components/CommentSection';
import DeleteRequestButton from '../components/DeleteRequestButton'; import DeleteRequestButton from '../components/DeleteRequestButton';
import type { DeleteStatus } from '../services/deleteRequestService'; import type { DeleteStatus } from '../services/deleteRequestService';
import issueService from '../services/issueService';
// Print Format Configuration // Print Format Configuration
const PRINT_FORMAT_NAME = 'Service_Report'; // Change this if your print format has a different name 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]; 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 ============== // ============== END DATE/TIME HELPER FUNCTIONS ==============
const WorkOrderDetail: React.FC = () => { const WorkOrderDetail: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { workOrderName } = useParams<{ workOrderName: string }>(); const { workOrderName } = useParams<{ workOrderName: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const duplicateFromWorkOrder = searchParams.get('duplicate'); const duplicateFromWorkOrder = searchParams.get('duplicate');
@ -192,10 +149,6 @@ const WorkOrderDetail: React.FC = () => {
const isNewWorkOrder = workOrderName === 'new'; const isNewWorkOrder = workOrderName === 'new';
const isDuplicating = isNewWorkOrder && !!duplicateFromWorkOrder; 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 * Open Service Report print format in a new window
@ -257,26 +210,6 @@ const WorkOrderDetail: React.FC = () => {
); );
const { createWorkOrder, updateWorkOrder, loading: saving } = useWorkOrderMutations(); 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 [isEditing, setIsEditing] = useState(isNewWorkOrder);
const [isLoadingAsset, setIsLoadingAsset] = useState(false); const [isLoadingAsset, setIsLoadingAsset] = useState(false);
const [confirmAction, setConfirmAction] = useState<{ action: string; nextState: string } | null>(null); const [confirmAction, setConfirmAction] = useState<{ action: string; nextState: string } | null>(null);
@ -416,7 +349,6 @@ const WorkOrderDetail: React.FC = () => {
custom_building?: string; custom_building?: string;
custom_type?: string; custom_type?: string;
custom_civil_work_category?: string; custom_civil_work_category?: string;
issue?: string;
}>({ }>({
company: 'King Fahad Specialist Hospital - Dammam', company: 'King Fahad Specialist Hospital - Dammam',
work_order_type: '', work_order_type: '',
@ -475,7 +407,6 @@ const WorkOrderDetail: React.FC = () => {
custom_technical_department: '', custom_technical_department: '',
custom_room_no: '', custom_room_no: '',
custom_building: '', custom_building: '',
issue: '',
// For Frappe field behavior evaluation // For Frappe field behavior evaluation
__islocal: false, __islocal: false,
}); });
@ -2758,57 +2689,6 @@ const fetchItemDefaultWarehouse = async (itemCode: string): Promise<string> => {
} }
}, [isNewWorkOrder, isCreatingFromAsset, isDuplicating, searchParams]); }, [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) // Store initial values only on FIRST load (for "Back To Controller" and restore logic)
useEffect(() => { useEffect(() => {
if (workOrder && !hasLoadedInitialData && !isNewWorkOrder && !isDuplicating) { if (workOrder && !hasLoadedInitialData && !isNewWorkOrder && !isDuplicating) {
@ -2843,9 +2723,7 @@ const fetchItemDefaultWarehouse = async (itemCode: string): Promise<string> => {
repair_status: isDuplicating ? 'Open' : (workOrder.repair_status || 'Open'), repair_status: isDuplicating ? 'Open' : (workOrder.repair_status || 'Open'),
workflow_state: isDuplicating ? 'Draft' : (workOrder.workflow_state || 'Draft'), workflow_state: isDuplicating ? 'Draft' : (workOrder.workflow_state || 'Draft'),
department: workOrder.department || '', department: workOrder.department || '',
custom_priority_: mapIssuePriorityToWorkOrderCustomPriority( custom_priority_: workOrder.custom_priority_ || 'Normal',
workOrder.custom_priority_ || undefined
),
asset_type: workOrder.asset_type || 'Non Biomedical', asset_type: workOrder.asset_type || 'Non Biomedical',
custom_type: workOrder.custom_type || 'Corrective', custom_type: workOrder.custom_type || 'Corrective',
manufacturer: workOrder.manufacturer || '', manufacturer: workOrder.manufacturer || '',
@ -2895,7 +2773,6 @@ const fetchItemDefaultWarehouse = async (itemCode: string): Promise<string> => {
custom_room_no: workOrder.custom_room_no || '', custom_room_no: workOrder.custom_room_no || '',
custom_building: workOrder.custom_building || '', custom_building: workOrder.custom_building || '',
custom_civil_work_category: workOrder.custom_civil_work_category || '', custom_civil_work_category: workOrder.custom_civil_work_category || '',
issue: workOrder.issue || '',
}); });
} }
}, [workOrder, isDuplicating]); }, [workOrder, isDuplicating]);
@ -3179,31 +3056,19 @@ const fetchItemDefaultWarehouse = async (itemCode: string): Promise<string> => {
return; return;
} }
// ✅ Prepare data with proper datetime format for Frappe (omit client-only keys) // ✅ Prepare data with proper datetime format for Frappe
const { __islocal: _omitIsLocal, ...formPayload } = formData as typeof formData & { __islocal?: boolean };
const dataToSave = { const dataToSave = {
...formPayload, ...formData,
custom_priority_: mapIssuePriorityToWorkOrderCustomPriority(formData.custom_priority_),
failure_date: formatDateTimeForFrappe(formData.failure_date), failure_date: formatDateTimeForFrappe(formData.failure_date),
first_responded_on: formatDateTimeForFrappe(formData.first_responded_on), first_responded_on: formatDateTimeForFrappe(formData.first_responded_on),
completion_date: formatDateTimeForFrappe(formData.completion_date), 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 { try {
if (isNewWorkOrder || isDuplicating) { if (isNewWorkOrder || isDuplicating) {
const newWorkOrder = await createWorkOrder(dataToSave); const newWorkOrder = await createWorkOrder(dataToSave);
const successMessage = isDuplicating const successMessage = isDuplicating
? t('workOrders.detail.workOrderDuplicatedSuccessfully') ? t('workOrders.detail.workOrderDuplicatedSuccessfully')
: isCreatingFromIssue
? t('workOrders.detail.workOrderCreatedFromIssueSuccessfully')
: isCreatingFromAsset : isCreatingFromAsset
? t('workOrders.detail.workOrderCreatedFromAssetSuccessfully') ? t('workOrders.detail.workOrderCreatedFromAssetSuccessfully')
: t('workOrders.detail.workOrderCreatedSuccessfully'); : t('workOrders.detail.workOrderCreatedSuccessfully');
@ -3504,21 +3369,12 @@ if (action === 'Send to Supervisor' && currentWfState === 'Sent to Engineer') {
} }
}; };
const troubleshootGuideCompleted = useMemo( if (loading) {
() => technicalReportHasGuideCompleted(workOrder?.actions_performed ?? formData.actions_performed),
[workOrder?.actions_performed, formData.actions_performed],
);
if (loading || (isCreatingFromIssue && !issuePrefillResolved)) {
return ( return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900"> <div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div> <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"> <p className="mt-4 text-gray-600 dark:text-gray-400">{t('workOrders.loadingDetails')}</p>
{isCreatingFromIssue && !issuePrefillResolved
? t('workOrders.detail.loadingSupportIssue')
: t('workOrders.loadingDetails')}
</p>
</div> </div>
</div> </div>
); );
@ -3542,7 +3398,6 @@ if (action === 'Send to Supervisor' && currentWfState === 'Sent to Engineer') {
const getPageTitle = () => { const getPageTitle = () => {
if (isDuplicating) return t('workOrders.detail.duplicateWorkOrder'); if (isDuplicating) return t('workOrders.detail.duplicateWorkOrder');
if (isCreatingFromIssue) return t('workOrders.detail.createFromSupportIssue');
if (isCreatingFromAsset) return t('workOrders.detail.createFromAsset'); if (isCreatingFromAsset) return t('workOrders.detail.createFromAsset');
if (isNewWorkOrder) return t('workOrders.detail.newWorkOrder'); if (isNewWorkOrder) return t('workOrders.detail.newWorkOrder');
return t('workOrders.detail.workOrderDetails'); return t('workOrders.detail.workOrderDetails');
@ -4267,39 +4122,6 @@ if (action === 'Send to Supervisor' && currentWfState === 'Sent to Engineer') {
</button> </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 */} {/* ✅ Show Edit button only if not submitted and not cancelled */}
{/* Hide for Contractor Supervisor unless they also have System Manager role */} {/* 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')} {t('workOrders.detail.description')}
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <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> <div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('workOrders.detail.natureOfComplaint')} {t('workOrders.detail.natureOfComplaint')}

View File

@ -20,8 +20,6 @@ import type { DeleteStatus } from '../services/deleteRequestService';
import apiService from '../services/apiService'; import apiService from '../services/apiService';
import API_CONFIG from '../config/api'; import API_CONFIG from '../config/api';
import DynamicExportModal from '../components/DynamicExportModal'; import DynamicExportModal from '../components/DynamicExportModal';
import VoiceStatusModal from '../components/VoiceStatusModal';
import { FaMicrophone } from 'react-icons/fa';
type ExportFormat = 'csv' | 'excel'; type ExportFormat = 'csv' | 'excel';
type ExportScope = 'selected' | 'all_on_page' | 'all_with_filters'; type ExportScope = 'selected' | 'all_on_page' | 'all_with_filters';
@ -159,7 +157,6 @@ const WorkOrderList: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const setSearchParamsRef = useRef(setSearchParams);
const page = useMemo(() => { const page = useMemo(() => {
const p = parseInt(searchParams.get('page') || '1', 10); const p = parseInt(searchParams.get('page') || '1', 10);
@ -167,15 +164,11 @@ const WorkOrderList: React.FC = () => {
}, [searchParams]); }, [searchParams]);
const setPage = useCallback((zeroBasedPage: number) => { const setPage = useCallback((zeroBasedPage: number) => {
setSearchParamsRef.current((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
next.set('page', String(zeroBasedPage + 1)); next.set('page', String(zeroBasedPage + 1));
return next; return next;
}); });
}, []);
useEffect(() => {
setSearchParamsRef.current = setSearchParams;
}, [setSearchParams]); }, [setSearchParams]);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
@ -193,7 +186,6 @@ const WorkOrderList: React.FC = () => {
const [showExportModal, setShowExportModal] = useState(false); const [showExportModal, setShowExportModal] = useState(false);
const [showReportModal, setShowReportModal] = useState(false); const [showReportModal, setShowReportModal] = useState(false);
const [showCloseModal, setShowCloseModal] = useState(false); const [showCloseModal, setShowCloseModal] = useState(false);
const [showVoiceModal, setShowVoiceModal] = useState(false);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [selectedWorkOrdersInfo, setSelectedWorkOrdersInfo] = useState<SelectedWorkOrderInfo[]>([]); const [selectedWorkOrdersInfo, setSelectedWorkOrdersInfo] = useState<SelectedWorkOrderInfo[]>([]);
const [isClusterManager, setIsClusterManager] = useState(false); const [isClusterManager, setIsClusterManager] = useState(false);
@ -373,7 +365,7 @@ const WorkOrderList: React.FC = () => {
const filtersChangedOnce = useRef(false); const filtersChangedOnce = useRef(false);
useEffect(() => { useEffect(() => {
if (!filtersChangedOnce.current) { filtersChangedOnce.current = true; return; } if (!filtersChangedOnce.current) { filtersChangedOnce.current = true; return; }
setSearchParamsRef.current((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by'); 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 (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
@ -665,7 +657,7 @@ const WorkOrderList: React.FC = () => {
setFilterSerialNumber(''); setTempSerialNumber(''); setFilterSerialNumber(''); setTempSerialNumber('');
setFilterManufacturer(''); setFilterSupplier(''); setFilterManufacturer(''); setFilterSupplier('');
setFilterDepartment(''); setFilterPriority(''); setFilterWorkflowState(''); setFilterDepartment(''); setFilterPriority(''); setFilterWorkflowState('');
setSearchParamsRef.current((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end'); 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'); next.delete('work_order_id'); next.delete('asset'); next.delete('work_order_type');
@ -810,13 +802,8 @@ const WorkOrderList: React.FC = () => {
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
{canViewWOReport && ( {canViewWOReport && (
<button <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">
onClick={() => setShowVoiceModal(true)} <FaTable /><span className="font-medium">WO Report</span>
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> </button>
)} )}
{isClusterManager && selectedRows.size > 0 && ( {isClusterManager && selectedRows.size > 0 && (
@ -1305,12 +1292,7 @@ const WorkOrderList: React.FC = () => {
{/* Work Order Report Modal */} {/* Work Order Report Modal */}
<WorkOrderReportModal isOpen={showReportModal} onClose={() => setShowReportModal(false)} /> <WorkOrderReportModal isOpen={showReportModal} onClose={() => setShowReportModal(false)} />
<VoiceStatusModal
isOpen={showVoiceModal}
onClose={() => setShowVoiceModal(false)}
selectedRows={selectedRows}
onUpdateSuccess={() => { refetch(); setSelectedRows(new Set()); }}
/>
<style>{` <style>{`
@keyframes scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } } @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; } .animate-scale-in { animation: scale-in 0.2s ease-out; }

View File

@ -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: 'WiFi, 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;
};

View File

@ -96,28 +96,13 @@ interface KycDetailsResponse {
}; };
} }
export interface LoginUserMessage { interface LoginResponse {
full_name?: string; message: {
user_id?: string; full_name: string;
email?: string; user_id: string;
home_page?: string; sid: 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 { interface LoginCredentials {
email: string; email: string;
@ -287,118 +272,9 @@ 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 // 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) { if (import.meta.env.DEV) {
console.log('[API Service] Login attempt for:', credentials.email); console.log('[API Service] Login attempt for:', credentials.email);
} }
@ -407,126 +283,82 @@ class ApiService {
formData.append('usr', credentials.email); formData.append('usr', credentials.email);
formData.append('pwd', credentials.password); formData.append('pwd', credentials.password);
const data = await this.postLoginRequest(formData); const url = `${this.baseURL}${this.endpoints.LOGIN}`;
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;
try { try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers, headers: {
body: new URLSearchParams({ user: trimmed }).toString(), 'Accept': 'application/json'
credentials: 'include', },
signal: combinedSignal, body: formData,
credentials: 'include', // Important: Include cookies
signal: controller.signal
}); });
const raw = await response.text(); clearTimeout(timeoutId);
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');
}
if (!response.ok) { 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( throw new ApiError(
msg || `HTTP error! status: ${response.status}`, import.meta.env.DEV ? error.message : 'Login failed. Please try again.'
response.status,
'REQUEST_FAILED'
); );
} }
throw error;
if (msg === 'disabled' || msg === 'not allowed') {
throw new ApiError('Reset not allowed', 200, 'RESET_NOT_ALLOWED');
} }
} finally {
clearTimeout(timeoutId);
}
}
/** 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> { // async login(credentials: LoginCredentials): Promise<LoginResponse> {
@ -734,4 +566,3 @@ class ApiError extends Error {
const apiService = new ApiService(); const apiService = new ApiService();
export default apiService; export default apiService;
export { ApiError }; export { ApiError };
export type { LoginCredentials };

View File

@ -36,7 +36,6 @@ export interface Asset {
workflow_state?: string; workflow_state?: string;
custom_delete_status?: string; custom_delete_status?: string;
custom_category?: string; custom_category?: string;
custom_recalled?: string;
calculate_depreciation?: boolean; calculate_depreciation?: boolean;
gross_purchase_amount?: number; gross_purchase_amount?: number;
@ -302,59 +301,6 @@ class AssetService {
return apiService.apiCall<Asset[]>(endpoint); 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) * Submit an asset document (changes docstatus from 0 to 1)
*/ */

View File

@ -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();

View File

@ -16,8 +16,6 @@ export interface Issue {
subject: string; subject: string;
raised_by: string; raised_by: string;
status: string; status: string;
/** Support Issue workflow (Asset Lite), e.g. Sent to Work Control */
workflow_state?: string;
priority?: string; priority?: string;
issue_type?: string; issue_type?: string;
description?: string; description?: string;

View File

@ -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;

View File

@ -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;

View File

@ -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();

View File

@ -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 Tasks `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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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