Compare commits
No commits in common. "master" and "admin_dashboard" have entirely different histories.
master
...
admin_dash
@ -1,2 +0,0 @@
|
||||
VITE_API_BASE_URL=https://ipi.venbait.in/api
|
||||
VITE_API_APP_SIGNATURE=fcsc.ipi.v3f4b8fcb261i73b6c5198f4e7ajd22c8d13e4a6f0e4c8d82b7c6yf8a9b17.2025
|
||||
@ -2,25 +2,12 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="" href="/assets/images/image.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>IIP Survey Platform</title>
|
||||
<style>
|
||||
html {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
if (self === top) {
|
||||
document.documentElement.style.display = 'block';
|
||||
} else {
|
||||
top.location = self.location;
|
||||
}
|
||||
</script>
|
||||
<title>ipi-survey-platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
1228
ipi-survey-platform/package-lock.json
generated
@ -10,21 +10,12 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.6",
|
||||
"@mui/material": "^7.3.6",
|
||||
"axios": "^1.12.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"lucide-react": "^0.548.0",
|
||||
"react": "^19.1.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-toastify": "^11.0.5",
|
||||
"recharts": "^3.5.0"
|
||||
"react-router-dom": "^7.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
RewriteRule ^index\.html$ - [L]
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule . /index.html [L]
|
||||
</IfModule>
|
||||
@ -1,3 +0,0 @@
|
||||
<svg width="6" height="10" viewBox="0 0 6 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.749811 0.483398L4.94336 4.67695L0.749811 8.87049" stroke="#C3C6CB" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 248 B |
@ -1,3 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.6667 2.66634H11.3334V1.99967C11.3334 1.82286 11.2631 1.65329 11.1381 1.52827C11.0131 1.40325 10.8435 1.33301 10.6667 1.33301C10.4899 1.33301 10.3203 1.40325 10.1953 1.52827C10.0703 1.65329 10 1.82286 10 1.99967V2.66634H6.00004V1.99967C6.00004 1.82286 5.9298 1.65329 5.80478 1.52827C5.67975 1.40325 5.51018 1.33301 5.33337 1.33301C5.15656 1.33301 4.98699 1.40325 4.86197 1.52827C4.73695 1.65329 4.66671 1.82286 4.66671 1.99967V2.66634H3.33337C2.80294 2.66634 2.29423 2.87705 1.91916 3.25213C1.54409 3.6272 1.33337 4.13591 1.33337 4.66634V12.6663C1.33337 13.1968 1.54409 13.7055 1.91916 14.0806C2.29423 14.4556 2.80294 14.6663 3.33337 14.6663H12.6667C13.1971 14.6663 13.7058 14.4556 14.0809 14.0806C14.456 13.7055 14.6667 13.1968 14.6667 12.6663V4.66634C14.6667 4.13591 14.456 3.6272 14.0809 3.25213C13.7058 2.87705 13.1971 2.66634 12.6667 2.66634ZM13.3334 12.6663C13.3334 12.8432 13.2631 13.0127 13.1381 13.1377C13.0131 13.2628 12.8435 13.333 12.6667 13.333H3.33337C3.15656 13.333 2.98699 13.2628 2.86197 13.1377C2.73695 13.0127 2.66671 12.8432 2.66671 12.6663V7.99967H13.3334V12.6663ZM13.3334 6.66634H2.66671V4.66634C2.66671 4.48953 2.73695 4.31996 2.86197 4.19494C2.98699 4.06991 3.15656 3.99967 3.33337 3.99967H4.66671V4.66634C4.66671 4.84315 4.73695 5.01272 4.86197 5.13775C4.98699 5.26277 5.15656 5.33301 5.33337 5.33301C5.51018 5.33301 5.67975 5.26277 5.80478 5.13775C5.9298 5.01272 6.00004 4.84315 6.00004 4.66634V3.99967H10V4.66634C10 4.84315 10.0703 5.01272 10.1953 5.13775C10.3203 5.26277 10.4899 5.33301 10.6667 5.33301C10.8435 5.33301 11.0131 5.26277 11.1381 5.13775C11.2631 5.01272 11.3334 4.84315 11.3334 4.66634V3.99967H12.6667C12.8435 3.99967 13.0131 4.06991 13.1381 4.19494C13.2631 4.31996 13.3334 4.48953 13.3334 4.66634V6.66634Z" fill="#6C4527"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23.1853 11.6962C23.1525 11.6222 22.3584 9.86062 20.5931 8.09531C18.2409 5.74312 15.27 4.5 12 4.5C8.72999 4.5 5.75905 5.74312 3.40687 8.09531C1.64155 9.86062 0.843741 11.625 0.814679 11.6962C0.772035 11.7922 0.75 11.896 0.75 12.0009C0.75 12.1059 0.772035 12.2097 0.814679 12.3056C0.847491 12.3797 1.64155 14.1403 3.40687 15.9056C5.75905 18.2569 8.72999 19.5 12 19.5C15.27 19.5 18.2409 18.2569 20.5931 15.9056C22.3584 14.1403 23.1525 12.3797 23.1853 12.3056C23.2279 12.2097 23.25 12.1059 23.25 12.0009C23.25 11.896 23.2279 11.7922 23.1853 11.6962ZM12 18C9.11437 18 6.59343 16.9509 4.50655 14.8828C3.65028 14.0313 2.92179 13.0603 2.34374 12C2.92164 10.9396 3.65014 9.9686 4.50655 9.11719C6.59343 7.04906 9.11437 6 12 6C14.8856 6 17.4066 7.04906 19.4934 9.11719C20.3514 9.9684 21.0815 10.9394 21.6609 12C20.985 13.2619 18.0403 18 12 18ZM12 7.5C11.11 7.5 10.2399 7.76392 9.49993 8.25839C8.7599 8.75285 8.18313 9.45566 7.84253 10.2779C7.50194 11.1002 7.41282 12.005 7.58646 12.8779C7.76009 13.7508 8.18867 14.5526 8.81801 15.182C9.44735 15.8113 10.2492 16.2399 11.1221 16.4135C11.995 16.5872 12.8998 16.4981 13.7221 16.1575C14.5443 15.8169 15.2471 15.2401 15.7416 14.5001C16.2361 13.76 16.5 12.89 16.5 12C16.4988 10.8069 16.0242 9.66303 15.1806 8.81939C14.337 7.97575 13.1931 7.50124 12 7.5ZM12 15C11.4066 15 10.8266 14.8241 10.3333 14.4944C9.83993 14.1648 9.45542 13.6962 9.22835 13.1481C9.00129 12.5999 8.94188 11.9967 9.05764 11.4147C9.17339 10.8328 9.45911 10.2982 9.87867 9.87868C10.2982 9.45912 10.8328 9.1734 11.4147 9.05764C11.9967 8.94189 12.5999 9.0013 13.148 9.22836C13.6962 9.45542 14.1648 9.83994 14.4944 10.3333C14.824 10.8266 15 11.4067 15 12C15 12.7956 14.6839 13.5587 14.1213 14.1213C13.5587 14.6839 12.7956 15 12 15Z" fill="#5F646D"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
@ -1,4 +0,0 @@
|
||||
<svg width="40" height="20" viewBox="0 0 40 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect y="2.5" width="40" height="15" rx="7.5" fill="#F7F7F7"/>
|
||||
<rect x="0.5" y="0.5" width="19" height="19" rx="9.5" fill="white" stroke="#E1E3E5"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@ -1,4 +0,0 @@
|
||||
<svg width="40" height="20" viewBox="0 0 40 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect y="2.5" width="40" height="15" rx="7.5" fill="#D7BC6D"/>
|
||||
<rect x="20.5" y="0.5" width="19" height="19" rx="9.5" fill="white" stroke="#92722A"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 253 B |
|
Before Width: | Height: | Size: 18 KiB |
@ -1,3 +0,0 @@
|
||||
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.125 0C6.51803 0 4.94714 0.476523 3.611 1.36931C2.27485 2.2621 1.23344 3.53105 0.618482 5.0157C0.00352044 6.50035 -0.157382 8.13401 0.156123 9.71011C0.469628 11.2862 1.24346 12.7339 2.37976 13.8702C3.51606 15.0065 4.9638 15.7804 6.5399 16.0939C8.11599 16.4074 9.74966 16.2465 11.2343 15.6315C12.719 15.0166 13.9879 13.9752 14.8807 12.639C15.7735 11.3029 16.25 9.73197 16.25 8.125C16.2477 5.97081 15.391 3.90551 13.8677 2.38227C12.3445 0.85903 10.2792 0.00227486 8.125 0ZM7.8125 3.75C7.99792 3.75 8.17918 3.80498 8.33335 3.908C8.48752 4.01101 8.60768 4.15743 8.67864 4.32873C8.7496 4.50004 8.76816 4.68854 8.73199 4.8704C8.69582 5.05225 8.60653 5.2193 8.47542 5.35041C8.3443 5.48152 8.17726 5.57081 7.9954 5.60699C7.81354 5.64316 7.62504 5.62459 7.45374 5.55364C7.28243 5.48268 7.13601 5.36252 7.033 5.20835C6.92999 5.05418 6.875 4.87292 6.875 4.6875C6.875 4.43886 6.97378 4.2004 7.14959 4.02459C7.32541 3.84877 7.56386 3.75 7.8125 3.75ZM8.75 12.5C8.41848 12.5 8.10054 12.3683 7.86612 12.1339C7.6317 11.8995 7.5 11.5815 7.5 11.25V8.125C7.33424 8.125 7.17527 8.05915 7.05806 7.94194C6.94085 7.82473 6.875 7.66576 6.875 7.5C6.875 7.33424 6.94085 7.17527 7.05806 7.05806C7.17527 6.94085 7.33424 6.875 7.5 6.875C7.83152 6.875 8.14947 7.0067 8.38389 7.24112C8.61831 7.47554 8.75 7.79348 8.75 8.125V11.25C8.91576 11.25 9.07474 11.3158 9.19195 11.4331C9.30916 11.5503 9.375 11.7092 9.375 11.875C9.375 12.0408 9.30916 12.1997 9.19195 12.3169C9.07474 12.4342 8.91576 12.5 8.75 12.5Z" fill="#286CFF"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@ -1,3 +0,0 @@
|
||||
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.125 0C6.51803 0 4.94714 0.476523 3.611 1.36931C2.27485 2.2621 1.23344 3.53105 0.618482 5.0157C0.00352044 6.50035 -0.157382 8.13401 0.156123 9.71011C0.469628 11.2862 1.24346 12.7339 2.37976 13.8702C3.51606 15.0065 4.9638 15.7804 6.5399 16.0939C8.11599 16.4074 9.74966 16.2465 11.2343 15.6315C12.719 15.0166 13.9879 13.9752 14.8807 12.639C15.7735 11.3029 16.25 9.73197 16.25 8.125C16.2477 5.97081 15.391 3.90551 13.8677 2.38227C12.3445 0.85903 10.2792 0.00227486 8.125 0ZM7.5 4.375C7.5 4.20924 7.56585 4.05027 7.68306 3.93306C7.80027 3.81585 7.95924 3.75 8.125 3.75C8.29076 3.75 8.44974 3.81585 8.56695 3.93306C8.68416 4.05027 8.75 4.20924 8.75 4.375V8.75C8.75 8.91576 8.68416 9.07473 8.56695 9.19194C8.44974 9.30915 8.29076 9.375 8.125 9.375C7.95924 9.375 7.80027 9.30915 7.68306 9.19194C7.56585 9.07473 7.5 8.91576 7.5 8.75V4.375ZM8.125 12.5C7.93958 12.5 7.75833 12.445 7.60416 12.342C7.44999 12.239 7.32982 12.0926 7.25887 11.9213C7.18791 11.75 7.16934 11.5615 7.20552 11.3796C7.24169 11.1977 7.33098 11.0307 7.46209 10.8996C7.5932 10.7685 7.76025 10.6792 7.94211 10.643C8.12396 10.6068 8.31246 10.6254 8.48377 10.6964C8.65507 10.7673 8.80149 10.8875 8.90451 11.0417C9.00752 11.1958 9.0625 11.3771 9.0625 11.5625C9.0625 11.8111 8.96373 12.0496 8.78792 12.2254C8.6121 12.4012 8.37364 12.5 8.125 12.5Z" fill="#EA4F49"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@ -1,3 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.19561 2.86196C4.32062 2.73698 4.49016 2.66677 4.66694 2.66677C4.84371 2.66677 5.01325 2.73698 5.13827 2.86196L7.80494 5.52863C7.92638 5.65436 7.99357 5.82276 7.99205 5.99756C7.99054 6.17236 7.92042 6.33957 7.79682 6.46317C7.67321 6.58678 7.506 6.65689 7.33121 6.65841C7.15641 6.65993 6.98801 6.59273 6.86227 6.47129L5.33361 4.94263V12.6666C5.33361 12.8434 5.26337 13.013 5.13834 13.138C5.01332 13.2631 4.84375 13.3333 4.66694 13.3333C4.49013 13.3333 4.32056 13.2631 4.19553 13.138C4.07051 13.013 4.00027 12.8434 4.00027 12.6666V4.94263L2.4716 6.47129C2.34587 6.59273 2.17747 6.65993 2.00267 6.65841C1.82787 6.65689 1.66066 6.58678 1.53706 6.46317C1.41345 6.33957 1.34334 6.17236 1.34182 5.99756C1.3403 5.82276 1.4075 5.65436 1.52894 5.52863L4.19561 2.86196ZM10.6669 11.0573V3.33329C10.6669 3.15648 10.7372 2.98691 10.8622 2.86189C10.9872 2.73686 11.1568 2.66663 11.3336 2.66663C11.5104 2.66663 11.68 2.73686 11.805 2.86189C11.93 2.98691 12.0003 3.15648 12.0003 3.33329V11.0573L13.5289 9.52863C13.6547 9.40719 13.8231 9.33999 13.9979 9.34151C14.1727 9.34303 14.3399 9.41314 14.4635 9.53675C14.5871 9.66035 14.6572 9.82756 14.6587 10.0024C14.6602 10.1772 14.593 10.3456 14.4716 10.4713L11.8049 13.138C11.6799 13.2629 11.5104 13.3332 11.3336 13.3332C11.1568 13.3332 10.9873 13.2629 10.8623 13.138L8.19561 10.4713C8.07417 10.3456 8.00697 10.1772 8.00849 10.0024C8.01001 9.82756 8.08012 9.66035 8.20373 9.53675C8.32733 9.41314 8.49454 9.34303 8.66934 9.34151C8.84414 9.33999 9.01254 9.40719 9.13827 9.52863L10.6669 11.0573Z" fill="#92722A"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@ -1,3 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.0306 8.53025L9.53063 13.0302C9.38973 13.1711 9.19863 13.2503 8.99938 13.2503C8.80012 13.2503 8.60902 13.1711 8.46813 13.0302C8.32723 12.8894 8.24807 12.6983 8.24807 12.499C8.24807 12.2997 8.32723 12.1086 8.46813 11.9677L11.6875 8.74962H2.5C2.30109 8.74962 2.11032 8.6706 1.96967 8.52995C1.82902 8.3893 1.75 8.19853 1.75 7.99962C1.75 7.80071 1.82902 7.60994 1.96967 7.46929C2.11032 7.32864 2.30109 7.24962 2.5 7.24962H11.6875L8.46937 4.02962C8.32848 3.88873 8.24932 3.69763 8.24932 3.49837C8.24932 3.29911 8.32848 3.10802 8.46937 2.96712C8.61027 2.82622 8.80137 2.74707 9.00062 2.74707C9.19988 2.74707 9.39098 2.82622 9.53187 2.96712L14.0319 7.46712C14.1018 7.53689 14.1573 7.61979 14.1951 7.71106C14.2329 7.80232 14.2523 7.90016 14.2522 7.99894C14.252 8.09773 14.2324 8.19552 14.1944 8.28669C14.1564 8.37787 14.1007 8.46064 14.0306 8.53025Z" fill="#7C2320"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 975 B |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 803 B |
@ -1,3 +0,0 @@
|
||||
<svg width="14" height="11" viewBox="0 0 14 11" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.85381 0.195333C2.97883 0.0703528 3.14837 0.000142415 3.32514 0.000142415C3.50192 0.000142415 3.67146 0.0703528 3.79647 0.195333L6.46314 2.862C6.58458 2.98774 6.65178 3.15614 6.65026 3.33093C6.64874 3.50573 6.57863 3.67294 6.45502 3.79655C6.33142 3.92015 6.16421 3.99026 5.98941 3.99178C5.81461 3.9933 5.64621 3.92611 5.52047 3.80467L3.99181 2.276V10C3.99181 10.1768 3.92157 10.3464 3.79655 10.4714C3.67152 10.5964 3.50195 10.6667 3.32514 10.6667C3.14833 10.6667 2.97876 10.5964 2.85374 10.4714C2.72871 10.3464 2.65847 10.1768 2.65847 10V2.276L1.12981 3.80467C1.00407 3.92611 0.835672 3.9933 0.660874 3.99178C0.486076 3.99026 0.318868 3.92015 0.195262 3.79655C0.0716568 3.67294 0.00154415 3.50573 2.52018e-05 3.33093C-0.00149374 3.15614 0.0657025 2.98774 0.187141 2.862L2.85381 0.195333ZM9.32514 8.39067V0.666667C9.32514 0.489856 9.39538 0.320287 9.5204 0.195262C9.64543 0.070238 9.815 0 9.99181 0C10.1686 0 10.3382 0.070238 10.4632 0.195262C10.5882 0.320287 10.6585 0.489856 10.6585 0.666667V8.39067L12.1871 6.862C12.3129 6.74056 12.4813 6.67337 12.6561 6.67488C12.8309 6.6764 12.9981 6.74652 13.1217 6.87012C13.2453 6.99373 13.3154 7.16093 13.3169 7.33573C13.3184 7.51053 13.2512 7.67893 13.1298 7.80467L10.4631 10.4713C10.3381 10.5963 10.1686 10.6665 9.99181 10.6665C9.81503 10.6665 9.64549 10.5963 9.52047 10.4713L6.85381 7.80467C6.73237 7.67893 6.66517 7.51053 6.66669 7.33573C6.66821 7.16093 6.73832 6.99373 6.86193 6.87012C6.98553 6.74652 7.15274 6.6764 7.32754 6.67488C7.50234 6.67337 7.67074 6.74056 7.79647 6.862L9.32514 8.39067Z" fill="#9EA2A9"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 17 KiB |
@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 2H6C5.46957 2 4.96086 2.21071 4.58579 2.58579C4.21071 2.96086 4 3.46957 4 4V20C4 20.5304 4.21071 21.0391 4.58579 21.4142C4.96086 21.7893 5.46957 22 6 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V8L14 2ZM6 4H13L18 9V17.58L16.16 15.74C16.7947 14.7789 17.0759 13.6272 16.9555 12.4817C16.8351 11.3362 16.3207 10.2682 15.5 9.46C15.0422 8.9972 14.4971 8.62985 13.8963 8.37923C13.2955 8.12861 12.651 7.99971 12 8C10.72 8 9.45 8.5 8.47 9.46C6.5 11.41 6.5 14.57 8.47 16.5C9.44 17.5 10.72 17.97 12 17.97C12.96 17.97 13.92 17.69 14.75 17.14L17.6 20H6V4ZM14.11 15.1C13.55 15.66 12.8 16 12 16C11.2 16 10.45 15.67 9.89 15.1C9.33 14.54 9 13.79 9 13C9 12.19 9.32 11.44 9.89 10.88C10.45 10.31 11.2 10 12 10C12.8 10 13.55 10.31 14.11 10.88C14.67 11.44 15 12.19 15 13C15 13.79 14.68 14.54 14.11 15.1Z" fill="#232528"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 953 B |
@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 2H6C5.46957 2 4.96086 2.21071 4.58579 2.58579C4.21071 2.96086 4 3.46957 4 4V20C4 20.5304 4.21071 21.0391 4.58579 21.4142C4.96086 21.7893 5.46957 22 6 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V8L14 2ZM6 4H13L18 9V17.58L16.16 15.74C16.7947 14.7789 17.0759 13.6272 16.9555 12.4817C16.8351 11.3362 16.3207 10.2682 15.5 9.46C15.0422 8.9972 14.4971 8.62985 13.8963 8.37923C13.2955 8.12861 12.651 7.99971 12 8C10.72 8 9.45 8.5 8.47 9.46C6.5 11.41 6.5 14.57 8.47 16.5C9.44 17.5 10.72 17.97 12 17.97C12.96 17.97 13.92 17.69 14.75 17.14L17.6 20H6V4ZM14.11 15.1C13.55 15.66 12.8 16 12 16C11.2 16 10.45 15.67 9.89 15.1C9.33 14.54 9 13.79 9 13C9 12.19 9.32 11.44 9.89 10.88C10.45 10.31 11.2 10 12 10C12.8 10 13.55 10.31 14.11 10.88C14.67 11.44 15 12.19 15 13C15 13.79 14.68 14.54 14.11 15.1Z" fill="#B68A35"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 953 B |
@ -1,3 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.6667 2H3.33333C2.97971 2 2.64057 2.14048 2.39052 2.39052C2.14048 2.64057 2 2.97971 2 3.33333V6H3.33333V3.33333H14.6667V12.6667H3.33333V10H2V12.6667C2 13.0203 2.14048 13.3594 2.39052 13.6095C2.64057 13.8595 2.97971 14 3.33333 14H14.6667C15.0203 14 15.3594 13.8595 15.6095 13.6095C15.8595 13.3594 16 13.0203 16 12.6667V3.33333C16 2.97971 15.8595 2.64057 15.6095 2.39052C15.3594 2.14048 15.0203 2 14.6667 2ZM4.66667 10V8.66667H0V7.33333H4.66667V6L7.33333 8L4.66667 10ZM13.3333 8.66667H8.66667V7.33333H13.3333V8.66667ZM13.3333 6H8.66667V4.66667H13.3333V6ZM11.3333 11.3333H8.66667V10H11.3333V11.3333Z" fill="#6C4527"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 730 B |
@ -1,3 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.00004 1.33301C11.682 1.33301 14.6667 4.31767 14.6667 7.99967C14.6667 11.6817 11.682 14.6663 8.00004 14.6663C4.31804 14.6663 1.33337 11.6817 1.33337 7.99967C1.33337 4.31767 4.31804 1.33301 8.00004 1.33301ZM8.00004 2.66634C6.58555 2.66634 5.229 3.22824 4.2288 4.22844C3.22861 5.22863 2.66671 6.58519 2.66671 7.99967C2.66671 9.41416 3.22861 10.7707 4.2288 11.7709C5.229 12.7711 6.58555 13.333 8.00004 13.333C9.41453 13.333 10.7711 12.7711 11.7713 11.7709C12.7715 10.7707 13.3334 9.41416 13.3334 7.99967C13.3334 6.58519 12.7715 5.22863 11.7713 4.22844C10.7711 3.22824 9.41453 2.66634 8.00004 2.66634ZM8.00004 3.99967C8.16333 3.9997 8.32093 4.05964 8.44296 4.16815C8.56498 4.27666 8.64294 4.42617 8.66204 4.58834L8.66671 4.66634V7.72367L10.4714 9.52834C10.5909 9.64831 10.6604 9.8093 10.6655 9.9786C10.6707 10.1479 10.6112 10.3128 10.4992 10.4399C10.3872 10.5669 10.231 10.6465 10.0624 10.6626C9.89378 10.6787 9.72537 10.6299 9.59137 10.5263L9.52871 10.471L7.52871 8.47101C7.42509 8.36731 7.35855 8.23234 7.33937 8.08701L7.33337 7.99967V4.66634C7.33337 4.48953 7.40361 4.31996 7.52864 4.19494C7.65366 4.06991 7.82323 3.99967 8.00004 3.99967Z" fill="#6C4527"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 25 KiB |
@ -1,3 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.99968 11.3333C8.18856 11.3333 8.34701 11.2693 8.47501 11.1413C8.60301 11.0133 8.66679 10.855 8.66634 10.6666V7.99992C8.66634 7.81103 8.60234 7.65281 8.47434 7.52525C8.34634 7.3977 8.18812 7.3337 7.99968 7.33325C7.81123 7.33281 7.65301 7.39681 7.52501 7.52525C7.39701 7.6537 7.33301 7.81192 7.33301 7.99992V10.6666C7.33301 10.8555 7.39701 11.0139 7.52501 11.1419C7.65301 11.2699 7.81123 11.3337 7.99968 11.3333ZM7.99968 5.99992C8.18856 5.99992 8.34701 5.93592 8.47501 5.80792C8.60301 5.67992 8.66679 5.5217 8.66634 5.33325C8.6659 5.14481 8.6019 4.98659 8.47434 4.85859C8.34679 4.73059 8.18856 4.66658 7.99968 4.66658C7.81079 4.66658 7.65256 4.73059 7.52501 4.85859C7.39745 4.98659 7.33345 5.14481 7.33301 5.33325C7.33256 5.5217 7.39656 5.68014 7.52501 5.80859C7.65345 5.93703 7.81168 6.00081 7.99968 5.99992ZM7.99968 14.6666C7.07745 14.6666 6.21079 14.4915 5.39968 14.1413C4.58856 13.791 3.88301 13.3161 3.28301 12.7166C2.68301 12.117 2.20812 11.4115 1.85834 10.5999C1.50856 9.78836 1.33345 8.9217 1.33301 7.99992C1.33256 7.07814 1.50768 6.21147 1.85834 5.39992C2.20901 4.58836 2.6839 3.88281 3.28301 3.28325C3.88212 2.6837 4.58768 2.20881 5.39968 1.85859C6.21168 1.50836 7.07834 1.33325 7.99968 1.33325C8.92101 1.33325 9.78767 1.50836 10.5997 1.85859C11.4117 2.20881 12.1172 2.6837 12.7163 3.28325C13.3155 3.88281 13.7906 4.58836 14.1417 5.39992C14.4928 6.21147 14.6677 7.07814 14.6663 7.99992C14.665 8.9217 14.4899 9.78836 14.141 10.5999C13.7921 11.4115 13.3172 12.117 12.7163 12.7166C12.1155 13.3161 11.4099 13.7913 10.5997 14.1419C9.78945 14.4926 8.92279 14.6675 7.99968 14.6666ZM7.99968 13.3333C9.48856 13.3333 10.7497 12.8166 11.783 11.7833C12.8163 10.7499 13.333 9.48881 13.333 7.99992C13.333 6.51103 12.8163 5.24992 11.783 4.21658C10.7497 3.18325 9.48856 2.66659 7.99968 2.66659C6.51079 2.66659 5.24968 3.18325 4.21634 4.21658C3.18301 5.24992 2.66634 6.51103 2.66634 7.99992C2.66634 9.48881 3.18301 10.7499 4.21634 11.7833C5.24968 12.8166 6.51079 13.3333 7.99968 13.3333Z" fill="#5F646D"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@ -1,6 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { requestOtp } from '@/services/auth/authService';
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Dashboard from '@/pages/Dashboard/Dashboard';
|
||||
import AdminDashboard from '@/pages/Admin/AdminDashboard';
|
||||
@ -9,406 +7,106 @@ import Validations from '@/pages/Admin/Validations';
|
||||
import ValidationReview from '@/pages/Admin/ValidationReview';
|
||||
import Configuration from '@/pages/Admin/Configuration';
|
||||
import Survey from '@/pages/Survey/Survey';
|
||||
import Overview from '@/pages/Overview/Overview';
|
||||
import Login from '@/pages/Login/Login';
|
||||
import OTPVerification from '@/pages/OTPVerification/OTPVerification';
|
||||
import History from '@/pages/History/History';
|
||||
import ChangePassword from '@/pages/ChangePassword/ChangePassword';
|
||||
import ForgotPassword from '@/pages/ForgotPassword/ForgotPassword';
|
||||
import PublicContactForm from '@/pages/PublicContactForm';
|
||||
import EditCompanyProfile from '@/pages/Admin/configuration/EditCompanyProfile';
|
||||
import Navbar from './pages/LandingPage/component/Navbar';
|
||||
import ManufacturingIndex from '@/pages/ManufacturingIndex/ManufacturingIndex';
|
||||
import Reports from '@/pages/Reports/Reports';
|
||||
|
||||
const RequireOTPVerification = ({ children }) => {
|
||||
const [isVerified, setIsVerified] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const checkVerification = async () => {
|
||||
try {
|
||||
const userProfile = JSON.parse(localStorage.getItem('user_profile') || '{}');
|
||||
|
||||
// Check if user is logged in and has a valid role
|
||||
const role = localStorage.getItem('user_role');
|
||||
const allowedRoles = ['EstablishmentUser', 'Admin'];
|
||||
|
||||
if (!role || !allowedRoles.includes(role)) {
|
||||
navigate('/index', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is OTP verified
|
||||
if (userProfile.isOtpVerified) {
|
||||
setIsVerified(true);
|
||||
} else {
|
||||
// If not verified, redirect to index page
|
||||
navigate('/index', { replace: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Verification check failed:', error);
|
||||
navigate('/index', { replace: true });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkVerification();
|
||||
}, [navigate]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return isVerified ? children : null;
|
||||
};
|
||||
|
||||
|
||||
const RequireRole = ({ allowedRoles = [], children }) => {
|
||||
let role;
|
||||
|
||||
let role
|
||||
let token
|
||||
try {
|
||||
role = localStorage.getItem("user_role");
|
||||
} catch {
|
||||
role = null;
|
||||
role = sessionStorage.getItem('user_role')
|
||||
token = sessionStorage.getItem('auth_token')
|
||||
} catch (error) {
|
||||
role = null
|
||||
token = null
|
||||
}
|
||||
|
||||
const normalizedRole = String(role || "").toLowerCase();
|
||||
|
||||
const isAllowed = allowedRoles.some(
|
||||
(allowed) => String(allowed || "").toLowerCase() === normalizedRole
|
||||
);
|
||||
|
||||
|
||||
const normalizedRole = String(role || '').toLowerCase()
|
||||
const isAllowed = Boolean(token) && allowedRoles.some((allowed) => String(allowed || '').toLowerCase() === normalizedRole)
|
||||
|
||||
if (isAllowed) {
|
||||
return children;
|
||||
return children
|
||||
}
|
||||
|
||||
return <Navigate to="/index" replace />;
|
||||
};
|
||||
|
||||
|
||||
const SessionWarningModal = ({ show, remainingTime, onExtend, onLogout }) => {
|
||||
if (!show) return null;
|
||||
|
||||
const minutes = Math.floor(remainingTime / 60);
|
||||
const seconds = remainingTime % 60;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-yellow-100 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Session Expiring Soon
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Your session will expire in <strong className="text-yellow-600 font-mono text-base">{minutes}:{seconds.toString().padStart(2, '0')}</strong> due to inactivity.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Would you like to stay signed in?
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onExtend}
|
||||
className="flex-1 bg-[#92722A] hover:bg-[#7b5c1f] text-white px-4 py-2 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Stay Signed In
|
||||
</button>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="flex-1 bg-gray-200 hover:bg-gray-300 text-gray-800 px-4 py-2 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Log Out Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [showWarning, setShowWarning] = React.useState(false);
|
||||
const [warningTime, setWarningTime] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const TIMEOUT_MINUTES = 15;
|
||||
const WARNING_MINUTES = 2;
|
||||
|
||||
let timer;
|
||||
let warningTimer;
|
||||
let warningInterval;
|
||||
|
||||
const clearAllTimers = () => {
|
||||
clearTimeout(timer);
|
||||
clearTimeout(warningTimer);
|
||||
clearInterval(warningInterval);
|
||||
timer = null;
|
||||
warningTimer = null;
|
||||
warningInterval = null;
|
||||
};
|
||||
|
||||
const performLogout = () => {
|
||||
sessionStorage.clear();
|
||||
localStorage.removeItem('failed_attempts');
|
||||
localStorage.removeItem('lock_until');
|
||||
setShowWarning(false);
|
||||
alert('Session expired. Please log in again.');
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
const showWarningModal = () => {
|
||||
setShowWarning(true);
|
||||
let timeLeft = WARNING_MINUTES * 60;
|
||||
setWarningTime(timeLeft);
|
||||
|
||||
warningInterval = setInterval(() => {
|
||||
timeLeft--;
|
||||
setWarningTime(timeLeft);
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
clearInterval(warningInterval);
|
||||
performLogout();
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const resetTimer = () => {
|
||||
clearAllTimers();
|
||||
setShowWarning(false);
|
||||
|
||||
warningTimer = setTimeout(() => {
|
||||
showWarningModal();
|
||||
}, (TIMEOUT_MINUTES - WARNING_MINUTES) * 60 * 1000);
|
||||
|
||||
timer = setTimeout(() => {
|
||||
performLogout();
|
||||
}, TIMEOUT_MINUTES * 60 * 1000);
|
||||
};
|
||||
|
||||
const handleExtendSession = () => {
|
||||
setShowWarning(false);
|
||||
resetTimer();
|
||||
};
|
||||
|
||||
const handleLogoutNow = () => {
|
||||
clearAllTimers();
|
||||
performLogout();
|
||||
};
|
||||
|
||||
window.handleExtendSession = handleExtendSession;
|
||||
window.handleLogoutNow = handleLogoutNow;
|
||||
|
||||
const events = ['mousemove', 'keydown', 'click', 'scroll', 'touchstart'];
|
||||
events.forEach(event => {
|
||||
window.addEventListener(event, resetTimer);
|
||||
});
|
||||
|
||||
resetTimer();
|
||||
|
||||
return () => {
|
||||
clearAllTimers();
|
||||
events.forEach(event => {
|
||||
window.removeEventListener(event, resetTimer);
|
||||
});
|
||||
delete window.handleExtendSession;
|
||||
delete window.handleLogoutNow;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SessionWarningModal
|
||||
show={showWarning}
|
||||
remainingTime={warningTime}
|
||||
onExtend={() => window.handleExtendSession?.()}
|
||||
onLogout={() => window.handleLogoutNow?.()}
|
||||
/>
|
||||
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/verify-otp"
|
||||
element={
|
||||
<RequireRole allowedRoles={['EstablishmentUser', 'Admin']}>
|
||||
<OTPVerification />
|
||||
</RequireRole>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/history"
|
||||
element={
|
||||
<RequireRole allowedRoles={['EstablishmentUser']}>
|
||||
<History />
|
||||
</RequireRole>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<RequireOTPVerification>
|
||||
<RequireRole allowedRoles={['EstablishmentUser']}>
|
||||
<Dashboard />
|
||||
</RequireRole>
|
||||
</RequireOTPVerification>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/dashboard"
|
||||
element={
|
||||
<RequireOTPVerification>
|
||||
<RequireRole allowedRoles={['Admin']}>
|
||||
<AdminDashboard />
|
||||
</RequireRole>
|
||||
</RequireOTPVerification>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/validations"
|
||||
element={
|
||||
<RequireOTPVerification>
|
||||
<RequireRole allowedRoles={['Admin']}>
|
||||
<Validations />
|
||||
</RequireRole>
|
||||
</RequireOTPVerification>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/validations/:id"
|
||||
element={
|
||||
<RequireOTPVerification>
|
||||
<RequireRole allowedRoles={['Admin']}>
|
||||
<ValidationReview />
|
||||
</RequireRole>
|
||||
</RequireOTPVerification>
|
||||
}
|
||||
/>
|
||||
{/* <Route path="/admin/configuration">
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<RequireRole allowedRoles={['Admin', 'EstablishmentUser']}>
|
||||
<Configuration />
|
||||
</RequireRole>
|
||||
}
|
||||
/>
|
||||
</Route> */}
|
||||
<Route
|
||||
path="/admin/configuration/*"
|
||||
element={
|
||||
<RequireOTPVerification>
|
||||
<RequireRole allowedRoles={['Admin', 'EstablishmentUser']}>
|
||||
<Configuration />
|
||||
</RequireRole>
|
||||
</RequireOTPVerification>
|
||||
}
|
||||
/>
|
||||
{/* /> */}
|
||||
{/* </Route> */}
|
||||
<Route
|
||||
path="/Reports"
|
||||
element={
|
||||
<RequireOTPVerification>
|
||||
<RequireRole allowedRoles={['Admin', 'EstablishmentUser']}>
|
||||
<Reports />
|
||||
</RequireRole>
|
||||
</RequireOTPVerification>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/manufacturing-index"
|
||||
element={
|
||||
<RequireOTPVerification>
|
||||
<RequireRole allowedRoles={['Admin', 'EstablishmentUser']}>
|
||||
<ManufacturingIndex />
|
||||
</RequireRole>
|
||||
</RequireOTPVerification>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
<RequireOTPVerification>
|
||||
<RequireRole allowedRoles={['Admin']}>
|
||||
<AdminUsers />
|
||||
</RequireRole>
|
||||
</RequireOTPVerification>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
path="/survey"
|
||||
element={
|
||||
<RequireOTPVerification>
|
||||
<RequireRole allowedRoles={['EstablishmentUser']}>
|
||||
<Survey />
|
||||
</RequireRole>
|
||||
</RequireOTPVerification>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/overview"
|
||||
element={
|
||||
<RequireOTPVerification>
|
||||
<RequireRole allowedRoles={['EstablishmentUser']}>
|
||||
<Overview />
|
||||
</RequireRole>
|
||||
</RequireOTPVerification>
|
||||
}
|
||||
/>
|
||||
{/* <Route path="profile">
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<RequireRole allowedRoles={['EstablishmentUser']}>
|
||||
<EditCompanyProfile />
|
||||
</RequireRole>
|
||||
}
|
||||
/>
|
||||
</Route> */}
|
||||
{/* <Route path="/profile/edit/:id" element={
|
||||
<RequireRole allowedRoles={['EstablishmentUser']}>
|
||||
<EditCompanyProfile />
|
||||
</RequireRole>
|
||||
} /> */}
|
||||
{/* <Route
|
||||
path="/admin/configuration/profile/:id?"
|
||||
element={
|
||||
<RequireRole allowedRoles={['EstablishmentUser']}>
|
||||
<EditCompanyProfile />
|
||||
</RequireRole>
|
||||
}
|
||||
/> */}
|
||||
<Route
|
||||
path="/edit-profile/:id"
|
||||
element={
|
||||
<RequireOTPVerification>
|
||||
<RequireRole allowedRoles={['EstablishmentUser']}>
|
||||
<EditCompanyProfile />
|
||||
</RequireRole>
|
||||
</RequireOTPVerification>
|
||||
}
|
||||
/>
|
||||
<Route path="/change-password" element={<ChangePassword />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<PublicContactForm />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
{/* landingpage */}
|
||||
|
||||
<Route path="/index" element={<Navbar />} />
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/history" element={(
|
||||
<RequireRole allowedRoles={['EstablishmentUser']}>
|
||||
<History />
|
||||
</RequireRole>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={(
|
||||
<RequireRole allowedRoles={['EstablishmentUser']}>
|
||||
<Dashboard />
|
||||
</RequireRole>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/dashboard"
|
||||
element={(
|
||||
<RequireRole allowedRoles={['Admin']}>
|
||||
<AdminDashboard />
|
||||
</RequireRole>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/validations"
|
||||
element={(
|
||||
<RequireRole allowedRoles={['Admin']}>
|
||||
<Validations />
|
||||
</RequireRole>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/validations/:id"
|
||||
element={(
|
||||
<RequireRole allowedRoles={['Admin']}>
|
||||
<ValidationReview />
|
||||
</RequireRole>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/configuration"
|
||||
element={(
|
||||
<RequireRole allowedRoles={['Admin']}>
|
||||
<Configuration />
|
||||
</RequireRole>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={(
|
||||
<RequireRole allowedRoles={['Admin']}>
|
||||
<AdminUsers />
|
||||
</RequireRole>
|
||||
)}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
path="/survey"
|
||||
element={(
|
||||
<RequireRole allowedRoles={['EstablishmentUser']}>
|
||||
<Survey />
|
||||
</RequireRole>
|
||||
)}
|
||||
/>
|
||||
<Route path="/change-password" element={<ChangePassword />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<PublicContactForm />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const DirhamSymbol = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
height="1em"
|
||||
fill="currentColor"
|
||||
className="inline-block w-4 h-4 -mt-0.5"
|
||||
{...props}
|
||||
>
|
||||
<path d="M14 9h5v2h-4v1h4v2h-4v1h4v2h-5v-1h3v-1h-3v-1h3v-1h-3v-1z" />
|
||||
<path d="M4 4h16v2H6v11h4v-2H8v-1h8v1h-2v2h3v2H4V4zm2 3h2v1H6V7zm0 3h2v1H6v-1z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default DirhamSymbol;
|
||||
|
Before Width: | Height: | Size: 4.1 KiB |
@ -1,8 +1,5 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { logout as logoutApi } from '@/services/auth/authService';
|
||||
import { TbReport } from "react-icons/tb";
|
||||
import { HiOutlineDocumentReport } from "react-icons/hi";
|
||||
|
||||
const logoSrc = '/assets/images/FCSCLogo.svg';
|
||||
const dashboardIconActiveSrc = '/assets/images/cuida_dashboard-outline.svg';
|
||||
@ -10,62 +7,32 @@ const dashboardIconInactiveSrc = '/assets/images/cuida_dashboard-outline-inactiv
|
||||
const checkCircleActiveSrc = '/assets/images/CheckCircle-active.svg';
|
||||
const configActiveSrc = '/assets/images/configrationicon-active.svg';
|
||||
const configInactiveSrc = '/assets/images/configrationicon-inactive.svg';
|
||||
const manufacturingIndexActive = <TbReport className="text-[#92722A]" />;
|
||||
const manufacturingIndexInactive = <TbReport className="text-[#232528]" />;
|
||||
const reportsActive = <HiOutlineDocumentReport className="text-[#92722A]" />;
|
||||
const reportsInactive = <HiOutlineDocumentReport className="text-[#232528]" />;
|
||||
const userActiveSrc = '/assets/images/User-active.svg';
|
||||
const userInactiveSrc = '/assets/images/User-inactive.svg';
|
||||
const manageSubmissionInactiveSrc = '/assets/images/managesubmission - inactive.svg';
|
||||
const bellIconSrc = '/assets/images/Bell.svg';
|
||||
|
||||
|
||||
|
||||
const NavItem = ({ label, to, iconActive, iconInactive, end = false }) => {
|
||||
const isReactElement = React.isValidElement(iconActive) || React.isValidElement(iconInactive);
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
`inline-flex items-center gap-2 pb-1 border-b-2 text-[14px] leading-5 ` +
|
||||
(isActive
|
||||
? 'text-[#92722A] font-medium border-[#92722A]'
|
||||
: 'text-[#232528] border-transparent hover:text-gray-900')
|
||||
}
|
||||
>
|
||||
{({ isActive }) => {
|
||||
const icon = isActive ? iconActive : iconInactive;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isReactElement ? (
|
||||
<span className={`h-[18px] w-[18px] flex items-center justify-center ${
|
||||
isActive ? 'text-[#92722A]' : 'text-[#6B7280]'
|
||||
}`}>
|
||||
{React.cloneElement(icon, { style: { fontSize: 18 } })}
|
||||
</span>
|
||||
) : icon ? (
|
||||
<img
|
||||
src={icon}
|
||||
alt={label}
|
||||
className="h-[18px] w-[18px]"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`inline-block h-[18px] w-[18px] rounded ${
|
||||
isActive ? 'bg-[#92722A]' : 'bg-[#C9CDD1]'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
const NavItem = ({ label, to, iconActive, iconInactive, end = false }) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
`inline-flex items-center gap-2 pb-1 border-b-2 text-[14px] leading-5 ` +
|
||||
(isActive ? 'text-[#92722A] font-medium border-[#92722A]' : 'text-[#232528] border-transparent hover:text-gray-900')
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{iconActive && iconInactive ? (
|
||||
<img src={isActive ? iconActive : iconInactive} alt={label} className="h-[18px] w-[18px]" />
|
||||
) : (
|
||||
<span className={`inline-block h-[18px] w-[18px] rounded ${isActive ? 'bg-[#92722A]' : 'bg-[#C9CDD1]'}`} />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
const AdminHeader = () => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
@ -73,44 +40,10 @@ const AdminHeader = () => {
|
||||
const [userName, setUserName] = React.useState('User');
|
||||
const [userEmail, setUserEmail] = React.useState('user@example.com');
|
||||
const navigate = useNavigate();
|
||||
const profileRef = useRef(null);
|
||||
const mobileMenuRef = useRef(null);
|
||||
const profileButtonRef = useRef(null);
|
||||
const mobileMenuButtonRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (
|
||||
profileOpen &&
|
||||
profileRef.current &&
|
||||
!profileRef.current.contains(event.target) &&
|
||||
profileButtonRef.current &&
|
||||
!profileButtonRef.current.contains(event.target)
|
||||
) {
|
||||
setProfileOpen(false);
|
||||
}
|
||||
|
||||
if (
|
||||
open &&
|
||||
mobileMenuRef.current &&
|
||||
!mobileMenuRef.current.contains(event.target) &&
|
||||
mobileMenuButtonRef.current &&
|
||||
!mobileMenuButtonRef.current.contains(event.target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [open, profileOpen]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('user_profile');
|
||||
const stored = sessionStorage.getItem('user_profile');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed?.name || parsed?.username) {
|
||||
@ -120,26 +53,22 @@ const AdminHeader = () => {
|
||||
setUserEmail(parsed.email);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// ignore corrupt storage
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 🚀 UPDATED: LOGOUT VIA API
|
||||
const handleLogout = async () => {
|
||||
const handleLogout = () => {
|
||||
try {
|
||||
await logoutApi(); // <-- API CALL
|
||||
|
||||
sessionStorage.clear();
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('user_profile');
|
||||
localStorage.removeItem('user_role');
|
||||
localStorage.removeItem('establishment_id');
|
||||
|
||||
setProfileOpen(false);
|
||||
navigate('/index');
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
navigate('/index');
|
||||
sessionStorage.removeItem('auth_token');
|
||||
sessionStorage.removeItem('user_profile');
|
||||
sessionStorage.removeItem('user_role');
|
||||
sessionStorage.removeItem('establishment_id');
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
setProfileOpen(false);
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
@ -147,66 +76,39 @@ const AdminHeader = () => {
|
||||
<div className="max-w-[1280px] mx-auto px-4">
|
||||
<div className="flex items-center h-20 gap-6 ">
|
||||
<img src={logoSrc} alt="FCSC" className="w-[140px] h-[44px] " />
|
||||
|
||||
<nav className="flex-1 flex justify-end items-center gap-6 xl:gap-8 text-[14px] leading-5 ml-auto ">
|
||||
<NavItem
|
||||
to="/admin/dashboard"
|
||||
end
|
||||
label="Dashboard"
|
||||
iconActive={dashboardIconActiveSrc}
|
||||
iconInactive={dashboardIconInactiveSrc}
|
||||
/>
|
||||
<NavItem
|
||||
to="/admin/validations"
|
||||
label="Submissions"
|
||||
iconActive={checkCircleActiveSrc}
|
||||
iconInactive={manageSubmissionInactiveSrc}
|
||||
/>
|
||||
<NavItem
|
||||
to="/admin/configuration"
|
||||
label="Configuration"
|
||||
iconActive={configActiveSrc}
|
||||
iconInactive={configInactiveSrc}
|
||||
/>
|
||||
<NavItem
|
||||
to="/manufacturing-index"
|
||||
label="Manufacturing Index"
|
||||
iconActive={manufacturingIndexActive}
|
||||
iconInactive={manufacturingIndexInactive}
|
||||
/>
|
||||
<NavItem
|
||||
to="/Reports"
|
||||
label="Reports"
|
||||
iconActive={reportsActive}
|
||||
iconInactive={reportsInactive}
|
||||
/>
|
||||
<NavItem to="/admin/dashboard" end label="Dashboard" iconActive={dashboardIconActiveSrc} iconInactive={dashboardIconInactiveSrc} />
|
||||
<NavItem to="/admin/validations" label="Submissions" iconActive={checkCircleActiveSrc} iconInactive={manageSubmissionInactiveSrc} />
|
||||
<NavItem to="/admin/configuration" label="Configuration" iconActive={configActiveSrc} iconInactive={configInactiveSrc} />
|
||||
<NavItem to="/admin/users" label="Users" iconActive={userActiveSrc} iconInactive={userInactiveSrc} />
|
||||
</nav>
|
||||
|
||||
<div className="hidden lg:flex items-center gap-3 ml-auto">
|
||||
<div className="relative" ref={profileRef}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Notifications"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-transparent hover:border-[#E5E7EB]"
|
||||
>
|
||||
<img src={bellIconSrc} alt="Notifications" className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={profileButtonRef}
|
||||
type="button"
|
||||
onClick={() => setProfileOpen((value) => !value)}
|
||||
className="h-9 w-9 rounded-full bg-[#F2F2F2] grid place-items-center text-[13px] font-medium text-[#232528]"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={profileOpen}
|
||||
>
|
||||
{userName?.slice(0, 1)?.toUpperCase() || 'U'}
|
||||
</button>
|
||||
|
||||
{profileOpen && (
|
||||
<div className="absolute right-0 mt-3 w-64 rounded-[12px] border border-[#E5E7EB] bg-white shadow-[0_20px_30px_rgba(15,23,42,0.12)] z-50">
|
||||
<div className="px-4 py-3 border-b border-[#F1F5F9] text-left space-y-1">
|
||||
<p className="text-sm font-semibold text-[#232528] truncate">
|
||||
{userName?.replace(/([a-z])([A-Z])/g, '$1 $2')}
|
||||
</p>
|
||||
<p className="text-xs text-[#6B7280] truncate">
|
||||
{userEmail}
|
||||
</p>
|
||||
<div className="absolute right-0 mt-3 w-64 rounded-[12px] border border-[#E5E7EB] bg-white shadow-[0_20px_30px_rgba(15,23,42,0.12)] z-50 text-center">
|
||||
<div className="px-4 py-3 border-b border-[#F1F5F9]">
|
||||
<p className="text-sm font-semibold text-[#232528] truncate" title={userName}>{userName}</p>
|
||||
<p className="text-xs text-[#6B7280] truncate" title={userEmail}>{userEmail}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-4 py-2.5 text-left text-sm text-[#232528] hover:bg-[#F2ECCF]"
|
||||
className="w-full px-4 py-2.5 text-center text-sm text-[#232528] hover:bg-[#F2ECCF]"
|
||||
onClick={() => {
|
||||
setProfileOpen(false);
|
||||
navigate('/reset-password');
|
||||
@ -214,10 +116,9 @@ const AdminHeader = () => {
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-4 py-2.5 text-left text-sm text-[#B52520] hover:bg-[#FEE2E2]"
|
||||
className="w-full px-4 py-2.5 text-center text-sm text-[#B52520] hover:bg-[#FEE2E2]"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Logout
|
||||
@ -226,66 +127,26 @@ const AdminHeader = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto lg:hidden" ref={mobileMenuRef}>
|
||||
<div className="ml-auto lg:hidden">
|
||||
<button
|
||||
ref={mobileMenuButtonRef}
|
||||
type="button"
|
||||
aria-label="Open Menu"
|
||||
className="inline-flex items-center justify-center h-10 w-10 rounded-full border border-[#E5E7EB]"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="lg:hidden pb-6 border-t border-[#E5E7EB]">
|
||||
<nav className="flex flex-col gap-3 pt-4 text-[14px] leading-5">
|
||||
<NavItem
|
||||
to="/admin/dashboard"
|
||||
end
|
||||
label="Dashboard"
|
||||
iconActive={dashboardIconActiveSrc}
|
||||
iconInactive={dashboardIconInactiveSrc}
|
||||
/>
|
||||
<NavItem
|
||||
to="/admin/validations"
|
||||
label="Manage Submissions"
|
||||
iconActive={checkCircleActiveSrc}
|
||||
iconInactive={manageSubmissionInactiveSrc}
|
||||
/>
|
||||
<NavItem
|
||||
to="/admin/configuration"
|
||||
label="Configuration"
|
||||
iconActive={configActiveSrc}
|
||||
iconInactive={configInactiveSrc}
|
||||
/>
|
||||
<NavItem
|
||||
to="/manufacturing-index"
|
||||
label="Manufacturing"
|
||||
iconActive={configActiveSrc}
|
||||
iconInactive={configInactiveSrc}
|
||||
/>
|
||||
<NavItem
|
||||
to="/admin/users"
|
||||
label="Users"
|
||||
iconActive={userActiveSrc}
|
||||
iconInactive={userInactiveSrc}
|
||||
/>
|
||||
<NavItem to="/admin/dashboard" end label="Dashboard" iconActive={dashboardIconActiveSrc} iconInactive={dashboardIconInactiveSrc} />
|
||||
<NavItem to="/admin/validations" label="Manage Submissions" iconActive={checkCircleActiveSrc} iconInactive={manageSubmissionInactiveSrc} />
|
||||
<NavItem to="/admin/configuration" label="Configuration" iconActive={configActiveSrc} iconInactive={configInactiveSrc} />
|
||||
<NavItem to="/admin/users" label="Users" iconActive={userActiveSrc} iconInactive={userInactiveSrc} />
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
const StatCard = ({ icon = null, label, value, tooltipText, width = 200.4 }) => {
|
||||
const resolvedWidth = typeof width === 'number' ? `${width}px` : width;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative group flex flex-col items-center justify-center rounded-lg border border-gray-200 bg-white px-5 py-5 shadow-sm ${typeof width === 'number' ? `w-[${width}px]` : `w-${width}`} min-h-[120px]`}
|
||||
className="relative group flex flex-col items-center justify-center rounded-[8px] border border-[#E3E5E8] bg-white px-5 py-5 shadow-[0_8px_20px_rgba(42,47,66,0.06)]"
|
||||
style={{ width: resolvedWidth, minHeight: '120px' }}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 whitespace-nowrap">
|
||||
{icon && <div className="flex items-center justify-center w-5 h-5">{icon}</div>}
|
||||
<span className="text-sm font-medium text-gray-600 leading-5 text-center truncate">
|
||||
<span className="text-[15px] font-medium text-[#5F646D] leading-[22px] text-center truncate">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -1,70 +1,37 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Table from '@/components/common/Table';
|
||||
import apiClient from '../../services/api/apiClient.js';
|
||||
import { CheckCircle2, XCircle, Eye, X } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { putRequest } from '@/services/api/CommonService';
|
||||
|
||||
|
||||
import { CheckCircle2, XCircle, Eye } from 'lucide-react';
|
||||
import Footer from '../common/Footer.jsx';
|
||||
const SubmissionTable = ({ selectedQuarter, selectedYear }) => {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = React.useState(1);
|
||||
const [showApproveModal, setShowApproveModal] = useState(false);
|
||||
const [showRejectModal, setShowRejectModal] = useState(false);
|
||||
const [selectedSubmission, setSelectedSubmission] = useState(null);
|
||||
const [actionType, setActionType] = useState('');
|
||||
const [rejectionReason, setRejectionReason] = useState('');
|
||||
const [showRejectError, setShowRejectError] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const pageSize = 10;
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fetchDashboardData = async (quarter, year) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (quarter && quarter !== 'All') {
|
||||
params.append('quarter', quarter);
|
||||
}
|
||||
|
||||
if (year && year !== 'All') {
|
||||
params.append('year', year);
|
||||
}
|
||||
|
||||
// const response = await apiClient.get(`/admin_dashboard?${params.toString()}`);
|
||||
const response = await apiClient.get(`/admin_dashboard?${params.toString()}`, { withCredentials: true });
|
||||
const result = response?.data;
|
||||
if (result?.status === 'success') {
|
||||
// Map the data to include the updated_at as reviewerOn
|
||||
const mappedData = (result.recent_submissions || []).map(item => ({
|
||||
...item,
|
||||
reviewerOn: item.updated_at || null
|
||||
}));
|
||||
setData(mappedData);
|
||||
} else {
|
||||
console.error('Error: Invalid response', result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching recent submissions:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData(selectedQuarter, selectedYear);
|
||||
}, [selectedQuarter, selectedYear]);
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.get('/admin_dashboard');
|
||||
const result = response?.data;
|
||||
if (result?.status === 'success') {
|
||||
setData(result.recent_submissions || []);
|
||||
} else {
|
||||
console.error('Error: Invalid response', result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching recent submissions:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = [...data];
|
||||
if (selectedQuarter !== 'All') {
|
||||
if (selectedQuarter !== 'None') {
|
||||
list = list.filter((item) => item.quarter === selectedQuarter);
|
||||
}
|
||||
if (selectedYear !== 'All') {
|
||||
if (selectedYear !== 'None') {
|
||||
list = list.filter((item) => String(item.year) === String(selectedYear));
|
||||
}
|
||||
return list;
|
||||
@ -72,41 +39,45 @@ const SubmissionTable = ({ selectedQuarter, selectedYear }) => {
|
||||
|
||||
const titleText = useMemo(() => {
|
||||
const count = filtered.length;
|
||||
if (selectedQuarter !== 'All' && selectedYear !== 'All') {
|
||||
return ` Recent Submissions for Selected Quarter ${selectedQuarter} ${selectedYear} (${count})`;
|
||||
} else if (selectedQuarter !== 'All') {
|
||||
return `Recent Submissions for Selected Quarter ${selectedQuarter} (${count})`;
|
||||
} else if (selectedYear !== 'All') {
|
||||
return `Recent Submissions for Selected Year ${selectedYear} (${count})`;
|
||||
if (selectedQuarter !== 'None' && selectedYear !== 'None') {
|
||||
return `Recent 10 Submissions for Selected Quarter ${selectedQuarter} ${selectedYear} (${count})`;
|
||||
} else if (selectedQuarter !== 'None') {
|
||||
return `Recent 10 Submissions for Selected Quarter ${selectedQuarter} (${count})`;
|
||||
} else if (selectedYear !== 'None') {
|
||||
return `Recent 10 Submissions for Selected Year ${selectedYear} (${count})`;
|
||||
} else {
|
||||
return `Recent Submissions (${count})`;
|
||||
return `Recent 10 Submissions (${count})`;
|
||||
}
|
||||
}, [selectedQuarter, selectedYear, filtered]);
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
// Format for Dubai timezone (Asia/Dubai)
|
||||
const options = {
|
||||
timeZone: 'Asia/Dubai',
|
||||
const formatted = date.toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
};
|
||||
|
||||
// Format the date in Dubai time with 24-hour format
|
||||
const formatter = new Intl.DateTimeFormat('en-GB', options);
|
||||
let formatted = formatter.format(date);
|
||||
|
||||
// Remove any AM/PM indicator since we're using 24-hour format
|
||||
formatted = formatted.replace(/\s*[AP]M$/, '').trim();
|
||||
|
||||
return formatted;
|
||||
hour12: true,
|
||||
});
|
||||
return formatted.replace(/\s?(am|pm)$/i, (match) => match.toUpperCase());
|
||||
};
|
||||
|
||||
const headers = [
|
||||
'Establishment',
|
||||
'Emirates',
|
||||
'Year',
|
||||
'Quarter',
|
||||
'Product Count',
|
||||
'Submitted By',
|
||||
'Submission Date & Time',
|
||||
'Status',
|
||||
'Reviewer',
|
||||
'Reviewer On',
|
||||
'Actions',
|
||||
];
|
||||
|
||||
const renderStatusBadge = (status) => {
|
||||
const normalized = status?.toLowerCase();
|
||||
if (normalized === 'approved') {
|
||||
@ -121,16 +92,10 @@ const SubmissionTable = ({ selectedQuarter, selectedYear }) => {
|
||||
Rejected
|
||||
</span>
|
||||
);
|
||||
} else if (normalized === 'submitted') {
|
||||
} else if (normalized === 'pending') {
|
||||
return (
|
||||
<span className="px-3 py-1 rounded-md text-blue-600 bg-blue-50 font-medium text-sm whitespace-nowrap">
|
||||
Submitted
|
||||
</span>
|
||||
);
|
||||
} else if (normalized === 'resubmitted') {
|
||||
return (
|
||||
<span className="px-3 py-1 rounded-md text-[#7C5E24] bg-[#F9F7ED] font-medium text-sm whitespace-nowrap">
|
||||
Resubmitted
|
||||
Pending
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
@ -142,431 +107,88 @@ const SubmissionTable = ({ selectedQuarter, selectedYear }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const userProfile = JSON.parse(localStorage.getItem("user_profile"));
|
||||
const tableRows = filtered.map((item) => [
|
||||
item?.establishment?.factory_name || '-',
|
||||
item?.establishment?.establishment_emirate?.name || '-',
|
||||
item?.year || '-',
|
||||
item?.quarter || '-',
|
||||
item?.product_count || '-',
|
||||
item?.created_by || '-',
|
||||
<span className="whitespace-nowrap">{formatDate(item?.created_at)}</span>,
|
||||
renderStatusBadge(item?.status),
|
||||
item?.updated_by || '-',
|
||||
<span className="whitespace-nowrap">{formatDate(item?.updated_at)}</span>,
|
||||
<div className="flex items-center justify-center gap-[10px]">
|
||||
<div className="relative group">
|
||||
<CheckCircle2 size={22} className="text-green-600 cursor-pointer hover:scale-110 transition-transform" />
|
||||
<span className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-black text-white text-xs px-2 py-[2px] rounded opacity-0 group-hover:opacity-100 transition">
|
||||
Approve
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<XCircle size={22} className="text-red-600 cursor-pointer hover:scale-110 transition-transform" />
|
||||
<span className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-black text-white text-xs px-2 py-[2px] rounded opacity-0 group-hover:opacity-100 transition">
|
||||
Reject
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<Eye
|
||||
size={22}
|
||||
className={`cursor-pointer hover:scale-110 transition-transform ${
|
||||
item.status?.toLowerCase() === 'pending' ? 'text-[#9E792B]' : 'text-gray-500'
|
||||
}`}
|
||||
onClick={() => navigate(`/admin/validations/${item.id}`)}
|
||||
/>
|
||||
<span className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-black text-white text-xs px-2 py-[2px] rounded opacity-0 group-hover:opacity-100 transition">
|
||||
View
|
||||
</span>
|
||||
</div>
|
||||
</div>,
|
||||
]);
|
||||
|
||||
const handleActionClick = (submission, type) => {
|
||||
setSelectedSubmission(submission);
|
||||
setActionType(type);
|
||||
if (type === 'approve') {
|
||||
setShowApproveModal(true);
|
||||
} else {
|
||||
// Clear previous state
|
||||
setRejectionReason('');
|
||||
setShowRejectError(false); // Don't show error immediately
|
||||
setShowRejectModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseApproveModal = () => {
|
||||
setShowApproveModal(false);
|
||||
setSelectedSubmission(null);
|
||||
};
|
||||
|
||||
const handleCloseRejectModal = () => {
|
||||
setShowRejectModal(false);
|
||||
setSelectedSubmission(null);
|
||||
setRejectionReason('');
|
||||
setShowRejectError(false);
|
||||
};
|
||||
|
||||
const handleApproveConfirm = async () => {
|
||||
if (!selectedSubmission || isProcessing) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const response = await putRequest(`/approveOrRejectSubmission/${selectedSubmission.id}`, {
|
||||
approve_reject_status: 1,
|
||||
reject_reason: ''
|
||||
});
|
||||
|
||||
const ok = (response && response.data) || response?.status === 200 || response?.status === 'success';
|
||||
|
||||
if (ok) {
|
||||
toast.success('Submission approved successfully');
|
||||
|
||||
const currentUser = JSON.parse(localStorage.getItem("user_profile"))?.name || 'Admin';
|
||||
const updatedAt = response?.data?.updated_at || new Date().toISOString();
|
||||
|
||||
// Update the status of the current item in the data array
|
||||
setData(prevData =>
|
||||
prevData.map(item =>
|
||||
item.id === selectedSubmission.id
|
||||
? {
|
||||
...item,
|
||||
status: 'Approved',
|
||||
reviewer_name: currentUser,
|
||||
reviewerOn: updatedAt,
|
||||
updated_at: updatedAt
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
} else {
|
||||
throw new Error('Failed to approve submission');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error approving submission:', error);
|
||||
toast.error(error.message || 'Failed to approve submission');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setShowApproveModal(false);
|
||||
setSelectedSubmission(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectConfirm = async () => {
|
||||
if (!selectedSubmission || isProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the rejection reason
|
||||
if (!rejectionReason.trim()) {
|
||||
setShowRejectError(true);
|
||||
toast.error('Please provide a reason for rejection');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const response = await putRequest(`/approveOrRejectSubmission/${selectedSubmission.id}`, {
|
||||
approve_reject_status: 0,
|
||||
reject_reason: rejectionReason.trim()
|
||||
});
|
||||
|
||||
const ok = (response && response.data) || response?.status === 200 || response?.status === 'success';
|
||||
|
||||
if (ok) {
|
||||
toast.error('Submission has been rejected');
|
||||
|
||||
const currentUser = JSON.parse(localStorage.getItem("user_profile"))?.name || 'Admin';
|
||||
const updatedAt = response?.data?.updated_at || new Date().toISOString();
|
||||
|
||||
// Update the status of the current item in the data array
|
||||
setData(prevData =>
|
||||
prevData.map(item =>
|
||||
item.id === selectedSubmission.id
|
||||
? {
|
||||
...item,
|
||||
status: 'Rejected',
|
||||
reviewer_name: currentUser,
|
||||
reviewerOn: updatedAt,
|
||||
updated_at: updatedAt
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
|
||||
setRejectionReason('');
|
||||
} else {
|
||||
throw new Error('Failed to reject submission');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rejecting submission:', error);
|
||||
toast.error(error.message || 'Failed to reject submission');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setShowRejectModal(false);
|
||||
setSelectedSubmission(null);
|
||||
}
|
||||
};
|
||||
const headers = [
|
||||
'Establishment',
|
||||
'Emirates',
|
||||
'Year',
|
||||
'Quarter',
|
||||
'Product Count',
|
||||
'Submitted By',
|
||||
'Submission Date & Time',
|
||||
'Status',
|
||||
'Reviewer',
|
||||
'Reviewed On',
|
||||
'Actions',
|
||||
];
|
||||
|
||||
// const tableRows = filtered.map((item) => [
|
||||
// item?.establishment?.factory_name || '-',
|
||||
// item?.establishment?.establishment_emirate?.name || '-',
|
||||
// item?.year || '-',
|
||||
// item?.quarter || '-',
|
||||
// item?.product_count || '-',
|
||||
// // Submitted By - get from created_by_name field
|
||||
// item?.created_by_name || '-',
|
||||
// <span className="whitespace-nowrap">{formatDate(item?.created_at)}</span>,
|
||||
// renderStatusBadge(item?.status),
|
||||
// // Reviewer - get from reviewer_name field (person who approved/rejected)
|
||||
// item?.reviewer_name || '-',
|
||||
// // Reviewed On - use reviewerOn for reviewed date
|
||||
// <span className="whitespace-nowrap">{formatDate(item?.reviewerOn)}</span>,
|
||||
// item,
|
||||
// ]);
|
||||
|
||||
const tableRows = filtered.map((item) => [
|
||||
item?.establishment?.factory_name || '-',
|
||||
item?.establishment?.establishment_emirate?.name || '-',
|
||||
item?.year || '-',
|
||||
item?.quarter || '-',
|
||||
item?.product_count || '-',
|
||||
// Submitted By - get from created_by_name field
|
||||
<div className="break-words">{item?.created_by_name || '-'}</div>,
|
||||
<span className="whitespace-nowrap">{formatDate(item?.created_at)}</span>,
|
||||
renderStatusBadge(item?.status),
|
||||
// Reviewer - show reviewer_name after approval/rejection, otherwise show '-'
|
||||
item?.status?.toLowerCase() === 'approved' || item?.status?.toLowerCase() === 'rejected'
|
||||
? (item?.reviewer_name || JSON.parse(localStorage.getItem("user_profile"))?.name || 'Admin')
|
||||
: '-',
|
||||
// Reviewed On - show updated_at after approval/rejection, otherwise show '-'
|
||||
item?.status?.toLowerCase() === 'approved' || item?.status?.toLowerCase() === 'rejected'
|
||||
? <span className="whitespace-nowrap">{formatDate(item?.updated_at || item?.reviewerOn)}</span>
|
||||
: '-',
|
||||
item,
|
||||
]);
|
||||
const columnWidths = [220, 150, 100, 100, 130, 150, 180, 150, 150, 180, 120];
|
||||
const columnWidths = [240, 120, 100, 100, 130, 150, 180, 150, 150, 180, 120];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`border border-gray-200 bg-white shadow-sm rounded-md w-full overflow-hidden py-2 ${
|
||||
filtered.length === 0 || loading ? 'min-h-auto' : 'min-h-[200px]'
|
||||
}`}
|
||||
className="border border-[#E5E7EB] bg-white shadow-[0_16px_32px_rgba(15,23,42,0.06)] rounded-[8px]"
|
||||
style={{ width: '1250px', minHeight: '780px' }}
|
||||
>
|
||||
<div className="flex justify-between items-center px-4 pr-6 py-2 mt-3">
|
||||
<h2 className="text-[18px] leading-[28px] font-medium text-[#232528]">
|
||||
{titleText}
|
||||
</h2>
|
||||
<div className="space-y-[1px] overflow-x-auto">
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<h2 className="text-[18px] leading-[28px] font-medium text-[#232528]">
|
||||
{titleText}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-[500px] text-gray-500">
|
||||
Loading submissions...
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-[1200px]">
|
||||
<Table
|
||||
headers={headers}
|
||||
rows={tableRows}
|
||||
separated
|
||||
rowGapClass="border-spacing-y-1"
|
||||
columnWidths={columnWidths}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end px-6 pb-14 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/admin/validations')}
|
||||
className="h-9 px-4 text-sm font-medium text-white rounded-md bg-[#9E792B] hover:bg-[#846522] shadow-[0_2px_8px_rgba(146,114,42,0.25)]"
|
||||
className="inline-flex h-[44px] w-[190px] items-center justify-center gap-2 rounded-md bg-[#9E792B] text-sm font-medium text-white shadow-[0_8px_18px_rgba(146,114,42,0.35)] hover:bg-[#846522]"
|
||||
>
|
||||
View All Submissions
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-[500px] text-gray-500">
|
||||
Loading submissions...
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 min-h-[180px] text-[#7B5C1F] font-semibold text-[15px]">
|
||||
No Submission Found
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full pt-5">
|
||||
<Table
|
||||
headers={headers}
|
||||
rows={tableRows}
|
||||
separated
|
||||
rowGapClass="border-spacing-y-1"
|
||||
columnWidths={columnWidths}
|
||||
renderCell={(value, _, columnIndex) => {
|
||||
if (columnIndex === 10) {
|
||||
const item = value;
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-[10px]">
|
||||
<div className="relative group">
|
||||
<CheckCircle2
|
||||
size={22}
|
||||
className={`${
|
||||
item.status?.toLowerCase() === 'approved' || item.status?.toLowerCase() === 'rejected'
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: 'text-green-600 cursor-pointer hover:scale-110'
|
||||
} transition-transform`}
|
||||
onClick={['submitted', 'resubmitted'].includes(item.status?.toLowerCase())
|
||||
? () => handleActionClick(item, 'approve')
|
||||
: undefined}
|
||||
/>
|
||||
<span className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-black text-white text-xs px-2 py-[2px] rounded opacity-0 group-hover:opacity-100 transition whitespace-nowrap">
|
||||
{item.status?.toLowerCase() === 'approved'
|
||||
? 'Already Approved'
|
||||
: item.status?.toLowerCase() === 'rejected'
|
||||
? 'Cannot approve rejected'
|
||||
: 'Approve'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<XCircle
|
||||
size={22}
|
||||
className={`${
|
||||
['approved', 'rejected'].includes(item.status?.toLowerCase())
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: 'text-red-600 cursor-pointer hover:scale-110'
|
||||
} transition-transform`}
|
||||
onClick={!['approved', 'rejected'].includes(item.status?.toLowerCase())
|
||||
? () => handleActionClick(item, 'reject')
|
||||
: undefined}
|
||||
/>
|
||||
<span className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-black text-white text-xs px-2 py-[2px] rounded opacity-0 group-hover:opacity-100 transition whitespace-nowrap">
|
||||
{item.status?.toLowerCase() === 'rejected'
|
||||
? 'Already Rejected'
|
||||
: item.status?.toLowerCase() === 'approved'
|
||||
? 'Cannot reject approved'
|
||||
: 'Reject'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<Eye
|
||||
size={22}
|
||||
className={`cursor-pointer hover:scale-110 transition-transform ${
|
||||
item.status?.toLowerCase() === 'pending'
|
||||
? 'text-[#9E792B]'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
onClick={() =>
|
||||
navigate(`/admin/validations/${item.id}`, {
|
||||
state: { actionType: 'view' },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-black text-white text-xs px-2 py-[2px] rounded opacity-0 group-hover:opacity-100 transition">
|
||||
View
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Approve Confirmation Modal */}
|
||||
{showApproveModal && selectedSubmission && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4 bg-black/30">
|
||||
<div className="bg-white rounded-lg w-full max-w-lg border border-gray-200 shadow-lg">
|
||||
<div className="flex items-center justify-between h-12 p-4 border-b border-gray-200">
|
||||
<h3 className="text-base font-medium text-gray-900">Approve this submission?</h3>
|
||||
<button
|
||||
onClick={handleCloseApproveModal}
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
You're about to approve <span className="font-medium text-gray-900">{selectedSubmission.establishment?.factory_name}</span> for <span className="font-medium text-gray-900">{selectedSubmission.quarter} {selectedSubmission.year}</span>.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
This will lock the record for the establishment.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseApproveModal}
|
||||
className="px-4 py-2 text-sm font-medium text-[#92722A] bg-white border border-[#92722A] rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#92722A]"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApproveConfirm}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-[#92722A] border border-transparent rounded-md shadow-sm hover:bg-[#7c5e24] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#92722A] flex items-center gap-2"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
|
||||
</svg>
|
||||
Approving...
|
||||
</>
|
||||
) : 'Approve'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reject Confirmation Modal */}
|
||||
{showRejectModal && selectedSubmission && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4 bg-black/30">
|
||||
<div className="bg-white rounded-lg w-full max-w-lg border border-gray-200 shadow-lg">
|
||||
<div className="flex items-center justify-between h-12 p-4 border-b border-gray-200">
|
||||
<h3 className="text-base font-medium text-gray-900">Reject this submission?</h3>
|
||||
<button
|
||||
onClick={handleCloseRejectModal}
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-gray-600 leading-6 mb-2">
|
||||
You're about to return <span className="font-medium text-gray-900">{selectedSubmission.establishment?.factory_name}</span>'s <span className="font-medium text-gray-900">{selectedSubmission.quarter} {selectedSubmission.year}</span> submission for correction.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 leading-6 mb-3">
|
||||
The status will change to Rejected and the establishment can resubmit within the survey window.
|
||||
</p>
|
||||
<div className="mb-0">
|
||||
<label htmlFor="rejectReason" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Reason for rejection: <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="rejectReason"
|
||||
rows={4}
|
||||
className={`w-full px-3 py-2 border ${showRejectError && !rejectionReason.trim() ? 'border-red-500' : 'border-gray-300'} rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#92722A] focus:border-[#92722A] text-sm`}
|
||||
placeholder="Please provide a reason for rejection"
|
||||
value={rejectionReason}
|
||||
onChange={(e) => {
|
||||
setRejectionReason(e.target.value);
|
||||
if (e.target.value.trim() && showRejectError) {
|
||||
setShowRejectError(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!rejectionReason.trim()) {
|
||||
setShowRejectError(true);
|
||||
}
|
||||
}}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
{showRejectError && !rejectionReason.trim() && (
|
||||
<p className="mt-1 text-sm text-red-600">Reason for rejection is required</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseRejectModal}
|
||||
className="px-4 py-2 text-sm font-medium text-[#92722A] bg-white border border-[#92722A] rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#92722A]"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!rejectionReason.trim()) {
|
||||
setShowRejectError(true);
|
||||
return;
|
||||
}
|
||||
handleRejectConfirm();
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-[#92722A] border border-transparent rounded-md shadow-sm hover:bg-[#7c5e24] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#92722A] flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
|
||||
</svg>
|
||||
Rejecting...
|
||||
</>
|
||||
) : 'Reject'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { CheckCircle, XCircle } from 'lucide-react';
|
||||
|
||||
const CustomToast = ({ message, type = 'success', onClose, duration = 4000 }) => {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onClose, duration);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onClose, duration]);
|
||||
|
||||
const isSuccess = type === 'success';
|
||||
const bgColor = isSuccess
|
||||
? 'bg-green-50 border border-green-500 text-green-700'
|
||||
: 'bg-red-50 border border-red-500 text-red-700';
|
||||
const Icon = isSuccess ? CheckCircle : XCircle;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-4 left-1/2 -translate-x-1/2 flex items-center gap-3 p-4 rounded-lg shadow-md ${bgColor} z-[10000] min-w-[320px] max-w-[90%] animate-[fadeIn_0.3s_ease-out]`}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
<div className="flex-1 text-sm font-medium">{message}</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700 ml-2 flex items-center justify-center"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomToast;
|
||||
@ -1,93 +1,71 @@
|
||||
import React from 'react';
|
||||
import { Phone, Smartphone, Headphones, Instagram, Facebook, Linkedin, Youtube } from 'lucide-react';
|
||||
|
||||
const Footer = () => {
|
||||
const mailIconSrc = '/assets/images/material-symbols_mail-outline.svg';
|
||||
const phoneIconSrc = '/assets/images/line-md_phone.svg';
|
||||
|
||||
return (
|
||||
<footer className="bg-white border-t border-gray-200 mt-12">
|
||||
{/* Top Gold Divider Line */}
|
||||
<div className="h-[2px] bg-[#92722A] mb-8" />
|
||||
<footer className="bg-white border-t border-gray-200 mt-10">
|
||||
<div className="h-[3px] bg-[#92722A]" />
|
||||
|
||||
{/* Main Footer Content */}
|
||||
<div className="max-w-7xl mx-auto px-6 py-8 grid grid-cols-1 md:grid-cols-4 gap-8 md:gap-12 text-[#232528]">
|
||||
{/* About FCSC */}
|
||||
<div className="max-w-7xl mx-auto px-6 py-10 grid grid-cols-1 md:grid-cols-4 gap-8 text-[#232528]">
|
||||
<div>
|
||||
<h3 className="text-[#92722A] font-semibold mb-3 text-lg">About FCSC</h3>
|
||||
<p className="text-sm">
|
||||
The Federal Competitiveness and Statistics Centre (FCSC) provides reliable data and insights to enhance the UAE's competitiveness and support sustainable national development.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Our Policies */}
|
||||
<div>
|
||||
<h3 className="text-[#92722A] font-semibold mb-3 ml-16 text-lg">Our Policies</h3>
|
||||
<ul className="space-y-1 ml-16 text-sm">
|
||||
<li>Disclaimer</li>
|
||||
<li>Privacy policy</li>
|
||||
<li>Terms and conditions</li>
|
||||
<h3 className="text-[#92722A] font-semibold mb-2">The Ministry</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li><a href="#" className="hover:underline">About us</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Information and Support */}
|
||||
<div>
|
||||
<h3 className="text-[#92722A] font-semibold mb-3 ml-1 text-lg">Information and Support</h3>
|
||||
<ul className="space-y-1 ml-1 text-sm">
|
||||
<li>Contact us</li>
|
||||
<li>FAQ’s</li>
|
||||
<li>Feedback and complaints</li>
|
||||
<h3 className="text-[#92722A] font-semibold mb-2">Using the website</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li><a href="#" className="hover:underline">Disclaimer</a></li>
|
||||
<li><a href="#" className="hover:underline">Privacy policy</a></li>
|
||||
<li><a href="#" className="hover:underline">Terms and conditions</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact Details */}
|
||||
<div className="flex flex-col items-start md:items-end gap-4">
|
||||
<img
|
||||
src="/assets/images/FCSCLogo.svg"
|
||||
alt="FCSC Logo"
|
||||
className="h-10 mb-2"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-[#92722A] font-semibold mb-2">Information and support</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li><a href="#" className="hover:underline">Contact us</a></li>
|
||||
<li><a href="#" className="hover:underline">FAQ’s</a></li>
|
||||
<li><a href="#" className="hover:underline">Feedback and complaints</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Phone Numbers */}
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<img
|
||||
src={phoneIconSrc}
|
||||
alt="Phone Icon"
|
||||
className="h-4 w-4 mt-[2px] opacity-80"
|
||||
/>
|
||||
<div className="flex flex-col leading-[1.4]">
|
||||
<span className="font-semibold text-[#232528]">+971506491429</span>
|
||||
{/* <span className="font-semibold text-[#232528]">+971 4 327 3535</span> */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-start md:items-end gap-2">
|
||||
<img src="/assets/images/TawasolLogo.png" alt="Tawasul" className="h-12 mb-2" />
|
||||
|
||||
{/* Email */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<img
|
||||
src={mailIconSrc}
|
||||
alt="Mail Icon"
|
||||
className="h-4 w-4 opacity-80"
|
||||
/>
|
||||
<a
|
||||
href="mailto:iip.survey@fcsc.gov.ae"
|
||||
className="text-[#232528] hover:underline"
|
||||
>
|
||||
iip.survey@fcsc.gov.ae
|
||||
</a>
|
||||
<Phone size={16} className="text-green-600" />
|
||||
<span className="font-semibold">171</span>
|
||||
<Smartphone size={16} className="text-green-600 ml-3" />
|
||||
<span className="font-semibold">04-7771777</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm mt-1">
|
||||
<Headphones size={16} className="text-gray-500" />
|
||||
<span className="text-gray-700">Toll free: <span className="font-semibold">800 12</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div> {/* properly closed main content div */}
|
||||
</div>
|
||||
|
||||
{/* Bottom Divider */}
|
||||
<div className="border-t border-gray-200" />
|
||||
<div className="border-t border-gray-200 my-2" />
|
||||
|
||||
{/* Copyright Section */}
|
||||
<div className="max-w-7xl mx-auto px-6 py-3 flex flex-col md:flex-row justify-between items-center text-xs text-gray-600">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex flex-col md:flex-row justify-between items-center text-xs text-gray-600">
|
||||
<p>
|
||||
Copyright © 2025 Federal Competitiveness and Statistics Centre, All Rights Reserved
|
||||
© 2023. Ministry of Human Resources & Emiratisation. All rights reserved.{' '}
|
||||
<span className="text-gray-400">Last updated on 24/04/2023 at 15:45</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-3 md:mt-0">
|
||||
<span>Follow us on:</span>
|
||||
<a href="#" className="hover:text-[#92722A]"><Facebook size={16} /></a>
|
||||
<a href="#" className="hover:text-[#92722A]"><Instagram size={16} /></a>
|
||||
<a href="#" className="hover:text-[#92722A]"><Linkedin size={16} /></a>
|
||||
<a href="#" className="hover:text-[#92722A]"><Youtube size={16} /></a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
export default Footer;
|
||||
@ -35,136 +35,13 @@ const idleBorderColors = {
|
||||
toolbar: '#C3C6CB',
|
||||
};
|
||||
|
||||
// export const TextField = ({
|
||||
// label,
|
||||
// placeholder,
|
||||
// value,
|
||||
// onChange,
|
||||
// type = 'text',
|
||||
// width = 'auto', // 'auto' | number | string
|
||||
// className = '',
|
||||
// style = {},
|
||||
// name,
|
||||
// id,
|
||||
// leftIcon,
|
||||
// onFocus,
|
||||
// onBlur,
|
||||
// variant = 'default',
|
||||
// required = false,
|
||||
// showToggle = false,
|
||||
// toggleLabels = { show: 'Show', hide: 'Hide' },
|
||||
// error = '',
|
||||
// readOnly = false,
|
||||
// }) => {
|
||||
// const wrapperStyle = {};
|
||||
// if (width !== 'auto') {
|
||||
// wrapperStyle.width = typeof width === 'number' ? `${width}px` : width;
|
||||
// }
|
||||
// const [focused, setFocused] = React.useState(false);
|
||||
// const isPasswordField = type === 'password';
|
||||
// const [showPassword, setShowPassword] = React.useState(false);
|
||||
// React.useEffect(() => {
|
||||
// if (!isPasswordField) {
|
||||
// setShowPassword(false);
|
||||
// }
|
||||
// }, [isPasswordField]);
|
||||
// const handleFocus = (event) => {
|
||||
// setFocused(true);
|
||||
// onFocus?.(event);
|
||||
// };
|
||||
// const handleBlur = (event) => {
|
||||
// setFocused(false);
|
||||
// onBlur?.(event);
|
||||
// };
|
||||
// const baseStyle = baseInputStyles[variant] || baseInputStyles.default;
|
||||
// const inputStyle = { ...baseStyle, ...style };
|
||||
// if (leftIcon) {
|
||||
// inputStyle.paddingLeft = '40px';
|
||||
// }
|
||||
// if (isPasswordField && showToggle) {
|
||||
// inputStyle.paddingRight = '44px';
|
||||
// }
|
||||
// const hasError = Boolean(error);
|
||||
// inputStyle.borderColor = hasError
|
||||
// ? '#B91C1C'
|
||||
// : focused && !readOnly
|
||||
// ? focusBorderColors[variant]
|
||||
// : idleBorderColors[variant] || idleBorderColors.default;
|
||||
// if (hasError) {
|
||||
// inputStyle.backgroundColor = '#FEF2F2';
|
||||
// inputStyle.color = '#B91C1C';
|
||||
// }
|
||||
// if (!hasError && variant !== 'toolbar') {
|
||||
// inputStyle.color = '#232528';
|
||||
// }
|
||||
// if (readOnly) {
|
||||
// inputStyle.backgroundColor = '#E6D7A2';
|
||||
// inputStyle.cursor = 'not-allowed';
|
||||
// inputStyle.opacity = '0.7';
|
||||
// }
|
||||
// if (!hasError && variant === 'toolbar') {
|
||||
// inputStyle.color = '#5F646D';
|
||||
// }
|
||||
// return (
|
||||
// <div style={wrapperStyle} className="w-full">
|
||||
// {label && (
|
||||
// <label htmlFor={id || name} className="block text-[14px] leading-[20px] font-medium text-[#232528] mb-1">
|
||||
// {label}
|
||||
// {required && <span className="text-[#B91C1C] ml-1">*</span>}
|
||||
// </label>
|
||||
// )}
|
||||
// <div className="relative">
|
||||
// <input
|
||||
// id={id || name}
|
||||
// name={name}
|
||||
// type={isPasswordField ? (showPassword ? 'text' : 'password') : type}
|
||||
// value={value}
|
||||
// onChange={onChange}
|
||||
// placeholder={placeholder}
|
||||
// className={`w-full text-sm focus:outline-none ${className}`}
|
||||
// style={inputStyle}
|
||||
// onFocus={handleFocus}
|
||||
// onBlur={handleBlur}
|
||||
// required={required}
|
||||
// readOnly={readOnly}
|
||||
// />
|
||||
// {leftIcon && (
|
||||
// <img src={leftIcon} alt="" className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4" />
|
||||
// )}
|
||||
// {isPasswordField && showToggle && (
|
||||
// <button
|
||||
// type="button"
|
||||
// onClick={() => setShowPassword((prev) => !prev)}
|
||||
// className="absolute right-3 top-1/2 -translate-y-1/2 text-[#92722A] hover:text-[#7b5c1f]"
|
||||
// aria-label={showPassword ? toggleLabels.hide : toggleLabels.show}
|
||||
// title={showPassword ? toggleLabels.hide : toggleLabels.show}
|
||||
// >
|
||||
// {showPassword ? (
|
||||
// <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
// <path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18" />
|
||||
// <path strokeLinecap="round" strokeLinejoin="round" d="M10.58 10.58A2 2 0 0012 14a2 2 0 001.42-.58M16.68 16.68C15.28 17.54 13.69 18 12 18 7 18 3.73 14.82 2 12c.58-.9 1.34-1.83 2.26-2.68M9.88 4.13C10.56 4.05 11.27 4 12 4c5 0 8.27 3.18 10 6-.46.72-1.03 1.43-1.71 2.1" />
|
||||
// </svg>
|
||||
// ) : (
|
||||
// <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
// <path strokeLinecap="round" strokeLinejoin="round" d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z" />
|
||||
// <circle cx="12" cy="12" r="3" />
|
||||
// </svg>
|
||||
// )}
|
||||
// </button>
|
||||
// )}
|
||||
// </div>
|
||||
// {error && <p className="mt-1 text-xs text-[#B91C1C]">{error}</p>}
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
|
||||
export const TextField = ({
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
type = 'text',
|
||||
width = 'auto',
|
||||
width = 'auto', // 'auto' | number | string
|
||||
className = '',
|
||||
style = {},
|
||||
name,
|
||||
@ -183,57 +60,49 @@ export const TextField = ({
|
||||
if (width !== 'auto') {
|
||||
wrapperStyle.width = typeof width === 'number' ? `${width}px` : width;
|
||||
}
|
||||
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
const isPasswordField = type === 'password';
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isPasswordField) setShowPassword(false);
|
||||
if (!isPasswordField) {
|
||||
setShowPassword(false);
|
||||
}
|
||||
}, [isPasswordField]);
|
||||
|
||||
const handleFocus = (event) => {
|
||||
if (!readOnly) setFocused(true);
|
||||
setFocused(true);
|
||||
onFocus?.(event);
|
||||
};
|
||||
|
||||
const handleBlur = (event) => {
|
||||
setFocused(false);
|
||||
onBlur?.(event);
|
||||
};
|
||||
|
||||
// Base styles
|
||||
const baseStyle = baseInputStyles[variant] || baseInputStyles.default;
|
||||
const inputStyle = { ...baseStyle, ...style };
|
||||
|
||||
if (leftIcon) inputStyle.paddingLeft = '40px';
|
||||
if (isPasswordField && showToggle) inputStyle.paddingRight = '44px';
|
||||
|
||||
// Error, focus, idle, readOnly colors
|
||||
if (leftIcon) {
|
||||
inputStyle.paddingLeft = '40px';
|
||||
}
|
||||
if (isPasswordField && showToggle) {
|
||||
inputStyle.paddingRight = '44px';
|
||||
}
|
||||
const hasError = Boolean(error);
|
||||
|
||||
inputStyle.borderColor = hasError
|
||||
? '#B91C1C'
|
||||
: focused && !readOnly
|
||||
? focusBorderColors[variant]
|
||||
: idleBorderColors[variant] || idleBorderColors.default;
|
||||
if (hasError) {
|
||||
inputStyle.borderColor = '#B91C1C'; // red border
|
||||
inputStyle.backgroundColor = '#FEF2F2';
|
||||
inputStyle.color = '#B91C1C';
|
||||
} else if (readOnly) {
|
||||
inputStyle.borderColor = '#CBA344'; // standard
|
||||
inputStyle.outline = 'none';
|
||||
inputStyle.backgroundColor = '#FFFFFF'; // ✅ disabled color
|
||||
inputStyle.cursor = 'not-allowed';
|
||||
inputStyle.opacity = '0.8';
|
||||
} else if (focused) {
|
||||
inputStyle.borderColor = '#92722A'; // ✅ focused color
|
||||
inputStyle.outline = 'none';
|
||||
inputStyle.backgroundColor = '#FFFFFF';
|
||||
} else {
|
||||
inputStyle.borderColor = '#CBA344'; // ✅ standard color
|
||||
inputStyle.outline = 'none';
|
||||
inputStyle.backgroundColor = '#FFFFFF';
|
||||
}
|
||||
|
||||
inputStyle.color = hasError ? '#B91C1C' : '#232528';
|
||||
|
||||
if (!hasError && variant !== 'toolbar') {
|
||||
inputStyle.color = '#232528';
|
||||
}
|
||||
if (readOnly) {
|
||||
inputStyle.backgroundColor = '#F9FAFB';
|
||||
}
|
||||
if (!hasError && variant === 'toolbar') {
|
||||
inputStyle.color = '#5F646D';
|
||||
}
|
||||
return (
|
||||
<div style={wrapperStyle} className="w-full">
|
||||
{label && (
|
||||
@ -258,34 +127,29 @@ export const TextField = ({
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
{leftIcon && (
|
||||
<img
|
||||
src={leftIcon}
|
||||
alt=""
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4"
|
||||
/>
|
||||
<img src={leftIcon} alt="" className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4" />
|
||||
)}
|
||||
{isPasswordField && showToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#92722A] hover:text-[#7b5c1f]"
|
||||
aria-label={showPassword ? toggleLabels.hide : toggleLabels.show}
|
||||
title={showPassword ? toggleLabels.hide : toggleLabels.show}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.58 10.58A2 2 0 0012 14a2 2 0 001.42-.58M16.68 16.68C15.28 17.54 13.69 18 12 18 7 18 3.73 14.82 2 12c.58-.9 1.34-1.83 2.26-2.68M9.88 4.13C10.56 4.05 11.27 4 12 4c5 0 8.27 3.18 10 6-.46.72-1.03 1.43-1.71 2.1" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{isPasswordField && showToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#92722A] hover:text-[#7b5c1f]"
|
||||
aria-label={showPassword ? toggleLabels.hide : toggleLabels.show}
|
||||
title={showPassword ? toggleLabels.hide : toggleLabels.show}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.58 10.58A2 2 0 0012 14a2 2 0 001.42-.58M16.68 16.68C15.28 17.54 13.69 18 12 18 7 18 3.73 14.82 2 12c.58-.9 1.34-1.83 2.26-2.68M9.88 4.13C10.56 4.05 11.27 4 12 4c5 0 8.27 3.18 10 6-.46.72-1.03 1.43-1.71 2.1" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
{error && <p className="mt-1 text-xs text-[#B91C1C]">{error}</p>}
|
||||
</div>
|
||||
@ -306,14 +170,12 @@ export const SelectField = ({
|
||||
placeholder,
|
||||
onFocus,
|
||||
onBlur,
|
||||
variant = '#92722A',
|
||||
variant = 'default',
|
||||
allowClear = false,
|
||||
onClear,
|
||||
clearValue = '',
|
||||
error = '',
|
||||
readOnly = false,
|
||||
required = false,
|
||||
forceDropdownBelow = false,
|
||||
}) => {
|
||||
const wrapperStyle = {};
|
||||
if (width !== 'auto') {
|
||||
@ -322,84 +184,20 @@ export const SelectField = ({
|
||||
const baseStyle = baseInputStyles[variant] || baseInputStyles.default;
|
||||
const inputStyle = { ...baseStyle, ...style };
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
const dropdownRef = React.useRef(null);
|
||||
const inputRef = React.useRef(null);
|
||||
const normalizedClear = clearValue ?? '';
|
||||
const hasValue = value !== undefined && value !== null && value !== '' && value !== normalizedClear;
|
||||
const showClear = allowClear && hasValue && !readOnly;
|
||||
|
||||
if (rightIcon) {
|
||||
inputStyle.paddingRight = '40px';
|
||||
}
|
||||
|
||||
// Filter options based on search term
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
if (!searchTerm) return options;
|
||||
return options.filter(opt =>
|
||||
opt.label.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}, [options, searchTerm]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
setSearchTerm('');
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleFocus = (event) => {
|
||||
setFocused(true);
|
||||
onFocus?.(event);
|
||||
};
|
||||
|
||||
const handleBlur = (event) => {
|
||||
setFocused(false);
|
||||
onBlur?.(event);
|
||||
};
|
||||
|
||||
const handleClear = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (onClear) {
|
||||
onClear();
|
||||
} else if (onChange) {
|
||||
onChange({ target: { value: normalizedClear } });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (selectedValue) => {
|
||||
if (!readOnly) {
|
||||
onChange({ target: { value: selectedValue } });
|
||||
setIsOpen(false);
|
||||
setSearchTerm('');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!readOnly) {
|
||||
setIsOpen(!isOpen);
|
||||
setSearchTerm('');
|
||||
}
|
||||
};
|
||||
|
||||
const getDisplayValue = () => {
|
||||
const selected = options.find(opt => opt.value === value);
|
||||
return selected ? selected.label : (placeholder || '');
|
||||
};
|
||||
|
||||
const hasError = Boolean(error);
|
||||
inputStyle.borderColor = hasError
|
||||
? '#B91C1C'
|
||||
@ -407,7 +205,7 @@ export const SelectField = ({
|
||||
? focusBorderColors[variant]
|
||||
: idleBorderColors[variant] || idleBorderColors.default;
|
||||
if (hasError) {
|
||||
inputStyle.backgroundColor = '#E6D7A2';
|
||||
inputStyle.backgroundColor = '#FEF2F2';
|
||||
inputStyle.color = '#B91C1C';
|
||||
} else if (variant === 'toolbar') {
|
||||
inputStyle.color = hasValue && value !== 'All' && value !== 'Status' && value !== 'Year' && value !== 'Quarter' && value !== 'Emirates' ? '#232528' : '#5F646D';
|
||||
@ -418,115 +216,23 @@ export const SelectField = ({
|
||||
inputStyle.backgroundColor = '#F9FAFB';
|
||||
inputStyle.cursor = 'not-allowed';
|
||||
}
|
||||
|
||||
// Custom dropdown implementation
|
||||
if (forceDropdownBelow) {
|
||||
return (
|
||||
<div className={`w-full ${wrapperStyle ? '' : ''}`} style={wrapperStyle || {}} ref={dropdownRef}>
|
||||
{label && (
|
||||
<label htmlFor={id || name} className="block text-sm font-medium text-gray-900 mb-1">
|
||||
{label}
|
||||
{required && <span className="text-red-700 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
className={`w-full text-sm focus:outline-none ${className}`}
|
||||
style={{
|
||||
...inputStyle,
|
||||
height: 'auto', // override fixed height
|
||||
minHeight: '40px',
|
||||
cursor: readOnly ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 16px',
|
||||
}}
|
||||
onClick={toggleDropdown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
tabIndex={readOnly ? -1 : 0}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: hasValue ? '#232528' : '#9CA3AF',
|
||||
flex: 1,
|
||||
whiteSpace: 'normal',
|
||||
lineHeight: '20px'
|
||||
}}
|
||||
>
|
||||
{getDisplayValue()}
|
||||
</span>
|
||||
{showClear ? (
|
||||
<button
|
||||
type="button"
|
||||
className="h-5 w-5 rounded-full text-[#9CA3AF] hover:text-[#4B5563]"
|
||||
onClick={handleClear}
|
||||
aria-label="Clear selection"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : (
|
||||
rightIcon && <img src={rightIcon} alt="" className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && !readOnly && (
|
||||
<div
|
||||
className="absolute z-50 w-full mt-1 bg-white border border-[#CBA344] rounded-md shadow-lg max-h-60 overflow-auto"
|
||||
style={{
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0
|
||||
}}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="p-2 border-b border-gray-200">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[#92722A] focus:border-transparent"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Options list */}
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="px-3 py-2 text-gray-500 text-sm">No options found</div>
|
||||
) : (
|
||||
filteredOptions.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
className={`px-3 py-2 cursor-pointer text-sm hover:bg-[#F9FAFB] ${
|
||||
opt.value === value ? 'bg-[#E6D7A2] text-[#232528]' : 'text-[#232528]'
|
||||
}`}
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{readOnly && <div className="absolute inset-0 pointer-events-none" />}
|
||||
{error && <p className="mt-1 text-xs text-[#B91C1C]">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Original native select implementation
|
||||
const handleClear = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (onClear) {
|
||||
onClear();
|
||||
} else if (onChange) {
|
||||
onChange({ target: { value: normalizedClear } });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className={`w-full ${wrapperStyle ? '' : ''}`} style={wrapperStyle || {}}>
|
||||
<div style={wrapperStyle} className="w-full">
|
||||
{label && (
|
||||
<label htmlFor={id || name} className="block text-sm font-medium text-gray-900 mb-1">
|
||||
<label htmlFor={id || name} className="block text-[14px] leading-[20px] font-medium text-[#232528] mb-1">
|
||||
{label}
|
||||
{required && <span className="text-red-700 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative w-full">
|
||||
<div className="relative" style={{ width: '100%' }}>
|
||||
<select
|
||||
id={id || name}
|
||||
name={name}
|
||||
@ -540,7 +246,6 @@ export const SelectField = ({
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
required={required}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled hidden>
|
||||
@ -709,7 +414,6 @@ export const DateField = ({
|
||||
rightIcon = calendarIconSrc,
|
||||
onBlur,
|
||||
onFocus,
|
||||
required=false,
|
||||
...rest
|
||||
}) => {
|
||||
const inputRef = React.useRef(null);
|
||||
@ -742,13 +446,6 @@ export const DateField = ({
|
||||
if (width !== 'auto') {
|
||||
wrapperStyle.width = typeof width === 'number' ? `${width}px` : width;
|
||||
}
|
||||
// Format the minDate prop if provided
|
||||
const formatDateForInput = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
const inputStyle = {
|
||||
...(baseInputStyles?.default || {}),
|
||||
...style,
|
||||
@ -794,7 +491,6 @@ export const DateField = ({
|
||||
{label && (
|
||||
<label htmlFor={id || name} className="block text-[14px] leading-[20px] font-medium text-[#232528] mb-1">
|
||||
{label}
|
||||
{required && <span className="text-[#B91C1C] ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
@ -810,8 +506,6 @@ export const DateField = ({
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
min={formatDateForInput(rest.minDate)}
|
||||
required={required}
|
||||
{...rest}
|
||||
/>
|
||||
{rightIcon && (
|
||||
|
||||
@ -1,109 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const statusStyles = {
|
||||
approved: 'bg-[#F3FAF4] text-[#2F663C]',
|
||||
'pending approval': 'bg-[#E7F5FF] text-[#003CFF]',
|
||||
'changes requested': 'bg-[#FEF2F2] text-[#B45309]',
|
||||
};
|
||||
|
||||
const statusDots = {
|
||||
approved: 'bg-[#2F663C]',
|
||||
'pending approval': 'bg-[#003CFF]',
|
||||
'changes requested': 'bg-[#B45309]',
|
||||
};
|
||||
|
||||
const ProductTable = ({ products, onViewDetails }) => {
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Product Name',
|
||||
render: (product) => (
|
||||
<span className="font-medium text-[#232528]">{product.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (product) => (
|
||||
<span className={`px-2.5 py-1 inline-flex items-center justify-center rounded-full text-xs font-medium ${
|
||||
statusStyles[product.status.toLowerCase()] || ''
|
||||
}`}>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full mr-1.5 ${
|
||||
statusDots[product.status.toLowerCase()] || ''
|
||||
}`}
|
||||
></span>
|
||||
{product.status}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'price',
|
||||
header: 'Price (AED)',
|
||||
render: (product) => (
|
||||
<span className="text-[#4B5563]">
|
||||
{new Intl.NumberFormat('en-AE', { minimumFractionDigits: 2 }).format(product.price)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastUpdated',
|
||||
header: 'Last Updated',
|
||||
render: (product) => (
|
||||
<span className="text-[#4B5563]">{product.lastUpdated}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
header: 'Action',
|
||||
align: 'right',
|
||||
render: (product) => (
|
||||
<button
|
||||
onClick={() => onViewDetails && onViewDetails(product)}
|
||||
className="text-[#92722A] hover:underline text-sm font-medium"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[#E2E8F0]">
|
||||
<thead className="bg-[#F8FAFC]">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`px-6 py-3 text-xs font-medium text-[#6B7280] uppercase tracking-wider ${
|
||||
column.align === 'right' ? 'text-right' : 'text-left'
|
||||
}`}
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-[#E2E8F0] text-sm">
|
||||
{products.map((product, index) => (
|
||||
<tr key={index}>
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={`${column.key}-${index}`}
|
||||
className={`px-6 py-4 whitespace-nowrap ${
|
||||
column.align === 'right' ? 'text-right' : 'text-left'
|
||||
}`}
|
||||
>
|
||||
{column.render ? column.render(product) : product[column.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductTable;
|
||||
@ -3,14 +3,12 @@ import React from 'react';
|
||||
const toneClasses = {
|
||||
green: 'bg-[#F3FAF4] text-[#2F663C] ring-1 ring-[#C4E5CC]',
|
||||
gray: 'bg-[#E9EDF3] text-[#232528] ring-1 ring-[#D6DBE4]',
|
||||
resubmitted: 'bg-[#F9F7ED] text-[#8B8000] ring-1 ring-[#E5DFC5]',
|
||||
};
|
||||
|
||||
const normalizeTone = (tone, status) => {
|
||||
if (tone) return tone;
|
||||
const normalizedStatus = String(status || '').toLowerCase();
|
||||
if (normalizedStatus === 'active' || normalizedStatus === 'open') return 'green';
|
||||
if (normalizedStatus === 'resubmitted') return 'resubmitted';
|
||||
return 'gray';
|
||||
};
|
||||
|
||||
|
||||
@ -77,9 +77,8 @@ const Table = ({
|
||||
const safeCurrentPage = enablePagination ? Math.min(Math.max(currentPage, 1), totalPages) : 1;
|
||||
const startIndex = enablePagination ? (safeCurrentPage - 1) * pageSize : 0;
|
||||
const endIndex = enablePagination ? startIndex + pageSize : rows.length;
|
||||
// Calculate range start and end, handling the case when there are no items
|
||||
const rangeStart = totalItems === 0 ? 0 : startIndex + 1;
|
||||
const rangeEnd = totalItems === 0 ? 0 : (enablePagination ? Math.min(endIndex, totalItems) : rows.length);
|
||||
const rangeEnd = enablePagination ? Math.min(endIndex, totalItems) : rows.length;
|
||||
|
||||
const displayedRows = React.useMemo(() => {
|
||||
if (!enablePagination) return rows;
|
||||
@ -139,12 +138,8 @@ const Table = ({
|
||||
<div className={`overflow-hidden rounded-lg bg-white ${className}`}>
|
||||
{beforeHeader}
|
||||
<div
|
||||
className={`
|
||||
${disableHorizontalScroll
|
||||
? 'overflow-x-hidden'
|
||||
: 'overflow-x-auto scrollbar-thin'}
|
||||
px-6 pt-2 pb-6
|
||||
`}
|
||||
className={`${disableHorizontalScroll ? 'overflow-x-hidden' : 'overflow-x-auto'} px-6 pt-2 pb-6`}
|
||||
style={disableHorizontalScroll ? undefined : { scrollbarWidth: 'thin' }}
|
||||
>
|
||||
<table
|
||||
className={`min-w-[720px] w-full table-fixed rounded-2xl px-4 py-4${
|
||||
@ -153,9 +148,7 @@ const Table = ({
|
||||
>
|
||||
<thead className={headerClassName || 'bg-[#F2ECCF] rounded-2xl'}>
|
||||
<tr>
|
||||
{headers.map((header, i) => {
|
||||
const headerText = typeof header === 'object' ? header.name : header;
|
||||
const headerClass = typeof header === 'object' ? header.className : 'text-left';
|
||||
{headers.map((h, i) => {
|
||||
const widthValue = columnWidths[i];
|
||||
const widthStyle = widthValue
|
||||
? {
|
||||
@ -166,12 +159,12 @@ const Table = ({
|
||||
return (
|
||||
<th
|
||||
key={i}
|
||||
className={`px-6 h-12 text-xs font-medium text-[#1B1D21] align-middle ${headerClass} ${
|
||||
className={`px-6 h-12 text-left text-xs font-medium text-[#1B1D21] align-middle ${
|
||||
i === 0 ? 'first:rounded-tl-md' : ''
|
||||
} ${i === headers.length - 1 ? 'last:rounded-tr-md' : ''}`}
|
||||
style={widthStyle}
|
||||
>
|
||||
{headerText}
|
||||
{h}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
@ -194,9 +187,8 @@ const Table = ({
|
||||
return (
|
||||
<td
|
||||
key={ci}
|
||||
className={`px-6 py-2 text-sm text-gray-900 border-b border-gray-300 align-top whitespace-normal break-words ${getCellClass ? getCellClass(ri, ci) : ''} ${
|
||||
widthValue ? `w-[${typeof widthValue === 'number' ? `${widthValue}px` : widthValue}] min-w-[${typeof widthValue === 'number' ? `${widthValue}px` : widthValue}]` : 'max-w-[200px]'
|
||||
}`}
|
||||
className={`px-6 h-12 text-sm text-[#232528] border-b border-[#C3C6CB] align-middle ${getCellClass ? getCellClass(ri, ci) : ''}`}
|
||||
style={widthStyle}
|
||||
>
|
||||
{renderCell ? renderCell(cell, ri, ci) : cell}
|
||||
</td>
|
||||
|
||||
@ -1,19 +1,10 @@
|
||||
// import React from 'react';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import React, { useState } from 'react';
|
||||
import Table from '@/components/common/Table';
|
||||
import Footer from '../common/Footer';
|
||||
import HeaderBar from '@/components/layout/HeaderBar';
|
||||
import DetailedOverview from '@/components/overview/DetailedOverview';
|
||||
import { deleteDraftSubmission } from '@/services/submissions/submissionService';
|
||||
import { HiMiniArrowPathRoundedSquare } from "react-icons/hi2";
|
||||
import CustomToast from '@/components/common/CustomToast';
|
||||
|
||||
const searchIconSrc = '/assets/images/material-symbols_search-rounded.svg';
|
||||
const pencilIconSrc = '/assets/images/PencilSimple.svg';
|
||||
import { fetchSubmissionDetail } from '@/services/submissions/submissionService';
|
||||
const trashActiveSrc = '/assets/images/Trash - active.svg';
|
||||
const trashInactiveSrc = '/assets/images/Trash - Inactive.svg';
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
@ -29,126 +20,32 @@ const formatDateTime = (value) => {
|
||||
return formatted.replace(/\s?(am|pm)$/i, (match) => match.toUpperCase());
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const getStatusClass = (status) => {
|
||||
const normalized = String(status || '').toLowerCase();
|
||||
if (normalized === 'approved') {
|
||||
return (
|
||||
<span className="px-3 py-1 rounded-md text-green-700 bg-green-50 font-medium text-sm whitespace-nowrap">
|
||||
Approved
|
||||
</span>
|
||||
);
|
||||
} else if (normalized === 'submitted') {
|
||||
return (
|
||||
<span className="px-3 py-1 rounded-md text-[#003CFF] bg-[#E7F5FF] font-medium text-sm whitespace-nowrap">
|
||||
Submitted
|
||||
</span>
|
||||
);
|
||||
} else if (normalized === 'resubmitted') {
|
||||
return (
|
||||
<span className="px-3 py-1 rounded-md text-[#7C5E24] bg-[#F9F7ED] font-medium text-sm whitespace-nowrap">
|
||||
Resubmitted
|
||||
</span>
|
||||
);
|
||||
} else if (['pending', 'under review', 'in-progress'].includes(normalized)) {
|
||||
return (
|
||||
<span className="px-3 py-1 rounded-md text-blue-600 bg-blue-50 font-medium text-sm whitespace-nowrap">
|
||||
Under Review
|
||||
</span>
|
||||
);
|
||||
} else if (normalized === 'rejected') {
|
||||
return (
|
||||
<span className="px-3 py-1 rounded-md text-red-600 bg-red-50 font-medium text-sm whitespace-nowrap">
|
||||
Rejected
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="px-3 py-1 rounded-md text-gray-600 bg-gray-50 font-medium text-sm whitespace-nowrap">
|
||||
{status || '—'}
|
||||
</span>
|
||||
);
|
||||
return 'inline-flex items-center rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-800';
|
||||
}
|
||||
if (['pending', 'under review', 'in-progress'].includes(normalized)) {
|
||||
return 'inline-flex items-center rounded-md bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-800';
|
||||
}
|
||||
if (['rejected', 'returned'].includes(normalized)) {
|
||||
return 'inline-flex items-center rounded-md bg-red-100 px-2 py-1 text-xs font-medium text-red-800';
|
||||
}
|
||||
return 'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-800';
|
||||
};
|
||||
|
||||
const isResubmitDisabled = (submission) => {
|
||||
// Only apply this logic for rejected records
|
||||
if (submission.originalData?.approve_reject_status !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rejectedAt = submission.originalData?.rejected_at;
|
||||
if (!rejectedAt) {
|
||||
return false; // If no rejection date, keep enabled
|
||||
}
|
||||
|
||||
const currentDate = new Date();
|
||||
const rejectionDate = new Date(rejectedAt);
|
||||
const blockOnDate = new Date(rejectionDate);
|
||||
blockOnDate.setDate(blockOnDate.getDate() + 14);
|
||||
|
||||
return currentDate >= blockOnDate;
|
||||
};
|
||||
|
||||
const downloadCsv = (data) => {
|
||||
if (!data.length) return;
|
||||
|
||||
const downloadCsv = (rows) => {
|
||||
if (!rows.length) return;
|
||||
const headers = ['Survey Name', 'Year', 'Quarter', 'Status', 'Submission On', 'Products'];
|
||||
|
||||
const csvRows = [headers.join(',')];
|
||||
|
||||
data.forEach((item) => {
|
||||
const statusText = (() => {
|
||||
const normalized = String(item.status || '').toLowerCase();
|
||||
if (normalized === 'approved') return 'Approved';
|
||||
if (['pending', 'under review', 'in-progress'].includes(normalized)) return 'Under Review';
|
||||
if (['rejected', 'rejected'].includes(normalized)) return 'Returned';
|
||||
return item.status || '—';
|
||||
})();
|
||||
|
||||
// Format date to Dubai time (GST, UTC+4) in 24-hour format
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '—';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
// Convert to Dubai time (UTC+4)
|
||||
const options = {
|
||||
timeZone: 'Asia/Dubai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false, // 24-hour format
|
||||
};
|
||||
const formatter = new Intl.DateTimeFormat('en-GB', options);
|
||||
const parts = formatter.formatToParts(date);
|
||||
|
||||
// Extract date and time parts
|
||||
const day = parts.find(part => part.type === 'day').value;
|
||||
const month = parts.find(part => part.type === 'month').value;
|
||||
const year = parts.find(part => part.type === 'year').value;
|
||||
const hour = parts.find(part => part.type === 'hour').value.padStart(2, '0');
|
||||
const minute = parts.find(part => part.type === 'minute').value.padStart(2, '0');
|
||||
|
||||
return `${day}/${month}/${year} ${hour}:${minute}`; // Format: DD/MM/YYYY HH:mm
|
||||
} catch (e) {
|
||||
console.error('Error formatting date:', e);
|
||||
return dateString || '—';
|
||||
}
|
||||
};
|
||||
|
||||
const row = [
|
||||
item.survey_name || '—',
|
||||
item.year || '—',
|
||||
item.quarter || '—',
|
||||
statusText,
|
||||
formatDate(item.submission_on),
|
||||
`${item.products || 0}`,
|
||||
];
|
||||
|
||||
csvRows.push(row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','));
|
||||
rows.forEach((row) => {
|
||||
csvRows.push(
|
||||
row
|
||||
.slice(0, 6)
|
||||
.map((cell) => `"${String(cell).replace(/"/g, '""')}"`)
|
||||
.join(',')
|
||||
);
|
||||
});
|
||||
|
||||
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
@ -162,376 +59,130 @@ const downloadCsv = (data) => {
|
||||
|
||||
export const SubmissionHistory = ({ history = [], loading = false, error = '' }) => {
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
const [selectedSubmission, setSelectedSubmission] = React.useState(null);
|
||||
const [currentPage, setCurrentPage] = React.useState(1);
|
||||
const [hoveredDelete, setHoveredDelete] = React.useState(null);
|
||||
const [toast, setToast] = React.useState({ show: false, message: '', type: 'success' });
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [draftToDelete, setDraftToDelete] = useState(null);
|
||||
const showToast = (message, type = 'success') => {
|
||||
setToast({ show: true, message, type });
|
||||
setTimeout(() => {
|
||||
setToast({ ...toast, show: false });
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const pageSize = 10;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleViewDetails = (submission) => {
|
||||
navigate('/overview', { state: { selectedSubmission: submission } });
|
||||
};
|
||||
|
||||
const handleResubmit = async (submission) => {
|
||||
try {
|
||||
const response = await fetchSubmissionDetail(submission.id);
|
||||
const submissionData = response.data;
|
||||
|
||||
// Structure the data in the format expected by the component
|
||||
const formattedData = {
|
||||
...submissionData,
|
||||
establishment: {
|
||||
...submissionData.establishment,
|
||||
establishment_contact_email: submissionData.establishment.establishment_contact_email,
|
||||
emirate: submissionData.establishment.establishment_emirate_id,
|
||||
establishment_emirate_id: submissionData.establishment.establishment_emirate_id
|
||||
},
|
||||
employeeInfo: {
|
||||
emiratiMale: submissionData.establishment.emirati_male,
|
||||
emiratiFemale: submissionData.establishment.emirati_female,
|
||||
nonEmiratiMale: submissionData.establishment.non_emirati_male,
|
||||
nonEmiratiFemale: submissionData.establishment.non_emirati_female,
|
||||
totalEmirati: submissionData.establishment.total_emirati,
|
||||
totalEmployees: submissionData.establishment.total_employees
|
||||
},
|
||||
products: submissionData.products || [],
|
||||
quarterWindow: submissionData.quarter_window || {}
|
||||
};
|
||||
|
||||
// Save the complete data to localStorage
|
||||
localStorage.setItem('resubmissionData', JSON.stringify(formattedData));
|
||||
|
||||
// Save products separately
|
||||
if (submissionData.products && submissionData.products.length > 0) {
|
||||
localStorage.setItem('submissionProducts', JSON.stringify(submissionData.products));
|
||||
}
|
||||
|
||||
// Set resubmission flags
|
||||
localStorage.setItem('isResubmission', 'true');
|
||||
|
||||
// Navigate to survey with resubmission state
|
||||
navigate('/survey', {
|
||||
state: {
|
||||
submission: formattedData,
|
||||
isResubmit: true,
|
||||
fromResubmit: true
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching submission details:', error);
|
||||
showToast('Failed to load submission for resubmission', 'error');
|
||||
}
|
||||
};
|
||||
const handleEdit = async (submission) => {
|
||||
localStorage.removeItem('autoSaveData');
|
||||
if (submission.status.toLowerCase() === 'draft') {
|
||||
try {
|
||||
const submissionData = await fetchSubmissionDetail(submission.id);
|
||||
navigate('/survey', {
|
||||
state: {
|
||||
submission: submissionData,
|
||||
isEdit: true,
|
||||
fromDraft: true
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching submission details:', error);
|
||||
// Optionally show an error message to the user
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (e, item) => {
|
||||
e.stopPropagation();
|
||||
setDraftToDelete(item);
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const handleDeleteDraft = async (e, submission) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (!submission?.id) {
|
||||
throw new Error('No submission ID provided');
|
||||
}
|
||||
|
||||
await deleteDraftSubmission(submission.id);
|
||||
showToast('Draft deleted successfully', 'error');
|
||||
|
||||
// Close the confirmation modal
|
||||
setShowDeleteConfirm(false);
|
||||
setDraftToDelete(null);
|
||||
|
||||
// Optionally reload the page or refresh data
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Error deleting submission:', error);
|
||||
showToast(error.message || 'Failed to delete draft', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const normalizedHistory = React.useMemo(() => {
|
||||
if (!Array.isArray(history)) return [];
|
||||
return history.map((entry, index) => {
|
||||
const quarter = entry?.quarter || '';
|
||||
const year = entry?.year || '';
|
||||
const surveyName = entry?.survey_name || 'IPI Survey Platform';
|
||||
|
||||
return {
|
||||
id: entry?.id ?? index,
|
||||
survey_name: surveyName,
|
||||
year: year || '—',
|
||||
quarter: quarter || '—',
|
||||
status: entry?.status || 'Pending',
|
||||
submission_on: formatDateTime(entry?.created_at || entry?.submitted_on),
|
||||
products: entry?.product_count ?? 0,
|
||||
originalData: entry // Keep original data for reference
|
||||
};
|
||||
});
|
||||
return history.map((entry, index) => ({
|
||||
id: entry?.id ?? index,
|
||||
survey_name: '—',
|
||||
year: entry?.year || '—',
|
||||
quarter: entry?.quarter || '—',
|
||||
status: entry?.status || 'Pending',
|
||||
submission_on: formatDateTime(entry?.created_at || entry?.submitted_on),
|
||||
products: entry?.product_count ?? 0,
|
||||
}));
|
||||
}, [history]);
|
||||
|
||||
const filtered = normalizedHistory.filter((entry) => {
|
||||
const term = searchTerm.trim().toLowerCase().replace(/\s+/g, '');
|
||||
if (!term) return true;
|
||||
|
||||
return Object.values(entry).some((val) =>
|
||||
String(val).toLowerCase().replace(/\s+/g, '').includes(term)
|
||||
const filteredRows = normalizedHistory.filter((entry) => {
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
return (
|
||||
!term ||
|
||||
entry.year.toString().includes(term) ||
|
||||
entry.quarter.toLowerCase().includes(term) ||
|
||||
entry.status.toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
|
||||
const tableHeaders = [
|
||||
'Survey Name',
|
||||
'Year',
|
||||
'Quarter',
|
||||
'Status',
|
||||
'Submission On',
|
||||
'Products',
|
||||
'Actions',
|
||||
];
|
||||
const navigateToView = (id) => navigate(`/history/${id}`);
|
||||
|
||||
const rowsData = filteredRows.map((item) => {
|
||||
const productCount = Number(item.products) || 0;
|
||||
const productLabel = `${productCount} ${productCount === 1 ? 'product' : 'products'}`;
|
||||
|
||||
const tableRows = filtered.map((item) => {
|
||||
const productsLabel = `${item.products}`;
|
||||
const isDraft = item.status.toLowerCase() === 'draft';
|
||||
return [
|
||||
item.survey_name,
|
||||
item.year,
|
||||
item.quarter,
|
||||
getStatusBadge(item.status),
|
||||
<span className="whitespace-nowrap">{item.submission_on}</span>,
|
||||
productsLabel,
|
||||
<div className="flex items-center gap-2">
|
||||
{isDraft ? (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(item);
|
||||
}}
|
||||
className="p-1 focus:outline-none"
|
||||
title="Edit"
|
||||
>
|
||||
<img
|
||||
src={pencilIconSrc}
|
||||
alt="Edit"
|
||||
className="h-5 w-5 opacity-60 hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent row click from triggering
|
||||
handleDeleteClick(e, item);
|
||||
}}
|
||||
onMouseEnter={() => setHoveredDelete(item.id)}
|
||||
onMouseLeave={() => setHoveredDelete(null)}
|
||||
className="p-1 focus:outline-none"
|
||||
title="Delete"
|
||||
>
|
||||
<img
|
||||
src={hoveredDelete === item.id ? trashInactiveSrc : trashActiveSrc}
|
||||
alt="Delete"
|
||||
className="h-5 w-5 transition-opacity"
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-5">
|
||||
<button
|
||||
onClick={() => handleViewDetails(item)}
|
||||
className="text-[#9E792B] hover:text-[#7b5f22] font-medium"
|
||||
title="View Details"
|
||||
>
|
||||
<img
|
||||
src="/assets/images/Eye.svg"
|
||||
alt="View"
|
||||
className="w-5 h-5 text-[#92722A] opacity-60 hover:opacity-100 transition-opacity"
|
||||
style={{ filter: 'invert(36%) sepia(49%) saturate(680%) hue-rotate(1deg) brightness(89%) contrast(91%)' }}
|
||||
/>
|
||||
</button>
|
||||
{item.status.toLowerCase() === 'rejected' && (
|
||||
<button
|
||||
onClick={() => !isResubmitDisabled(item) && handleResubmit(item)}
|
||||
disabled={isResubmitDisabled(item)}
|
||||
className={`font-medium ${
|
||||
isResubmitDisabled(item)
|
||||
? 'text-[#A88632] cursor-not-allowed opacity-55'
|
||||
: 'text-[#9E792B] hover:text-[#7b5f22] cursor-pointer'
|
||||
}`}
|
||||
title={isResubmitDisabled(item) ? 'Resubmission deadline passed' : 'Resubmit'}
|
||||
>
|
||||
<HiMiniArrowPathRoundedSquare
|
||||
className={`w-5 h-5 transition-opacity ${
|
||||
isResubmitDisabled(item)
|
||||
? 'text-[#A88632] cursor-not-allowed opacity-55'
|
||||
: 'text-[#92722A] opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<span className={getStatusClass(item.status)}>{item.status}</span>,
|
||||
<span className="whitespace-nowrap">{item.submission_on}</span>,
|
||||
productLabel,
|
||||
<button
|
||||
onClick={() => navigateToView(item.id)}
|
||||
className="text-[#9E792B] hover:text-[#7b5f22] font-medium"
|
||||
>
|
||||
View Details
|
||||
</button>,
|
||||
];
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-[4rem] mt-[-30px]">
|
||||
{/* Add this toast component */}
|
||||
{toast.show && (
|
||||
<CustomToast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ ...toast, show: false })}
|
||||
/>
|
||||
)}
|
||||
<div className="max-w-[1280px] mx-auto mb-5 bg-white border border-[#E5E7EB] shadow-[0_16px_32px_rgba(15,23,42,0.06)] rounded-[8px] mt-8 w-full overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 pt-6 pb-4">
|
||||
<h2 className="text-[18px] leading-[28px] font-medium text-[#232528]">
|
||||
Submission History ({filtered.length})
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-56">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search Survey Name, Quarter, Year"
|
||||
className="w-full h-10 rounded-md border border-[#E5E7EB] pl-10 pr-3 text-sm focus:outline-none focus:ring-1 focus:ring-[#92722A]"
|
||||
/>
|
||||
<img
|
||||
src={searchIconSrc}
|
||||
alt="Search"
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadCsv(filtered)}
|
||||
disabled={!filtered.length}
|
||||
className={`inline-flex h-10 items-center gap-2 rounded-md border px-4 text-sm font-medium ${filtered.length
|
||||
? 'border-[#92722A] text-[#92722A] hover:bg-[#F2ECCF]'
|
||||
: 'border-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5m0 0l5-5m-5 5V4"
|
||||
/>
|
||||
</svg>
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-[500px] text-gray-500">
|
||||
Loading submissions...
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-20 text-[#7B5C1F] font-semibold text-[15px]">
|
||||
No Submission Found
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[1240px] ">
|
||||
<Table
|
||||
headers={tableHeaders}
|
||||
rows={tableRows}
|
||||
separated
|
||||
rowGapClass="border-spacing-y-1"
|
||||
pagination={{
|
||||
currentPage,
|
||||
onPageChange: setCurrentPage,
|
||||
pageSize,
|
||||
totalItems: filtered.length,
|
||||
}}
|
||||
columnWidths={[150, 100, 100, 150, 170, 140, 110]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
const toolbar = (
|
||||
<div className="px-4 pt-6 pb-2 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-[18px] leading-[28px] font-medium text-[#232528]">
|
||||
Submission History
|
||||
</h3>
|
||||
<div className="flex gap-3 text-sm text-gray-500">
|
||||
{loading && <span>Loading…</span>}
|
||||
{error && <span className="text-red-600">{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="bg-white w-full max-w-md rounded-lg shadow-2xl border border-gray-200">
|
||||
<div className="p-6 text-center flex flex-col gap-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900">Confirm Deletion</h3>
|
||||
<p className="text-gray-600">Are you sure you want to delete this draft?</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteDraft(e, draftToDelete);
|
||||
setShowDeleteConfirm(false);
|
||||
}}
|
||||
className="px-4 py-2 bg-[#92722A] text-white rounded-md hover:bg-[#A88632] focus:outline-none"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowDeleteConfirm(false);
|
||||
setDraftToDelete(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-[#92722A] text-[#92722A] font-medium rounded-md hover:bg-[#F5F0E1] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-3 md:items-center">
|
||||
<div className="relative w-full md:w-56">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search Establishment"
|
||||
className="w-full h-10 rounded-md border border-[#E5E7EB] pl-10 pr-3 text-sm focus:outline-none"
|
||||
/>
|
||||
<img
|
||||
src={searchIconSrc}
|
||||
alt="Search"
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadCsv(rowsData)}
|
||||
disabled={!rowsData.length}
|
||||
className={`inline-flex h-10 items-center gap-2 rounded-md border px-4 text-sm font-medium ${
|
||||
rowsData.length
|
||||
? 'border-[#92722A] text-[#92722A] hover:bg-[#F2ECCF]'
|
||||
: 'border-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5m0 0l5-5m-5 5V4"
|
||||
/>
|
||||
</svg>
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="max-w-[1280px] mx-auto px-0">
|
||||
<Table
|
||||
headers={[
|
||||
'Survey Name',
|
||||
'Year',
|
||||
'Quarter',
|
||||
'Status',
|
||||
'Submission On',
|
||||
'Products',
|
||||
'Actions',
|
||||
]}
|
||||
rows={rowsData}
|
||||
beforeHeader={toolbar}
|
||||
separated
|
||||
rowGapClass="border-spacing-y-2"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, ChevronRight, Pause } from 'lucide-react';
|
||||
import { CalendarDays, Clock3 } from 'lucide-react';
|
||||
import { GrTasks } from "react-icons/gr";
|
||||
import { fetchSubmissionDetail } from '@/services/submissions/submissionService';
|
||||
|
||||
const SurveyCarousel = ({
|
||||
surveys = [],
|
||||
@ -13,7 +9,6 @@ const SurveyCarousel = ({
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRotate || isPaused) return;
|
||||
@ -33,71 +28,33 @@ const SurveyCarousel = ({
|
||||
setCurrentIndex((prev) => (prev === surveys.length - 1 ? 0 : prev + 1));
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[1280px] mx-auto px-1 mt-1">
|
||||
<div className="bg-black rounded-lg shadow-sm ring-1 ring-gray-200 min-h-[180px] mb-4">
|
||||
<div className="h-full rounded-none bg-[#F2ECCF] p-5 md:p-6 lg:p-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-4 transition-all duration-500">
|
||||
<div className="flex-1 w-full">
|
||||
<h3 className="text-base md:text-lg font-medium text-[#232528] line-clamp-1">
|
||||
<div className="bg-white rounded-lg shadow-sm ring-1 ring-gray-200 mb-8 overflow-hidden">
|
||||
<div className="rounded-none bg-[#F2ECCF] px-6 py-4 min-h-[148px] flex items-center justify-between transition-all duration-500">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-[18px] leading-[28px] font-medium text-[#232528]">
|
||||
{current?.title || '—'}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm md:text-base text-[#232528] line-clamp-2">
|
||||
<p className="mt-1 text-[16px] leading-[24px] font-medium text-[#232528]">
|
||||
{current?.subtitle || ''}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2 md:gap-4 text-xs md:text-sm text-[#232528]">
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<CalendarDays size={14} className="text-[#92722A] flex-shrink-0" />
|
||||
<span className="font-semibold hidden sm:inline">Due:</span>
|
||||
<span className="font-medium">{current?.dueDate || '—'}</span>
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<Clock3 size={14} className="text-[#92722A] flex-shrink-0" />
|
||||
<span className="font-semibold hidden sm:inline">Est. time:</span>
|
||||
<span className="font-medium">{current?.estTime || '—'}</span>
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-1">
|
||||
<GrTasks size={14} className="text-[#92722A] flex-shrink-0" />
|
||||
<span className="font-semibold">Status:</span>
|
||||
<span className="font-medium">{current?.status || '—'}</span>
|
||||
</span>
|
||||
<div className="mt-2 flex items-center gap-6 text-[12px] leading-[16px] font-medium text-[#232528]">
|
||||
<span>Due Date: {current?.dueDate || '—'}</span>
|
||||
<span>Est. time: {current?.estTime || '—'}</span>
|
||||
<span>Status: {current?.status || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
|
||||
if (current?.status?.toLowerCase() === 'draft') {
|
||||
try {
|
||||
const submissionData = await fetchSubmissionDetail(current.draftId);
|
||||
localStorage.removeItem('currentSubmission');
|
||||
localStorage.removeItem('autoSaveData');
|
||||
navigate('/survey', {
|
||||
state: {
|
||||
submission: submissionData,
|
||||
isEdit: true,
|
||||
fromDraft: true
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching submission details:', error);
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem('currentSubmission');
|
||||
localStorage.removeItem('autoSaveData');
|
||||
onStartSurvey?.(current);
|
||||
navigate('/survey', { state: { survey: current } });
|
||||
}
|
||||
}}
|
||||
className="w-full sm:w-auto mt-0 flex-shrink-0 items-center justify-center px-5 py-3 text-base font-medium rounded-lg text-white bg-[#92722A] hover:bg-[#7b5c1f] cursor-pointer whitespace-nowrap transition-colors"
|
||||
onClick={() => onStartSurvey?.(current)}
|
||||
className="inline-flex items-center justify-center w-[196px] h-[52px] px-7 py-3 text-[18px] leading-[28px] font-medium rounded-lg text-white bg-[#92722A] hover:bg-[#7b5c1f] cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
Start Survey Now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-4 sm:px-6 py-2 sm:py-3 bg-[#F2ECCF]">
|
||||
<div className="flex items-center justify-between px-6 py-3 bg-[#F2ECCF]">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
className="p-1 rounded-full hover:bg-gray-100 transition"
|
||||
@ -105,11 +62,11 @@ const SurveyCarousel = ({
|
||||
<ChevronLeft size={20} className="text-gray-700" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsPaused(!isPaused)}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-full border border-black bg-transparent hover:bg-gray-100 transition"
|
||||
>
|
||||
<Pause size={12} className="text-black" />
|
||||
</button>
|
||||
onClick={() => setIsPaused(!isPaused)}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-full border border-black bg-transparent hover:bg-gray-100 transition"
|
||||
>
|
||||
<Pause size={12} className="text-black" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="p-1 rounded-full hover:bg-gray-100 transition"
|
||||
@ -130,7 +87,6 @@ const SurveyCarousel = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import SurveyCarousel from './SurveyCarousel';
|
||||
import { fetchEstablishmentDashboard } from '@/services/establishments/establishmentService.js';
|
||||
|
||||
const formatQuarterYear = (quarter, year) => {
|
||||
if (!quarter || !year) return '—';
|
||||
return `${quarter} ${year}`;
|
||||
};
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '—';
|
||||
@ -27,122 +31,44 @@ const formatRelativeDays = (value) => {
|
||||
return `Due in ${diffDays} day${diffDays === 1 ? '' : 's'}`;
|
||||
};
|
||||
|
||||
const getQuarterData = () => {
|
||||
const now = new Date();
|
||||
const month = now.getMonth() + 1;
|
||||
const year = now.getFullYear();
|
||||
|
||||
let currentQuarter = '';
|
||||
if (month >= 1 && month <= 3) currentQuarter = 'Q1';
|
||||
else if (month >= 4 && month <= 6) currentQuarter = 'Q2';
|
||||
else if (month >= 7 && month <= 9) currentQuarter = 'Q3';
|
||||
else currentQuarter = 'Q4';
|
||||
|
||||
const quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
const currentIndex = quarters.indexOf(currentQuarter);
|
||||
|
||||
const previousQuarter =
|
||||
currentIndex === 0 ? 'Q4' : quarters[currentIndex - 1];
|
||||
const nextQuarter =
|
||||
currentIndex === 3 ? 'Q1' : quarters[currentIndex + 1];
|
||||
|
||||
const previousYear = currentIndex === 0 ? year - 1 : year;
|
||||
const nextYear = currentIndex === 3 ? year + 1 : year;
|
||||
|
||||
return {
|
||||
previous: { quarter: previousQuarter, year: previousYear },
|
||||
current: { quarter: currentQuarter, year },
|
||||
next: { quarter: nextQuarter, year: nextYear },
|
||||
};
|
||||
};
|
||||
|
||||
export const SurveyStatus = () => {
|
||||
const [dashboardData, setDashboardData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
export const SurveyStatus = ({ data = null, loading = false, error = '' }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetchEstablishmentDashboard();
|
||||
if (response.status === 'success') {
|
||||
setDashboardData(response.data);
|
||||
} else {
|
||||
setError('Failed to load dashboard data');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An error occurred while fetching data');
|
||||
console.error('Error fetching dashboard data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const handleStartSurvey = (survey) => {
|
||||
console.log('Starting survey:', survey.title);
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleStartSurvey = (survey) => {
|
||||
// Prepare survey data with all required fields
|
||||
const surveyData = {
|
||||
quarter: survey.quarter,
|
||||
year: survey.year,
|
||||
startDate: survey.startDate,
|
||||
endDate: survey.endDate,
|
||||
title: survey.title,
|
||||
// Include establishment data if available
|
||||
establishment: survey.establishment || null
|
||||
};
|
||||
|
||||
// Store the survey data in localStorage
|
||||
localStorage.setItem('currentSurvey', JSON.stringify(surveyData));
|
||||
|
||||
// Navigate to the survey page with survey data in state
|
||||
navigate('/survey', {
|
||||
state: {
|
||||
survey: surveyData,
|
||||
isResubmit: survey.status === 'Submitted', // Mark as resubmit if status is 'Submitted'
|
||||
// Include establishment data in the location state
|
||||
establishment: survey.establishment || null
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const nextDeadline = dashboardData?.next_deadline;
|
||||
const surveyData = data || {};
|
||||
const nextDeadline = surveyData?.next_deadline;
|
||||
const formattedDueDate = formatDate(nextDeadline);
|
||||
const relativeDue = formatRelativeDays(nextDeadline);
|
||||
//here add condition to check q1 and year based on that check submission_history status and store in variable here
|
||||
const surveys = dashboardData?.survey_ready?.map(survey => {
|
||||
// Get the quarter and year from the survey_ready item
|
||||
const { quarter, year, start_date, end_date, survey_name } = survey;
|
||||
|
||||
// Check if there's a draft submission for this survey
|
||||
const draftSubmission = dashboardData?.submission_history?.find(
|
||||
submission =>
|
||||
submission.quarter === quarter &&
|
||||
submission.year === year &&
|
||||
submission.status.toLowerCase() === 'draft' &&
|
||||
submission.survey_name === survey_name
|
||||
);
|
||||
|
||||
|
||||
|
||||
return {
|
||||
title: `${quarter} ${year} Survey`,
|
||||
subtitle: 'Complete your quarterly Index of Industrial Production (IIP) data submission',
|
||||
dueDate: formatDate(end_date),
|
||||
relativeDue: formatRelativeDays(end_date),
|
||||
const surveys = [
|
||||
{
|
||||
title: `${formatQuarterYear(surveyData.previous_quarter, surveyData.previous_quarter_year)} Survey Ready`,
|
||||
subtitle: 'Complete your quarterly industrial production data submission',
|
||||
dueDate: formattedDueDate,
|
||||
relativeDue,
|
||||
estTime: '15–20 minutes',
|
||||
status: draftSubmission ? 'Draft' : 'Pending',
|
||||
startDate: start_date,
|
||||
endDate: end_date,
|
||||
quarter: quarter, // This will be used in the navigation
|
||||
year: year, // This will be used in the navigation
|
||||
survey_name: survey_name, // Add survey_name for matching
|
||||
draftId: draftSubmission?.id || null // Include draft ID if exists
|
||||
};
|
||||
}) || [];
|
||||
status: surveyData?.submission_status || 'Pending',
|
||||
},
|
||||
{
|
||||
title: `${formatQuarterYear(surveyData.current_quarter, surveyData.current_quarter_year)} Survey Ready`,
|
||||
subtitle: 'Complete your quarterly industrial production data submission',
|
||||
dueDate: formattedDueDate,
|
||||
relativeDue,
|
||||
estTime: '15–20 minutes',
|
||||
status: surveyData?.submission_status || 'Pending',
|
||||
},
|
||||
{
|
||||
title: `${formatQuarterYear(surveyData.next_quarter, surveyData.next_quarter_year)} Survey Ready`,
|
||||
subtitle: 'Complete your quarterly industrial production data submission',
|
||||
dueDate: formattedDueDate,
|
||||
relativeDue,
|
||||
estTime: '15–20 minutes',
|
||||
status: surveyData?.submission_status || 'Pending',
|
||||
},
|
||||
];
|
||||
|
||||
const heroTitle = loading
|
||||
? 'Loading survey status…'
|
||||
@ -157,13 +83,12 @@ export const SurveyStatus = () => {
|
||||
: 'Complete your quarterly industrial production data submission';
|
||||
|
||||
return (
|
||||
<section className="w-full max-w-[1340px] mx-auto px-4 py-2">
|
||||
<section className="max-w-[1280px] mx-auto px-0">
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-500 text-sm py-4">
|
||||
Loading survey status...
|
||||
</div>
|
||||
<div className="text-center text-gray-500 text-sm py-10">Loading survey status...</div>
|
||||
) : error ? (
|
||||
<div className="text-center text-red-600 text-sm py-4">{error}</div>
|
||||
<div className="text-center text-red-600 text-sm py-10">{error}</div>
|
||||
) : (
|
||||
<SurveyCarousel
|
||||
surveys={surveys}
|
||||
@ -172,6 +97,7 @@ export const SurveyStatus = () => {
|
||||
onStartSurvey={handleStartSurvey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,93 +1,49 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import HeaderBar from '@/components/layout/HeaderBar';
|
||||
import { fetchEstablishmentDashboard } from '@/services/establishments/establishmentService';
|
||||
|
||||
// const getCurrentQuarterAndYear = () => {
|
||||
// const now = new Date();
|
||||
// const month = now.getMonth() + 1;
|
||||
// const year = now.getFullYear();
|
||||
// let quarter = '';
|
||||
|
||||
// if (month >= 1 && month <= 3) quarter = 'Q1';
|
||||
// else if (month >= 4 && month <= 6) quarter = 'Q2';
|
||||
// else if (month >= 7 && month <= 9) quarter = 'Q3';
|
||||
// else quarter = 'Q4';
|
||||
|
||||
// return { quarter, year };
|
||||
// };
|
||||
|
||||
const formatName = (name = '') => {
|
||||
return name
|
||||
// camelCase → add space
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
// split by space
|
||||
.split(' ')
|
||||
// capitalize each word
|
||||
.map(
|
||||
word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||
)
|
||||
.join(' ');
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
};
|
||||
|
||||
export const WelcomeSection = ({ data = null, loading = false, error = '' }) => {
|
||||
const [pendingSurveys, setPendingSurveys] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [apiError, setApiError] = useState('');
|
||||
const currentQuarter = data?.current_quarter;
|
||||
const currentYear = data?.current_quarter_year;
|
||||
const deadline = data?.next_deadline;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// Get establishment ID from localStorage or props as needed
|
||||
const establishmentId = localStorage.getItem('establishment_id'); // Adjust this line based on how you store the establishment ID
|
||||
const dashboardData = await fetchEstablishmentDashboard(establishmentId);
|
||||
// Get the count of pending surveys from survey_ready array
|
||||
const pendingSurveysCount = dashboardData?.data?.survey_ready?.length || 0;
|
||||
setPendingSurveys(pendingSurveysCount);
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error);
|
||||
setApiError('Failed to load survey information');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
let description = 'Current reporting information unavailable.';
|
||||
if (loading) {
|
||||
description = 'Loading dashboard information…';
|
||||
} else if (error) {
|
||||
description = error;
|
||||
} else if (currentQuarter && currentYear) {
|
||||
const formattedDeadline = formatDate(deadline);
|
||||
description = `Current reporting period: ${currentQuarter} ${currentYear}${formattedDeadline !== '—' ? ` (Due: ${formattedDeadline})` : ''}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50">
|
||||
<div className="min-h-[4rem] bg-gray-50">
|
||||
<HeaderBar />
|
||||
<div className="max-w-[1280px] mx-auto pt-2 pb-1">
|
||||
|
||||
<div className="max-w-[1280px] mx-auto px-4 pt-4 pb-2">
|
||||
<div className="space-y-1">
|
||||
<h2 className="w-[360px] text-[18px] pt-2 leading-[28px] font-medium text-[#232528]">
|
||||
Welcome{' '}
|
||||
{(() => {
|
||||
try {
|
||||
const userProfile = JSON.parse(localStorage.getItem('user_profile'));
|
||||
return userProfile?.name ? formatName(userProfile.name) : '';
|
||||
} catch (error) {
|
||||
console.error('Error reading user_profile from localStorage:', error);
|
||||
return '';
|
||||
}
|
||||
})()}
|
||||
!
|
||||
</h2>
|
||||
<div className="text-base text-black mt-2">
|
||||
{isLoading ? (
|
||||
<span>Loading survey information...</span>
|
||||
) : apiError ? (
|
||||
<span className="text-red-600">{apiError}</span>
|
||||
) : pendingSurveys > 0 ? (
|
||||
<span>You currently have <span className="font-bold">{pendingSurveys}</span> pending surveys to complete.</span>
|
||||
) : (
|
||||
<span>You have no pending surveys.</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="w-[360px] text-[18px] leading-[28px] font-medium text-[#232528]">
|
||||
Welcome{' '}
|
||||
{(() => {
|
||||
try {
|
||||
const userProfile = JSON.parse(sessionStorage.getItem('user_profile'));
|
||||
return userProfile?.name ? userProfile.name : '';
|
||||
} catch (error) {
|
||||
console.error('Error reading user_profile from sessionStorage:', error);
|
||||
return '';
|
||||
}
|
||||
})()} !
|
||||
</h2>
|
||||
<p className={`w-[360px] text-[14px] leading-[24px] font-normal ${error ? 'text-red-600' : 'text-[#5F646D]'}`}>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WelcomeSection;
|
||||
@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import authService from "@/services/auth/authService";
|
||||
|
||||
import { FileSearch } from 'lucide-react';
|
||||
const logoSrc = '/assets/images/FCSCLogo.svg';
|
||||
const surveyIconActiveSrc = '/assets/images/Vector.svg';
|
||||
const surveyIconInactiveSrc = '/assets/images/Vectorblack.svg';
|
||||
@ -9,64 +8,43 @@ const dashboardIconActiveSrc = '/assets/images/cuida_dashboard-outline.svg';
|
||||
const dashboardIconInactiveSrc = '/assets/images/cuida_dashboard-outline-inactive.svg';
|
||||
const historyIconActiveSrc = '/assets/images/mdi_history.svg';
|
||||
const historyIconInactiveSrc = '/assets/images/history.svg';
|
||||
const overviewIconActiveSrc = '/assets/images/mdi_file-find-outline.svg';
|
||||
const overviewIconInactiveSrc = '/assets/images/mdi_file-find-outline-active.svg';
|
||||
|
||||
const HeaderBar = () => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [userOpen, setUserOpen] = React.useState(false);
|
||||
const [userName, setUserName] = React.useState('User');
|
||||
const [userEmail, setUserEmail] = React.useState('');
|
||||
const [userOpen, setUserOpen] = React.useState(false);
|
||||
const profileRef = React.useRef(null);
|
||||
const navigate = useNavigate();
|
||||
const establishmentId = localStorage.getItem("establishment_id") || "";
|
||||
|
||||
// Close dropdown on outside click
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (profileRef.current && !profileRef.current.contains(event.target)) {
|
||||
setUserOpen(false);
|
||||
let parsed;
|
||||
try {
|
||||
const stored = sessionStorage.getItem('user_profile');
|
||||
if (stored) {
|
||||
parsed = JSON.parse(stored);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('user_profile');
|
||||
const parsed = stored ? JSON.parse(stored) : {};
|
||||
|
||||
setUserName(parsed?.name || parsed?.username || parsed?.email || 'User');
|
||||
setUserEmail(parsed?.email || '');
|
||||
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
// ================================
|
||||
// BACKEND LOGOUT API CONNECTED
|
||||
// ================================
|
||||
const onLogout = async () => {
|
||||
try {
|
||||
await authService.logout(); // <-- LOGOUT API CALL
|
||||
} catch (error) {
|
||||
console.error("Logout API failed:", error);
|
||||
parsed = undefined;
|
||||
}
|
||||
const name = parsed?.name || parsed?.username || parsed?.email || '';
|
||||
const email = parsed?.email || '';
|
||||
if (name) {
|
||||
setUserName(name);
|
||||
}
|
||||
if (email) {
|
||||
setUserEmail(email);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Clear storage after API
|
||||
sessionStorage.removeItem('auth_token');
|
||||
sessionStorage.removeItem('user_profile');
|
||||
sessionStorage.removeItem('user_role');
|
||||
sessionStorage.removeItem('establishment_id');
|
||||
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('user_profile');
|
||||
localStorage.removeItem('user_role');
|
||||
localStorage.removeItem('establishment_id');
|
||||
|
||||
const onLogout = () => {
|
||||
try {
|
||||
sessionStorage.removeItem('auth_token');
|
||||
sessionStorage.removeItem('user_profile');
|
||||
sessionStorage.removeItem('user_role');
|
||||
sessionStorage.removeItem('establishment_id');
|
||||
} catch {}
|
||||
setUserOpen(false);
|
||||
navigate('/index');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const onChangePassword = () => {
|
||||
@ -76,130 +54,100 @@ const HeaderBar = () => {
|
||||
|
||||
const initials = React.useMemo(() => {
|
||||
const source = userName || userEmail;
|
||||
if (!source) return 'U';
|
||||
const parts = source.trim().split(' ');
|
||||
const letters = parts.slice(0, 2).map((p) => p[0]).join('');
|
||||
return letters.toUpperCase() || 'U';
|
||||
const letters = parts.slice(0, 2).map((part) => part.charAt(0)).join('');
|
||||
return letters ? letters.toUpperCase() : 'U';
|
||||
}, [userEmail, userName]);
|
||||
|
||||
return (
|
||||
<header className="bg-white border-b border-b-[#E2E8F0]">
|
||||
<div className="max-w-[1280px] mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<img src={logoSrc} alt="FCSC" className="w-[131px] h-[40px]" />
|
||||
|
||||
{/* DESKTOP NAV */}
|
||||
<nav className="hidden md:flex items-center gap-8 text-[14px]">
|
||||
<img src={logoSrc} alt="FCSC" className="w-[131px] h-[40px] opacity-100 rotate-0" />
|
||||
<nav className="hidden md:flex items-center gap-8 text-[14px] leading-5">
|
||||
<NavLink
|
||||
to="/dashboard"
|
||||
className={({ isActive }) =>
|
||||
`inline-flex items-center gap-2 pb-1 border-b-2 ${
|
||||
isActive
|
||||
? 'text-[#92722A] font-medium border-[#92722A]'
|
||||
: 'text-[#232528] hover:text-gray-900 border-transparent'
|
||||
}`
|
||||
}
|
||||
className={({ isActive }) => `inline-flex items-center gap-2 pb-1 border-b-2 ${isActive ? 'text-[#92722A] font-medium border-[#92722A]' : 'text-[#232528] hover:text-gray-900 border-transparent'}`}
|
||||
aria-current="page"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<img src={isActive ? dashboardIconActiveSrc : dashboardIconInactiveSrc} className="h-[18px] w-[18px]" />
|
||||
<img src={isActive ? dashboardIconActiveSrc : dashboardIconInactiveSrc} alt="Dashboard" className="h-[18px] w-[18px]" />
|
||||
<span>Dashboard</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/survey"
|
||||
className={({ isActive }) =>
|
||||
`inline-flex items-center gap-2 pb-1 border-b-2 ${
|
||||
isActive
|
||||
? 'text-[#92722A] font-medium border-[#92722A]'
|
||||
: window.location.pathname === '/survey'
|
||||
? 'text-[#232528] hover:text-gray-900 border-transparent'
|
||||
: 'text-gray-400 cursor-not-allowed'
|
||||
}`
|
||||
}
|
||||
onClick={(e) => {
|
||||
if (window.location.pathname !== '/survey') {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className={({ isActive }) => `inline-flex items-center gap-2 pb-1 border-b-2 ${isActive ? 'text-[#92722A] font-medium border-[#92722A]' : 'text-[#232528] hover:text-gray-900 border-transparent'}`}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<img
|
||||
src={isActive ? surveyIconActiveSrc : surveyIconInactiveSrc}
|
||||
className={`h-[18px] w-[18px] ${
|
||||
window.location.pathname !== '/survey' ? 'opacity-50' : ''
|
||||
}`}
|
||||
/>
|
||||
<img src={isActive ? surveyIconActiveSrc : surveyIconInactiveSrc} alt="Survey" className="h-[18px] w-[18px]" />
|
||||
<span>Survey</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/survey"
|
||||
className={({ isActive }) =>
|
||||
`inline-flex items-center gap-2 pb-1 border-b-2 ${
|
||||
isActive
|
||||
? 'text-[#92722A] font-medium border-[#92722A]'
|
||||
: 'text-[#232528] hover:text-gray-900 border-transparent'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<FileSearch
|
||||
className={`h-[18px] w-[18px] ${
|
||||
isActive ? 'text-[#92722A]' : 'text-[#232528]'
|
||||
}`}
|
||||
/>
|
||||
<span>Overview</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/overview"
|
||||
className={({ isActive }) =>
|
||||
`inline-flex items-center gap-2 pb-1 border-b-2 ${
|
||||
isActive
|
||||
? 'text-[#92722A] font-medium border-[#92722A]'
|
||||
: 'text-[#232528] hover:text-gray-900 border-transparent'
|
||||
}`
|
||||
}
|
||||
to="/history"
|
||||
className={({ isActive }) => `inline-flex items-center gap-2 pb-1 border-b-2 ${isActive ? 'text-[#92722A] font-medium border-[#92722A]' : 'text-[#232528] hover:text-gray-900 border-transparent'}`}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<img src={isActive ? overviewIconActiveSrc : overviewIconInactiveSrc} className="h-[18px] w-[18px]" />
|
||||
<span>Overview</span>
|
||||
<img src={isActive ? historyIconActiveSrc : historyIconInactiveSrc} alt="History" className="h-[18px] w-[18px]" />
|
||||
<span>History</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to={`/edit-profile/${establishmentId}`}
|
||||
className={({ isActive }) =>
|
||||
`inline-flex items-center gap-2 pb-1 border-b-2 ${
|
||||
isActive
|
||||
? "text-[#92722A] font-medium border-[#92722A]"
|
||||
: "text-[#232528] hover:text-gray-900 border-transparent"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<svg className="h-[16px] w-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span>Profile</span>
|
||||
</NavLink>
|
||||
|
||||
{/* PROFILE DROPDOWN */}
|
||||
<div className="relative" ref={profileRef}>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setUserOpen((v) => !v);
|
||||
}}
|
||||
className="h-8 w-8 rounded-full bg-gray-200 grid place-items-center text-[12px]"
|
||||
type="button"
|
||||
onClick={() => setUserOpen((v) => !v)}
|
||||
className="h-8 w-8 rounded-full bg-gray-200 grid place-items-center text-[12px] text-gray-700 cursor-pointer"
|
||||
aria-label="Profile"
|
||||
>
|
||||
{initials}
|
||||
</button>
|
||||
|
||||
{userOpen && (
|
||||
<div className="absolute right-0 mt-3 w-64 rounded-[12px] bg-white shadow z-50">
|
||||
<div className="px-4 py-3 ">
|
||||
<p className="text-sm font-semibold">{userName}</p>
|
||||
{userEmail && <p className="text-xs text-gray-600">{userEmail}</p>}
|
||||
<div className="absolute right-0 mt-3 w-48 rounded-lg border border-[#E2E8F0] bg-white shadow-lg z-50">
|
||||
<div className="px-4 py-3 border-b border-[#F1F5F9] text-center space-y-0.5">
|
||||
<p className="text-sm font-semibold text-[#232528] truncate" title={userName}>{userName}</p>
|
||||
{userEmail && <p className="text-xs text-[#64748B] truncate" title={userEmail}>{userEmail}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChangePassword}
|
||||
className="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100"
|
||||
className="w-full px-4 py-2.5 text-center text-sm text-[#232528] hover:bg-[#F2ECCF] transition-colors"
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="w-full px-4 py-2.5 text-left text-sm text-red-600 hover:bg-red-100"
|
||||
className="w-full px-4 py-2.5 text-center text-sm text-[#B91C1C] hover:bg-[#FEE2E2] transition-colors"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
@ -207,32 +155,48 @@ const HeaderBar = () => {
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* MOBILE MENU BUTTON */}
|
||||
<button
|
||||
className="md:hidden h-9 w-9 rounded hover:bg-gray-100"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
type="button"
|
||||
aria-label="Open Menu"
|
||||
className="md:hidden inline-flex items-center justify-center h-9 w-9 rounded hover:bg-gray-100"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* MOBILE NAV */}
|
||||
{open && (
|
||||
<div className="md:hidden pb-3">
|
||||
<nav className="flex flex-col gap-2 text-[14px]">
|
||||
<NavLink to="/dashboard" className="px-2 py-2">Dashboard</NavLink>
|
||||
<NavLink to="/survey" className="px-2 py-2">Survey</NavLink>
|
||||
<NavLink to="/overview" className="px-2 py-2">Overview</NavLink>
|
||||
|
||||
<button className="px-2 py-2" onClick={() => navigate('/reset-password')}>
|
||||
Change Password
|
||||
<nav className="flex flex-col gap-2 text-[14px] leading-5">
|
||||
<NavLink to="/dashboard" onClick={() => setOpen(false)} className={({ isActive }) => `inline-flex items-center gap-2 px-2 py-2 rounded border-b-2 ${isActive ? 'text-[#92722A] bg-[#F2ECCF] border-[#92722A]' : 'text-[#232528] hover:bg-gray-100 border-transparent'}`}>
|
||||
<img src={dashboardIconInactiveSrc} alt="Dashboard" className="h-[18px] w-[18px]" />
|
||||
<span>Dashboard</span>
|
||||
</NavLink>
|
||||
<NavLink to="/survey" onClick={() => setOpen(false)} className={({ isActive }) => `inline-flex items-center gap-2 px-2 py-2 rounded border-b-2 ${isActive ? 'text-[#92722A] bg-[#F2ECCF] border-[#92722A]' : 'text-[#232528] hover:bg-gray-100 border-transparent'}`}>
|
||||
<img src={surveyIconInactiveSrc} alt="Survey" className="h-[18px] w-[18px]" />
|
||||
<span>Survey</span>
|
||||
</NavLink>
|
||||
<NavLink to="/history" onClick={() => setOpen(false)} className={({ isActive }) => `inline-flex items-center gap-2 px-2 py-2 rounded border-b-2 ${isActive ? 'text-[#92722A] bg-[#F2ECCF] border-[#92722A]' : 'text-[#232528] hover:bg-gray-100 border-transparent'}`}>
|
||||
<img src={historyIconInactiveSrc} alt="History" className="h-[18px] w-[18px]" />
|
||||
<span>History</span>
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
navigate('/reset-password');
|
||||
}}
|
||||
className="inline-flex items-center gap-2 px-2 py-2 rounded text-[#232528] hover:bg-gray-100 text-left"
|
||||
>
|
||||
<span>Change Password</span>
|
||||
</button>
|
||||
|
||||
<button className="px-2 py-2 text-red-600" onClick={onLogout}>
|
||||
Logout
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="inline-flex items-center gap-2 px-2 py-2 rounded text-[#232528] hover:bg-gray-100 text-left"
|
||||
>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@ -1,555 +0,0 @@
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { fetchSubmissionDetail, getQuarterPeriods } from '@/services/submissions/submissionService';
|
||||
import Table from '@/components/common/Table';
|
||||
import ProductDetails from './ProductDetails';
|
||||
|
||||
// Format date to readable format
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '—';
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
};
|
||||
return new Date(dateString).toLocaleString('en-US', options);
|
||||
};
|
||||
|
||||
const searchIconSrc = '/assets/images/material-symbols_search-rounded.svg';
|
||||
const caretDownSrc = '/assets/images/CaretDown.svg';
|
||||
const downloadIconSrc = '/assets/images/DownloadSimple.svg';
|
||||
const uae_currency = '/assets/images/UAE_Dirham_Symbol.svg';
|
||||
|
||||
|
||||
export const DetailedOverview = ({ submission, onBack }) => {
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedStatus, setSelectedStatus] = useState('');
|
||||
const [selectedProduct, setSelectedProduct] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submissionData, setSubmissionData] = useState(null);
|
||||
const [quarterPeriods, setQuarterPeriods] = useState(null);
|
||||
const [loadingProduct, setLoadingProduct] = useState(false);
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
totalItems: 0,
|
||||
pageSizeOptions: [10, 20, 50, 100]
|
||||
});
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const statusMap = {
|
||||
'Approved': 'bg-[#F3FAF4] text-[#2F663C]',
|
||||
'Submitted': 'bg-[#E7F5FF] text-[#003CFF]',
|
||||
'Pending': 'bg-[#F9F7ED] text-[#7C5E24]',
|
||||
'Rejected': 'bg-[#FEF2F2] text-[#B52520]',
|
||||
'Resubmitted': 'bg-[#F9F7ED] text-[#7C5E24]', // Added this line
|
||||
};
|
||||
|
||||
// Normalize the status (trim and capitalize first letter)
|
||||
const normalizedStatus = status
|
||||
? status.trim().charAt(0).toUpperCase() + status.trim().slice(1).toLowerCase()
|
||||
: 'Pending';
|
||||
|
||||
const statusClass = statusMap[normalizedStatus] || 'bg-gray-100 text-gray-800';
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-md px-3 py-1 text-xs font-medium ${statusClass}`}>
|
||||
{normalizedStatus}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Add state for tracking loading and error states
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSubmissionDetails = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await fetchSubmissionDetail(submission.id);
|
||||
if (response && response.data) {
|
||||
setSubmissionData(response.data);
|
||||
setQuarterPeriods(response.quarter_periods);
|
||||
|
||||
// Log the products array to verify it's being set correctly
|
||||
// Store quarter_window.end_date in localStorage
|
||||
if (response.quarter_window?.end_date) {
|
||||
localStorage.setItem('quarterWindowEndDate', response.quarter_window.end_date);
|
||||
}
|
||||
if (response.data.products) {
|
||||
}
|
||||
} else {
|
||||
console.warn('No data received in response');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching submission details:', error);
|
||||
setError('Failed to load submission details. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (submission?.id) {
|
||||
fetchSubmissionDetails();
|
||||
} else {
|
||||
console.error('No submission ID provided');
|
||||
setError('No submission ID provided');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [submission]);
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
if (!submissionData?.products || !Array.isArray(submissionData.products)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Log is_active status for each product
|
||||
submissionData.products.forEach((product, index) => {
|
||||
});
|
||||
|
||||
const normalizedTerm = searchTerm.trim().toLowerCase();
|
||||
const filtered = submissionData.products.filter((product) => {
|
||||
// Add null checks for product and product.product
|
||||
if (!product || !product.product) return false;
|
||||
|
||||
const productName = product.product?.product_name || '';
|
||||
const hsCode = product.product?.hs_code || '';
|
||||
const status = product.status || '';
|
||||
|
||||
const matchesSearch =
|
||||
!normalizedTerm ||
|
||||
productName.toString().toLowerCase().includes(normalizedTerm) ||
|
||||
hsCode.toString().toLowerCase().includes(normalizedTerm) ||
|
||||
status.toLowerCase().includes(normalizedTerm) ||
|
||||
'pending'.includes(normalizedTerm);
|
||||
|
||||
const matchesStatus = selectedStatus === '' ||
|
||||
(selectedStatus === 'pending' && !product.is_active) ||
|
||||
(selectedStatus === 'approved' && product.is_active)
|
||||
(selectedStatus === 'resubmitted' && product.status === 'Resubmitted')
|
||||
(selectedStatus === 'submitted' && product.status === 'Submitted')
|
||||
(selectedStatus === 'rejected' && product.status === 'Rejected');
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
// Update pagination total items and reset to first page when filters change
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
totalItems: filtered.length,
|
||||
currentPage: 1 // Reset to first page when filters change
|
||||
}));
|
||||
return filtered;
|
||||
}, [searchTerm, selectedStatus, submissionData]);
|
||||
|
||||
const downloadCsv = useCallback(() => {
|
||||
if (!filteredProducts.length || !submissionData || !quarterPeriods) return;
|
||||
|
||||
// Get the quarter and year from quarterPeriods
|
||||
const prevQuarter = quarterPeriods.previous_quarter || 'Q1';
|
||||
const currQuarter = quarterPeriods.current_quarter || 'Q2';
|
||||
const forecastQuarter = quarterPeriods.forecast_quarter || 'Q3';
|
||||
const year = quarterPeriods.year || new Date().getFullYear();
|
||||
|
||||
const headers = [
|
||||
'HS Code',
|
||||
'Product',
|
||||
'Unit',
|
||||
`Quantity (${prevQuarter} ${year})`,
|
||||
`Cost (${prevQuarter} ${year}) (AED)`,
|
||||
`Quantity (${currQuarter} ${year})`,
|
||||
`Cost (${currQuarter} ${year}) (AED)`,
|
||||
`Forecast Quantity (${forecastQuarter} ${year})`,
|
||||
`Forecast Cost (${forecastQuarter} ${year}) (AED)`,
|
||||
'Status'
|
||||
];
|
||||
|
||||
const csvRows = [headers.join(',')];
|
||||
|
||||
filteredProducts.forEach((product) => {
|
||||
csvRows.push([
|
||||
product.product.hs_code,
|
||||
product.product.product_name,
|
||||
product.unit?.uom || 'N/A',
|
||||
product.previous_quantity || '0',
|
||||
product.previous_cost || '0',
|
||||
product.current_quantity || '0',
|
||||
product.current_cost || '0',
|
||||
product.forecast_quantity || '0',
|
||||
product.forecast_cost || '0',
|
||||
product.status || 'Submitted'
|
||||
].map((value) => `"${String(value).replace(/"/g, '""')}"`).join(','));
|
||||
});
|
||||
|
||||
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', 'product-list.csv');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [filteredProducts]);
|
||||
|
||||
const handleViewDetails = async (product) => {
|
||||
try {
|
||||
setLoadingProduct(true);
|
||||
// Extract quarter and year from the submission data
|
||||
const quarter = submissionData.quarter; // e.g., 'Q1'
|
||||
const year = submissionData.year; // e.g., 2025
|
||||
|
||||
// Fetch quarter periods
|
||||
const response = await getQuarterPeriods(year, quarter);
|
||||
|
||||
// Update the selected product with quarter periods data
|
||||
setSelectedProduct({
|
||||
...product,
|
||||
quarterPeriods: response.data
|
||||
});
|
||||
|
||||
setShowToast(true);
|
||||
} catch (error) {
|
||||
console.error('Error fetching quarter periods:', error);
|
||||
// Still show the product details even if quarter periods fetch fails
|
||||
setSelectedProduct(product);
|
||||
setShowToast(true);
|
||||
} finally {
|
||||
setLoadingProduct(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToastShown = () => {
|
||||
setShowToast(false);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage) => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
currentPage: newPage
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (newSize) => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
pageSize: newSize,
|
||||
currentPage: 1 // Reset to first page when changing page size
|
||||
}));
|
||||
};
|
||||
|
||||
// Apply pagination to filtered products
|
||||
const paginatedProducts = useMemo(() => {
|
||||
if (!filteredProducts || !Array.isArray(filteredProducts)) return [];
|
||||
const startIndex = (pagination.currentPage - 1) * pagination.pageSize;
|
||||
return filteredProducts.slice(startIndex, startIndex + pagination.pageSize);
|
||||
}, [filteredProducts, pagination.currentPage, pagination.pageSize]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!filteredProducts || !Array.isArray(filteredProducts)) {
|
||||
return [];
|
||||
}
|
||||
return paginatedProducts.map((product, index) => {
|
||||
// Add null checks for product and product properties
|
||||
const productData = product.product || {};
|
||||
const unit = product.unit || {};
|
||||
|
||||
return [
|
||||
productData.hs_code || '—',
|
||||
<div className="min-w-0 max-w-[200px] md:max-w-[300px] lg:max-w-[400px] xl:max-w-[500px] 2xl:max-w-[600px]">
|
||||
<div
|
||||
className="break-words whitespace-normal text-ellipsis overflow-hidden line-clamp-2"
|
||||
title={productData.product_name || ''}
|
||||
>
|
||||
{productData.product_name || '—'}
|
||||
</div>
|
||||
</div>,
|
||||
unit.uom || 'N/A',
|
||||
product.current_quantity || '0',
|
||||
<span><img src={uae_currency} alt="AED" className="inline-block w-5 h-5 -mt-0.5 opacity-80" /> {product.current_cost || '0'}</span>,
|
||||
// <span
|
||||
// key={`status-${index}`}
|
||||
// className={`inline-flex items-center justify-center rounded-md px-3 py-1 text-xs font-medium ${
|
||||
// product.is_active ? 'text-[#2F663C] bg-[#F3FAF4]' :
|
||||
// 'text-[#7C5E24] bg-[#F9F7ED]'
|
||||
// }`}
|
||||
// >
|
||||
// {product.is_active ? 'Submitted' : 'Pending'}
|
||||
// {/* {product.is_active ? 'Rejected' : 'Rejected'} */}
|
||||
// </span>,
|
||||
// new Date(product.updated_at || product.created_at).toLocaleString('en-US', {
|
||||
// year: 'numeric',
|
||||
// month: 'short',
|
||||
// day: 'numeric',
|
||||
// hour: '2-digit',
|
||||
// minute: '2-digit',
|
||||
// hour12: true
|
||||
// }),
|
||||
<div className="flex items-start">
|
||||
<span
|
||||
key={`status-${index}`}
|
||||
className={`inline-flex items-center justify-start rounded-md px-3 py-1 ml-[-18px] text-xs font-medium w-full ${getStatusBadge(submission.status)}`}
|
||||
>
|
||||
{getStatusBadge(submission.status) || 'Pending'}
|
||||
</span>
|
||||
</div>,
|
||||
<span
|
||||
key={`date-${index}`}
|
||||
className="text-sm text-gray-600"
|
||||
>
|
||||
{formatDate(product.created_at) || '—'}
|
||||
</span>,
|
||||
|
||||
<button
|
||||
key={`view-${index}`}
|
||||
onClick={() => handleViewDetails(product)}
|
||||
disabled={loadingProduct}
|
||||
className="text-[#92722A] hover:underline text-sm font-medium disabled:opacity-50 pl-6"
|
||||
>
|
||||
{loadingProduct && selectedProduct?.id === product.id ? 'Loading...' : 'View Details'}
|
||||
</button>
|
||||
|
||||
];
|
||||
});
|
||||
}, [filteredProducts]);
|
||||
|
||||
// Add pagination props to the Table component
|
||||
const tablePagination = {
|
||||
currentPage: pagination.currentPage,
|
||||
onPageChange: handlePageChange,
|
||||
pageSize: pagination.pageSize,
|
||||
totalItems: filteredProducts.length,
|
||||
pageSizeOptions: pagination.pageSizeOptions,
|
||||
onPageSizeChange: handlePageSizeChange
|
||||
};
|
||||
|
||||
// Show loading state for initial data load
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-[#92722A]"></div>
|
||||
<p className="mt-4 text-gray-600">Loading submission details...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Show error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-red-50 text-red-700 rounded-md">
|
||||
<p className="font-medium">Error</p>
|
||||
<p className="mt-1">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-3 px-4 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show no data state
|
||||
if (!submissionData) {
|
||||
return (
|
||||
<div className="text-center py-10">
|
||||
<p className="text-gray-600">No submission data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toolbar = (
|
||||
|
||||
<div className="px-6 pt-6 pb-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-[18px] font-medium text-[#232528]">Selected Quarter: {submission.quarter} </h2>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-3">
|
||||
<div className="relative w-full md:w-72">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder="Search establishment"
|
||||
className="w-full h-10 rounded-md border border-[#E2E8F0] pl-10 pr-3 text-sm text-[#232528] focus:outline-none"
|
||||
/>
|
||||
<img
|
||||
src={searchIconSrc}
|
||||
alt="Search"
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative w-full md:w-40">
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(event) => setSelectedStatus(event.target.value)}
|
||||
className="h-10 w-full appearance-none rounded-md border border-[#E2E8F0] bg-white pl-3 pr-9 text-sm text-[#232528] focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="submitted">Submitted</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="resubmitted">Resubmitted</option>
|
||||
</select>
|
||||
<img
|
||||
src={caretDownSrc}
|
||||
alt="Open"
|
||||
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadCsv}
|
||||
disabled={!filteredProducts.length}
|
||||
className={`inline-flex items-center gap-2 h-10 rounded-md border px-4 text-sm font-medium ${filteredProducts.length ? 'border-[#92722A] text-[#92722A] hover:bg-[#F2ECCF]' : 'border-gray-200 text-gray-400 cursor-not-allowed'}`}
|
||||
>
|
||||
<img src={downloadIconSrc} alt="Export" className="h-4 w-4" />
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const columns = [
|
||||
'HS Code',
|
||||
'Product',
|
||||
'Unit',
|
||||
'Quantity',
|
||||
'Value (AED)',
|
||||
'Status',
|
||||
'Submission Date & Time',
|
||||
'Action'
|
||||
];
|
||||
|
||||
const columnWidths = {
|
||||
'HS Code': '5%',
|
||||
'Product': '1%',
|
||||
'Unit': '15%',
|
||||
'Quantity': '10%',
|
||||
'Value (AED)': '10%',
|
||||
'Status': '15%',
|
||||
'Submission Date & Time': '15%',
|
||||
'Action': { width: '10%', className: 'justify-start' }
|
||||
};
|
||||
|
||||
// Calculate total products and average cost
|
||||
const totalProducts = filteredProducts?.length || 0;
|
||||
const totalCost = filteredProducts?.reduce((sum, product) => {
|
||||
return sum + (parseFloat(product.previous_cost) || 0);
|
||||
}, 0) || 0;
|
||||
const averageCost = totalProducts > 0 ? (totalCost / totalProducts).toFixed(2) : 0;
|
||||
|
||||
// If a product is selected, show the product details view
|
||||
if (selectedProduct) {
|
||||
return (
|
||||
<ProductDetails
|
||||
// product={selectedProduct}
|
||||
product={{
|
||||
...selectedProduct,
|
||||
reject_reason: submissionData?.reject_reason // Add this line
|
||||
}}
|
||||
onClose={() => setShowToast(false)}
|
||||
onBack={() => setSelectedProduct(null)} // Add this line to handle back navigation
|
||||
showToast={showToast}
|
||||
onToastShown={handleToastShown}
|
||||
submissionDate={selectedProduct?.created_at || submissionData?.created_at || submissionData?.updated_at || new Date().toISOString()}
|
||||
submissionStatus={selectedProduct?.status || submissionData?.status || 'submitted'}
|
||||
quarterPeriods={selectedProduct?.quarterPeriods || quarterPeriods}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise show the main overview
|
||||
return (
|
||||
<div className="space-y-6 w-full">
|
||||
{/* ----- EXISTING CARD SECTION ----- */}
|
||||
<div className="bg-white p-4 rounded-t-lg border-b border-[#E2E8F0]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-[#6B7280] hover:text-[#374151] p-1 -ml-1"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5 15L7.5 10L12.5 5" stroke="#6B7280" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex items-center space-x-2 pr-6">
|
||||
<p className="text-sm text-[#232528] font-medium">Establishment:</p>
|
||||
<p className="text-sm text-[#232528]">{submissionData.establishment?.factory_name || 'N/A'}</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 px-6">
|
||||
<p className="text-sm text-[#232528] font-medium">Total Submitted Products:</p>
|
||||
<p className="text-sm text-[#232528]">{totalProducts}</p>
|
||||
</div>
|
||||
{/* <div className="flex items-center space-x-2 pl-6">
|
||||
<p className="text-sm text-[#232528] font-medium">Average Cost (AED):</p>
|
||||
<p className="text-sm text-[#232528]">{averageCost}</p>
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative">
|
||||
<select
|
||||
className="h-10 w-32 rounded-md border border-[#E2E8F0] bg-white pl-3 pr-8 text-sm text-[#232528] focus:outline-none cursor-pointer appearance-none"
|
||||
value={submissionData.year}
|
||||
disabled
|
||||
>
|
||||
<option>{submissionData.year}</option>
|
||||
</select>
|
||||
<svg className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#6B7280]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
className="h-10 w-32 rounded-md border border-[#E2E8F0] bg-white pl-3 pr-8 text-sm text-[#232528] focus:outline-none cursor-pointer appearance-none"
|
||||
value={submissionData.quarter}
|
||||
disabled
|
||||
>
|
||||
<option>{submissionData.quarter}</option>
|
||||
</select>
|
||||
<svg className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#6B7280]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ----- ADDED TABLE SECTION BELOW CARD ----- */}
|
||||
<section className="bg-white rounded-lg border border-[#E2E8F0] shadow-sm">
|
||||
<Table
|
||||
headers={columns}
|
||||
columnWidths={columnWidths}
|
||||
rows={rows}
|
||||
beforeHeader={toolbar}
|
||||
separated
|
||||
rowGapClass="border-spacing-y-2"
|
||||
className="w-full border-collapse [&_td]:whitespace-nowrap [&_th]:whitespace-nowrap [&_th]:px-3 [&_td]:px-3 [&_th:last-child]:text-right [&_td:last-child]:text-right [&_th:last-child]:pr-4 [&_td:last-child]:pr-4"
|
||||
pagination={tablePagination}
|
||||
/>
|
||||
</section>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md border border-[#92722A] text-[#92722A] bg-transparent hover:bg-[#92722A]/10"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailedOverview;
|
||||
@ -1,695 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
|
||||
// Default onBack function if not provided
|
||||
const defaultOnBack = () => {
|
||||
};
|
||||
|
||||
const ProductDetails = ({
|
||||
product = {},
|
||||
onClose = () => {},
|
||||
showToast = false,
|
||||
onToastShown = () => {},
|
||||
submissionDate = new Date().toISOString(),
|
||||
submissionStatus = 'submitted',
|
||||
quarterPeriods = null,
|
||||
onBack = defaultOnBack
|
||||
}) => {
|
||||
const [toast, setToast] = useState(null);
|
||||
const toastShownRef = useRef(false);
|
||||
|
||||
// Format date to 'DD-MM-YYYY, hh:mm A' format
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showToast && !toastShownRef.current) {
|
||||
const formattedDate = formatDate(submissionDate);
|
||||
|
||||
if (submissionStatus) {
|
||||
const status = String(submissionStatus).toLowerCase();
|
||||
const toastConfig = {
|
||||
approved: {
|
||||
type: 'approved',
|
||||
bgColor: 'bg-[#F3FAF4]',
|
||||
textColor: 'text-[#3F8E50]',
|
||||
iconColor: 'text-[#4A9D5C]',
|
||||
message: 'Your survey was approved successfully.'
|
||||
},
|
||||
rejected: {
|
||||
type: 'rejected',
|
||||
bgColor: 'bg-red-50',
|
||||
textColor: 'text-red-600',
|
||||
iconColor: 'text-red-500',
|
||||
message: 'Your survey has been rejected.',
|
||||
icon: '/assets/images/Vector-rejected.svg',
|
||||
iconAlt: 'Rejected',
|
||||
iconClassName: 'mr-2 h-5 w-5 flex-shrink-0'
|
||||
},
|
||||
submitted: {
|
||||
type: 'submitted',
|
||||
bgColor: 'bg-[#E7F5FF]',
|
||||
textColor: 'text-[#043DFF]',
|
||||
iconColor: 'text-[#286CFF]',
|
||||
message: 'Your survey was successfully submitted and is waiting for approval.'
|
||||
},
|
||||
resubmitted: {
|
||||
type: 'resubmitted',
|
||||
bgColor: 'bg-[#E7F5FF]',
|
||||
textColor: 'text-[#043DFF]',
|
||||
iconColor: 'text-[#286CFF]',
|
||||
message: 'Your survey was successfully Resubmitted and is waiting for approval.'
|
||||
}
|
||||
};
|
||||
|
||||
const toastType = status in toastConfig ? status : 'submitted';
|
||||
setToast(toastConfig[toastType]);
|
||||
} else {
|
||||
// Default pending state (amber)
|
||||
setToast({
|
||||
type: 'pending',
|
||||
message: 'Your survey is in draft status. Please complete and submit it for review.',
|
||||
bgColor: 'bg-amber-50',
|
||||
textColor: 'text-amber-700',
|
||||
iconColor: 'text-amber-500'
|
||||
});
|
||||
}
|
||||
|
||||
toastShownRef.current = true;
|
||||
onToastShown?.();
|
||||
}
|
||||
}, [showToast, product?.is_active, onToastShown, submissionDate, submissionStatus]);
|
||||
|
||||
const closeToast = () => {
|
||||
setToast(null);
|
||||
};
|
||||
|
||||
// Debug log to check product status and is_active
|
||||
useEffect(() => {
|
||||
}, [product?.status, product?.is_active]);
|
||||
|
||||
// Default quarter periods if not provided
|
||||
const defaultQuarterPeriods = {
|
||||
previous_quarter: 'Q4',
|
||||
previous_year: new Date().getFullYear() - 1,
|
||||
current_quarter: 'Q1',
|
||||
current_year: new Date().getFullYear(),
|
||||
forecast_quarter: 'Q2',
|
||||
forecast_year: new Date().getFullYear(),
|
||||
previous_month: {
|
||||
previous_period_one: 'Oct',
|
||||
previous_period_two: 'Nov',
|
||||
previous_period_three: 'Dec'
|
||||
},
|
||||
current_month: {
|
||||
current_period_one: 'Jan',
|
||||
current_period_two: 'Feb',
|
||||
current_period_three: 'Mar'
|
||||
},
|
||||
forecast_month: {
|
||||
forecast_period_one: 'Apr',
|
||||
forecast_period_two: 'May',
|
||||
forecast_period_three: 'Jun'
|
||||
}
|
||||
};
|
||||
|
||||
// Use provided quarterPeriods or fall back to defaults
|
||||
const periods = quarterPeriods || product?.quarterPeriods || defaultQuarterPeriods;
|
||||
|
||||
// Determine if we should hide PREVIOUS QUARTER section for specific year/quarter combinations
|
||||
// Uses quarterPeriods (API response) as primary source since that's what drives the UI
|
||||
const shouldHidePreviousQuarter = React.useMemo(() => {
|
||||
const effectiveYear = (periods?.current_year || new Date().getFullYear()).toString();
|
||||
const rawQuarter = periods?.current_quarter || 'Q1';
|
||||
const normalizedQuarter = rawQuarter.toString().toUpperCase().startsWith('Q')
|
||||
? rawQuarter.toString().toUpperCase()
|
||||
: `Q${rawQuarter}`;
|
||||
return (effectiveYear === '2022' && normalizedQuarter === 'Q1') ||
|
||||
(effectiveYear === '2025' && normalizedQuarter === 'Q1');
|
||||
}, [periods]);
|
||||
|
||||
// Determine if we should hide FORECAST section for specific year/quarter combinations
|
||||
// Uses quarterPeriods (API response) as primary source since that's what drives the UI
|
||||
const shouldHideForecast = React.useMemo(() => {
|
||||
const effectiveYear = (periods?.current_year || new Date().getFullYear()).toString();
|
||||
const rawQuarter = periods?.current_quarter || 'Q1';
|
||||
const normalizedQuarter = rawQuarter.toString().toUpperCase().startsWith('Q')
|
||||
? rawQuarter.toString().toUpperCase()
|
||||
: `Q${rawQuarter}`;
|
||||
return effectiveYear === '2022' && normalizedQuarter === 'Q4';
|
||||
}, [periods]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Function to save resubmission data to localStorage
|
||||
const saveResubmissionData = (productData) => {
|
||||
try {
|
||||
const resubmissionData = {
|
||||
submissionId: productData.submission_id,
|
||||
product: {
|
||||
id: productData.product?.id,
|
||||
product_id: productData.product_id,
|
||||
product_name: productData.product?.product_name,
|
||||
hs_code: productData.product?.hs_code,
|
||||
unit: productData.unit ? {
|
||||
id: productData.unit.id,
|
||||
uom: productData.unit.uom
|
||||
} : null,
|
||||
// Add all relevant product data that needs to be pre-filled
|
||||
previous_quantity_period_one: productData.previous_quantity_period_one,
|
||||
previous_quantity_period_two: productData.previous_quantity_period_two,
|
||||
previous_quantity_period_three: productData.previous_quantity_period_three,
|
||||
current_quantity_period_one: productData.current_quantity_period_one,
|
||||
current_quantity_period_two: productData.current_quantity_period_two,
|
||||
current_quantity_period_three: productData.current_quantity_period_three,
|
||||
forecast_quantity_period_one: productData.forecast_quantity_period_one,
|
||||
forecast_quantity_period_two: productData.forecast_quantity_period_two,
|
||||
forecast_quantity_period_three: productData.forecast_quantity_period_three,
|
||||
previous_cost_period_one: productData.previous_cost_period_one,
|
||||
previous_cost_period_two: productData.previous_cost_period_two,
|
||||
previous_cost_period_three: productData.previous_cost_period_three,
|
||||
current_cost_period_one: productData.current_cost_period_one,
|
||||
current_cost_period_two: productData.current_cost_period_two,
|
||||
current_cost_period_three: productData.current_cost_period_three,
|
||||
forecast_cost_period_one: productData.forecast_cost_period_one,
|
||||
forecast_cost_period_two: productData.forecast_cost_period_two,
|
||||
forecast_cost_period_three: productData.forecast_cost_period_three,
|
||||
variation_reason: productData.variation_reason,
|
||||
zero_target_reason: productData.zero_target_reason,
|
||||
other_variation_reason: productData.other_variation_reason,
|
||||
other_zero_target_reason: productData.other_zero_target_reason,
|
||||
remarks: productData.remarks,
|
||||
annual_installed_capacity: productData.annual_installed_capacity,
|
||||
// Add quarter and year directly in product data
|
||||
quarter: productData.quarter || productData.product?.quarter,
|
||||
year: productData.year || productData.product?.year
|
||||
},
|
||||
establishment: {
|
||||
id: productData.establishment_id,
|
||||
name: productData.establishment_name,
|
||||
permanent_factory_code: productData.permanent_factory_code,
|
||||
industry_code: productData.industry_code,
|
||||
license_number: productData.license_number,
|
||||
isic_code: productData.isic_code,
|
||||
emirate: productData.emirate
|
||||
},
|
||||
// Add quarter and year at the root level as well
|
||||
quarter: productData.quarter || productData.product?.quarter,
|
||||
year: productData.year || productData.product?.year,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
localStorage.setItem('resubmissionData', JSON.stringify(resubmissionData));
|
||||
} catch (error) {
|
||||
console.error('Error saving resubmission data to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!product) return null;
|
||||
|
||||
// Format the product data to match the component's expected structure
|
||||
const formatProductData = (product) => ({
|
||||
...product,
|
||||
name: product.product?.product_name || '—',
|
||||
hsCode: product.product?.hs_code || '—',
|
||||
unit: product.unit?.uom || 'PCS',
|
||||
status: product.is_active ? 'Active' : 'Inactive',
|
||||
q4_oct_quantity: product.previous_quantity_period_one || '—',
|
||||
q4_nov_quantity: product.previous_quantity_period_two || '—',
|
||||
q4_dec_quantity: product.previous_quantity_period_three || '—',
|
||||
q1_jan_quantity: product.current_quantity_period_one || '—',
|
||||
q1_feb_quantity: product.current_quantity_period_two || '—',
|
||||
q1_mar_quantity: product.current_quantity_period_three || '—',
|
||||
q2_apr_quantity: product.forecast_quantity_period_one || '—',
|
||||
q2_may_quantity: product.forecast_quantity_period_two || '—',
|
||||
q2_jun_quantity: product.forecast_quantity_period_three || '—',
|
||||
q4_oct_cost: product.previous_cost_period_one || '—',
|
||||
q4_nov_cost: product.previous_cost_period_two || '—',
|
||||
q4_dec_cost: product.previous_cost_period_three || '—',
|
||||
q1_jan_cost: product.current_cost_period_one || '—',
|
||||
q1_feb_cost: product.current_cost_period_two || '—',
|
||||
q1_mar_cost: product.current_cost_period_three || '—',
|
||||
q2_apr_cost: product.forecast_cost_period_one || '—',
|
||||
q2_may_cost: product.forecast_cost_period_two || '—',
|
||||
q2_jun_cost: product.forecast_cost_period_three || '—',
|
||||
// variationReason: product.variation_reason?.reason || '—',
|
||||
// zeroTargetReason: product.zero_target_reason?.reason || '—',
|
||||
remarks: product.remarks || 'NA',
|
||||
variationReason: product.other_variation_reason || product.variation_reason?.reason || '—',
|
||||
zeroTargetReason: product.other_zero_target_reason || product.zero_target_reason?.reason || '—',
|
||||
other_variation_reason: product.other_variation_reason || '—',
|
||||
other_zero_target_reason: product.other_zero_target_reason || '—',
|
||||
remarks: product.remarks || 'NA'
|
||||
});
|
||||
|
||||
const formattedProduct = formatProductData(product);
|
||||
|
||||
return (
|
||||
<div className="w-full relative">
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<div className="flex justify-center mb-2">
|
||||
<div className={`w-full max-w-[1281px] h-[56px] ${toast.bgColor} ${toast.textColor} rounded-md px-4 py-3 flex items-center shadow-sm`}>
|
||||
{toast.type === 'approved' ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={`w-5 h-5 mr-2 ${toast.iconColor}`}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : toast.type === 'submitted' ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={`w-5 h-5 mr-2 ${toast.iconColor}`}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 01.67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 11-.67-1.34l.041-.022zM12 9a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : toast.type === 'rejected' ? (
|
||||
<img
|
||||
src={toast.icon}
|
||||
alt={toast.iconAlt || 'Rejected'}
|
||||
className={toast.iconClassName || 'w-5 h-5 mr-2'}
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={`w-5 h-5 mr-2 ${toast.iconColor}`}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-sm font-medium">{toast.message}</span>
|
||||
{toast.type === 'rejected' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
// Make the API call to get the submission details
|
||||
const response = await fetch(`/api/submissions/${product.submission_id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch submission data');
|
||||
}
|
||||
const submissionData = await response.json();
|
||||
|
||||
// Get quarter and year from the product or submission data
|
||||
const quarter = submissionData.quarter || product.quarter || 'Q1'; // Default to Q1 if not found
|
||||
const year = submissionData.year || product.year || new Date().getFullYear().toString(); // Default to current year if not found
|
||||
|
||||
// Prepare the data to be saved
|
||||
const dataToStore = {
|
||||
...submissionData,
|
||||
quarter: quarter,
|
||||
year: year,
|
||||
product: {
|
||||
...submissionData.product,
|
||||
quarter: quarter,
|
||||
year: year,
|
||||
remarks: submissionData.product?.remarks || ''
|
||||
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('resubmissionData', JSON.stringify(dataToStore));
|
||||
// Navigate to the survey page with the submission data
|
||||
navigate('/survey', {
|
||||
state: {
|
||||
submission: {
|
||||
...submissionData,
|
||||
id: product.submission_id,
|
||||
quarter: quarter,
|
||||
year: year,
|
||||
establishment_name: product.establishment_name
|
||||
},
|
||||
isResubmit: true,
|
||||
submissionId: product.submission_id,
|
||||
fromResubmit: true,
|
||||
// Add quarter and year to the root of location state as well
|
||||
quarter: quarter,
|
||||
year: year
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error during resubmission:', error);
|
||||
// Fallback to the previous method if API call fails
|
||||
saveResubmissionData(product);
|
||||
|
||||
navigate('/survey', {
|
||||
state: {
|
||||
submission: {
|
||||
id: product.submission_id,
|
||||
quarter: product.quarter,
|
||||
year: product.year,
|
||||
establishment_name: product.establishment_name
|
||||
},
|
||||
isResubmit: true,
|
||||
submissionId: product.submission_id,
|
||||
fromResubmit: true
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="ml-auto px-3 py-1 bg-[#FEF2F2] text-[#7C2320] text-sm font-medium rounded hover:bg-[#FEF2F2] transition-colors"
|
||||
>
|
||||
{/* Resubmit */}
|
||||
{/* <img src="/assets/images/arrow-right-bold 1.svg" alt="" className="w-4 h-4 ml-1 inline-block" /> */}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white p-4 rounded-t-lg border-b border-[#E2E8F0] mt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-[#6B7280] hover:text-[#374151] p-1 -ml-1"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.8332 10H4.1665M4.1665 10L9.99984 15.8333M4.1665 10L9.99984 4.16667" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<h2 className="text-lg font-medium text-gray-900">Product Details</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-[#E2E8F0] p-6 mt-3">
|
||||
{/* Basic Information */}
|
||||
<div className="mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-4">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm font-medium text-[#6B7280]">Product Name</label>
|
||||
<div className="mt-1">
|
||||
<p className="block w-full rounded-md border-0 py-1.5 text-gray-900 bg-white">
|
||||
{formattedProduct.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm font-medium text-[#6B7280]">HS Code</label>
|
||||
<div className="mt-1">
|
||||
<p className="block w-full rounded-md border-0 py-1.5 text-gray-900 bg-white">
|
||||
{formattedProduct.hsCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm font-medium text-[#6B7280]">Unit</label>
|
||||
<div className="mt-1">
|
||||
<p className="block w-full rounded-md border-0 py-1.5 text-gray-900 bg-white">
|
||||
{formattedProduct.unit}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm font-medium text-[#6B7280]">Submission Status</label>
|
||||
<div className="mt-1">
|
||||
{(() => {
|
||||
const status = String(submissionStatus).toLowerCase();
|
||||
let statusClass = 'px-2 py-0.5 text-xs font-medium';
|
||||
let displayText = status.charAt(0).toUpperCase() + status.slice(1);
|
||||
|
||||
if (status === 'approved') {
|
||||
statusClass += ' bg-[#F3FAF4] text-[#2F663C]';
|
||||
} else if (status === 'rejected') {
|
||||
statusClass += ' bg-red-50 text-red-600';
|
||||
} else if (status === 'submitted' || status === 'active') {
|
||||
statusClass += ' bg-[#E7F5FF] text-[#043DFF]';
|
||||
displayText = status === 'active' ? 'Submitted' : displayText;
|
||||
} else if (status === 'resubmitted') {
|
||||
statusClass += ' bg-[#E7F5FF] text-[#043DFF]';
|
||||
} else {
|
||||
// Default/pending state
|
||||
statusClass += ' bg-amber-50 text-amber-700';
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded ${statusClass}`}>
|
||||
{displayText}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
{String(submissionStatus).toLowerCase() === 'rejected' && (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm font-medium text-[#6B7280]">Reason for Rejection</label>
|
||||
<div className="mt-1">
|
||||
<p className="block w-full rounded-md border-0 py-1.5 text-gray-900 bg-white break-words whitespace-normal">
|
||||
{product.reject_reason || 'No reason provided'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quarterly Data Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-300 text-sm text-center">
|
||||
<thead>
|
||||
<tr className="font-semibold text-[#232528] text-center">
|
||||
<th rowSpan="2" className="border border-gray-300 py-2 px-3 text-left bg-[#F2ECCF] w-[140px]">
|
||||
Metric
|
||||
</th>
|
||||
{!shouldHidePreviousQuarter && (
|
||||
<th colSpan="3" className="border border-gray-300 py-2 px-3 bg-[#E9F6FF]">
|
||||
{periods.previous_quarter}-{periods.previous_year} (Previous Quarter)
|
||||
</th>
|
||||
)}
|
||||
<th colSpan="3" className="border border-gray-300 py-2 px-3 bg-[#F3FAF4]">
|
||||
{periods.current_quarter}-{periods.current_year} (Current Quarter)
|
||||
</th>
|
||||
{!shouldHideForecast && (
|
||||
<th colSpan="3" className="border border-gray-300 py-2 px-3 bg-[#FFF7E9]">
|
||||
{periods.forecast_quarter}-{periods.forecast_year} (Next Quarter)
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
|
||||
{/* Second header row */}
|
||||
<tr className="bg-white text-[13px] font-medium">
|
||||
{!shouldHidePreviousQuarter && (
|
||||
<>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#E9F6FF] text-[#286CFF]">
|
||||
{periods.previous_month.previous_period_one}
|
||||
</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#E9F6FF] text-[#286CFF]">
|
||||
{periods.previous_month.previous_period_two}
|
||||
</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#E9F6FF] text-[#286CFF]">
|
||||
{periods.previous_month.previous_period_three}
|
||||
</th>
|
||||
</>
|
||||
)}
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#F3FAF4] text-[#2F663C]">
|
||||
{periods.current_month.current_period_one}
|
||||
</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#F3FAF4] text-[#2F663C]">
|
||||
{periods.current_month.current_period_two}
|
||||
</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#F3FAF4] text-[#2F663C]">
|
||||
{periods.current_month.current_period_three}
|
||||
</th>
|
||||
{!shouldHideForecast && (
|
||||
<>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#FFF7E9] text-[#F29F0E]">
|
||||
{periods.forecast_month.forecast_period_one}
|
||||
</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#FFF7E9] text-[#F29F0E]">
|
||||
{periods.forecast_month.forecast_period_two}
|
||||
</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#FFF7E9] text-[#F29F0E]">
|
||||
{periods.forecast_month.forecast_period_three}
|
||||
</th>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{/* Quantity Row */}
|
||||
<tr>
|
||||
<td className="border border-gray-300 py-2 px-3 text-left font-medium">
|
||||
Quantity ({formattedProduct.unit})
|
||||
</td>
|
||||
{!shouldHidePreviousQuarter && (
|
||||
<>
|
||||
<td className="border border-gray-300 py-2">{formattedProduct.q4_oct_quantity}</td>
|
||||
<td className="border border-gray-300 py-2">{formattedProduct.q4_nov_quantity}</td>
|
||||
<td className="border border-gray-300 py-2">{formattedProduct.q4_dec_quantity}</td>
|
||||
</>
|
||||
)}
|
||||
<td className="border border-gray-300 py-2">{formattedProduct.q1_jan_quantity}</td>
|
||||
<td className="border border-gray-300 py-2">{formattedProduct.q1_feb_quantity}</td>
|
||||
<td className="border border-gray-300 py-2">{formattedProduct.q1_mar_quantity}</td>
|
||||
{!shouldHideForecast && (
|
||||
<>
|
||||
<td className="border border-gray-300 py-2">{formattedProduct.q2_apr_quantity}</td>
|
||||
<td className="border border-gray-300 py-2">{formattedProduct.q2_may_quantity}</td>
|
||||
<td className="border border-gray-300 py-2">{formattedProduct.q2_jun_quantity}</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
|
||||
{/* Cost Row */}
|
||||
<tr>
|
||||
<td className="border border-gray-300 py-2 px-3 text-left font-medium">
|
||||
Value (AED)
|
||||
</td>
|
||||
{!shouldHidePreviousQuarter && (
|
||||
<>
|
||||
<td className="border border-gray-300 py-2">{formattedProduct.q4_oct_cost}</td>
|
||||
<td className="border border-gray-300 py-2">{formattedProduct.q4_nov_cost}</td>
|
||||
<td className="border border-gray-300 py-2">{formattedProduct.q4_dec_cost}</td>
|
||||
</>
|
||||
)}
|
||||
<td className="border border-gray-300 py-2">{formattedProduct.q1_jan_cost}</td>
|
||||
<td className="border border-gray-300 py-2">{formattedProduct.q1_feb_cost}</td>
|
||||
<td className="border border-gray-300 py-2">{formattedProduct.q1_mar_cost}</td>
|
||||
{!shouldHideForecast && (
|
||||
<>
|
||||
<td className="border border-gray-300 py-2">{formattedProduct.q2_apr_cost}</td>
|
||||
<td className="border border-gray-300 py-2">{formattedProduct.q2_may_cost}</td>
|
||||
<td className="border border-gray-300 py-2">{formattedProduct.q2_jun_cost}</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
|
||||
{/* Reason Current */}
|
||||
<tr>
|
||||
<td className="border border-gray-300 py-2 px-3 text-left font-medium">
|
||||
Reason (Current)
|
||||
</td>
|
||||
{!shouldHidePreviousQuarter && (
|
||||
<td colSpan="3" className="border border-gray-300 py-2 text-center">
|
||||
-- --
|
||||
</td>
|
||||
)}
|
||||
<td colSpan="3" className="border border-gray-300 py-2 text-center bg-[#F3FAF4] text-[#2F663C]">
|
||||
{formattedProduct.variationReason}
|
||||
</td>
|
||||
{!shouldHideForecast && (
|
||||
<td colSpan="3" className="border border-gray-300 py-2 text-center">
|
||||
-- --
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
|
||||
{/* Reason Forecast */}
|
||||
<tr>
|
||||
<td className="border border-gray-300 py-2 px-3 text-left font-medium">
|
||||
Reason (Forecast)
|
||||
</td>
|
||||
{!shouldHidePreviousQuarter && (
|
||||
<td colSpan="3" className="border border-gray-300 py-2 text-center">
|
||||
-- --
|
||||
</td>
|
||||
)}
|
||||
<td colSpan="3" className="border border-gray-300 py-2 text-center">
|
||||
-- --
|
||||
</td>
|
||||
{!shouldHideForecast && (
|
||||
<td colSpan="3" className="border border-gray-300 py-2 text-center bg-[#FFF7E9] text-[#F29F0E]">
|
||||
{formattedProduct.zeroTargetReason}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Remarks */}
|
||||
<div className="mt-6">
|
||||
<h3 className="text-sm font-medium text-[#232528] mb-2">Remarks</h3>
|
||||
<div className="border border-[#CBA344] rounded-md p-3 text-sm bg-white">
|
||||
{formattedProduct.remarks}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submission Info */}
|
||||
{/* <div className="mt-6 pt-4 border-t border-[#E2E8F0] text-sm">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-[#6B7280]">Submitted by: <span className="text-[#232528] font-medium">
|
||||
{product.created_by || 'N/A'}
|
||||
</span></p>
|
||||
<p className="text-[#6B7280]">Submission Date: <span className="text-[#232528] font-medium">
|
||||
{new Date(product.created_at).toLocaleDateString() || '—'}
|
||||
</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#6B7280]">Last Updated: <span className="text-[#232528] font-medium">
|
||||
{product.updated_at ? new Date(product.updated_at).toLocaleDateString() : '—'}
|
||||
</span></p>
|
||||
<p className="text-[#6B7280]">Status:
|
||||
<span className={`inline-flex items-center justify-center rounded-md px-2 py-0.5 text-xs font-medium ml-1 ${
|
||||
formattedProduct.status === 'Active' ? 'text-[#2F663C] bg-[#F3FAF4]' : 'text-[#B45309] bg-[#FEF2F2]'
|
||||
}`}>
|
||||
{formattedProduct.status}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
{/* <button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-1" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Back to List
|
||||
</button> */}
|
||||
{/* <button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProductDetails.defaultProps = {
|
||||
onBack: () => {}
|
||||
};
|
||||
|
||||
export default ProductDetails;
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import Table from '@/components/common/Table';
|
||||
import CustomToast from '@/components/common/CustomToast';
|
||||
import { getQuarterPeriods , submitSurvey, resubmitSurvey } from '@/services/submissions/submissionService';
|
||||
import { getEmirates, fetchVariationReasons, fetchZeroTargetReasons } from '@/services/masters/masterService';
|
||||
const caretUpSrc = '/assets/images/caret-up.svg';
|
||||
const caretDownSrc = '/assets/images/CaretDown-black.svg';
|
||||
const establishmentIconSrc = '/assets/images/Establishment.svg';
|
||||
@ -14,7 +11,7 @@ const maleEmployeesIconSrc = '/assets/images/maleemployes.svg';
|
||||
const femaleEmployeesIconSrc = '/assets/images/femaleemployess.svg';
|
||||
const uaeIconSrc = '/assets/images/uae.svg';
|
||||
const nonUaeIconSrc = '/assets/images/nonuae.svg';
|
||||
const agreeIconSrc = '/assets/images/Vector-agree.svg';
|
||||
|
||||
const Card = ({ title, children, className = '' }) => (
|
||||
<div className={`bg-white rounded-lg border border-gray-200 overflow-hidden ${className}`}>
|
||||
{title && (
|
||||
@ -58,22 +55,13 @@ const formatNumber = (value) => {
|
||||
return new Intl.NumberFormat('en-US').format(number);
|
||||
};
|
||||
|
||||
const buildEstablishmentDetails = (establishment, emirateName) => {
|
||||
const buildEstablishmentDetails = (establishment) => {
|
||||
if (!establishment) return [];
|
||||
let userEmail = '';
|
||||
try {
|
||||
const userProfile = JSON.parse(localStorage.getItem('user_profile'));
|
||||
userEmail = userProfile?.email || '';
|
||||
} catch (e) {
|
||||
console.error('Error parsing user profile from localStorage:', e);
|
||||
}
|
||||
const email = establishment.establishment_contact_email || userEmail || '';
|
||||
|
||||
return [
|
||||
{ icon: establishmentIconSrc, label: 'Establishment Name', value: establishment.establishmentName },
|
||||
{ icon: establishmentIdIconSrc, label: 'Establishment ID', value: establishment.licenseNumber },
|
||||
{ icon: emailIconSrc, label: 'Email', value: email },
|
||||
{ icon: emiratesIconSrc, label: 'Emirate', value: emirateName || establishment.emirate },
|
||||
{ icon: emailIconSrc, label: 'Email', value: establishment.email },
|
||||
{ icon: emiratesIconSrc, label: 'Emirate', value: establishment.emirate },
|
||||
];
|
||||
};
|
||||
|
||||
@ -155,6 +143,8 @@ const findDisplayName = (options, value) => {
|
||||
};
|
||||
|
||||
const buildProductRows = (products = [], options = {}) => {
|
||||
console.log('Raw products data:', products); // Debug log
|
||||
console.log('Available options:', options); // Debug log
|
||||
|
||||
if (!Array.isArray(products)) {
|
||||
console.warn('buildProductRows: Expected an array but got', typeof products);
|
||||
@ -180,35 +170,32 @@ const buildProductRows = (products = [], options = {}) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the full product details from originalData or product
|
||||
const originalData = product.originalData || {};
|
||||
// Find the full product details from productOptions if available
|
||||
const fullProduct = productOptions.find(p => p.id === product.productId || p.value === product.productId);
|
||||
console.log("fullProduct",fullProduct)
|
||||
// Extract product details with better fallbacks
|
||||
const productId = product.id || `product-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const productName = fullProduct?.label || fullProduct?.name || product.productName || product.product?.name || product.product;
|
||||
console.log("productName",productName)
|
||||
const productCode = fullProduct?.code || product.productCode || product.code || product.product?.code || product.id || `CODE-${index + 1}`;
|
||||
console.log("productCode",productCode)
|
||||
// Find unit display name
|
||||
const unitnames = findDisplayName(unitOptions)
|
||||
console.log("unitnames",unitnames)
|
||||
const unitName = findDisplayName(unitOptions, product.unit);
|
||||
console.log("unitName",unitName)
|
||||
|
||||
// Get HS Code and Product Name from original data or product
|
||||
const hsCode = originalData['product.hs_code'] || product.hs_code || '';
|
||||
const productName = originalData['product.product_name'] || product.product_name || product.productName || product.product?.name || product.product;
|
||||
|
||||
// Format the display name as "HS Code - Product Name" if HS code exists
|
||||
const displayName = hsCode ? `${hsCode} - ${productName}` : productName;
|
||||
const productCode = hsCode || product.id || `CODE-${index + 1}`;
|
||||
// Use stored unitName if available, otherwise try to find it in unitOptions
|
||||
let unitName = product.unitName;
|
||||
if (!unitName && product.unit) {
|
||||
unitName = findDisplayName(unitOptions, product.unit);
|
||||
}
|
||||
// Use stored reason names if available, otherwise try to find them in the options
|
||||
const variationReasonName = product.variationReasonName || findDisplayName(variationReasons, product.variationReason);
|
||||
const zeroTargetReasonName = product.zeroTargetReasonName || findDisplayName(zeroTargetReasons, product.zeroTargetReason);
|
||||
// Find reason display names
|
||||
const variationReasonName = findDisplayName(variationReasons, product.variationReason);
|
||||
const zeroTargetReasonName = findDisplayName(zeroTargetReasons, product.zeroTargetReason);
|
||||
|
||||
const capacity = product.capacity !== undefined && product.capacity !== null ? formatNumber(product.capacity) : '—';
|
||||
|
||||
// Map the quantity and cost data from the product form
|
||||
const row = {
|
||||
id: productId,
|
||||
product: displayName,
|
||||
product: productName,
|
||||
productCode: productCode,
|
||||
hsCode: hsCode,
|
||||
productName: productName,
|
||||
unit: unitName, // Use display name instead of ID
|
||||
capacity: capacity,
|
||||
|
||||
@ -229,24 +216,23 @@ const buildProductRows = (products = [], options = {}) => {
|
||||
q2_jun_cost: formatNumber(product.junCost) || '—',
|
||||
|
||||
// Previous Quarter (Q4) - October to December (if available)
|
||||
q4_oct_quantity: formatNumber(product.octQuantity) || '—',
|
||||
q4_oct_quantity: formatNumber(product.previousQuantity) || '—',
|
||||
q4_nov_quantity: formatNumber(product.novQuantity) || '—',
|
||||
q4_dec_quantity: formatNumber(product.decQuantity) || '—',
|
||||
q4_oct_cost: formatNumber(product.octCost) || '—',
|
||||
q4_nov_cost: formatNumber(product.novCost) || '—',
|
||||
q4_dec_cost: formatNumber(product.decCost) || '—',
|
||||
|
||||
// Reasons and remarks - use display names and include other reasons
|
||||
// Reasons and remarks - use display names
|
||||
variationReason: variationReasonName,
|
||||
otherVariationReason: product.otherVariationReason || '',
|
||||
zeroTargetReason: zeroTargetReasonName,
|
||||
otherZeroTargetReason: product.otherZeroTargetReason || '',
|
||||
remarks: product.remarks || '',
|
||||
remarks: product.remarks || '—',
|
||||
|
||||
// Keep the raw product data for debugging
|
||||
_raw: { ...product }
|
||||
};
|
||||
|
||||
console.log(`Processed product ${index + 1}:`, row); // Debug log
|
||||
return row;
|
||||
} catch (error) {
|
||||
console.error(`Error processing product at index ${index}:`, error, product);
|
||||
@ -258,426 +244,20 @@ const buildProductRows = (products = [], options = {}) => {
|
||||
return rows.filter(Boolean);
|
||||
};
|
||||
|
||||
const formatSubmissionData = (establishment, products, remarks = '') => {
|
||||
if (!establishment) {
|
||||
throw new Error('Establishment data is required');
|
||||
}
|
||||
|
||||
// Get employee info with proper fallbacks
|
||||
const employeeInfo = establishment.employeeInfo || {};
|
||||
const emiratiMale = parseInt(employeeInfo.emiratiMale) || 0;
|
||||
const emiratiFemale = parseInt(employeeInfo.emiratiFemale) || 0;
|
||||
const nonEmiratiMale = parseInt(employeeInfo.nonEmiratiMale) || 0;
|
||||
const nonEmiratiFemale = parseInt(employeeInfo.nonEmiratiFemale) || 0;
|
||||
const totalEmirati = emiratiMale + emiratiFemale;
|
||||
const totalEmployees = totalEmirati + nonEmiratiMale + nonEmiratiFemale;
|
||||
|
||||
// Format products data
|
||||
const formattedProducts = products.map(product => {
|
||||
// Get the actual product_id from the originalData if available
|
||||
const originalData = product.originalData || {};
|
||||
// Get the product_id directly from the originalData object
|
||||
// originalData has both id (establishment product ID) and product_id (actual product ID)
|
||||
const productId = originalData.product_id || originalData.id; // Use product_id (56) with fallback to id
|
||||
return {
|
||||
product_id: parseInt(productId) || 0,
|
||||
unit_id: parseInt(product.unit) || 0,
|
||||
annual_installed_capacity: product.capacity?.toString() || '',
|
||||
|
||||
// Previous quarter (Oct-Dec)
|
||||
previous_quantity_period_one: product.octQuantity?.toString() || '0',
|
||||
previous_quantity_period_two: product.novQuantity?.toString() || '0',
|
||||
previous_quantity_period_three: product.decQuantity?.toString() || '0',
|
||||
previous_cost_period_one: product.octCost?.toString() || '0',
|
||||
previous_cost_period_two: product.novCost?.toString() || '0',
|
||||
previous_cost_period_three: product.decCost?.toString() || '0',
|
||||
|
||||
// Current quarter (Jan-Mar)
|
||||
current_quantity_period_one: product.janQuantity?.toString() || '0',
|
||||
current_quantity_period_two: product.febQuantity?.toString() || '0',
|
||||
current_quantity_period_three: product.marQuantity?.toString() || '0',
|
||||
current_cost_period_one: product.janCost?.toString() || '0',
|
||||
current_cost_period_two: product.febCost?.toString() || '0',
|
||||
current_cost_period_three: product.marCost?.toString() || '0',
|
||||
|
||||
// Next quarter forecast (Apr-Jun)
|
||||
forecast_quantity_period_one: product.aprQuantity?.toString() || '0',
|
||||
forecast_quantity_period_two: product.mayQuantity?.toString() || '0',
|
||||
forecast_quantity_period_three: product.junQuantity?.toString() || '0',
|
||||
forecast_cost_period_one: product.aprCost?.toString() || '0',
|
||||
forecast_cost_period_two: product.mayCost?.toString() || '0',
|
||||
forecast_cost_period_three: product.junCost?.toString() || '0',
|
||||
|
||||
// Totals (calculated on backend if needed)
|
||||
previous_quantity: '',
|
||||
previous_cost: '',
|
||||
current_quantity: '',
|
||||
current_cost: '',
|
||||
forecast_quantity: '',
|
||||
forecast_cost: '',
|
||||
|
||||
// Reasons and remarks
|
||||
variation_reason_master_id: product.variationReason?.toString() || '',
|
||||
other_variation_reason: product.otherVariationReason?.toString() || '',
|
||||
zero_target_reason_master_id: product.zeroTargetReason?.toString() || '',
|
||||
other_zero_target_reason: product.otherZeroTargetReason?.toString() || '',
|
||||
remarks: product.remarks || remarks || ''
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
establishment_id: parseInt(establishment.id) || 0,
|
||||
quarter: establishment.quarter || 'Q1', // Should come from your app state
|
||||
year: parseInt(establishment.year) || new Date().getFullYear(),
|
||||
emirati_male: emiratiMale,
|
||||
emirati_female: emiratiFemale,
|
||||
non_emirati_male: nonEmiratiMale,
|
||||
non_emirati_female: nonEmiratiFemale,
|
||||
total_emirati: totalEmirati,
|
||||
total_employees: totalEmployees,
|
||||
products: formattedProducts
|
||||
};
|
||||
};
|
||||
|
||||
const ReviewSubmit = ({
|
||||
establishment,
|
||||
products = [],
|
||||
onBack = () => {},
|
||||
onSubmit = () => {},
|
||||
toast = null,
|
||||
onToastClose = () => {},
|
||||
isSubmitting = false,
|
||||
hasSubmitted = false,
|
||||
submitButtonText = 'Submit',
|
||||
quarter = 'Q1',
|
||||
// remarks = '',
|
||||
year = new Date().getFullYear(),
|
||||
isResubmit = false
|
||||
}) => {
|
||||
const [quarterPeriods, setQuarterPeriods] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [confirm, setConfirm] = React.useState(false);
|
||||
const [emirates, setEmirates] = useState([]);
|
||||
const [emirateName, setEmirateName] = useState('');
|
||||
const [variationReasons, setVariationReasons] = useState([]);
|
||||
const [zeroTargetReasons, setZeroTargetReasons] = useState([]);
|
||||
const [isSavingDraft, setIsSavingDraft] = React.useState(false);
|
||||
const [toast, setToast] = React.useState({
|
||||
show: false,
|
||||
message: '',
|
||||
type: 'success' // 'success' or 'error'
|
||||
});
|
||||
const [productOptions, setProductOptions] = React.useState([]);
|
||||
const [remarks, setRemarks] = React.useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('surveyRemarks') || '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Determine if we should hide PREVIOUS QUARTER section for specific year/quarter combinations
|
||||
// Uses quarterPeriods (API response) as primary source since that's what drives the UI
|
||||
const shouldHidePreviousQuarter = React.useMemo(() => {
|
||||
const effectiveYear = (quarterPeriods?.current_year || year || '').toString();
|
||||
const rawQuarter = quarterPeriods?.current_quarter || quarter || '';
|
||||
const normalizedQuarter = rawQuarter.toString().toUpperCase().startsWith('Q')
|
||||
? rawQuarter.toString().toUpperCase()
|
||||
: `Q${rawQuarter}`;
|
||||
return (effectiveYear === '2022' && normalizedQuarter === 'Q1') ||
|
||||
(effectiveYear === '2025' && normalizedQuarter === 'Q1');
|
||||
}, [quarterPeriods, year, quarter]);
|
||||
|
||||
// Determine if we should hide FORECAST section for specific year/quarter combinations
|
||||
// Uses quarterPeriods (API response) as primary source since that's what drives the UI
|
||||
const shouldHideForecast = React.useMemo(() => {
|
||||
const effectiveYear = (quarterPeriods?.current_year || year || '').toString();
|
||||
const rawQuarter = quarterPeriods?.current_quarter || quarter || '';
|
||||
const normalizedQuarter = rawQuarter.toString().toUpperCase().startsWith('Q')
|
||||
? rawQuarter.toString().toUpperCase()
|
||||
: `Q${rawQuarter}`;
|
||||
return effectiveYear === '2022' && normalizedQuarter === 'Q4';
|
||||
}, [quarterPeriods, year, quarter]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEmiratesData = async () => {
|
||||
try {
|
||||
const response = await getEmirates();
|
||||
if (response?.data) {
|
||||
setEmirates(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching emirates:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEmiratesData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (establishment?.emirate && emirates.length > 0) {
|
||||
const emirate = emirates.find(e => e.id === parseInt(establishment.emirate));
|
||||
setEmirateName(emirate?.name || establishment.emirate);
|
||||
} else if (establishment?.emirate) {
|
||||
// Fallback to ID if emirates list is not yet loaded
|
||||
setEmirateName(establishment.emirate);
|
||||
} else {
|
||||
setEmirateName('');
|
||||
}
|
||||
}, [establishment?.emirate, emirates]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchQuarterPeriods = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// const savedSurveyPeriod = localStorage.getItem('currentSurveyPeriod');
|
||||
// if (!savedSurveyPeriod) {
|
||||
// throw new Error('No survey period found in localStorage');
|
||||
// }
|
||||
|
||||
// const { quarter, year } = JSON.parse(savedSurveyPeriod);
|
||||
// if (!quarter || !year) {
|
||||
// throw new Error('Incomplete survey period data');
|
||||
// }
|
||||
const quarter = localStorage.getItem('currentQuarter');
|
||||
const year = localStorage.getItem('currentYear');
|
||||
|
||||
const response = await getQuarterPeriods(year, quarter);
|
||||
if (response.status === 'success') {
|
||||
setQuarterPeriods(response.data);
|
||||
} else {
|
||||
throw new Error('Failed to fetch quarter periods');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching quarter periods:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchQuarterPeriods();
|
||||
|
||||
// Fetch variation and zero target reasons
|
||||
const fetchReasons = async () => {
|
||||
try {
|
||||
const [variationReasonsData, zeroTargetReasonsData] = await Promise.all([
|
||||
fetchVariationReasons(),
|
||||
fetchZeroTargetReasons()
|
||||
]);
|
||||
setVariationReasons(variationReasonsData || []);
|
||||
setZeroTargetReasons(zeroTargetReasonsData || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching reason options:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchReasons();
|
||||
}, []);
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
try {
|
||||
setIsSavingDraft(true);
|
||||
|
||||
// Get current submission from localStorage
|
||||
let currentSubmission;
|
||||
const currentSubmissionStr = localStorage.getItem('currentSubmission');
|
||||
const establishmentFormDataStr = localStorage.getItem('establishmentFormData');
|
||||
|
||||
if (!currentSubmissionStr) {
|
||||
// If no current submission, try to use establishmentFormData
|
||||
if (!establishmentFormDataStr) {
|
||||
throw new Error('No submission data found. Please complete the establishment information first.');
|
||||
}
|
||||
currentSubmission = JSON.parse(establishmentFormDataStr);
|
||||
} else {
|
||||
currentSubmission = JSON.parse(currentSubmissionStr);
|
||||
}
|
||||
// const currentSubmission = JSON.parse(currentSubmissionStr);
|
||||
const currentQuarter = localStorage.getItem('currentQuarter');
|
||||
const currentYear = localStorage.getItem('currentYear')
|
||||
// Get establishment ID from current submission
|
||||
const submissionId = currentSubmission.id; // Changed this line to get ID from currentSubmission
|
||||
|
||||
const establishmentId = currentSubmission.establishment_id ||
|
||||
(currentSubmission.establishment && currentSubmission.establishment.id) ||
|
||||
localStorage.getItem('establishment_id');
|
||||
if (!establishmentId) {
|
||||
throw new Error('Establishment ID not found in submission');
|
||||
}
|
||||
|
||||
// Get user profile from localStorage
|
||||
const userProfileStr = localStorage.getItem('user_profile');
|
||||
if (!userProfileStr) {
|
||||
throw new Error('User profile not found');
|
||||
}
|
||||
const userProfile = JSON.parse(userProfileStr);
|
||||
const createdById = userProfile?.id;
|
||||
if (!createdById) {
|
||||
throw new Error('User ID not found in profile');
|
||||
}
|
||||
|
||||
// Get quarter and year from current submission
|
||||
const effectiveQuarter = currentQuarter;
|
||||
const effectiveYear = currentYear;
|
||||
if (!effectiveQuarter || !effectiveYear) {
|
||||
throw new Error('Quarter and year are required in submission');
|
||||
}
|
||||
|
||||
// Get establishment data
|
||||
// Get establishment data
|
||||
let establishment;
|
||||
if (currentSubmission.establishment) {
|
||||
establishment = currentSubmission.establishment;
|
||||
} else {
|
||||
// Try to get establishment data from establishmentFormData in localStorage
|
||||
const establishmentFormDataStr = localStorage.getItem('establishmentFormData');
|
||||
if (establishmentFormDataStr) {
|
||||
const establishmentFormData = JSON.parse(establishmentFormDataStr);
|
||||
establishment = {
|
||||
emirati_male: establishmentFormData.employeeInfo?.emiratiMale || 0,
|
||||
emirati_female: establishmentFormData.employeeInfo?.emiratiFemale || 0,
|
||||
non_emirati_male: establishmentFormData.employeeInfo?.nonEmiratiMale || 0,
|
||||
non_emirati_female: establishmentFormData.employeeInfo?.nonEmiratiFemale || 0,
|
||||
total_emirati: establishmentFormData.employeeInfo?.totalEmirati || 0,
|
||||
total_employees: establishmentFormData.employeeInfo?.totalEmployees || 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!establishment) {
|
||||
throw new Error('Establishment data not found in submission or localStorage');
|
||||
}
|
||||
|
||||
// Prepare products data in the required format
|
||||
const formattedProducts = products.map(product => {
|
||||
// Find the existing product in the current submission's products
|
||||
const selectedProduct = productOptions.find(p => p.value === product.product);
|
||||
const existingProduct = currentSubmission.products?.find(p =>
|
||||
p.product_id == product.product_id || // Loose equality to handle string/number mismatch
|
||||
p.product?.id == product.product_id // Also check nested product.id
|
||||
);
|
||||
|
||||
|
||||
return {
|
||||
// Use the existing product ID if found, otherwise use 0 for new products
|
||||
id: existingProduct?.id || 0,
|
||||
product_id: parseInt(product.product_id) || 0,
|
||||
unit_id: parseInt(product.unit) || 0,
|
||||
annual_installed_capacity: product.capacity || '0',
|
||||
// ... rest of the product fields remain the same
|
||||
previous_quantity_period_one: product.octQuantity || '0',
|
||||
previous_quantity_period_two: product.novQuantity || '0',
|
||||
previous_quantity_period_three: product.decQuantity || '0',
|
||||
previous_cost_period_one: product.octCost || '0',
|
||||
previous_cost_period_two: product.novCost || '0',
|
||||
previous_cost_period_three: product.decCost || '0',
|
||||
current_quantity_period_one: product.janQuantity || '0',
|
||||
current_quantity_period_two: product.febQuantity || '0',
|
||||
current_quantity_period_three: product.marQuantity || '0',
|
||||
current_cost_period_one: product.janCost || '0',
|
||||
current_cost_period_two: product.febCost || '0',
|
||||
current_cost_period_three: product.marCost || '0',
|
||||
forecast_quantity_period_one: product.aprQuantity || '0',
|
||||
forecast_quantity_period_two: product.mayQuantity || '0',
|
||||
forecast_quantity_period_three: product.junQuantity || '0',
|
||||
forecast_cost_period_one: product.aprCost || '0',
|
||||
forecast_cost_period_two: product.mayCost || '0',
|
||||
forecast_cost_period_three: product.junCost || '0',
|
||||
previous_quantity: product.octQuantity || '0',
|
||||
previous_cost: product.octCost || '0',
|
||||
current_quantity: product.novQuantity || '0',
|
||||
current_cost: product.novCost || '0',
|
||||
forecast_quantity: product.decQuantity || '0',
|
||||
forecast_cost: product.decCost || '0',
|
||||
variation_reason_master_id: parseInt(product.variationReason) || 0,
|
||||
other_variation_reason: product.otherVariationReason || '',
|
||||
zero_target_reason_master_id: parseInt(product.zeroTargetReason) || 0,
|
||||
other_zero_target_reason: product.otherZeroTargetReason || '',
|
||||
remarks: product.remarks || remarks || ''
|
||||
};
|
||||
});
|
||||
|
||||
// Prepare the complete payload with values from currentSubmission
|
||||
const payload = {
|
||||
establishment_id: establishmentId,
|
||||
quarter: effectiveQuarter,
|
||||
year: effectiveYear,
|
||||
emirati_male: establishment.emirati_male || 0,
|
||||
emirati_female: establishment.emirati_female || 0,
|
||||
non_emirati_male: establishment.non_emirati_male || 0,
|
||||
non_emirati_female: establishment.non_emirati_female || 0,
|
||||
total_emirati: establishment.total_emirati || 0,
|
||||
total_employees: establishment.total_employees || 0,
|
||||
status: "Draft",
|
||||
created_by: createdById,
|
||||
products: formattedProducts
|
||||
};
|
||||
|
||||
// Example: const response = await api.saveDraft(payload);
|
||||
let response;
|
||||
if (submissionId) {
|
||||
// Update existing submission
|
||||
response = await resubmitSurvey(submissionId, payload);
|
||||
setToast({
|
||||
show: true,
|
||||
message: 'Draft Updated Successfully',
|
||||
type: 'success'
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = '/dashboard'; // Update this path if your dashboard route is different
|
||||
}, 1000);
|
||||
// setShowDraftSaved(true);
|
||||
} else {
|
||||
// Create new submission
|
||||
response = await submitSurvey(payload);
|
||||
|
||||
// Update local storage with the new submission ID if this was a create operation
|
||||
if (response && response.submission_data) {
|
||||
localStorage.setItem('currentSubmission', JSON.stringify(response.submission_data));
|
||||
}
|
||||
setToast({
|
||||
show: true,
|
||||
message: 'Draft Saved Successfully',
|
||||
type: 'success'
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = '/dashboard'; // Update this path if your dashboard route is different
|
||||
}, 1000);
|
||||
// setShowDraftSaved(true);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving draft:', error);
|
||||
alert(error.message || 'Failed to save draft');
|
||||
} finally {
|
||||
setIsSavingDraft(false);
|
||||
}
|
||||
};
|
||||
const handleSubmit = () => {
|
||||
try {
|
||||
const submissionData = formatSubmissionData(
|
||||
{ ...establishment, quarter, year },
|
||||
products,
|
||||
remarks
|
||||
);
|
||||
onSubmit(submissionData);
|
||||
setConfirm(false);
|
||||
} catch (error) {
|
||||
console.error('Error formatting submission data:', error);
|
||||
if (onToastClose) {
|
||||
onToastClose({
|
||||
type: 'error',
|
||||
message: 'Failed to prepare data for submission. Please check your input.'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
const establishmentDetails = React.useMemo(() => buildEstablishmentDetails(establishment, emirateName), [establishment, emirateName]);
|
||||
const establishmentDetails = React.useMemo(() => buildEstablishmentDetails(establishment), [establishment]);
|
||||
const employmentDetails = React.useMemo(() => buildEmploymentDetails(establishment), [establishment]);
|
||||
const productRows = React.useMemo(
|
||||
() => buildProductRows(products, { variationReasons, zeroTargetReasons }),
|
||||
[products, variationReasons, zeroTargetReasons]
|
||||
);
|
||||
const productRows = React.useMemo(() => buildProductRows(products), [products]);
|
||||
|
||||
const infoBadgeClass = 'inline-flex items-center rounded-[10px] border border-[#E5D7AA] bg-[#FDF9EB] px-3 py-1 text-sm font-medium text-[#92722A]';
|
||||
|
||||
@ -689,14 +269,7 @@ if (!establishment) {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{toast.show && toast.message && (
|
||||
<CustomToast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast(prev => ({ ...prev, show: false }))}
|
||||
/>
|
||||
)}
|
||||
{toast.show && toast.message && (
|
||||
{toast && (
|
||||
<div
|
||||
className={`flex items-start justify-between gap-3 rounded-md border px-4 py-3 text-sm ${
|
||||
toast.type === 'success'
|
||||
@ -824,11 +397,6 @@ if (!establishment) {
|
||||
<span className="font-medium">Variation Reason:</span> {product.variationReason}
|
||||
</p>
|
||||
)}
|
||||
{product.remarks && (
|
||||
<p className="text-xs text-gray-600">
|
||||
<span className="font-medium">Remarks:</span> {product.remarks}
|
||||
</p>
|
||||
)}
|
||||
{product.zeroTargetReason && product.zeroTargetReason !== '—' && (
|
||||
<p className="text-xs text-gray-600">
|
||||
<span className="font-medium">Zero Target Reason:</span> {product.zeroTargetReason}
|
||||
@ -870,8 +438,7 @@ if (!establishment) {
|
||||
<span>I hereby confirm that all data entered is accurate and complete to the best of my knowledge.</span>
|
||||
</label>
|
||||
</div> */}
|
||||
{/* changes done here */}
|
||||
<h2 className="text-xl font-semibold text-[#232528] mb-2">Review & Submit</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Establishment and Employment Information */}
|
||||
<section className="min-h-[219px] rounded-[16px] border border-[#E6EAF5] bg-white px-0 py-6 shadow-[0_16px_32px_rgba(15,23,42,0.08)]">
|
||||
@ -1007,11 +574,11 @@ if (!establishment) {
|
||||
<section key={product.id || index} className="border border-gray-200 rounded-md bg-white p-4 mb-6 shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="text-sm font-bold text-[#232528]">
|
||||
<span className="font-bold">Product:</span>{' '}
|
||||
<h3 className="text-sm font-semibold text-[#2E2E2E]">
|
||||
<span className="text-gray-500">Product:</span>{' '}
|
||||
<span className="font-normal">{product.product}</span>
|
||||
{product.productCode && product.productCode !== product.product && (
|
||||
<span className="text-gray-400 text-xs ml-2"></span>
|
||||
<span className="text-gray-400 text-xs ml-2">(ID: {product.productCode})</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="flex gap-3">
|
||||
@ -1026,15 +593,6 @@ if (!establishment) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remarks Section */}
|
||||
{/* {remarks && (
|
||||
<Card title="Remarks" className="mb-6">
|
||||
<div className="p-4 bg-gray-50 rounded-md">
|
||||
<p className="text-gray-700 whitespace-pre-line">{remarks}</p>
|
||||
</div>
|
||||
</Card>
|
||||
)} */}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-300 text-sm text-center">
|
||||
@ -1043,83 +601,28 @@ if (!establishment) {
|
||||
<th rowSpan="2" className="border border-gray-300 py-2 px-3 text-left bg-[#F2ECCF] w-[140px]">
|
||||
Metric
|
||||
</th>
|
||||
{isLoading ? (
|
||||
<th colSpan={shouldHidePreviousQuarter && shouldHideForecast ? "3" : (shouldHidePreviousQuarter || shouldHideForecast ? "6" : "9")} className="border border-gray-300 py-2 px-3 bg-[#F2ECCF]">
|
||||
Loading quarters...
|
||||
</th>
|
||||
) : error ? (
|
||||
<th colSpan={shouldHidePreviousQuarter && shouldHideForecast ? "3" : (shouldHidePreviousQuarter || shouldHideForecast ? "6" : "9")} className="border border-gray-300 py-2 px-3 bg-[#F2ECCF] text-red-500">
|
||||
{error}
|
||||
</th>
|
||||
) : (
|
||||
<>
|
||||
{!shouldHidePreviousQuarter && (
|
||||
<th colSpan="3" className="border border-gray-300 py-2 px-3 bg-[#F2ECCF]">
|
||||
{quarterPeriods?.previous_quarter}-{quarterPeriods?.previous_year} (Previous Quarter)
|
||||
</th>
|
||||
)}
|
||||
<th colSpan="3" className="border border-gray-300 py-2 px-3 bg-[#F2ECCF]">
|
||||
{quarterPeriods?.current_quarter}-{quarterPeriods?.current_year} (Current Quarter)
|
||||
</th>
|
||||
{!shouldHideForecast && (
|
||||
<th colSpan="3" className="border border-gray-300 py-2 px-3 bg-[#F2ECCF]">
|
||||
{quarterPeriods?.forecast_quarter}-{quarterPeriods?.forecast_year} (Next Quarter)
|
||||
</th>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<th colSpan="3" className="border border-gray-300 py-2 px-3 bg-[#F2ECCF]">
|
||||
Q4-2024 (Previous Quarter)
|
||||
</th>
|
||||
<th colSpan="3" className="border border-gray-300 py-2 px-3 bg-[#F2ECCF]">
|
||||
Q1-2025 (Current Quarter)
|
||||
</th>
|
||||
<th colSpan="3" className="border border-gray-300 py-2 px-3 bg-[#F2ECCF]">
|
||||
Q2-2025 (Next Quarter)
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
{/* Second header row */}
|
||||
<tr className="bg-white text-[13px] font-medium">
|
||||
{isLoading ? (
|
||||
<th colSpan={shouldHidePreviousQuarter && shouldHideForecast ? "3" : (shouldHidePreviousQuarter || shouldHideForecast ? "6" : "9")} className="py-2">Loading months...</th>
|
||||
) : error ? (
|
||||
<th colSpan={shouldHidePreviousQuarter && shouldHideForecast ? "3" : (shouldHidePreviousQuarter || shouldHideForecast ? "6" : "9")} className="py-2 text-red-500">{error}</th>
|
||||
) : (
|
||||
<>
|
||||
{/* Previous Quarter Months */}
|
||||
{!shouldHidePreviousQuarter && (
|
||||
<>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#E9F6FF] text-[#286CFF]">
|
||||
{quarterPeriods?.previous_month?.previous_period_one}
|
||||
</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#E9F6FF] text-[#286CFF]">
|
||||
{quarterPeriods?.previous_month?.previous_period_two}
|
||||
</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#E9F6FF] text-[#286CFF]">
|
||||
{quarterPeriods?.previous_month?.previous_period_three}
|
||||
</th>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Current Quarter Months */}
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#F3FAF4] text-[#2F663C]">
|
||||
{quarterPeriods?.current_month?.current_period_one}
|
||||
</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#F3FAF4] text-[#2F663C]">
|
||||
{quarterPeriods?.current_month?.current_period_two}
|
||||
</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#F3FAF4] text-[#2F663C]">
|
||||
{quarterPeriods?.current_month?.current_period_three}
|
||||
</th>
|
||||
|
||||
{/* Next Quarter Months */}
|
||||
{!shouldHideForecast && (
|
||||
<>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#FFF7E9] text-[#F29F0E]">
|
||||
{quarterPeriods?.forecast_month?.forecast_period_one}
|
||||
</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#FFF7E9] text-[#F29F0E]">
|
||||
{quarterPeriods?.forecast_month?.forecast_period_two}
|
||||
</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#FFF7E9] text-[#F29F0E]">
|
||||
{quarterPeriods?.forecast_month?.forecast_period_three}
|
||||
</th>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#E9F6FF] text-[#286CFF]">Oct</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#E9F6FF] text-[#286CFF]">Nov</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#E9F6FF] text-[#286CFF]">Dec</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#F3FAF4] text-[#2F663C]">Jan</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#F3FAF4] text-[#2F663C]">Feb</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#F3FAF4] text-[#2F663C]">Mar</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#FFF7E9] text-[#F29F0E]">Apr</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#FFF7E9] text-[#F29F0E]">May</th>
|
||||
<th className="border border-gray-300 py-2 px-3 bg-[#FFF7E9] text-[#F29F0E]">Jun</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@ -1129,47 +632,31 @@ if (!establishment) {
|
||||
<td className="border border-gray-300 py-2 px-3 text-left font-medium">
|
||||
Quantity ({product.unit || 'units'})
|
||||
</td>
|
||||
{!shouldHidePreviousQuarter && (
|
||||
<>
|
||||
<td className="border border-gray-300 py-2">{product.q4_oct_quantity || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q4_nov_quantity || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q4_dec_quantity || '—'}</td>
|
||||
</>
|
||||
)}
|
||||
<td className="border border-gray-300 py-2">{product.q4_oct_quantity || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q4_nov_quantity || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q4_dec_quantity || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q1_jan_quantity || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q1_feb_quantity || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q1_mar_quantity || '—'}</td>
|
||||
{!shouldHideForecast && (
|
||||
<>
|
||||
<td className="border border-gray-300 py-2">{product.q2_apr_quantity || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q2_may_quantity || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q2_jun_quantity || '—'}</td>
|
||||
</>
|
||||
)}
|
||||
<td className="border border-gray-300 py-2">{product.q2_apr_quantity || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q2_may_quantity || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q2_jun_quantity || '—'}</td>
|
||||
</tr>
|
||||
|
||||
{/* Cost Row */}
|
||||
<tr>
|
||||
<td className="border border-gray-300 py-2 px-3 text-left font-medium">
|
||||
Value (AED)
|
||||
Cost (AED)
|
||||
</td>
|
||||
{!shouldHidePreviousQuarter && (
|
||||
<>
|
||||
<td className="border border-gray-300 py-2">{product.q4_oct_cost || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q4_nov_cost || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q4_dec_cost || '—'}</td>
|
||||
</>
|
||||
)}
|
||||
<td className="border border-gray-300 py-2">{product.q4_oct_cost || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q4_nov_cost || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q4_dec_cost || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q1_jan_cost || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q1_feb_cost || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q1_mar_cost || '—'}</td>
|
||||
{!shouldHideForecast && (
|
||||
<>
|
||||
<td className="border border-gray-300 py-2">{product.q2_apr_cost || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q2_may_cost || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q2_jun_cost || '—'}</td>
|
||||
</>
|
||||
)}
|
||||
<td className="border border-gray-300 py-2">{product.q2_apr_cost || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q2_may_cost || '—'}</td>
|
||||
<td className="border border-gray-300 py-2">{product.q2_jun_cost || '—'}</td>
|
||||
</tr>
|
||||
|
||||
{/* Reason Current */}
|
||||
@ -1177,25 +664,15 @@ if (!establishment) {
|
||||
<td className="border border-gray-300 py-2 px-3 text-left font-medium">
|
||||
Reason (Current)
|
||||
</td>
|
||||
{!shouldHidePreviousQuarter && (
|
||||
<td colSpan="3" className="border border-gray-300 py-2 text-center">
|
||||
-- --
|
||||
</td>
|
||||
)}
|
||||
<td colSpan="3" className="border border-gray-300 py-2 text-center bg-[#F3FAF4] text-[#2F663C]">
|
||||
<div className="flex flex-col items-center">
|
||||
{product.otherVariationReason ? (
|
||||
<div className="text-sm">{product.otherVariationReason}</div>
|
||||
) : (
|
||||
<div>{product.variationReasonName?.replace('Others: ', '') || product.variationReason?.replace('Others: ', '') || '—'}</div>
|
||||
)}
|
||||
</div>
|
||||
<td colSpan="3" className="border border-gray-300 py-2 text-center">
|
||||
-- --
|
||||
</td>
|
||||
<td colSpan="3" className="border border-gray-300 py-2 text-center bg-[#F3FAF4] text-[#2F663C]">
|
||||
{product.variationReason || '—'}
|
||||
</td>
|
||||
<td colSpan="3" className="border border-gray-300 py-2 text-center">
|
||||
-- --
|
||||
</td>
|
||||
{!shouldHideForecast && (
|
||||
<td colSpan="3" className="border border-gray-300 py-2 text-center">
|
||||
-- --
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
|
||||
{/* Reason Forecast */}
|
||||
@ -1203,90 +680,48 @@ if (!establishment) {
|
||||
<td className="border border-gray-300 py-2 px-3 text-left font-medium">
|
||||
Reason (Forecast)
|
||||
</td>
|
||||
{!shouldHidePreviousQuarter && (
|
||||
<td colSpan="3" className="border border-gray-300 py-2 text-center">
|
||||
-- --
|
||||
</td>
|
||||
)}
|
||||
<td colSpan="3" className="border border-gray-300 py-2 text-center">
|
||||
<td colSpan="6" className="border border-gray-300 py-2 text-center">
|
||||
-- --
|
||||
</td>
|
||||
{!shouldHideForecast && (
|
||||
<td colSpan="3" className="border border-gray-300 py-2 text-center bg-[#FFF7E9] text-[#F29F0E]">
|
||||
<div className="flex flex-col items-center">
|
||||
{product.otherZeroTargetReason ? (
|
||||
<div className="text-sm">{product.otherZeroTargetReason}</div>
|
||||
) : (
|
||||
<div>{product.zeroTargetReasonName?.replace('Others: ', '') || product.zeroTargetReason?.replace('Others: ', '') || '—'}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
<td colSpan="3" className="border border-gray-300 py-2 text-center bg-[#FFF7E9] text-[#F29F0E]">
|
||||
{product.zeroTargetReason || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Remarks */}
|
||||
{/* <div className="mt-4">
|
||||
<p className="text-sm font-semibold text-[#2E2E2E] mb-2">Remarks</p>
|
||||
<div className="border border-[#CBA344] rounded-md p-3 text-sm w-[100%] min-h-[60px]">
|
||||
{product.remarks?.trim() ? (
|
||||
product.remarks
|
||||
) : (
|
||||
<span className="text-gray-400 ">Add Short Note..</span>
|
||||
)}
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* Remarks */}
|
||||
<div className="mt-4 text-xs text-gray-600">
|
||||
<p className="font-semibold mb-1">Remarks:</p>
|
||||
<div className="border border-[#F3B340] rounded-md p-2 text-sm bg-white">
|
||||
{product.remarks || 'No remarks provided'}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
|
||||
|
||||
|
||||
{/* Remarks Section */}
|
||||
<div className="rounded-[16px] border border-[#E6EAF5] bg-white p-6 shadow-[0_16px_32px_rgba(15,23,42,0.08)] mb-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-4">Remarks</h3>
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
readOnly
|
||||
value={remarks || ''}
|
||||
placeholder="No remarks provided"
|
||||
className="w-full min-h-[100px] p-3 border border-gray-200 rounded-md bg-gray-50 text-gray-700 resize-none focus:outline-none focus:ring-0 focus:border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation and Buttons */}
|
||||
<div className="rounded-[16px] border border-[#E6EAF5] bg-white p-6 shadow-[0_16px_32px_rgba(15,23,42,0.08)]">
|
||||
{!hasSubmitted && (
|
||||
<div className="mb-6 flex items-start gap-2 rounded-md bg-[#E7F0FF] border border-[#C6DBFF] px-4 py-3 text-sm text-[#2563EB]">
|
||||
<img
|
||||
src={agreeIconSrc}
|
||||
alt="Info"
|
||||
className="w-5 h-4 mt-[2px] flex-shrink-0"
|
||||
/>
|
||||
<p className="leading-snug">
|
||||
Review your data carefully before submission. You can go back to previous steps to edit any details. Once submitted, changes will require admin approval.
|
||||
</p>
|
||||
<div className="mb-6 rounded-md bg-[#E7F0FF] border border-[#C6DBFF] px-4 py-3 text-sm text-[#2563EB]">
|
||||
Review your data carefully before submission. You can go back to previous steps to edit any details. Once submitted, changes will require admin approval.
|
||||
</div>
|
||||
)}
|
||||
{/* </div> */}
|
||||
|
||||
|
||||
<div className="bg-white rounded-md">
|
||||
<label className="flex items-start gap-3 text-sm text-[#4B5563]">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-[#92722A] border-gray-300 rounded focus:ring-[#92722A]"
|
||||
checked={confirm}
|
||||
disabled={hasSubmitted}
|
||||
onChange={(e) => setConfirm(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<span className="leading-relaxed">I hereby confirm that all data entered is accurate and complete to the best of my knowledge.</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 text-[#92722A] border-gray-300 rounded"
|
||||
checked={confirm}
|
||||
disabled={hasSubmitted}
|
||||
onChange={(e) => setConfirm(e.target.checked)}
|
||||
/>
|
||||
<span>I hereby confirm that all data entered is accurate and complete to the best of my knowledge.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@ -1298,30 +733,19 @@ if (!establishment) {
|
||||
<img src={caretDownSrc} alt="" className="h-4 w-4 rotate-90" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<div className="flex gap-4">
|
||||
{!isResubmit && (
|
||||
<button
|
||||
className="inline-flex items-center justify-center h-9 px-4 rounded-md border border-[#92722A] text-[#92722A] bg-transparent hover:bg-[#F5F0E1] transition-colors"
|
||||
onClick={handleSaveDraft}
|
||||
disabled={isSavingDraft}
|
||||
>
|
||||
{isSavingDraft ? 'Saving...' : 'Save as Draft'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
disabled={!confirm || isSubmitting}
|
||||
disabled={!confirm || isSubmitting || hasSubmitted}
|
||||
onClick={onSubmit}
|
||||
className={`inline-flex items-center justify-center h-9 px-5 rounded-md transition-colors ${
|
||||
!confirm || isSubmitting
|
||||
!confirm || isSubmitting || hasSubmitted
|
||||
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-[#92722A] text-white hover:bg-[#7b5c1f]'
|
||||
}`}
|
||||
>
|
||||
<span>{submitButtonText}</span>
|
||||
<span>{submitLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
export const ASSETS = {
|
||||
IMAGES: {
|
||||
LOGO: '/assets/images/FCSCLogo.svg'
|
||||
},
|
||||
CLASSES: {
|
||||
AUTH_CONTAINER: 'min-h-screen bg-[#F7F7F7] flex items-center justify-center px-4'
|
||||
}
|
||||
};
|
||||
@ -1,16 +0,0 @@
|
||||
export const VALIDATION_MESSAGES = {
|
||||
OLD_PASSWORD_REQUIRED: 'Old password is required',
|
||||
PASSWORD_REQUIRED: 'Password is required',
|
||||
PASSWORD_MIN_LENGTH: 'Password must be at least 8 characters',
|
||||
PASSWORD_UPPERCASE: 'Must contain at least one uppercase letter',
|
||||
PASSWORD_LOWERCASE: 'Must contain at least one lowercase letter',
|
||||
PASSWORD_NUMBER: 'Must contain at least one number',
|
||||
PASSWORD_SPECIAL_CHAR: 'Must contain at least one special character (!@#$%^&*)',
|
||||
CONFIRM_PASSWORD_REQUIRED: 'Please confirm your password',
|
||||
PASSWORDS_DONT_MATCH: 'Passwords do not match',
|
||||
INCORRECT_CURRENT_PASSWORD: 'The current password is incorrect',
|
||||
PASSWORD_SAME_AS_CURRENT: 'New password cannot be the same as current password',
|
||||
PASSWORD_TOO_WEAK: 'Password is too weak. Use a stronger password.',
|
||||
PASSWORD_COMPLEXITY_ERROR: 'Password must meet complexity requirements.'
|
||||
|
||||
};
|
||||
@ -3,13 +3,11 @@ import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from '@/App';
|
||||
import '@/index.css';
|
||||
import '@/utils/chartsetup';
|
||||
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@ -1,16 +1,15 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import AdminHeader from '@/components/admin/AdminHeader';
|
||||
import StatCard from '@/components/admin/StatCard';
|
||||
import SubmissionTable from '@/components/admin/SubmissionTable';
|
||||
import apiClient from '@/services/api/apiClient';
|
||||
import Footer from '@/components/common/Footer';
|
||||
|
||||
const surveyIconSrc = '/assets/images/wpf_survey.svg';
|
||||
const submittedIconSrc = '/assets/images/check-circle-fill 1.svg';
|
||||
const approvedIconSrc = '/assets/images/check-circle-fill 1-approved.svg';
|
||||
const rejectedIconSrc = '/assets/images/warning-circle-fill 1.svg';
|
||||
const overdueIconSrc = '/assets/images/warning-fill 1.svg';
|
||||
const pendingIconSrc = '/assets/images/info-fill 1.svg';
|
||||
const dueDateIconSrc = '/assets/images/Duedate.svg';
|
||||
|
||||
const AdminDashboard = () => {
|
||||
const [summary, setSummary] = useState({
|
||||
@ -21,87 +20,41 @@ const AdminDashboard = () => {
|
||||
not_started: 0,
|
||||
pending: 0,
|
||||
});
|
||||
const [quarterlyWindows, setQuarterlyWindows] = useState({
|
||||
end_date: null,
|
||||
start_date: null,
|
||||
grace_periods_days: 0
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Function to get current quarter (1-4)
|
||||
const getCurrentQuarter = () => {
|
||||
const month = new Date().getMonth();
|
||||
return `Q${Math.floor(month / 3) + 1}`;
|
||||
};
|
||||
|
||||
const [selectedQuarter, setSelectedQuarter] = useState(getCurrentQuarter());
|
||||
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear().toString());
|
||||
const isMounted = useRef(false);
|
||||
const prevParams = useRef({ quarter: null, year: null });
|
||||
|
||||
const fetchDashboardData = React.useCallback(async (quarter = 'All', year = 'All') => {
|
||||
// Skip if params haven't changed
|
||||
if (prevParams.current.quarter === quarter && prevParams.current.year === year) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update previous params
|
||||
prevParams.current = { quarter, year };
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Always send both quarter and year, but use 'All' if not specified
|
||||
params.append('quarter', quarter || 'All');
|
||||
params.append('year', year || 'All');
|
||||
|
||||
const response = await apiClient.get(`/admin_dashboard?${params.toString()}`, { withCredentials: true });
|
||||
|
||||
if (!isMounted.current) return;
|
||||
|
||||
const data = response?.data?.summary || {};
|
||||
const quarterlyWindowsData = response?.data?.quarterly_windows || {};
|
||||
setSummary({
|
||||
total_establishments: data.total_establishments || 0,
|
||||
submitted: data.submitted || 0,
|
||||
approved: data.approved || 0,
|
||||
rejected: data.rejected || 0,
|
||||
not_started: data.not_started || 0,
|
||||
pending: data.pending || 0,
|
||||
});
|
||||
|
||||
setQuarterlyWindows(prev => ({
|
||||
...prev,
|
||||
end_date: quarterlyWindowsData.end_date,
|
||||
start_date: quarterlyWindowsData.start_date,
|
||||
grace_periods_days: quarterlyWindowsData.grace_periods_days || 0
|
||||
}));
|
||||
|
||||
// Keep the selected values as they are
|
||||
// The dropdowns will maintain their current selection
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard summary:', error);
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
const [selectedQuarter, setSelectedQuarter] = useState('None');
|
||||
const [selectedYear, setSelectedYear] = useState('None');
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
fetchDashboardData(selectedQuarter, selectedYear);
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.get('/admin_dashboard');
|
||||
const data = response?.data?.summary || {};
|
||||
const notStartedCombined = (data.not_started || 0) + (data.overdue || 0);
|
||||
|
||||
setSummary({
|
||||
total_establishments: data.total_establishments || 0,
|
||||
submitted: data.submitted || 0,
|
||||
approved: data.approved || 0,
|
||||
rejected: data.rejected || 0,
|
||||
not_started: notStartedCombined,
|
||||
pending: data.pending || 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard summary:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [fetchDashboardData, selectedQuarter, selectedYear]);
|
||||
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F6F5F1]">
|
||||
<AdminHeader />
|
||||
<div className="max-w-[1280px] mx-auto box-border px-4 py-4 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="max-w-[1280px] mx-auto box-border px-4 py-8 flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between w-full max-w-[1280px] mx-auto mt-6 px-4">
|
||||
<h1 className="text-[18px] font-semibold text-[#232528]">
|
||||
Admin Dashboard{' '}
|
||||
<span className="font-normal text-[#5F646D]">– Survey Overview</span>
|
||||
@ -117,11 +70,11 @@ const AdminDashboard = () => {
|
||||
onChange={(e) => setSelectedQuarter(e.target.value)}
|
||||
className="border border-[#D0D5DD] rounded-md h-8 px-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#92722A]"
|
||||
>
|
||||
<option value="All">All</option>
|
||||
<option value="Q1">Q1</option>
|
||||
<option value="Q2">Q2</option>
|
||||
<option value="Q3">Q3</option>
|
||||
<option value="Q4">Q4</option>
|
||||
<option>None</option>
|
||||
<option>Q1</option>
|
||||
<option>Q2</option>
|
||||
<option>Q3</option>
|
||||
<option>Q4</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
@ -129,74 +82,58 @@ const AdminDashboard = () => {
|
||||
onChange={(e) => setSelectedYear(e.target.value)}
|
||||
className="border border-[#D0D5DD] rounded-md h-8 px-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#92722A]"
|
||||
>
|
||||
<option value="All">All</option>
|
||||
{Array.from(
|
||||
{ length: new Date().getFullYear() - 2021 },
|
||||
(_, i) => new Date().getFullYear() - i
|
||||
).map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
))}
|
||||
<option>None</option>
|
||||
<option>2025</option>
|
||||
<option>2024</option>
|
||||
<option>2023</option>
|
||||
<option>2022</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<div className="cursor-pointer mx-auto flex w-full max-w-[1280px] items-center gap-4">
|
||||
<div className="mx-auto flex w-full max-w-[1280px] items-center gap-4">
|
||||
<StatCard
|
||||
label="Total Establishments"
|
||||
value={loading ? '--' : summary.total_establishments}
|
||||
width={200}
|
||||
className="flex-1 min-w-0"
|
||||
width={290}
|
||||
icon={<img src={surveyIconSrc} alt="Total Establishments" className="h-5 w-5" />}
|
||||
tooltipText="Total establishments enrolled in the survey for selected quarter."
|
||||
/>
|
||||
<StatCard
|
||||
label="Submitted"
|
||||
value={loading ? '--' : summary.submitted}
|
||||
width={200}
|
||||
className="flex-1 min-w-0"
|
||||
width={190}
|
||||
icon={<img src={submittedIconSrc} alt="Submitted" className="h-5 w-5" />}
|
||||
tooltipText="Establishments that have submitted all products for this quarter."
|
||||
/>
|
||||
<StatCard
|
||||
label="Approved"
|
||||
value={loading ? '--' : summary.approved}
|
||||
width={200}
|
||||
className="flex-1 min-w-0"
|
||||
width={190}
|
||||
icon={<img src={approvedIconSrc} alt="Approved" className="h-5 w-5" />}
|
||||
tooltipText="Submissions fully validated and approved."
|
||||
/>
|
||||
<StatCard
|
||||
label="Rejected"
|
||||
value={loading ? '--' : summary.rejected}
|
||||
width={200}
|
||||
className="flex-1 min-w-0"
|
||||
width={190}
|
||||
icon={<img src={rejectedIconSrc} alt="Rejected" className="h-5 w-5" />}
|
||||
tooltipText="Submissions rejected and pending resubmission."
|
||||
/>
|
||||
<StatCard
|
||||
label="Not Started"
|
||||
value={loading ? '--' : summary.not_started}
|
||||
width={200}
|
||||
className="flex-1 min-w-0"
|
||||
width={190}
|
||||
icon={<img src={overdueIconSrc} alt="Not Started" className="h-5 w-5" />}
|
||||
tooltipText="Establishments past submission deadline."
|
||||
/>
|
||||
<StatCard
|
||||
label="Due Date"
|
||||
labelClassName="text-sm"
|
||||
value={loading ? '--' :
|
||||
(selectedQuarter === 'All' || selectedYear === 'All') ? 'NA' :
|
||||
(quarterlyWindows.end_date ? new Date(quarterlyWindows.end_date).toLocaleDateString('en-GB') : '--')}
|
||||
valueClassName="text-sm"
|
||||
width={200}
|
||||
className="flex-1 min-w-0"
|
||||
icon={<img src={dueDateIconSrc} alt="Due Date" className="h-5 w-5" />}
|
||||
tooltipText={selectedQuarter === 'All' || selectedYear === 'All' ?
|
||||
"Due date is not applicable when viewing all quarters or years" :
|
||||
`Submission deadline for ${selectedQuarter} ${selectedYear}`}
|
||||
label="Pending"
|
||||
value={loading ? '--' : summary.pending}
|
||||
width={190}
|
||||
icon={<img src={pendingIconSrc} alt="Pending" className="h-5 w-5" />}
|
||||
tooltipText="Establishments not yet submitted."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -206,7 +143,6 @@ const AdminDashboard = () => {
|
||||
selectedYear={selectedYear}
|
||||
/>
|
||||
</div>
|
||||
<Footer/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import AdminHeader from '@/components/admin/AdminHeader';
|
||||
// import AdminUsersContent from './configuration/AdminUsers';
|
||||
import AdminUsersContent from './configuration/AdminUsers';
|
||||
|
||||
const caretUpSrc = '/assets/images/caret-up.svg';
|
||||
|
||||
|
||||
@ -1,89 +1,31 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useLocation } from "react-router-dom";
|
||||
import AdminHeader from "@/components/admin/AdminHeader";
|
||||
import QuarterlyWindows from "@/pages/Admin/configuration/QuarterlyWindows";
|
||||
import IsicHsCodes from "@/pages/Admin/configuration/IsicHsCodes";
|
||||
import UnitMaster from "@/pages/Admin/configuration/UnitMaster";
|
||||
import AdminUsers from "@/pages/Admin/configuration/AdminUsers/ListAdminUsers";
|
||||
import CompanyProfile from "@/pages/Admin/configuration/CompanyProfile";
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import AdminHeader from '@/components/admin/AdminHeader';
|
||||
import QuarterlyWindows from '@/pages/Admin/configuration/QuarterlyWindows';
|
||||
import IsicHsCodes from '@/pages/Admin/configuration/IsicHsCodes';
|
||||
import UnitMaster from '@/pages/Admin/configuration/UnitMaster';
|
||||
import AdminUsers from '@/pages/Admin/configuration/AdminUsers';
|
||||
import CompanyProfile from '@/pages/Admin/configuration/CompanyProfile';
|
||||
|
||||
const caretUpSrc = '/assets/images/caret-up.svg';
|
||||
|
||||
const Configuration = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const tabs = ['Quarterly Windows', 'HS Codes', 'Unit Master', 'Admin Users', 'Company Profile'];
|
||||
const [active, setActive] = React.useState(tabs[0]);
|
||||
|
||||
const tabs = [
|
||||
"Quarterly Windows",
|
||||
"HS Codes",
|
||||
"Unit Master",
|
||||
"Admin Users",
|
||||
"Company Profile",
|
||||
];
|
||||
|
||||
const [active, setActive] = useState(tabs[0]);
|
||||
|
||||
// ✅ Map tab names to routes
|
||||
const tabToPath = {
|
||||
"Quarterly Windows": "quarterly",
|
||||
"HS Codes": "hscodes",
|
||||
"Unit Master": "unitmaster",
|
||||
"Admin Users": "admin-users",
|
||||
"Company Profile": "profile",
|
||||
};
|
||||
|
||||
// Map paths to tab names
|
||||
const pathToTab = {
|
||||
quarterly: "Quarterly Windows",
|
||||
hscodes: "HS Codes",
|
||||
unitmaster: "Unit Master",
|
||||
"admin-users": "Admin Users",
|
||||
profile: "Company Profile",
|
||||
};
|
||||
|
||||
// Navigate to URL when tab changes
|
||||
useEffect(() => {
|
||||
if (tabToPath[active]) {
|
||||
navigate(`/admin/configuration/${tabToPath[active]}`, { replace: true });
|
||||
}
|
||||
}, [active, navigate]);
|
||||
|
||||
// Update active tab when URL changes
|
||||
useEffect(() => {
|
||||
const path = location.pathname.split("/").pop();
|
||||
if (pathToTab[path] && pathToTab[path] !== active) {
|
||||
setActive(pathToTab[path]);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
//Breadcrumb with ">" separators
|
||||
const Breadcrumbs = (
|
||||
<nav className="mb-6 text-sm text-[#8F9299] flex items-center gap-2">
|
||||
<Link
|
||||
to="/admin/dashboard"
|
||||
className="text-[#232528] hover:underline font-medium flex items-center gap-1"
|
||||
>
|
||||
<nav className="mb-6 text-sm text-[#8F9299] flex items-center gap-3">
|
||||
<Link to="/admin/dashboard" className="flex items-center gap-1 text-[#232528] hover:underline">
|
||||
Dashboard
|
||||
<img src="/assets/images/Breadcrumb-vector.svg" alt="" className=" ml-1 w-3 h-3" />
|
||||
<img src={caretUpSrc} alt="Dashboard" className="h-3 w-3" />
|
||||
</Link>
|
||||
{/* <span className="text-[#8F9299]">{">"}</span> */}
|
||||
|
||||
<Link
|
||||
to="/admin/configuration"
|
||||
className="text-[#232528] hover:underline font-medium"
|
||||
>
|
||||
<span className="flex items-center gap-1 text-[#92722A] font-medium">
|
||||
Configuration
|
||||
|
||||
</Link>
|
||||
|
||||
{active && (
|
||||
<>
|
||||
<img src="/assets/images/Breadcrumb-vector.svg" alt="" className=" ml-1 w-3 h-3" />
|
||||
<span className="text-[#92722A] font-medium">{active}</span>
|
||||
</>
|
||||
)}
|
||||
<img src={caretUpSrc} alt="Configuration" className="h-3 w-3" />
|
||||
</span>
|
||||
</nav>
|
||||
);
|
||||
|
||||
// Tab header
|
||||
const PageHeader = (
|
||||
<div className="w-full mb-6">
|
||||
<div className="h-14 w-full border-b border-[#E5E7EB] flex items-end">
|
||||
@ -95,8 +37,8 @@ const Configuration = () => {
|
||||
onClick={() => setActive(t)}
|
||||
className={`pb-3 text-sm whitespace-nowrap border-b-2 cursor-pointer transition-colors ${
|
||||
active === t
|
||||
? "text-[#92722A] border-[#92722A] font-medium"
|
||||
: "text-[#232528] border-transparent hover:text-[#92722A]"
|
||||
? 'text-[#92722A] border-[#92722A] font-medium'
|
||||
: 'text-[#232528] border-transparent hover:text-[#92722A]'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
@ -107,29 +49,11 @@ const Configuration = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render selected tab content
|
||||
const renderActiveTab = () => {
|
||||
switch (active) {
|
||||
case "Quarterly Windows":
|
||||
return <QuarterlyWindows />;
|
||||
case "HS Codes":
|
||||
return <IsicHsCodes />;
|
||||
case "Unit Master":
|
||||
return <UnitMaster />;
|
||||
case "Admin Users":
|
||||
return <AdminUsers />;
|
||||
case "Company Profile":
|
||||
return <CompanyProfile />;
|
||||
default:
|
||||
return (
|
||||
<div className="bg-white w-full rounded-lg shadow-sm ring-1 ring-gray-200">
|
||||
<div className="px-6 py-10 text-sm text-[#5F646D]">
|
||||
{active} content coming soon…
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
const isQuarterTab = active === 'Quarterly Windows';
|
||||
const isIsicTab = active === 'HS Codes';
|
||||
const isUnitMasterTab = active === 'Unit Master';
|
||||
const isAdminTab = active === 'Admin Users';
|
||||
const isCompanyProfileTab = active === 'Company Profile';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F7F7F7]">
|
||||
@ -137,7 +61,22 @@ const Configuration = () => {
|
||||
<div className="max-w-[1280px] mx-auto px-4 py-6">
|
||||
{Breadcrumbs}
|
||||
{PageHeader}
|
||||
{renderActiveTab()}
|
||||
|
||||
{isQuarterTab && <QuarterlyWindows />}
|
||||
|
||||
{isIsicTab && <IsicHsCodes />}
|
||||
|
||||
{isUnitMasterTab && <UnitMaster />}
|
||||
|
||||
{isAdminTab && <AdminUsers />}
|
||||
|
||||
{isCompanyProfileTab && <CompanyProfile />}
|
||||
|
||||
{!isQuarterTab && !isIsicTab && !isUnitMasterTab && !isAdminTab && !isCompanyProfileTab && (
|
||||
<div className="bg-white w-full rounded-lg shadow-sm ring-1 ring-gray-200">
|
||||
<div className="px-6 py-10 text-sm text-[#5F646D]">{active} content coming soon…</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -4,61 +4,46 @@ import AdminHeader from '@/components/admin/AdminHeader';
|
||||
import Table from '@/components/common/Table';
|
||||
import { SelectField } from '@/components/common/FormControls';
|
||||
import { getSubmissions } from '@/services/admin/submission';
|
||||
import { CheckCircle2, XCircle, Eye, X } from 'lucide-react';
|
||||
import { CheckCircle2, XCircle, Eye } from 'lucide-react';
|
||||
import Footer from '@/components/common/Footer';
|
||||
const searchIconSrc = '/assets/images/material-symbols_search-rounded.svg';
|
||||
const caretUpSrc = '/assets/images/caret-up.svg';
|
||||
import apiClient from '@/services/api/apiClient';
|
||||
import { putRequest } from '@/services/api/CommonService';
|
||||
|
||||
const STATUS_CLASSES = {
|
||||
Approved: 'bg-green-50 text-green-800 border-green-50',
|
||||
Pending: 'bg-blue-50 text-blue-600 border-blue-50',
|
||||
Rejected: 'bg-red-50 text-red-700 border-red-50',
|
||||
Resubmitted: 'bg-amber-50 text-amber-800 border-amber-200',
|
||||
Submitted: 'bg-blue-50 text-blue-600 border-blue-50',
|
||||
default: 'bg-gray-50 text-gray-600 border-gray-200'
|
||||
};
|
||||
|
||||
const STATUS_VARIANTS = {
|
||||
Approved: { background: '#F3FAF4', text: '#2F663C', border: '#F3FAF4' },
|
||||
Pending: { background: '#E7F5FF', text: '#286CFF', border: '#E7F5FF' },
|
||||
Rejected: { background: '#FEF2F2', text: '#B52520', border: '#FEF2F2' },
|
||||
Resubmitted: { background: '#F9F7ED', text: '#7C5E24', border: '#7C5E24' },
|
||||
Submitted: { background: '#E7F5FF', text: '#003CFF', border: '#E7F5FF' },
|
||||
};
|
||||
|
||||
const SUMMARY_VARIANTS = {
|
||||
total: { background: '#F9F7ED', label: '#7C5E24', value: '#232528' },
|
||||
approved: { background: '#F3FAF4', label: '#2F663C', value: '#232528' },
|
||||
// pending: { background: '#E7F5FF', label: '#003CFF', value: '#232528' },
|
||||
submitted: { background: '#E7F5FF', text: '#003CFF', value: '#232528',border: '#FEF2F2' },
|
||||
pending: { background: '#E7F5FF', label: '#003CFF', value: '#232528' },
|
||||
rejected: { background: '#FEF2F2', label: '#B52520', value: '#232528' },
|
||||
resubmitted: { background: '#F9F7ED', label: '#7C5E24', value: '#7C5E24' },
|
||||
|
||||
};
|
||||
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
const formatted = date.toLocaleString('en-AE', {
|
||||
const formatted = date.toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'Asia/Dubai',
|
||||
hour12: true,
|
||||
});
|
||||
return formatted;
|
||||
return formatted.replace(/\s?(am|pm)$/i, (m) => m.toUpperCase());
|
||||
};
|
||||
|
||||
const Badge = ({ children, status }) => {
|
||||
const variantClass = STATUS_CLASSES[status] || STATUS_CLASSES.default;
|
||||
const variant = STATUS_VARIANTS[status] || { background: '#F2F4F7', text: '#475467', border: '#EAECF0' };
|
||||
return (
|
||||
<span className={`inline-flex h-6 min-w-[67px] items-center justify-center rounded px-2 text-xs font-medium border ${variantClass}`}>
|
||||
<span
|
||||
className="inline-flex h-6 min-w-[67px] items-center justify-center rounded px-2 text-xs font-medium"
|
||||
style={{ background: variant.background, color: variant.text, border: `1px solid ${variant.border}` }}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
@ -71,36 +56,13 @@ const ManageSubmissions = () => {
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState(null);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [year, setYear] = React.useState('All');
|
||||
// Function to get current quarter (1-4)
|
||||
const getCurrentQuarter = () => {
|
||||
const month = new Date().getMonth();
|
||||
return `Q${Math.floor(month / 3) + 1}`;
|
||||
};
|
||||
|
||||
const [quarter, setQuarter] = React.useState('All');
|
||||
const [emirate, setEmirate] = React.useState('All');
|
||||
const [status, setStatus] = React.useState('All');
|
||||
const [selectedQuarter, setSelectedQuarter] = React.useState('All');
|
||||
const [selectedYear, setSelectedYear] = React.useState('All');
|
||||
const [year, setYear] = React.useState('');
|
||||
const [quarter, setQuarter] = React.useState('');
|
||||
const [emirate, setEmirate] = React.useState('');
|
||||
const [status, setStatus] = React.useState('');
|
||||
const [currentPage, setCurrentPage] = React.useState(1);
|
||||
const [toast, setToast] = React.useState(null);
|
||||
const [showApproveModal, setShowApproveModal] = React.useState(false);
|
||||
const [showRejectModal, setShowRejectModal] = React.useState(false);
|
||||
const [selectedSubmission, setSelectedSubmission] = React.useState(null);
|
||||
const [isApproving, setIsApproving] = React.useState(false);
|
||||
const [isRejecting, setIsRejecting] = React.useState(false);
|
||||
const [rejectReason, setRejectReason] = React.useState('');
|
||||
const [showRejectError, setShowRejectError] = React.useState(false);
|
||||
const [pageSize, setPageSize] = React.useState(10);
|
||||
const [isInitialLoad, setIsInitialLoad] = React.useState(true);
|
||||
const [filters, setFilters] = React.useState({
|
||||
search: '',
|
||||
year: 'All',
|
||||
quarter: 'All',
|
||||
emirate: 'All',
|
||||
status: 'All'
|
||||
});
|
||||
const pageSize = 10;
|
||||
|
||||
const toastTimeoutRef = React.useRef(null);
|
||||
|
||||
@ -125,127 +87,6 @@ const ManageSubmissions = () => {
|
||||
setToast(null);
|
||||
}, []);
|
||||
|
||||
const handleApproveClick = (submission) => {
|
||||
setSelectedSubmission(submission);
|
||||
setShowApproveModal(true);
|
||||
};
|
||||
|
||||
const handleCloseApproveModal = () => {
|
||||
setShowApproveModal(false);
|
||||
setSelectedSubmission(null);
|
||||
};
|
||||
|
||||
const handleRejectClick = (submission) => {
|
||||
setSelectedSubmission(submission);
|
||||
setRejectReason('');
|
||||
setShowRejectError(false);
|
||||
setShowRejectModal(true);
|
||||
};
|
||||
|
||||
const handleCloseRejectModal = () => {
|
||||
setShowRejectModal(false);
|
||||
setSelectedSubmission(null);
|
||||
setRejectReason('');
|
||||
setShowRejectError(false);
|
||||
};
|
||||
|
||||
const handleApproveConfirm = async () => {
|
||||
if (!selectedSubmission || isApproving) return;
|
||||
|
||||
setIsApproving(true);
|
||||
try {
|
||||
const response = await putRequest(`/approveOrRejectSubmission/${selectedSubmission.id}`, {
|
||||
approve_reject_status: 1,
|
||||
reject_reason: ''
|
||||
});
|
||||
|
||||
const ok = (response && response.data) || response?.status === 200 || response?.status === 'success';
|
||||
|
||||
if (ok) {
|
||||
showToast('success', 'Submission has been approved');
|
||||
// Refresh the submissions list
|
||||
const data = await fetchDashboardData(selectedQuarter, selectedYear);
|
||||
const userProfile = JSON.parse(localStorage.getItem("user_profile"));
|
||||
const mapped = (data || []).map((item) => ({
|
||||
id: item.id,
|
||||
establishment: item?.establishment?.factory_name || '-',
|
||||
emirate: item?.establishment?.establishment_emirate?.name || '-',
|
||||
year: String(item.year ?? '-'),
|
||||
quarter: item.quarter ?? '-',
|
||||
products: item?.product_count || '-',
|
||||
submittedBy: item.created_by_name || '-',
|
||||
submittedAt: formatDateTime(item.created_at),
|
||||
status: item.status ?? '-',
|
||||
reviewer: item.reviewer_name || '-',
|
||||
reviewerOn: formatDateTime(item.updated_at ),
|
||||
}));
|
||||
setSubmissions(mapped);
|
||||
} else {
|
||||
throw new Error('Failed to approve submission');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error approving submission:', error);
|
||||
showToast('error', error.message || 'Failed to approve submission');
|
||||
} finally {
|
||||
setIsApproving(false);
|
||||
setShowApproveModal(false);
|
||||
setSelectedSubmission(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectConfirm = async () => {
|
||||
if (!selectedSubmission || isRejecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the rejection reason
|
||||
if (!rejectReason.trim()) {
|
||||
setShowRejectError(true);
|
||||
// showToast('error', 'Please provide a reason for rejection');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRejecting(true);
|
||||
try {
|
||||
const response = await putRequest(`/approveOrRejectSubmission/${selectedSubmission.id}`, {
|
||||
approve_reject_status: 0,
|
||||
reject_reason: rejectReason.trim()
|
||||
});
|
||||
|
||||
const ok = (response && response.data) || response?.status === 200 || response?.status === 'success';
|
||||
|
||||
if (ok) {
|
||||
showToast('error', 'Submission has been rejected');
|
||||
// Refresh the submissions list
|
||||
const data = await fetchDashboardData(selectedQuarter, selectedYear);
|
||||
const mapped = (data || []).map((item) => ({
|
||||
id: item.id,
|
||||
establishment: item?.establishment?.factory_name || '-',
|
||||
emirate: item?.establishment?.establishment_emirate?.name || '-',
|
||||
year: String(item.year ?? '-'),
|
||||
quarter: item.quarter ?? '-',
|
||||
products: item?.product_count || '-',
|
||||
submittedBy: item.created_by_name || '-',
|
||||
submittedAt: formatDateTime(item.created_at),
|
||||
status: item.status ?? '-',
|
||||
reviewer: item.reviewer_name || '-',
|
||||
reviewerOn: formatDateTime(item.updated_at),
|
||||
}));
|
||||
setSubmissions(mapped);
|
||||
setRejectReason('');
|
||||
} else {
|
||||
throw new Error('Failed to reject submission');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rejecting submission:', error);
|
||||
showToast('error', error.message || 'Failed to reject submission');
|
||||
} finally {
|
||||
setIsRejecting(false);
|
||||
setShowRejectModal(false);
|
||||
setSelectedSubmission(null);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (toastTimeoutRef.current) {
|
||||
@ -254,98 +95,19 @@ const ManageSubmissions = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch data when component mounts or when quarter/year changes
|
||||
React.useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null); // Reset error state before fetching
|
||||
const data = await fetchDashboardData(selectedQuarter, selectedYear);
|
||||
const mapped = (data || []).map((item) => ({
|
||||
id: item.id,
|
||||
establishment: item?.establishment?.factory_name || '-',
|
||||
emirate: item?.establishment?.establishment_emirate?.name || '-',
|
||||
year: String(item.year ?? '-'),
|
||||
quarter: item.quarter ?? '-',
|
||||
products: item?.product_count || '-',
|
||||
submittedBy: item.created_by_name || '-',
|
||||
submittedAt: formatDateTime(item.created_at),
|
||||
status: item.status || 'Pending',
|
||||
reviewer: item.reviewer_name || '-',
|
||||
reviewerOn: formatDateTime(item.updated_at || item.created_at),
|
||||
}));
|
||||
|
||||
setSubmissions(mapped);
|
||||
} catch (err) {
|
||||
console.error('Failed to load submissions', err);
|
||||
// Only show error if there are no existing submissions and it's not the initial load
|
||||
if (submissions.length === 0 && !isInitialLoad) {
|
||||
setError('Unable to load submissions. Please try again later.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsInitialLoad(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Always load data on initial render or when filters change
|
||||
loadData();
|
||||
}, [selectedQuarter, selectedYear]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (location.state?.showSuccessToast && location.state?.successMessage) {
|
||||
showToast(location.state.toastType || 'success', location.state.successMessage);
|
||||
showToast('success', location.state.successMessage);
|
||||
window.history.replaceState({}, document.title);
|
||||
}
|
||||
}, [location.state, showToast]);
|
||||
|
||||
const fetchDashboardData = async (quarter, year, page = 1, limit = 10000) => {
|
||||
try {
|
||||
const params = {
|
||||
quarter: quarter && quarter !== 'All' ? quarter : undefined,
|
||||
year: year && year !== 'All' ? year : undefined,
|
||||
page,
|
||||
limit
|
||||
};
|
||||
|
||||
// Ensure year is included in the API call if provided
|
||||
if (year && year !== 'All') {
|
||||
params.year = year;
|
||||
}
|
||||
|
||||
const result = await getSubmissions(params);
|
||||
// Check if result has a data property or is the data array itself
|
||||
const data = Array.isArray(result) ? result : (result?.data || []);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch current quarter and year from admin_dashboard endpoint and initial data
|
||||
React.useEffect(() => {
|
||||
const fetchInitialData = async () => {
|
||||
const fetchSubmissions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// First, get the current quarter and year
|
||||
const params = new URLSearchParams();
|
||||
const response = await apiClient.get('/admin_dashboard', {
|
||||
params,
|
||||
withCredentials: true
|
||||
});
|
||||
// Always use 'All' as default for both quarter and year
|
||||
const currentQuarter = 'All';
|
||||
const currentYear = 'All';
|
||||
// Set the filter states
|
||||
setSelectedQuarter(currentQuarter);
|
||||
setSelectedYear(currentYear);
|
||||
setQuarter(currentQuarter);
|
||||
setYear(currentYear);
|
||||
|
||||
// Then fetch the submissions with the current quarter and year
|
||||
const data = await fetchDashboardData(currentQuarter, currentYear);
|
||||
const result = await getSubmissions();
|
||||
const data = result?.status === 'success' ? result.data : result;
|
||||
|
||||
const mapped = (data || []).map((item) => ({
|
||||
id: item.id,
|
||||
establishment: item?.establishment?.factory_name || '-',
|
||||
@ -353,91 +115,52 @@ React.useEffect(() => {
|
||||
year: String(item.year ?? '-'),
|
||||
quarter: item.quarter ?? '-',
|
||||
products: item?.product_count || '-',
|
||||
submittedBy: item.created_by_name || '-',
|
||||
submittedBy: item.created_by ?? '-',
|
||||
submittedAt: formatDateTime(item.created_at),
|
||||
status: item.status || 'Pending',
|
||||
reviewer: item.reviewer_name || '-',
|
||||
reviewerOn: formatDateTime(item.updated_at || item.created_at),
|
||||
status: item.status ?? '-',
|
||||
reviewer: item?.updated_by || '-',
|
||||
reviewerOn: formatDateTime(item?.updated_at),
|
||||
}));
|
||||
|
||||
setSubmissions(mapped);
|
||||
} catch (err) {
|
||||
console.error('Failed to load initial data:', err);
|
||||
// Don't show error during initial load
|
||||
console.error('Failed to load submissions', err);
|
||||
setError('Unable to load submissions. Please try again later.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsInitialLoad(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchInitialData();
|
||||
fetchSubmissions();
|
||||
}, []);
|
||||
|
||||
const yearOptions = React.useMemo(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years = [];
|
||||
// Generate years from 2022 to current year
|
||||
for (let y = 2022; y <= currentYear; y++) {
|
||||
years.push(y.toString());
|
||||
}
|
||||
return ['All', ...years].sort((a, b) => {
|
||||
if (a === 'All') return -1;
|
||||
if (b === 'All') return 1;
|
||||
return b.localeCompare(a);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const quarterOptions = React.useMemo(() => ['All', 'Q1', 'Q2', 'Q3', 'Q4'], []);
|
||||
const yearOptions = React.useMemo(() => ['All', ...new Set(submissions.map((i) => i.year))], [submissions]);
|
||||
const quarterOptions = React.useMemo(() => ['All', ...new Set(submissions.map((i) => i.quarter))], [submissions]);
|
||||
const emirateOptions = React.useMemo(() => ['All', ...new Set(submissions.map((i) => i.emirate))], [submissions]);
|
||||
const statusOptions = React.useMemo(() => {
|
||||
// Always return all possible statuses
|
||||
return ['All', 'Approved', 'Submitted', 'Rejected', 'Resubmitted'];
|
||||
}, []);
|
||||
const statusOptions = React.useMemo(() => ['All', ...new Set(submissions.map((i) => i.status))], [submissions]);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
|
||||
const filteredItems = submissions.filter((item) => {
|
||||
if (!item || item.status === 'Draft') return false;
|
||||
|
||||
const allValues = Object.values(item)
|
||||
.map((v) => String(v ?? '').toLowerCase())
|
||||
.join(' ');
|
||||
|
||||
const matchSearch = !term || allValues.includes(term);
|
||||
const matchYear = !year || year === 'All' || String(item.year) === String(year);
|
||||
const matchQuarter = !quarter || quarter === 'All' || item.quarter === quarter;
|
||||
const matchEmirate = !emirate || emirate === 'All' || item.emirate === emirate;
|
||||
const matchStatus = !status || status === 'All' || item.status === status;
|
||||
|
||||
const matches = matchSearch && matchYear && matchQuarter && matchEmirate && matchStatus;
|
||||
|
||||
if (matches) {
|
||||
} else {
|
||||
('Filtered out item:', item, { matchSearch, matchYear, matchQuarter, matchEmirate, matchStatus });
|
||||
}
|
||||
|
||||
return matches;
|
||||
return submissions.filter((item) => {
|
||||
const matchSearch =
|
||||
!search ||
|
||||
item.establishment?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.submittedBy?.toLowerCase().includes(search.toLowerCase());
|
||||
const matchYear = !year || year === 'All' || item.year === year;
|
||||
const matchQuarter = !quarter || quarter === 'All' || item.quarter === quarter;
|
||||
const matchEmirate = !emirate || emirate === 'All' || item.emirate === emirate;
|
||||
const matchStatus = !status || status === 'All' || item.status === status;
|
||||
return matchSearch && matchYear && matchQuarter && matchEmirate && matchStatus;
|
||||
});
|
||||
|
||||
return filteredItems;
|
||||
}, [submissions, search, year, quarter, emirate, status]);
|
||||
|
||||
const nonDraftSubmissions = React.useMemo(
|
||||
() => submissions.filter((i) => i.status !== 'Draft'),
|
||||
[submissions]
|
||||
);
|
||||
|
||||
const summaryCounts = React.useMemo(
|
||||
() => ({
|
||||
total: nonDraftSubmissions.length,
|
||||
approved: nonDraftSubmissions.filter((i) => i.status === 'Approved').length,
|
||||
submitted: nonDraftSubmissions.filter((i) => i.status === 'Submitted').length,
|
||||
rejected: nonDraftSubmissions.filter((i) => i.status === 'Rejected').length,
|
||||
resubmitted: nonDraftSubmissions.filter((i) => i.status === 'Resubmitted').length,
|
||||
}),
|
||||
[nonDraftSubmissions]
|
||||
);
|
||||
const summaryCounts = React.useMemo(
|
||||
() => ({
|
||||
total: submissions.length,
|
||||
approved: submissions.filter((i) => i.status === 'Approved').length,
|
||||
pending: submissions.filter((i) => i.status === 'Pending').length,
|
||||
rejected: submissions.filter((i) => i.status === 'Rejected').length,
|
||||
}),
|
||||
[submissions]
|
||||
);
|
||||
|
||||
const headers = [
|
||||
'Establishment',
|
||||
@ -449,7 +172,7 @@ const summaryCounts = React.useMemo(
|
||||
'Submission Date & Time',
|
||||
'Status',
|
||||
'Reviewer',
|
||||
'Reviewed On',
|
||||
'Reviewer On',
|
||||
'Actions',
|
||||
];
|
||||
|
||||
@ -467,13 +190,13 @@ const summaryCounts = React.useMemo(
|
||||
item,
|
||||
]);
|
||||
|
||||
const columnWidths = [220, 150, 83, 91, 100, 152, 208, 120, 120, 208, 130];
|
||||
const columnWidths = [302, 105, 83, 91, 100, 152, 208, 120, 104, 140, 130];
|
||||
|
||||
const toolbar = (
|
||||
<div className="px-6 pt-6 pb-5 space-y-4">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<h2 className="text-[18px] font-medium text-[#232528]">
|
||||
Manage Submissions <span className="text-[#232528]">({filtered.length})</span>
|
||||
Manage Submissions <span className="text-[#7A7F87]">({filtered.length})</span>
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="relative w-[320px]">
|
||||
@ -485,29 +208,8 @@ const summaryCounts = React.useMemo(
|
||||
className="h-10 w-full rounded-md border border-[#C3C6CB] pl-9 pr-3 text-sm focus:outline-none focus:ring-1 focus:ring-[#92722A]"
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
value={selectedQuarter}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value || 'All';
|
||||
setSelectedQuarter(value);
|
||||
setQuarter(value);
|
||||
}}
|
||||
options={quarterOptions.map((v) => ({ label: v, value: v }))}
|
||||
placeholder="Quarter"
|
||||
width="134px"
|
||||
variant="toolbar"
|
||||
/>
|
||||
<SelectField
|
||||
value={selectedYear}
|
||||
onChange={(e) => {
|
||||
setSelectedYear(e.target.value);
|
||||
setYear(e.target.value);
|
||||
}}
|
||||
options={yearOptions.map((v) => ({ label: v, value: v }))}
|
||||
placeholder="Year"
|
||||
width="100px"
|
||||
variant="toolbar"
|
||||
/>
|
||||
<SelectField value={quarter} onChange={(e) => setQuarter(e.target.value)} options={quarterOptions.map((v) => ({ label: v, value: v }))} placeholder="Quarter" width="134px" variant="toolbar" />
|
||||
<SelectField value={year} onChange={(e) => setYear(e.target.value)} options={yearOptions.map((v) => ({ label: v, value: v }))} placeholder="Year" width="134px" variant="toolbar" />
|
||||
<SelectField value={status} onChange={(e) => setStatus(e.target.value)} options={statusOptions.map((v) => ({ label: v, value: v }))} placeholder="Status" width="134px" variant="toolbar" />
|
||||
<SelectField value={emirate} onChange={(e) => setEmirate(e.target.value)} options={emirateOptions.map((v) => ({ label: v, value: v }))} placeholder="Emirates" width="134px" variant="toolbar" />
|
||||
</div>
|
||||
@ -515,7 +217,7 @@ const summaryCounts = React.useMemo(
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading && isInitialLoad){
|
||||
if (loading)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen text-[#92722A]">
|
||||
<svg className="animate-spin h-6 w-6 mr-3 text-[#92722A]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
@ -525,66 +227,9 @@ const summaryCounts = React.useMemo(
|
||||
Loading submissions...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) return <div className="flex items-center justify-center h-screen text-red-600">{error}</div>;
|
||||
const downloadCsv = (data) => {
|
||||
if (!data.length) return;
|
||||
|
||||
const headers = [
|
||||
'Establishment',
|
||||
'Emirates',
|
||||
'Year',
|
||||
'Quarter',
|
||||
'Products',
|
||||
'Submitted By',
|
||||
'Submission Date & Time',
|
||||
'Status',
|
||||
'Reviewer',
|
||||
'Reviewed On',
|
||||
];
|
||||
|
||||
const csvRows = [headers.join(',')];
|
||||
|
||||
data.forEach((item) => {
|
||||
const normalized = String(item.status || '').toLowerCase();
|
||||
let status = '—';
|
||||
if (normalized === 'submitted') status = 'Submitted';
|
||||
else if (normalized === 'rejected') status = 'Rejected';
|
||||
else if (['approved'].includes(normalized)) status = 'Approved';
|
||||
else status = item.status || '—';
|
||||
|
||||
const productCount = Number(item.products) || 0;
|
||||
const products = `${productCount} `;
|
||||
|
||||
const row = [
|
||||
item.establishment || '—',
|
||||
item.emirate || '—',
|
||||
item.year || '—',
|
||||
item.quarter || '—',
|
||||
products,
|
||||
item.submittedBy || '—',
|
||||
item.submittedAt || '—',
|
||||
status,
|
||||
item.reviewer || '—',
|
||||
item.reviewerOn || '—',
|
||||
];
|
||||
|
||||
csvRows.push(row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','));
|
||||
});
|
||||
|
||||
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
link.href = url;
|
||||
link.setAttribute('download', `manage-submissions-${timestamp}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-[#F6F5F1]">
|
||||
@ -596,8 +241,6 @@ const summaryCounts = React.useMemo(
|
||||
className={`text-sm rounded px-3 py-2 border flex items-start justify-between gap-3 shadow ${
|
||||
toast.type === 'success'
|
||||
? 'bg-[#F3FAF4] border-[#C6E7D8] text-[#2F663C]'
|
||||
: toast.type === 'reject'
|
||||
? 'bg-[#FEF2F2] border-[#FECACA] text-[#B91C1C]'
|
||||
: 'bg-[#FEF2F2] border-[#FECACA] text-[#B91C1C]'
|
||||
}`}
|
||||
>
|
||||
@ -620,300 +263,87 @@ const summaryCounts = React.useMemo(
|
||||
<Link to="/admin/dashboard" className="flex items-center gap-2 text-[#232528] hover:underline">
|
||||
Dashboard <img src={caretUpSrc} alt="Dashboard" className="h-3 w-3" />
|
||||
</Link>
|
||||
<span className="text-[#7A7F87] font-medium">Manage Submissions</span>
|
||||
<span className="text-[#92722A] font-medium">Manage Submissions</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-wrap gap-[6px] rounded-[12px] bg-white px-5 py-3 shadow items-center justify-between">
|
||||
<div className="flex flex-wrap gap-[6px]">
|
||||
{Object.entries(summaryCounts).map(([key, val]) => {
|
||||
const v = SUMMARY_VARIANTS[key];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`inline-flex items-center gap-[6px] rounded-[8px] px-2 py-[2px] ${
|
||||
v.background === '#F9F7ED' ? 'bg-amber-50' :
|
||||
v.background === '#F3FAF4' ? 'bg-green-50' :
|
||||
v.background === '#FEF2F2' ? 'bg-red-50' : 'bg-blue-50'
|
||||
}`}
|
||||
>
|
||||
<span className={`font-medium text-[13px] ${
|
||||
v.label === '#7C5E24' ? 'text-amber-800' :
|
||||
v.label === '#2F663C' ? 'text-green-800' :
|
||||
v.label === '#B52520' ? 'text-red-700' : 'text-blue-600'
|
||||
}`}>
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)}:
|
||||
</span>
|
||||
<span className={`font-semibold text-[13px] ${
|
||||
v.value === '#232528' ? 'text-gray-900' : 'text-gray-600'
|
||||
}`}>
|
||||
{val}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-[6px]">
|
||||
{Object.entries(summaryCounts).map(([key, val]) => {
|
||||
const v = SUMMARY_VARIANTS[key];
|
||||
return (
|
||||
<div key={key} className="inline-flex items-center gap-[6px] rounded-[8px] px-2 py-[2px]" style={{ background: v.background }}>
|
||||
<span className="font-medium text-[13px]" style={{ color: v.label }}>
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)}:
|
||||
</span>
|
||||
<span className="font-semibold text-[13px]" style={{ color: v.value }}>
|
||||
{val}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadCsv(filtered)}
|
||||
disabled={!filtered.length}
|
||||
className={`inline-flex h-10 items-center gap-2 rounded-md border px-4 text-sm font-medium ${
|
||||
filtered.length
|
||||
? 'border-[#92722A] text-[#92722A] hover:bg-[#F2ECCF]'
|
||||
: 'border-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5m0 0l5-5m-5 5V4"
|
||||
/>
|
||||
</svg>
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white shadow overflow-hidden">
|
||||
{toolbar}
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-16 text-[#7B5C1F] font-semibold text-sm border-t border-[#E5E7EB]">
|
||||
No Submission Found
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
headers={headers}
|
||||
rows={tableRows}
|
||||
separated
|
||||
columnWidths={columnWidths}
|
||||
pagination={{
|
||||
currentPage,
|
||||
onPageChange: setCurrentPage,
|
||||
pageSize,
|
||||
totalItems: filtered.length,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
onPageSizeChange: (size) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}}
|
||||
renderCell={(value, _, columnIndex) => {
|
||||
if (columnIndex === 7)
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<Badge status={String(value)}>{value}</Badge>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (columnIndex === 10) {
|
||||
const item = value;
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-[10px]">
|
||||
<div className="relative group">
|
||||
<CheckCircle2
|
||||
size={22}
|
||||
className={`${
|
||||
item.status?.toLowerCase() === 'approved' || item.status?.toLowerCase() === 'rejected'
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: 'text-green-600 cursor-pointer hover:scale-110'
|
||||
} transition-transform`}
|
||||
onClick={['submitted', 'resubmitted'].includes(item.status?.toLowerCase())
|
||||
? () => handleApproveClick(item)
|
||||
: undefined}
|
||||
/>
|
||||
<span className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-black text-white text-xs px-2 py-[2px] rounded opacity-0 group-hover:opacity-100 transition whitespace-nowrap">
|
||||
{item.status?.toLowerCase() === 'approved'
|
||||
? 'Already Approved'
|
||||
: item.status?.toLowerCase() === 'rejected'
|
||||
? 'Cannot approve rejected'
|
||||
: 'Approve'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<XCircle
|
||||
size={22}
|
||||
className={`${
|
||||
['approved', 'rejected'].includes(item.status?.toLowerCase())
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: 'text-red-600 cursor-pointer hover:scale-110'
|
||||
} transition-transform`}
|
||||
onClick={!['approved', 'rejected'].includes(item.status?.toLowerCase())
|
||||
? () => handleRejectClick(item)
|
||||
: undefined}
|
||||
/>
|
||||
<span className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-black text-white text-xs px-2 py-[2px] rounded opacity-0 group-hover:opacity-100 transition whitespace-nowrap">
|
||||
{item.status?.toLowerCase() === 'rejected'
|
||||
? 'Already Rejected'
|
||||
: item.status?.toLowerCase() === 'approved'
|
||||
? 'Cannot reject approved'
|
||||
: 'Reject'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<Eye
|
||||
size={22}
|
||||
className={`cursor-pointer hover:scale-110 transition-transform ${
|
||||
item.status?.toLowerCase() === 'pending'
|
||||
? 'text-[#9E792B]'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
onClick={() =>
|
||||
navigate(`/admin/validations/${item.id}`, {
|
||||
state: { actionType: 'view' },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-black text-white text-xs px-2 py-[2px] rounded opacity-0 group-hover:opacity-100 transition">
|
||||
View
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white shadow overflow-hidden">
|
||||
<Table
|
||||
headers={headers}
|
||||
rows={tableRows}
|
||||
beforeHeader={toolbar}
|
||||
separated
|
||||
columnWidths={columnWidths}
|
||||
pagination={{
|
||||
currentPage,
|
||||
onPageChange: setCurrentPage,
|
||||
pageSize,
|
||||
totalItems: filtered.length,
|
||||
}}
|
||||
renderCell={(value, _, columnIndex) => {
|
||||
if (columnIndex === 7)
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<Badge status={String(value)}>{value}</Badge>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (columnIndex === 10) {
|
||||
const item = value;
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-[10px]">
|
||||
<div className="relative group">
|
||||
<CheckCircle2 size={22} className="text-green-600 cursor-pointer hover:scale-110 transition-transform" />
|
||||
<span className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-black text-white text-xs px-2 py-[2px] rounded opacity-0 group-hover:opacity-100 transition">
|
||||
Approve
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<XCircle size={22} className="text-red-600 cursor-pointer hover:scale-110 transition-transform" />
|
||||
<span className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-black text-white text-xs px-2 py-[2px] rounded opacity-0 group-hover:opacity-100 transition">
|
||||
Reject
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<Eye
|
||||
size={22}
|
||||
className={`cursor-pointer hover:scale-110 transition-transform ${
|
||||
item.status?.toLowerCase() === 'pending' ? 'text-[#9E792B]' : 'text-gray-500'
|
||||
}`}
|
||||
onClick={() => navigate(`/admin/validations/${item.id}`)}
|
||||
/>
|
||||
<span className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-black text-white text-xs px-2 py-[2px] rounded opacity-0 group-hover:opacity-100 transition">
|
||||
View
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Approve Confirmation Modal */}
|
||||
{showApproveModal && selectedSubmission && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4 bg-black/30">
|
||||
<div className="bg-white rounded-lg w-full max-w-lg border border-gray-200 shadow-lg">
|
||||
<div className="flex items-center justify-between h-12 p-4 border-b border-gray-200">
|
||||
<h3 className="text-base font-medium text-gray-900">Approve this submission?</h3>
|
||||
<button
|
||||
onClick={handleCloseApproveModal}
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
You're about to approve <span className="font-medium text-gray-900">{selectedSubmission.establishment}</span> for <span className="font-medium text-gray-900">{selectedSubmission.quarter} {selectedSubmission.year}</span>.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
This will lock the record for the establishment.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseApproveModal}
|
||||
className="px-4 py-2 text-sm font-medium text-[#92722A] bg-white border border-[#92722A] rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
disabled={isApproving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApproveConfirm}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-[#92722A] border border-transparent rounded-md shadow-sm hover:bg-[#7c5e24] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#92722A] flex items-center gap-2"
|
||||
disabled={isApproving}
|
||||
>
|
||||
{isApproving ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
|
||||
</svg>
|
||||
Approving...
|
||||
</>
|
||||
) : 'Approve'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reject Confirmation Modal */}
|
||||
{showRejectModal && selectedSubmission && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4 bg-black/30">
|
||||
<div className="bg-white rounded-lg w-full max-w-lg border border-gray-200 shadow-lg">
|
||||
<div className="flex items-center justify-between h-12 p-4 border-b border-gray-200">
|
||||
<h3 className="text-base font-medium text-gray-900">Reject this submission?</h3>
|
||||
<button
|
||||
onClick={handleCloseRejectModal}
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-gray-600 leading-6 mb-2">
|
||||
You're about to return <span className="font-medium text-gray-900">{selectedSubmission.establishment}</span>'s <span className="font-medium text-gray-900">{selectedSubmission.quarter} {selectedSubmission.year}</span> submission for correction.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 leading-6 mb-3">
|
||||
The status will change to Rejected and the establishment can resubmit within the survey window.
|
||||
</p>
|
||||
<div className="mb-0">
|
||||
<label htmlFor="rejectReason" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Reason for rejection: <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="rejectReason"
|
||||
rows={4}
|
||||
className={`w-full px-3 py-2 border ${showRejectError && !rejectReason.trim() ? 'border-red-500' : 'border-gray-300'} rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#92722A] focus:border-[#92722A] text-sm`}
|
||||
placeholder="Please provide a reason for rejection"
|
||||
value={rejectReason}
|
||||
onChange={(e) => {
|
||||
setRejectReason(e.target.value);
|
||||
// Clear error when user starts typing
|
||||
if (e.target.value.trim() && showRejectError) {
|
||||
setShowRejectError(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!rejectReason.trim()) {
|
||||
setShowRejectError(true);
|
||||
}
|
||||
}}
|
||||
disabled={isRejecting}
|
||||
/>
|
||||
{showRejectError && !rejectReason.trim() && (
|
||||
<p className="mt-1 text-sm text-red-600">Reason for rejection is required</p>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRejectConfirm}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-[#92722A] border border-transparent rounded-md shadow-sm hover:bg-[#7c5e24] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#92722A] flex items-center gap-2"
|
||||
disabled={isRejecting}
|
||||
>
|
||||
{isRejecting ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
|
||||
</svg>
|
||||
Rejecting...
|
||||
</>
|
||||
) : 'Reject'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Footer />
|
||||
</>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
218
ipi-survey-platform/src/pages/Admin/configuration/AdminUsers.jsx
Normal file
@ -0,0 +1,218 @@
|
||||
import React from 'react';
|
||||
import Table from '@/components/common/Table';
|
||||
|
||||
const searchIconSrc = '/assets/images/material-symbols_search-rounded.svg';
|
||||
const exportIconSrc = '/assets/images/downloadsimple.svg';
|
||||
const addIconSrc = '/assets/images/ic_baseline-plus.svg';
|
||||
const pencilActiveSrc = '/assets/images/pencilsimple.svg';
|
||||
const pencilInactiveSrc = '/assets/images/pencilsimple-inactive.svg';
|
||||
const trashActiveSrc = '/assets/images/trash - active.svg';
|
||||
const trashInactiveSrc = '/assets/images/trash - inactive.svg';
|
||||
const adminUserIconSrc = '/assets/images/usergear.svg';
|
||||
const activeUserIconSrc = '/assets/images/active-user.svg';
|
||||
const inactiveUserIconSrc = '/assets/images/inactive-user.svg';
|
||||
const resetPasswordIconSrc = '/assets/images/hugeicons_reset-password.svg';
|
||||
const resetPasswordInactiveIconSrc = '/assets/images/hugeicons_reset-password-inactive.svg';
|
||||
|
||||
const SummaryCard = ({ label, count, icon, iconBg = '#FDF7EB' }) => (
|
||||
<div className="flex min-h-[96px] min-w-[220px] flex-1 items-center justify-between rounded-[16px] border border-[#E6EAF5] bg-white px-6 py-5 shadow-[0_12px_24px_rgba(15,23,42,0.06)]">
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: iconBg }}
|
||||
>
|
||||
{icon && <img src={icon} alt={label} className="h-6 w-6" />}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-[#5F646D]">{label}</span>
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-[#232528]">{count}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const StatusBadge = ({ status }) => {
|
||||
const map = {
|
||||
Active: 'bg-green-50 text-green-700 ring-1 ring-green-200',
|
||||
Closed: 'bg-gray-100 text-gray-700 ring-1 ring-gray-200',
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${map[status] || map.Closed}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminUsers = () => {
|
||||
const [users, setUsers] = React.useState([
|
||||
{
|
||||
name: 'Ahmed Al-Mansouri',
|
||||
email: 'ahmed.mansouri@fcsc.gov.ae',
|
||||
lastLogin: '11th Mar, 2025 14:30',
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
name: 'Sarah Johnson',
|
||||
email: 'sarah.johnson@fcsc.gov.ae',
|
||||
lastLogin: '13th Mar, 2025 13:00',
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
name: 'Mohammed Hassan',
|
||||
email: 'mohammed.hassan@fcsc.gov.ae',
|
||||
lastLogin: '18th Mar, 2025 22:40',
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
name: 'Emily Davis',
|
||||
email: 'emily.davis@fcsc.gov.ae',
|
||||
lastLogin: '20th Mar, 2025 18:40',
|
||||
status: 'Closed',
|
||||
},
|
||||
]);
|
||||
const [editingRow, setEditingRow] = React.useState(null);
|
||||
const [deletingRow, setDeletingRow] = React.useState(null);
|
||||
|
||||
const totalUsers = users.length;
|
||||
const activeUsers = users.filter((user) => user.status === 'Active').length;
|
||||
const inactiveUsers = totalUsers - activeUsers;
|
||||
|
||||
const summaryItems = [
|
||||
{
|
||||
label: 'Total Users',
|
||||
count: totalUsers,
|
||||
icon: adminUserIconSrc,
|
||||
iconBg: '#FDF7EB',
|
||||
},
|
||||
{
|
||||
label: 'Active User',
|
||||
count: activeUsers,
|
||||
icon: activeUserIconSrc,
|
||||
iconBg: '#E7F2FF',
|
||||
},
|
||||
{
|
||||
label: 'Inactive User',
|
||||
count: inactiveUsers,
|
||||
icon: inactiveUserIconSrc,
|
||||
iconBg: '#F5F5F7',
|
||||
},
|
||||
];
|
||||
|
||||
const headers = ['Name', 'Email', 'Last Login', 'Status', 'Actions'];
|
||||
const rows = users.map((user) => [
|
||||
user.name,
|
||||
user.email,
|
||||
user.lastLogin,
|
||||
<StatusBadge status={user.status} />,
|
||||
'actions',
|
||||
]);
|
||||
|
||||
const handleEdit = (index) => {
|
||||
setEditingRow(index);
|
||||
// Future: open edit modal
|
||||
};
|
||||
|
||||
const handleDelete = (index) => {
|
||||
setDeletingRow(index);
|
||||
// Future: open delete confirmation
|
||||
};
|
||||
|
||||
const tableToolbar = (
|
||||
<div className="px-6 py-4 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 bg-white">
|
||||
<h3 className="text-sm font-medium text-[#232528]">Admin Users</h3>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:gap-3 w-full lg:w-auto">
|
||||
<div className="relative flex-1 min-w-[220px]">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by Names, Email or Role..."
|
||||
className="w-full h-10 rounded-md border border-[#E5E7EB] pl-10 pr-4 text-sm focus:outline-none"
|
||||
/>
|
||||
<img
|
||||
src={searchIconSrc}
|
||||
alt="Search"
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:justify-end">
|
||||
<button className="h-9 px-3 rounded-md bg-[#F7F7F7] border border-[#C3C6CB] text-sm inline-flex items-center gap-2 cursor-pointer">
|
||||
<img src={exportIconSrc} alt="Export" className="h-4 w-4" />
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
<button className="h-9 px-3 rounded-md bg-[#B68A35] text-white text-sm inline-flex items-center gap-2 cursor-pointer">
|
||||
<img src={addIconSrc} alt="Add" className="h-4 w-4" />
|
||||
<span>Add User</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{summaryItems.map((item) => (
|
||||
<SummaryCard key={item.label} {...item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Table
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
beforeHeader={tableToolbar}
|
||||
renderCell={(value, rowIndex, colIndex) => {
|
||||
if (colIndex === 4) {
|
||||
const user = users[rowIndex];
|
||||
const isActive = user.status === 'Active';
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 grid place-items-center rounded hover:bg-gray-50 cursor-pointer"
|
||||
title="Edit"
|
||||
aria-label="Edit user"
|
||||
onClick={() => handleEdit(rowIndex)}
|
||||
>
|
||||
<img
|
||||
src={editingRow === rowIndex ? pencilActiveSrc : pencilInactiveSrc}
|
||||
alt="Edit"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 grid place-items-center rounded hover:bg-gray-50 cursor-pointer"
|
||||
title="Delete"
|
||||
aria-label="Delete user"
|
||||
onClick={() => handleDelete(rowIndex)}
|
||||
>
|
||||
<img
|
||||
src={deletingRow === rowIndex ? trashActiveSrc : trashInactiveSrc}
|
||||
alt="Delete"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`h-8 w-8 grid place-items-center rounded hover:bg-gray-50 cursor-pointer ${
|
||||
isActive ? '' : 'opacity-60'
|
||||
}`}
|
||||
title={isActive ? 'Reset password' : 'Reset disabled for inactive user'}
|
||||
aria-label="Reset user password"
|
||||
disabled={!isActive}
|
||||
>
|
||||
<img
|
||||
src={isActive ? resetPasswordIconSrc : resetPasswordInactiveIconSrc}
|
||||
alt="Reset password"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
;
|
||||
|
||||
export default AdminUsers;
|
||||
@ -1,287 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { TextField, SelectField } from '@/components/common/FormControls';
|
||||
import { createadminusers } from '@/services/configuration/adminService';
|
||||
import CustomToast from '@/components/common/CustomToast';
|
||||
import { VALIDATION_MESSAGES } from '../../../../constants/validationMessages';
|
||||
|
||||
const AddAdminUsers = ({ isOpen, onClose, onSave }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: 'Super Admin',
|
||||
status: 'Active',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState({});
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [passwordTouched, setPasswordTouched] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [toastData, setToastData] = useState(null); // 👈 for custom toast
|
||||
|
||||
// show toast helper
|
||||
const showToast = (message, type = 'success') => {
|
||||
setToastData({ message, type });
|
||||
// Clear any existing timeout
|
||||
if (window.toastTimeout) {
|
||||
clearTimeout(window.toastTimeout);
|
||||
}
|
||||
// Set new timeout
|
||||
window.toastTimeout = setTimeout(() => {
|
||||
setToastData(null);
|
||||
}, 4000);
|
||||
};
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.name.trim()) newErrors.name = 'Name is required';
|
||||
|
||||
if (!formData.email.trim()) newErrors.email = 'Email is required';
|
||||
else if (!/^\S+@\S+\.\S+$/.test(formData.email))
|
||||
newErrors.email = 'Please enter a valid email';
|
||||
|
||||
if (!formData.status) newErrors.status = 'Status is required';
|
||||
|
||||
const passwordValidation = validatePassword(formData.password);
|
||||
if (passwordValidation) {
|
||||
newErrors.password = passwordValidation;
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = VALIDATION_MESSAGES.PASSWORDS_DONT_MATCH;
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFormData({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: 'Super Admin',
|
||||
status: 'Active',
|
||||
});
|
||||
setErrors({});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const validatePassword = (password) => {
|
||||
if (!password) return VALIDATION_MESSAGES.PASSWORD_REQUIRED;
|
||||
if (password.length < 8) return VALIDATION_MESSAGES.PASSWORD_MIN_LENGTH;
|
||||
if (!/[A-Z]/.test(password)) return VALIDATION_MESSAGES.PASSWORD_UPPERCASE;
|
||||
if (!/[a-z]/.test(password)) return VALIDATION_MESSAGES.PASSWORD_LOWERCASE;
|
||||
if (!/\d/.test(password)) return VALIDATION_MESSAGES.PASSWORD_NUMBER;
|
||||
if (!/[!@#$%^&*]/.test(password)) return VALIDATION_MESSAGES.PASSWORD_SPECIAL_CHAR;
|
||||
return ''; // Return empty string if password is valid
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
|
||||
// Clear any existing error for this field
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({ ...prev, [name]: '' }));
|
||||
}
|
||||
|
||||
// Handle password field changes
|
||||
if (name === 'password') {
|
||||
setPasswordTouched(true);
|
||||
const error = validatePassword(value);
|
||||
setPasswordError(error);
|
||||
|
||||
// If password is valid and matches confirm password, clear confirm password error
|
||||
if (!error && formData.confirmPassword && value === formData.confirmPassword) {
|
||||
setErrors(prev => ({ ...prev, confirmPassword: '' }));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle confirm password field changes
|
||||
if (name === 'confirmPassword') {
|
||||
if (value && formData.password !== value) {
|
||||
setErrors(prev => ({ ...prev, confirmPassword: 'Passwords do not match' }));
|
||||
} else if (value && formData.password === value) {
|
||||
setErrors(prev => ({ ...prev, confirmPassword: '' }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const formErrors = validateForm();
|
||||
|
||||
if (Object.keys(formErrors).length > 0) {
|
||||
setErrors(formErrors);
|
||||
showToast('Please fix the form errors before submitting', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const adminData = {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
is_active: formData.status === 'Active',
|
||||
};
|
||||
|
||||
const response = await createadminusers(adminData);
|
||||
showToast('Admin user created successfully!', 'success');
|
||||
|
||||
// Add a small delay before closing to show the success message
|
||||
setTimeout(() => {
|
||||
onSave?.(response?.data || {});
|
||||
onClose();
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating admin user:', error);
|
||||
const errorMessage = error.response?.data?.message || 'Failed to create admin user';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-[9998]">
|
||||
<div className="bg-white rounded-2xl w-[628px] max-h-[calc(100vh-2rem)] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-6 border-b sticky top-0 bg-white z-10">
|
||||
<h2 className="text-xl font-semibold text-[#232528]">Add User</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<TextField
|
||||
label="Name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
error={errors.name}
|
||||
required
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
error={errors.email}
|
||||
required
|
||||
placeholder="Enter email"
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<TextField
|
||||
label="Password"
|
||||
name="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
onBlur={() => setPasswordTouched(true)}
|
||||
error={passwordTouched && passwordError ? passwordError : ''}
|
||||
required
|
||||
placeholder="Enter password"
|
||||
showToggle
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<TextField
|
||||
label="Confirm Password"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
error={errors.confirmPassword}
|
||||
required
|
||||
placeholder="Confirm password"
|
||||
showToggle
|
||||
/>
|
||||
{/* {formData.confirmPassword && !errors.confirmPassword && (
|
||||
<p className="mt-1 text-xs text-green-600">Passwords match</p>
|
||||
)} */}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<SelectField
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
options={[
|
||||
{ label: 'Active', value: 'Active' },
|
||||
{ label: 'Inactive', value: 'Inactive' },
|
||||
]}
|
||||
error={errors.status}
|
||||
/>
|
||||
{errors.status && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.status}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end space-x-3 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#B68A35] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className={`px-5 py-2.5 text-sm font-medium text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#B68A35] transition-colors ${
|
||||
loading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-[#92722A] hover:bg-[#9A762D]'
|
||||
}`}
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*Custom Toast Display */}
|
||||
{toastData && (
|
||||
<div className="fixed inset-0 z-[9999]">
|
||||
<CustomToast
|
||||
message={toastData.message}
|
||||
type={toastData.type}
|
||||
onClose={() => setToastData(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddAdminUsers;
|
||||
@ -1,264 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { TextField, SelectField } from '@/components/common/FormControls';
|
||||
import CustomToast from '@/components/common/CustomToast';
|
||||
|
||||
const AdminUserForm = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
mode = 'add',
|
||||
initialData = null
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: 'Super Admin',
|
||||
status: 'Active',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [toastData, setToastData] = useState(null);
|
||||
|
||||
// Set form data when initialData changes (for edit mode)
|
||||
useEffect(() => {
|
||||
if (mode === 'edit' && initialData) {
|
||||
setFormData({
|
||||
name: initialData.name || '',
|
||||
email: initialData.email || '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: initialData.role || 'Super Admin',
|
||||
status: initialData.is_active ? 'Active' : 'Inactive',
|
||||
});
|
||||
} else if (mode === 'add') {
|
||||
// Reset form for add mode
|
||||
setFormData({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: 'Super Admin',
|
||||
status: 'Active',
|
||||
});
|
||||
setErrors({});
|
||||
}
|
||||
}, [mode, initialData]);
|
||||
|
||||
const showToast = (message, type = 'success') => {
|
||||
setToastData({ message, type });
|
||||
if (window.toastTimeout) {
|
||||
clearTimeout(window.toastTimeout);
|
||||
}
|
||||
window.toastTimeout = setTimeout(() => {
|
||||
setToastData(null);
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
if (!formData.name.trim()) newErrors.name = 'Name is required';
|
||||
if (!formData.email.trim()) newErrors.email = 'Email is required';
|
||||
else if (!/^\S+@\S+\.\S+$/.test(formData.email))
|
||||
newErrors.email = 'Please enter a valid email';
|
||||
if (!formData.status) newErrors.status = 'Status is required';
|
||||
|
||||
// Only validate password if it's add mode or password is being changed
|
||||
if (mode === 'add' || formData.password) {
|
||||
if (!formData.password) newErrors.password = 'Password is required';
|
||||
else if (formData.password.length < 12)
|
||||
newErrors.password = 'Password must be at least 12 characters';
|
||||
if (formData.password !== formData.confirmPassword)
|
||||
newErrors.confirmPassword = 'Passwords do not match';
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
if (errors[name]) setErrors((prev) => ({ ...prev, [name]: '' }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const formErrors = validateForm();
|
||||
|
||||
if (Object.keys(formErrors).length > 0) {
|
||||
setErrors(formErrors);
|
||||
showToast('Please fix the form errors before submitting', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const adminData = {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
...(formData.password && { password: formData.password }), // Only include password if provided
|
||||
is_active: formData.status === 'Active',
|
||||
};
|
||||
|
||||
await onSave(adminData);
|
||||
showToast(
|
||||
mode === 'add'
|
||||
? 'Admin user created successfully!'
|
||||
: 'Admin user updated successfully!',
|
||||
'success'
|
||||
);
|
||||
|
||||
// Add a small delay before closing to show the success message
|
||||
setTimeout(() => {
|
||||
if (mode === 'add') {
|
||||
onClose();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error ${mode === 'add' ? 'creating' : 'updating'} admin user:`, error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
`Failed to ${mode === 'add' ? 'create' : 'update'} admin user`;
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-[9998]">
|
||||
<div className="bg-white rounded-xl w-[628px] max-h-[calc(100vh-2rem)] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-6 border-b sticky top-0 bg-white z-10">
|
||||
<h2 className="text-xl font-semibold text-[#232528]">
|
||||
{mode === 'add' ? 'Add User' : 'Edit User'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<TextField
|
||||
label="Name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
error={errors.name}
|
||||
required
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
error={errors.email}
|
||||
required
|
||||
placeholder="Enter email"
|
||||
disabled={mode === 'edit'} // Disable email in edit mode
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={mode === 'add' ? 'Password' : 'New Password (leave blank to keep current)'}
|
||||
name="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
error={errors.password}
|
||||
required={mode === 'add'}
|
||||
placeholder={mode === 'add' ? 'Enter password' : 'Enter new password'}
|
||||
showToggle
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={mode === 'add' ? 'Confirm Password' : 'Confirm New Password'}
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
error={errors.confirmPassword}
|
||||
required={mode === 'add'}
|
||||
placeholder={mode === 'add' ? 'Confirm password' : 'Confirm new password'}
|
||||
showToggle
|
||||
/>
|
||||
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<SelectField
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
options={[
|
||||
{ label: 'Active', value: 'Active' },
|
||||
{ label: 'Inactive', value: 'Inactive' },
|
||||
]}
|
||||
error={errors.status}
|
||||
/>
|
||||
{errors.status && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.status}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end space-x-3 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#B68A35] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className={`px-5 py-2.5 text-sm font-medium text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#B68A35] transition-colors ${
|
||||
loading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-[#92722A] hover:bg-[#9A762D]'
|
||||
}`}
|
||||
>
|
||||
{loading
|
||||
? (mode === 'add' ? 'Saving...' : 'Updating...')
|
||||
: (mode === 'add' ? 'Save' : 'Update')
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Toast Display */}
|
||||
{toastData && (
|
||||
<div className="fixed inset-0 z-[9999]">
|
||||
<CustomToast
|
||||
message={toastData.message}
|
||||
type={toastData.type}
|
||||
onClose={() => setToastData(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUserForm;
|
||||
@ -1,40 +0,0 @@
|
||||
import React from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
const DeleteUserModal = ({ userName, onClose, onDelete }) => {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/20">
|
||||
<div className="bg-white rounded-lg w-[420px] p-6 shadow-lg relative">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="bg-red-100 p-2 rounded-full">
|
||||
<AlertTriangle className="text-red-500" size={20} />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">Delete User</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-6">
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-gray-900">{userName}</span>?<br />
|
||||
This action will permanently delete the data and cannot be undone.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2 rounded-md border border-gray-300 text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="px-5 py-2 rounded-md bg-[#92722A] text-white font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteUserModal;
|
||||
@ -1,220 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { TextField, SelectField } from '@/components/common/FormControls';
|
||||
import { getAdminUserById, updateAdminUser } from '@/services/configuration/adminService';
|
||||
import CustomToast from '@/components/common/CustomToast';
|
||||
|
||||
const EditAdminUser = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
status: 'Active',
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toastData, setToastData] = useState(null);
|
||||
|
||||
// Show toast helper
|
||||
const showToast = (message, type = 'success') => {
|
||||
setToastData({ message, type });
|
||||
if (window.toastTimeout) {
|
||||
clearTimeout(window.toastTimeout);
|
||||
}
|
||||
window.toastTimeout = setTimeout(() => {
|
||||
setToastData(null);
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
// Fetch user data
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const userData = location.state?.user || await getAdminUserById(id);
|
||||
|
||||
setFormData({
|
||||
name: userData.name || '',
|
||||
email: userData.email || '',
|
||||
status: userData.is_active ? 'Active' : 'Inactive',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching admin user:', error);
|
||||
showToast('Failed to load user data', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, [id, location.state]);
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
if (!formData.name.trim()) newErrors.name = 'Name is required';
|
||||
if (!formData.email.trim()) newErrors.email = 'Email is required';
|
||||
else if (!/^\S+@\S+\.\S+$/.test(formData.email))
|
||||
newErrors.email = 'Please enter a valid email';
|
||||
if (!formData.status) newErrors.status = 'Status is required';
|
||||
return newErrors;
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
if (errors[name]) setErrors(prev => ({ ...prev, [name]: '' }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const formErrors = validateForm();
|
||||
|
||||
if (Object.keys(formErrors).length > 0) {
|
||||
setErrors(formErrors);
|
||||
showToast('Please fix the form errors before submitting', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const adminData = {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
is_active: formData.status === 'Active',
|
||||
};
|
||||
|
||||
await updateAdminUser(id, adminData);
|
||||
showToast('Admin user updated successfully!', 'success');
|
||||
|
||||
// Navigate back after a short delay
|
||||
setTimeout(() => {
|
||||
navigate('/admin/configuration/admin-users');
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating admin user:', error);
|
||||
const errorMessage = error.response?.data?.message || 'Failed to update admin user';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
navigate('/admin/configuration/admin-users');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-[#92722A]"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F7F7F7] p-6">
|
||||
<div className="max-w-4xl mx-auto bg-white rounded-2xl shadow-sm overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-6 border-b sticky top-0 bg-white z-10">
|
||||
<h2 className="text-xl font-semibold text-[#232528]">Edit User</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-8">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<TextField
|
||||
label="Name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
error={errors.name}
|
||||
required
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
error={errors.email}
|
||||
required
|
||||
placeholder="Enter email"
|
||||
disabled={true} // Email is typically not editable
|
||||
/>
|
||||
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<SelectField
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
options={[
|
||||
{ label: 'Active', value: 'Active' },
|
||||
{ label: 'Inactive', value: 'Inactive' },
|
||||
]}
|
||||
error={errors.status}
|
||||
/>
|
||||
{errors.status && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.status}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end space-x-3 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="px-5 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#B68A35] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className={`px-5 py-2.5 text-sm font-medium text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#B68A35] transition-colors ${
|
||||
saving
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-[#92722A] hover:bg-[#9A762D]'
|
||||
}`}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Update'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast Notification */}
|
||||
{toastData && (
|
||||
<div className="fixed inset-0 z-[9999]">
|
||||
<CustomToast
|
||||
message={toastData.message}
|
||||
type={toastData.type}
|
||||
onClose={() => setToastData(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditAdminUser;
|
||||
@ -1,120 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const EditUserModal = ({ formData, setFormData, onClose, onUpdate, showToast }) => {
|
||||
const handleUpdate = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Check if the deactivated user is the currently logged-in user
|
||||
const currentUser = JSON.parse(localStorage.getItem('user_profile') || '{}');
|
||||
const currentUserEmail = currentUser?.email?.toLowerCase()?.trim();
|
||||
const updatedUserEmail = formData.email?.toLowerCase()?.trim();
|
||||
const shouldLogout = currentUserEmail === updatedUserEmail && formData.status === "Inactive";
|
||||
// Call the original onUpdate with an additional parameter
|
||||
onUpdate(e, shouldLogout);
|
||||
|
||||
// If it's the current user being deactivated, show a warning and log out
|
||||
if (shouldLogout) {
|
||||
showToast('This account has been deactivated. You will be logged out.', 'warning');
|
||||
setTimeout(() => {
|
||||
sessionStorage.clear();
|
||||
window.location.href = '/login';
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/10">
|
||||
<div className="bg-white rounded-lg w-[480px] shadow-lg p-6 relative">
|
||||
{/* Close Button */}
|
||||
<button
|
||||
className="absolute top-3 right-3 text-gray-500 hover:text-gray-800"
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-6">Edit User</h3>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
onSubmit={handleUpdate}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{/* Name & Email in one row */}
|
||||
<div className="flex gap-4">
|
||||
{/* Name */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter Name"
|
||||
className="w-full border border-[#D9C9A3] rounded-md px-3 py-2 focus:ring-1 focus:ring-[#B68A35]"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Enter Email"
|
||||
className="w-full border border-[#D9C9A3] rounded-md px-3 py-2 focus:ring-1 focus:ring-[#B68A35]"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, email: e.target.value }))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
className="w-full border border-[#D9C9A3] rounded-md px-3 py-2 focus:ring-1 focus:ring-[#B68A35]"
|
||||
value={formData.status}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, status: e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="Active">Active</option>
|
||||
<option value="Inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="px-5 py-2 rounded-md border border-gray-300 text-gray-700 hover:bg-gray-100"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2 rounded-md bg-[#B68A35] text-white font-medium hover:bg-[#9c6f28]"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditUserModal;
|
||||
@ -1,994 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Table from '@/components/common/Table';
|
||||
import AddAdminUsers from './AddAdminUsers';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { VALIDATION_MESSAGES } from '../../../../constants/validationMessages';
|
||||
import {
|
||||
getAdminUser,
|
||||
getAdminUserById,
|
||||
updateAdminUser,
|
||||
deleteAdminUser,
|
||||
changeAdminUserPassword,
|
||||
} from '@/services/configuration/adminService';
|
||||
|
||||
import CustomToast from '@/components/common/CustomToast';
|
||||
import { TextField, SelectField } from '@/components/common/FormControls';
|
||||
import EditUserModal from './EditUserModal';
|
||||
import DeleteUserModal from './DeleteUserModal';
|
||||
import ResetPasswordModal from './ResetPasswordModal';
|
||||
|
||||
const searchIconSrc = '/assets/images/material-symbols_search-rounded.svg';
|
||||
const exportIconSrc = '/assets/images/DownloadSimple.svg';
|
||||
const addIconSrc = '/assets/images/ic_baseline-plus.svg';
|
||||
const pencilActiveSrc = '/assets/images/PencilSimple.svg';
|
||||
const pencilInactiveSrc = '/assets/images/pencilsimple-inactive.svg';
|
||||
const trashActiveSrc = '/assets/images/Trash - active.svg';
|
||||
const trashInactiveSrc = '/assets/images/Trash - Inactive.svg';
|
||||
const adminUserIconSrc = '/assets/images/UserGear.svg';
|
||||
const activeUserIconSrc = '/assets/images/active-user.svg';
|
||||
const inactiveUserIconSrc = '/assets/images/inactive-user.svg';
|
||||
const resetPasswordIconSrc = '/assets/images/hugeicons_reset-password.svg';
|
||||
const resetPasswordInactiveIconSrc =
|
||||
'/assets/images/hugeicons_reset-password-inactive.svg';
|
||||
|
||||
const SummaryCard = ({ label, count, icon, iconBg = '#FDF7EB' }) => (
|
||||
<div className="flex min-h-[96px] min-w-[220px] flex-1 items-center justify-between rounded-[16px] border border-[#E6EAF5] bg-white px-6 py-5 shadow-[0_12px_24px_rgba(15,23,42,0.06)]">
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full ${iconBg ? `bg-[${iconBg}]` : 'bg-[#FDF7EB]'}`}
|
||||
>
|
||||
{icon && <img src={icon} alt={label} className="h-6 w-6" />}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-[#5F646D]">{label}</span>
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-[#232528]">{count}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const StatusBadge = ({ status }) => {
|
||||
const map = {
|
||||
Active: 'bg-green-50 text-green-700 ring-1 ring-green-200',
|
||||
Inactive: 'bg-gray-100 text-gray-700 ring-1 ring-gray-200',
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${
|
||||
map[status] || map.Inactive
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminUsers = () => {
|
||||
const [isAddUserModalOpen, setIsAddUserModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [editingRow, setEditingRow] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
// const pageSize = 10;
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [deletingRow, setDeletingRow] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
status: 'Active',
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [toastData, setToastData] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Reset password modal state
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [resetUser, setResetUser] = useState(null);
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [resettingPassword, setResettingPassword] = useState(false);
|
||||
const [resetErrors, setResetErrors] = useState({});
|
||||
const [pageSize, setPageSize] = React.useState(10);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortConfig, setSortConfig] = useState({
|
||||
key: null,
|
||||
direction: 'asc'
|
||||
});
|
||||
|
||||
// Helper: show toast
|
||||
const showToast = (message, type = 'success') => {
|
||||
setToastData({ message, type });
|
||||
if (window.toastTimeout) {
|
||||
clearTimeout(window.toastTimeout);
|
||||
}
|
||||
window.toastTimeout = setTimeout(() => {
|
||||
setToastData(null);
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
// Filter users based on search term
|
||||
const filteredUsers = React.useMemo(() => {
|
||||
if (!searchTerm.trim()) return users;
|
||||
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return users.filter(user =>
|
||||
user.name.toLowerCase().includes(searchLower) ||
|
||||
user.email.toLowerCase().includes(searchLower) ||
|
||||
user.status.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}, [users, searchTerm]);
|
||||
|
||||
// Get current page data
|
||||
const getCurrentPageData = () => {
|
||||
const dataSource = searchTerm ? filteredUsers : users;
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
return dataSource.slice(startIndex, startIndex + pageSize);
|
||||
};
|
||||
|
||||
// Fetch users with pagination
|
||||
const fetchUsers = React.useCallback(async (search = searchTerm) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await getAdminUser(search);
|
||||
|
||||
const usersData = Array.isArray(res)
|
||||
? res
|
||||
: Array.isArray(res?.data)
|
||||
? res.data
|
||||
: Array.isArray(res?.data?.data)
|
||||
? res.data.data
|
||||
: [];
|
||||
|
||||
if (!usersData) {
|
||||
showToast('Failed to load users', 'error');
|
||||
setUsers([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedUsers = usersData.map((user) => {
|
||||
const id = user.id ?? user._id ?? user.user_id ?? null;
|
||||
const name = user.name ?? user.fullName ?? 'N/A';
|
||||
const email = user.email ?? 'N/A';
|
||||
const lastLoginRaw = user.updatedAt ?? user.last_login ?? user.lastLogin ?? null;
|
||||
const lastLogin = lastLoginRaw
|
||||
? new Date(lastLoginRaw).toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: 'Never logged in';
|
||||
const is_active =
|
||||
user.is_active !== undefined ? !!user.is_active : user.active ?? true;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
lastLogin,
|
||||
status: is_active ? 'Active' : 'Inactive',
|
||||
is_active,
|
||||
raw: user,
|
||||
};
|
||||
});
|
||||
|
||||
setUsers(mappedUsers);
|
||||
} catch (error) {
|
||||
console.error('Error fetching admin users:', error);
|
||||
showToast('Error fetching admin users', 'error');
|
||||
setUsers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [currentPage, fetchUsers]);
|
||||
|
||||
const totalUsers = searchTerm ? filteredUsers.length : users.length;
|
||||
const activeUsers = (searchTerm ? filteredUsers : users).filter((user) => user.status === 'Active').length;
|
||||
const inactiveUsers = totalUsers - activeUsers;
|
||||
|
||||
const summaryItems = [
|
||||
{
|
||||
label: 'Total Users',
|
||||
count: totalUsers,
|
||||
icon: adminUserIconSrc,
|
||||
iconBg: '#FDF7EB',
|
||||
},
|
||||
{
|
||||
label: 'Active User',
|
||||
count: activeUsers,
|
||||
icon: activeUserIconSrc,
|
||||
iconBg: '#E7F2FF',
|
||||
},
|
||||
{
|
||||
label: 'Inactive User',
|
||||
count: inactiveUsers,
|
||||
icon: inactiveUserIconSrc,
|
||||
iconBg: '#F5F5F7',
|
||||
},
|
||||
];
|
||||
|
||||
const columnWidths = ['150px', '250px', '200px', '120px', '150px'];
|
||||
|
||||
const handleSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
const getSortedData = (data) => {
|
||||
if (!sortConfig.key) return data;
|
||||
|
||||
return [...data].sort((a, b) => {
|
||||
// Handle different data types for sorting
|
||||
let aValue = a[sortConfig.key];
|
||||
let bValue = b[sortConfig.key];
|
||||
|
||||
// For status, sort by the active/inactive state
|
||||
if (sortConfig.key === 'status') {
|
||||
aValue = a.is_active ? 1 : 0;
|
||||
bValue = b.is_active ? 1 : 0;
|
||||
}
|
||||
|
||||
// For dates (lastLogin)
|
||||
if (sortConfig.key === 'lastLogin' && aValue !== 'Never logged in' && bValue !== 'Never logged in') {
|
||||
aValue = new Date(a.raw.updatedAt || a.raw.last_login || 0).getTime();
|
||||
bValue = new Date(b.raw.updatedAt || b.raw.last_login || 0).getTime();
|
||||
}
|
||||
|
||||
// For string comparison
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (aValue < bValue) {
|
||||
return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
}
|
||||
if (aValue > bValue) {
|
||||
return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
const headers = [
|
||||
{
|
||||
name: 'Name',
|
||||
key: 'name',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'Email',
|
||||
key: 'email',
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
name: 'Last Login',
|
||||
key: 'lastLogin',
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
name: 'Status',
|
||||
key: 'status',
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
name: 'Actions',
|
||||
key: 'actions',
|
||||
sortable: false
|
||||
}
|
||||
].map(header => {
|
||||
const baseClasses = "text-sm font-medium text-gray-700";
|
||||
|
||||
if (!header.sortable) {
|
||||
return {
|
||||
name: header.name,
|
||||
className: 'text-left pl-6 pr-4 py-3',
|
||||
style: { textAlign: 'left' }
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: (
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer"
|
||||
onClick={() => handleSort(header.key)}
|
||||
>
|
||||
{header.name}
|
||||
<img
|
||||
src={
|
||||
sortConfig.key === header.key
|
||||
? "/assets/images/active-sort.svg"
|
||||
: "/assets/images/inactive-sort.svg"
|
||||
}
|
||||
alt="Sort"
|
||||
className={`w-3 h-3 transition-transform ${
|
||||
sortConfig.key === header.key && sortConfig.direction === 'asc' ? 'transform rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
className: 'text-left pl-6 pr-4 py-3',
|
||||
style: { textAlign: 'left' }
|
||||
};
|
||||
});
|
||||
|
||||
// Apply sorting to filtered users
|
||||
const sortedUsers = getSortedData(searchTerm ? filteredUsers : users);
|
||||
|
||||
const rows = sortedUsers.map((user) => [
|
||||
user.name,
|
||||
user.email,
|
||||
user.lastLogin,
|
||||
<StatusBadge status={user.status} />,
|
||||
'actions',
|
||||
]);
|
||||
|
||||
const handleFormChange = (e) => {
|
||||
if (!e) return;
|
||||
if (e.target && e.target.name !== undefined) {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
if (errors[name]) setErrors((prev) => ({ ...prev, [name]: '' }));
|
||||
} else if (e.name && e.value !== undefined) {
|
||||
setFormData((prev) => ({ ...prev, [e.name]: e.value }));
|
||||
if (errors[e.name]) setErrors((prev) => ({ ...prev, [e.name]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateUser = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const updatedUser = {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
is_active: formData.status === "Active",
|
||||
};
|
||||
|
||||
const res = await updateAdminUser(currentUser.id, updatedUser);
|
||||
|
||||
const success =
|
||||
res?.status === "success" ||
|
||||
res?.status === 200 ||
|
||||
res?.data?.status === "success" ||
|
||||
(!res.error && res);
|
||||
|
||||
if (!success) throw new Error(res?.message || "Failed to update user");
|
||||
|
||||
showToast("User updated successfully!", "success");
|
||||
setIsEditModalOpen(false);
|
||||
|
||||
setUsers((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === currentUser.id
|
||||
? {
|
||||
...u,
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
status: formData.status,
|
||||
is_active: formData.status === "Active",
|
||||
}
|
||||
: u
|
||||
)
|
||||
);
|
||||
|
||||
setCurrentUser(null);
|
||||
} catch (error) {
|
||||
console.error("Error updating user:", error);
|
||||
showToast(error?.response?.data?.message || "Failed to update user", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
if (!formData.name || !formData.name.trim()) newErrors.name = 'Name is required';
|
||||
if (!formData.status) newErrors.status = 'Status is required';
|
||||
return newErrors;
|
||||
};
|
||||
|
||||
const handleDeleteClick = (user) => {
|
||||
setSelectedUser(user);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
try {
|
||||
if (!selectedUser) return;
|
||||
setDeletingRow(selectedUser.id);
|
||||
await deleteAdminUser(selectedUser.id);
|
||||
|
||||
showToast("User deleted successfully!", "success");
|
||||
|
||||
// Update both the main users list and filtered list
|
||||
setUsers((prev) => prev.filter((u) => u.id !== selectedUser.id));
|
||||
|
||||
// If we're in search mode, also update the filtered list
|
||||
if (searchTerm) {
|
||||
setSearchTerm(''); // Clear search to show updated list
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
showToast(error?.response?.data?.message || "Failed to delete user!", "error");
|
||||
} finally {
|
||||
setDeletingRow(null);
|
||||
setShowDeleteModal(false);
|
||||
setSelectedUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async (user) => {
|
||||
if (!user || !user.id) {
|
||||
showToast('Invalid user selected', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch the latest user data from the API using the user's ID
|
||||
const response = await getAdminUserById(user.id);
|
||||
const userData = response?.data?.data || response?.data || response;
|
||||
|
||||
if (!userData) {
|
||||
throw new Error('Failed to fetch user data');
|
||||
}
|
||||
|
||||
// Map the API response to match our form data structure
|
||||
const userToEdit = {
|
||||
id: userData.id || userData._id || user.id,
|
||||
name: userData.name || userData.fullName || '',
|
||||
email: userData.email || '',
|
||||
status: userData.is_active ? 'Active' : 'Inactive',
|
||||
is_active: userData.is_active !== undefined ? userData.is_active : true,
|
||||
raw: userData // Store the raw data for reference
|
||||
};
|
||||
|
||||
// Set the current user and form data
|
||||
setCurrentUser(userToEdit);
|
||||
setFormData({
|
||||
name: userToEdit.name,
|
||||
email: userToEdit.email,
|
||||
status: userToEdit.status,
|
||||
});
|
||||
|
||||
setEditingRow(userToEdit);
|
||||
setIsEditModalOpen(true);
|
||||
setErrors({});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching user data:', error);
|
||||
showToast('Failed to load user data. Please try again.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenResetModal = (user) => {
|
||||
setResetUser(user);
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setResetErrors({});
|
||||
setIsResetModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseResetModal = () => {
|
||||
setResetUser(null);
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setResetErrors({});
|
||||
setIsResetModalOpen(false);
|
||||
setResettingPassword(false);
|
||||
};
|
||||
|
||||
// Validate reset password form - Updated to allow 8 to 12 characters
|
||||
const validateResetForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
// Old password validation
|
||||
if (!oldPassword?.trim()) {
|
||||
newErrors.oldPassword = VALIDATION_MESSAGES.OLD_PASSWORD_REQUIRED;
|
||||
}
|
||||
|
||||
// New password validation
|
||||
if (!newPassword?.trim()) {
|
||||
newErrors.newPassword = VALIDATION_MESSAGES.PASSWORD_REQUIRED;
|
||||
} else if (newPassword.length < 8) {
|
||||
newErrors.newPassword = VALIDATION_MESSAGES.PASSWORD_MIN_LENGTH;
|
||||
}
|
||||
|
||||
// Confirm password validation
|
||||
if (!confirmPassword?.trim()) {
|
||||
newErrors.confirmPassword = VALIDATION_MESSAGES.CONFIRM_PASSWORD_REQUIRED;
|
||||
} else if (newPassword !== confirmPassword) {
|
||||
newErrors.confirmPassword = VALIDATION_MESSAGES.PASSWORDS_DONT_MATCH;
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Reset Password API Integration
|
||||
const handleResetPassword = async () => {
|
||||
if (!resetUser || !resetUser.id) {
|
||||
showToast('No user selected for password reset', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate form
|
||||
const formErrors = validateResetForm();
|
||||
if (Object.keys(formErrors).length > 0) {
|
||||
setResetErrors(formErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setResettingPassword(true);
|
||||
|
||||
// Prepare data according to API specification
|
||||
const passwordData = {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
confirm_password: confirmPassword
|
||||
};
|
||||
|
||||
|
||||
const res = await changeAdminUserPassword(resetUser.id, passwordData);
|
||||
|
||||
|
||||
// Check for success based on common response patterns
|
||||
const success =
|
||||
res?.status === "success" ||
|
||||
res?.status === 200 ||
|
||||
res?.code === 200 ||
|
||||
res?.data?.status === "success" ||
|
||||
res?.message?.toLowerCase().includes("success") ||
|
||||
res?.message?.toLowerCase().includes("updated") ||
|
||||
res?.message?.toLowerCase().includes("changed") ||
|
||||
(!res.error && res);
|
||||
|
||||
if (!success) {
|
||||
throw new Error(res?.message || res?.data?.message || "Failed to reset password");
|
||||
}
|
||||
|
||||
showToast('Admin password reset successfully!', 'success');
|
||||
handleCloseResetModal();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error resetting admin password:', error);
|
||||
|
||||
// Handle specific error cases
|
||||
let errorMessage = 'Failed to reset password';
|
||||
|
||||
if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
// Check for common admin password change errors
|
||||
if (errorMessage.toLowerCase().includes('old password') ||
|
||||
errorMessage.toLowerCase().includes('current password') ||
|
||||
errorMessage.toLowerCase().includes('incorrect password')) {
|
||||
setResetErrors(prev => ({
|
||||
...prev,
|
||||
oldPassword: VALIDATION_MESSAGES.INCORRECT_CURRENT_PASSWORD
|
||||
}));
|
||||
showToast(VALIDATION_MESSAGES.INCORRECT_CURRENT_PASSWORD, 'error');
|
||||
} else if (errorMessage.toLowerCase().includes('match')) {
|
||||
setResetErrors(prev => ({
|
||||
...prev,
|
||||
confirmPassword: VALIDATION_MESSAGES.PASSWORDS_DONT_MATCH
|
||||
}));
|
||||
showToast(VALIDATION_MESSAGES.PASSWORDS_DONT_MATCH, 'error');
|
||||
showToast('New passwords do not match', 'error');
|
||||
} else if (errorMessage.toLowerCase().includes('same') ||
|
||||
errorMessage.toLowerCase().includes('previous')) {
|
||||
setResetErrors(prev => ({
|
||||
...prev,
|
||||
newPassword: VALIDATION_MESSAGES.PASSWORD_SAME_AS_CURRENT
|
||||
}));
|
||||
showToast(VALIDATION_MESSAGES.PASSWORD_SAME_AS_CURRENT, 'error');
|
||||
} else if (errorMessage.toLowerCase().includes('weak') ||
|
||||
errorMessage.toLowerCase().includes('strength')) {
|
||||
setResetErrors(prev => ({
|
||||
...prev,
|
||||
newPassword: VALIDATION_MESSAGES.PASSWORD_TOO_WEAK
|
||||
}));
|
||||
showToast('Password is too weak. Please use a stronger password.', 'error');
|
||||
} else if (error.status === 404) {
|
||||
showToast('Admin user not found', 'error');
|
||||
} else {
|
||||
showToast(errorMessage, 'error');
|
||||
}
|
||||
} finally {
|
||||
setResettingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (rowIndex) => {
|
||||
// Get the current data source based on search
|
||||
const dataSource = searchTerm ? filteredUsers : users;
|
||||
// Calculate the actual index in the current data source based on pagination
|
||||
const actualIndex = (currentPage - 1) * pageSize + rowIndex;
|
||||
const user = dataSource[actualIndex];
|
||||
if (user) {
|
||||
setSelectedUser(user);
|
||||
setShowDeleteModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditSubmit = async (e) => {
|
||||
// Prevent default form submission if event is provided
|
||||
if (e && e.preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const formErrors = validateForm();
|
||||
if (Object.keys(formErrors).length > 0) {
|
||||
setErrors(formErrors);
|
||||
showToast('Please fix the form errors', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentUser || !currentUser.id) {
|
||||
showToast('No user selected for update', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
is_active: formData.status === 'Active',
|
||||
};
|
||||
|
||||
const res = await updateAdminUser(currentUser.id, payload);
|
||||
const success =
|
||||
res?.status === 'success' ||
|
||||
res?.status === 200 ||
|
||||
res?.data?.status === 'success' ||
|
||||
(!res.error && res);
|
||||
|
||||
if (!success) throw new Error(res?.message || 'Failed to update user');
|
||||
|
||||
// Update the users list with the updated user data
|
||||
setUsers((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === currentUser.id
|
||||
? {
|
||||
...u,
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
status: formData.status,
|
||||
is_active: formData.status === 'Active',
|
||||
lastLogin: u.lastLogin, // Preserve the last login time
|
||||
raw: {
|
||||
...(u.raw || {}),
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
is_active: formData.status === 'Active'
|
||||
}
|
||||
}
|
||||
: u
|
||||
)
|
||||
);
|
||||
|
||||
showToast('User updated successfully', 'success');
|
||||
setIsEditModalOpen(false);
|
||||
setEditingRow(null);
|
||||
setCurrentUser(null);
|
||||
|
||||
// Reset form data
|
||||
setFormData({
|
||||
name: '',
|
||||
email: '',
|
||||
status: 'Active',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error);
|
||||
showToast(error?.response?.data?.message || 'Failed to update user', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setCurrentPage(page);
|
||||
// Clear any row states when changing pages
|
||||
setEditingRow(null);
|
||||
setDeletingRow(null);
|
||||
setResetUser(null);
|
||||
};
|
||||
|
||||
const handleSearch = (e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1); // Reset to first page on search
|
||||
// Clear any row states
|
||||
setEditingRow(null);
|
||||
setDeletingRow(null);
|
||||
setResetUser(null);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1);
|
||||
// Clear any row states when changing page size
|
||||
setEditingRow(null);
|
||||
setDeletingRow(null);
|
||||
setResetUser(null);
|
||||
};
|
||||
|
||||
const tableToolbar = (
|
||||
<div className="px-6 py-4 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 bg-white">
|
||||
<h3 className="text-sm font-medium text-[#232528] whitespace-nowrap">Admin Users</h3>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-end gap-4 w-full">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={searchIconSrc}
|
||||
alt="Search"
|
||||
className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={handleSearch}
|
||||
placeholder="Search by name, email, or status"
|
||||
className="h-10 w-[350px] rounded-lg border border-[#E6EAF5] pl-10 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-[#B68A35]"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!users.length) return;
|
||||
|
||||
const csvHeader = ['Name', 'Email', 'Status', 'Last Login'].join(',');
|
||||
|
||||
const csvRows = users.map((user) =>
|
||||
[user.name, user.email, user.status, user.lastLogin]
|
||||
.map((value) => `"${String(value ?? '').replace(/"/g, '""')}"`)
|
||||
.join(',')
|
||||
);
|
||||
|
||||
const blob = new Blob([csvHeader + '\n' + csvRows.join('\n')], {
|
||||
type: 'text/csv;charset=utf-8;',
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', 'admin-users.csv');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
className={`h-9 px-3 rounded-md border text-sm inline-flex items-center gap-2 ${
|
||||
users.length
|
||||
? 'bg-[#F7F7F7] border-[#C3C6CB] text-[#232528]'
|
||||
: 'bg-[#F3F4F6] border-[#E5E7EB] text-[#9CA3AF] cursor-not-allowed'
|
||||
}`}
|
||||
disabled={!users.length}
|
||||
>
|
||||
<img src={exportIconSrc} alt="Export" className="h-4 w-4" />
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsAddUserModalOpen(true)}
|
||||
className="h-9 px-3 rounded-md bg-[#B68A35] text-white text-sm inline-flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<img src={addIconSrc} alt="Add" className="h-4 w-4" />
|
||||
<span>Add User</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{summaryItems.map((item) => (
|
||||
<SummaryCard key={item.label} {...item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Table
|
||||
headers={headers}
|
||||
columnWidths={columnWidths}
|
||||
rows={rows}
|
||||
loading={loading}
|
||||
beforeHeader={tableToolbar}
|
||||
pagination={{
|
||||
currentPage,
|
||||
onPageChange: handlePageChange,
|
||||
pageSize,
|
||||
totalItems: searchTerm ? filteredUsers.length : users.length,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
onPageSizeChange: handlePageSizeChange
|
||||
}}
|
||||
renderCell={(value, rowIndex, colIndex) => {
|
||||
if (colIndex === 4) {
|
||||
// Get the current page data
|
||||
const currentPageData = getCurrentPageData();
|
||||
const user = currentPageData[rowIndex];
|
||||
if (!user) return null;
|
||||
|
||||
const isActive = user.status === 'Active';
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 grid place-items-center rounded hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => handleEdit(user.raw || user)}
|
||||
title="Edit user"
|
||||
>
|
||||
<img
|
||||
src={editingRow?.id === user.id ? pencilActiveSrc : pencilInactiveSrc}
|
||||
alt="Edit"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 grid place-items-center rounded hover:bg-gray-50 cursor-pointer"
|
||||
title="Delete user"
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={deletingRow === user.id ? trashActiveSrc : trashInactiveSrc}
|
||||
alt="Delete"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`h-8 w-8 grid place-items-center rounded hover:bg-gray-50 cursor-pointer ${
|
||||
isActive ? '' : 'opacity-60'
|
||||
}`}
|
||||
title={isActive ? 'Reset password' : 'Reset disabled for inactive user'}
|
||||
disabled={!isActive}
|
||||
onClick={() => handleOpenResetModal(user)}
|
||||
>
|
||||
<img
|
||||
src={isActive ? resetPasswordIconSrc : resetPasswordInactiveIconSrc}
|
||||
alt="Reset password"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Add User Modal */}
|
||||
{isAddUserModalOpen && (
|
||||
<AddAdminUsers
|
||||
isOpen={isAddUserModalOpen}
|
||||
onClose={() => setIsAddUserModalOpen(false)}
|
||||
onSave={async (newUser) => {
|
||||
try {
|
||||
// First, optimistically update the UI
|
||||
setUsers(prev => [
|
||||
...prev,
|
||||
{
|
||||
...newUser,
|
||||
id: newUser.id ?? `local-${Date.now()}`,
|
||||
lastLogin: new Date().toLocaleString('en-GB'),
|
||||
status: 'Active',
|
||||
is_active: true,
|
||||
},
|
||||
]);
|
||||
|
||||
// Then fetch fresh data to ensure consistency
|
||||
await fetchUsers();
|
||||
|
||||
setIsAddUserModalOpen(false);
|
||||
showToast('User added successfully', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error adding user:', error);
|
||||
showToast('Error adding user', 'error');
|
||||
}
|
||||
}}
|
||||
passwordMinLength={8}
|
||||
passwordMaxLength={12}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{isEditModalOpen && currentUser && (
|
||||
<EditUserModal
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
onClose={() => {
|
||||
setIsEditModalOpen(false);
|
||||
setCurrentUser(null);
|
||||
setFormData({ name: '', email: '', status: 'Active' });
|
||||
setErrors({});
|
||||
}}
|
||||
onUpdate={handleEditSubmit}
|
||||
showToast={showToast}
|
||||
key={currentUser.id} // Add key to force re-render when user changes
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Modal */}
|
||||
{showDeleteModal && (
|
||||
<DeleteUserModal
|
||||
userName={selectedUser?.name}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onDelete={handleConfirmDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
<ResetPasswordModal
|
||||
isOpen={isResetModalOpen}
|
||||
onClose={handleCloseResetModal}
|
||||
oldPassword={oldPassword}
|
||||
setOldPassword={setOldPassword}
|
||||
newPassword={newPassword}
|
||||
setNewPassword={setNewPassword}
|
||||
confirmPassword={confirmPassword}
|
||||
setConfirmPassword={setConfirmPassword}
|
||||
onReset={handleResetPassword}
|
||||
loading={resettingPassword}
|
||||
errors={resetErrors}
|
||||
setErrors={setResetErrors}
|
||||
passwordMinLength={8}
|
||||
passwordMaxLength={12}
|
||||
/>
|
||||
|
||||
{/* Toast */}
|
||||
{toastData && (
|
||||
<div className="fixed inset-0 z-[9999] pointer-events-none">
|
||||
<div className="absolute right-4 top-4 pointer-events-auto">
|
||||
<CustomToast
|
||||
message={toastData.message}
|
||||
type={toastData.type}
|
||||
onClose={() => setToastData(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUsers;
|
||||
@ -1,279 +0,0 @@
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { VALIDATION_MESSAGES } from '../../../../constants/validationMessages';
|
||||
|
||||
const ResetPasswordModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
oldPassword,
|
||||
setOldPassword,
|
||||
newPassword,
|
||||
setNewPassword,
|
||||
confirmPassword,
|
||||
setConfirmPassword,
|
||||
onReset,
|
||||
loading = false,
|
||||
errors = {},
|
||||
setErrors
|
||||
}) => {
|
||||
const [showOldPassword, setShowOldPassword] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [passwordTouched, setPasswordTouched] = useState(false);
|
||||
const [confirmPasswordTouched, setConfirmPasswordTouched] = useState(false);
|
||||
|
||||
const validatePassword = (password) => {
|
||||
if (!password) return VALIDATION_MESSAGES.PASSWORD_REQUIRED;
|
||||
if (password.length < 8) return VALIDATION_MESSAGES.PASSWORD_MIN_LENGTH;
|
||||
if (!/[A-Z]/.test(password)) return VALIDATION_MESSAGES.PASSWORD_UPPERCASE;
|
||||
if (!/[a-z]/.test(password)) return VALIDATION_MESSAGES.PASSWORD_LOWERCASE;
|
||||
if (!/\d/.test(password)) return VALIDATION_MESSAGES.PASSWORD_NUMBER;
|
||||
if (!/[!@#$%^&*]/.test(password)) return VALIDATION_MESSAGES.PASSWORD_SPECIAL_CHAR;
|
||||
return '';
|
||||
};
|
||||
|
||||
const passwordError = validatePassword(newPassword);
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!oldPassword.trim()) {
|
||||
newErrors.oldPassword = VALIDATION_MESSAGES.OLD_PASSWORD_REQUIRED;
|
||||
}
|
||||
|
||||
if (passwordTouched && passwordError) {
|
||||
newErrors.newPassword = passwordError;
|
||||
}
|
||||
|
||||
if (!confirmPassword.trim()) {
|
||||
newErrors.confirmPassword = VALIDATION_MESSAGES.CONFIRM_PASSWORD_REQUIRED;
|
||||
} else if (newPassword !== confirmPassword) {
|
||||
newErrors.confirmPassword = VALIDATION_MESSAGES.PASSWORDS_DONT_MATCH;
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const formErrors = validateForm();
|
||||
|
||||
if (Object.keys(formErrors).length > 0) {
|
||||
setErrors(formErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrors({});
|
||||
onReset();
|
||||
};
|
||||
|
||||
const handleInputChange = (field, value) => {
|
||||
if (field === 'oldPassword') setOldPassword(value);
|
||||
if (field === 'newPassword') {
|
||||
setNewPassword(value);
|
||||
// If confirm password has value, re-validate it when new password changes
|
||||
if (confirmPassword) {
|
||||
setConfirmPasswordTouched(true);
|
||||
}
|
||||
}
|
||||
if (field === 'confirmPassword') {
|
||||
setConfirmPassword(value);
|
||||
setConfirmPasswordTouched(true);
|
||||
}
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !loading) {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 ">
|
||||
<div className="bg-white rounded-2xl w-[90%] max-w-md shadow-xl p-6 relative border border-gray-200">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-900">Reset Password</h3>
|
||||
<button
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form Fields */}
|
||||
<div className="space-y-4">
|
||||
{/* Old Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Old Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showOldPassword ? "text" : "password"}
|
||||
placeholder="Enter old password"
|
||||
className={`w-full border rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-2 transition-all pr-10 ${
|
||||
errors.oldPassword
|
||||
? 'border-red-300 focus:ring-red-500 bg-red-50'
|
||||
: 'border-gray-300 focus:border-[#B68A35] focus:ring-2 focus:ring-[#B68A35] focus:ring-opacity-20'
|
||||
}`}
|
||||
value={oldPassword}
|
||||
onChange={(e) => handleInputChange('oldPassword', e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setShowOldPassword(!showOldPassword)}
|
||||
disabled={loading}
|
||||
>
|
||||
{showOldPassword ? <Eye className="w-5 h-5 text-[#B68A35]" /> : <EyeOff className="w-5 h-5 text-[#B68A35]" />}
|
||||
|
||||
</button>
|
||||
</div>
|
||||
{errors.oldPassword && (
|
||||
<p className="text-red-600 text-xs mt-2">{errors.oldPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
New Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
placeholder="Enter new password"
|
||||
className={`w-full border rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-2 transition-all pr-10 ${
|
||||
(passwordTouched && passwordError) || errors.newPassword
|
||||
? 'border-red-300 focus:ring-red-500 bg-red-50'
|
||||
: 'border-gray-300 focus:border-[#B68A35] focus:ring-2 focus:ring-[#B68A35] focus:ring-opacity-20'
|
||||
}`}
|
||||
value={newPassword}
|
||||
onChange={(e) => {
|
||||
setNewPassword(e.target.value);
|
||||
if (errors.newPassword) setErrors(prev => ({ ...prev, newPassword: '' }));
|
||||
}}
|
||||
onFocus={() => setPasswordTouched(true)}
|
||||
onBlur={() => setPasswordTouched(true)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
disabled={loading}
|
||||
>
|
||||
{showNewPassword ? <Eye className="w-5 h-5 text-[#B68A35]" /> : <EyeOff className="w-5 h-5 text-[#B68A35]" />}
|
||||
|
||||
</button>
|
||||
</div>
|
||||
{passwordTouched && passwordError && (
|
||||
<p className="text-red-600 text-xs mt-2">{passwordError}</p>
|
||||
)}
|
||||
{!passwordTouched && errors.newPassword && (
|
||||
<p className="text-red-600 text-xs mt-2">{errors.newPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirm Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="Confirm new password"
|
||||
className={`w-full border rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-2 transition-all pr-10 ${
|
||||
(confirmPasswordTouched && confirmPassword && newPassword !== confirmPassword) || errors.confirmPassword
|
||||
? 'border-red-300 focus:ring-red-500 bg-red-50'
|
||||
: confirmPassword && newPassword === confirmPassword
|
||||
? 'border-green-300 focus:ring-green-500 bg-green-50'
|
||||
: 'border-gray-300 focus:border-[#B68A35] focus:ring-2 focus:ring-[#B68A35] focus:ring-opacity-20'
|
||||
}`}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
|
||||
onFocus={() => setConfirmPasswordTouched(true)}
|
||||
onBlur={() => setConfirmPasswordTouched(true)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
disabled={loading}
|
||||
>
|
||||
{showConfirmPassword ? <Eye className="w-5 h-5 text-[#B68A35]" /> : <EyeOff className="w-5 h-5 text-[#B68A35]" />}
|
||||
|
||||
</button>
|
||||
</div>
|
||||
{confirmPasswordTouched && confirmPassword && newPassword !== confirmPassword && (
|
||||
<p className="text-red-600 text-xs mt-2">Passwords do not match</p>
|
||||
)}
|
||||
{confirmPasswordTouched && confirmPassword && newPassword === confirmPassword && (
|
||||
<p className="text-green-600 text-xs mt-2">Passwords match</p>
|
||||
)}
|
||||
{!confirmPassword && errors.confirmPassword && (
|
||||
<p className="text-red-600 text-xs mt-2">{errors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<div className="mt-4 p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-4 h-4 text-blue-600 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<p className="text-sm text-blue-700">
|
||||
<span className="font-medium">Note:</span> Password must be at least <b>8 characters</b> long.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
className="px-6 py-2.5 rounded-lg border border-gray-300 text-gray-700 font-medium hover:bg-gray-50 disabled:opacity-50"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="px-6 py-2.5 rounded-lg bg-[#B68A35] text-white font-medium hover:bg-[#9c6f28] disabled:opacity-50 flex items-center gap-2"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Resetting...
|
||||
</>
|
||||
) : (
|
||||
'Reset Password'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPasswordModal;
|
||||
@ -35,6 +35,7 @@ const EmailTemplates = () => {
|
||||
|
||||
const handleSave = () => {
|
||||
// Replace with API call
|
||||
console.log('Saving template', selectedTemplate, form);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -67,7 +68,13 @@ const EmailTemplates = () => {
|
||||
value={form.body}
|
||||
onChange={handleChange('body')}
|
||||
rows={10}
|
||||
className="w-full text-sm focus:outline-none border-2 border-[#CBA344] rounded p-3"
|
||||
className="w-full text-sm focus:outline-none"
|
||||
style={{
|
||||
width: '100%',
|
||||
border: '2px solid #CBA344',
|
||||
borderRadius: '4px',
|
||||
padding: '12px 16px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -75,7 +82,8 @@ const EmailTemplates = () => {
|
||||
<div className="px-6 pb-6 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="h-9 px-5 rounded-md border border-[#92722A] text-[#92722A] bg-white cursor-pointer"
|
||||
className="h-9 px-5 rounded-md border text-[#92722A] bg-white cursor-pointer"
|
||||
style={{ borderColor: '#92722A' }}
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@ -307,7 +307,8 @@ const Establishment = () => {
|
||||
<div className="px-6 py-4 border-t border-[#E5E7EB] flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="h-9 px-5 rounded-md border border-[#92722A] text-[#92722A] bg-white cursor-pointer"
|
||||
className="h-9 px-5 rounded-md border text-[#92722A] bg-white cursor-pointer"
|
||||
style={{ borderColor: '#92722A' }}
|
||||
onClick={closeModal}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@ -24,6 +24,7 @@ const SubmissionDeadlines = () => {
|
||||
|
||||
const handleSave = () => {
|
||||
// Future: Replace with save logic
|
||||
console.log('Saving submission deadlines', form);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -62,7 +63,8 @@ const SubmissionDeadlines = () => {
|
||||
<div className="px-6 pb-6 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="h-9 px-5 rounded-md border border-[#92722A] text-[#92722A] bg-white cursor-pointer"
|
||||
className="h-9 px-5 rounded-md border text-[#92722A] bg-white cursor-pointer"
|
||||
style={{ borderColor: '#92722A' }}
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import apiClient from '@/services/api/apiClient';
|
||||
import { VALIDATION_MESSAGES } from '@/constants/validationMessages';
|
||||
import { ASSETS } from '@/constants/assets';
|
||||
|
||||
const logoSrc = '/assets/images/FCSCLogo.svg';
|
||||
|
||||
const ToggleableInput = ({
|
||||
label,
|
||||
@ -13,7 +13,6 @@ const ToggleableInput = ({
|
||||
toggleShow,
|
||||
placeholder,
|
||||
required = false,
|
||||
error,
|
||||
}) => (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
@ -26,46 +25,42 @@ const ToggleableInput = ({
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className={`block w-full h-10 rounded-md border-2 ${
|
||||
error ? "border-red-500" : "border-[#92722A]"
|
||||
} focus:border-[#92722A] focus:ring-0 px-4 pr-11 text-sm bg-white`}
|
||||
required={required}
|
||||
className="block w-full h-10 rounded-md border-2 border-[#92722A] focus:border-[#92722A] focus:ring-0 px-4 pr-11 text-sm bg-white"
|
||||
/>
|
||||
{type === 'password' && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 px-3 flex items-center cursor-pointer"
|
||||
onClick={toggleShow}
|
||||
aria-label="Toggle visibility"
|
||||
>
|
||||
{show ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-[#92722A]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z" />
|
||||
<circle cx="12" cy="12" r="3" strokeWidth="2" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-[#92722A]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0 1 12 19c-5.523 0-10-4.477-10-10a9.96 9.96 0 0 1 2.122-6.21m3.086-1.955A9.953 9.953 0 0 1 12 3c5.523 0 10 4.477 10 10 0 1.591-.372 3.093-1.034 4.432M9.88 9.88a3 3 0 1 0 4.24 4.24" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m3 3 18 18" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 px-3 flex items-center cursor-pointer"
|
||||
onClick={toggleShow}
|
||||
aria-label="Toggle visibility"
|
||||
>
|
||||
{show ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-[#92722A]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0 1 12 19c-5.523 0-10-4.477-10-10a9.96 9.96 0 0 1 2.122-6.21m3.086-1.955A9.953 9.953 0 0 1 12 3c5.523 0 10 4.477 10 10 0 1.591-.372 3.093-1.034 4.432M9.88 9.88a3 3 0 1 0 4.24 4.24"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m3 3 18 18" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-[#92722A]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z" />
|
||||
<circle cx="12" cy="12" r="3" strokeWidth="2" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
const ChangePassword = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const { email: registeredEmail, user_type: userType } = location.state || {};
|
||||
|
||||
// Ensure user_type is set in the form data
|
||||
const [formData, setFormData] = React.useState({
|
||||
user_type: userType
|
||||
});
|
||||
const registeredEmail = location.state?.email || "";
|
||||
|
||||
const [otp, setOtp] = React.useState("");
|
||||
const [newPassword, setNewPassword] = React.useState("");
|
||||
@ -76,111 +71,102 @@ const ChangePassword = () => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState("");
|
||||
const [success, setSuccess] = React.useState("");
|
||||
const [otpError, setOtpError] = React.useState("");
|
||||
const [newPasswordError, setNewPasswordError] = React.useState("");
|
||||
const [confirmPasswordError, setConfirmPasswordError] = React.useState("");
|
||||
|
||||
const validatePassword = (password) => {
|
||||
const rules = [
|
||||
{ regex: /.{8,}/, msg: VALIDATION_MESSAGES.PASSWORD_MIN_LENGTH },
|
||||
{ regex: /[A-Z]/, msg: VALIDATION_MESSAGES.PASSWORD_UPPERCASE },
|
||||
{ regex: /[a-z]/, msg: VALIDATION_MESSAGES.PASSWORD_LOWERCASE },
|
||||
{ regex: /[0-9]/, msg: VALIDATION_MESSAGES.PASSWORD_NUMBER },
|
||||
{ regex: /[!@#$%^&*(),.?\":{}|<>]/, msg: VALIDATION_MESSAGES.PASSWORD_SPECIAL_CHAR },
|
||||
];
|
||||
|
||||
const failedRule = rules.find(r => !r.regex.test(password));
|
||||
return failedRule ? VALIDATION_MESSAGES.PASSWORD_COMPLEXITY_ERROR : "";
|
||||
const minLength = /.{8,}/;
|
||||
const uppercase = /[A-Z]/;
|
||||
const lowercase = /[a-z]/;
|
||||
const number = /[0-9]/;
|
||||
const specialChar = /[!@#$%^&*(),.?":{}|<>]/;
|
||||
|
||||
if (!minLength.test(password))
|
||||
return "Password must be at least 8 characters long.";
|
||||
if (!uppercase.test(password))
|
||||
return "Password must contain at least one uppercase letter.";
|
||||
if (!lowercase.test(password))
|
||||
return "Password must contain at least one lowercase letter.";
|
||||
if (!number.test(password))
|
||||
return "Password must contain at least one number.";
|
||||
if (!specialChar.test(password))
|
||||
return "Password must contain at least one special character.";
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
setError("");
|
||||
setSuccess("");
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSuccess("");
|
||||
|
||||
setOtpError("");
|
||||
setNewPasswordError("");
|
||||
setConfirmPasswordError("");
|
||||
|
||||
let hasError = false;
|
||||
|
||||
if (!otp.trim()) {
|
||||
setOtpError("Verification code is required");
|
||||
hasError = true;
|
||||
}
|
||||
if (!newPassword.trim()) {
|
||||
setNewPasswordError(VALIDATION_MESSAGES.PASSWORD_REQUIRED);
|
||||
hasError = true;
|
||||
}
|
||||
if (!confirmPassword.trim()) {
|
||||
setConfirmPasswordError("Confirm password is required.");
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
if (hasError) return;
|
||||
|
||||
const passwordError = validatePassword(newPassword);
|
||||
if (passwordError) {
|
||||
setNewPasswordError(passwordError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setConfirmPasswordError(VALIDATION_MESSAGES.PASSWORDS_DONT_MATCH);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = {
|
||||
registered_email: registeredEmail,
|
||||
otp: otp.trim(),
|
||||
password: newPassword,
|
||||
confirm_password: confirmPassword,
|
||||
user_type: userType // Use the user_type from location state
|
||||
};
|
||||
|
||||
const response = await apiClient.post("/forgot-password/verify-otp", payload);
|
||||
|
||||
if (response?.data) {
|
||||
setSuccess("Password changed successfully. Redirecting to login...");
|
||||
setTimeout(() => {
|
||||
navigate("/login", {
|
||||
state: {
|
||||
showSuccessToast: true,
|
||||
successMessage:
|
||||
"Password reset successful. Please login with your new password.",
|
||||
},
|
||||
});
|
||||
}, 1500);
|
||||
} else {
|
||||
setError("Something went wrong. Please try again.");
|
||||
if (!registeredEmail) {
|
||||
setError("Missing registered email. Please restart the process.");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Password change failed. Please verify the details.";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!otp || !newPassword || !confirmPassword) {
|
||||
setError("Please fill in all fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordError = validatePassword(newPassword);
|
||||
if (passwordError) {
|
||||
setError(passwordError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = {
|
||||
registered_email: registeredEmail,
|
||||
otp: otp.trim(),
|
||||
password: newPassword,
|
||||
confirm_password: confirmPassword,
|
||||
};
|
||||
|
||||
const response = await apiClient.post("/forgot-password/verify-otp", payload);
|
||||
|
||||
if (response?.data) {
|
||||
setSuccess("Password changed successfully. Redirecting to login...");
|
||||
setTimeout(() => {
|
||||
navigate("/login", {
|
||||
state: {
|
||||
showSuccessToast: true,
|
||||
successMessage: "Password reset successful. Please login with your new password."
|
||||
}
|
||||
});
|
||||
}, 1500);
|
||||
} else {
|
||||
setError("Something went wrong. Please try again.");
|
||||
}
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Password change failed. Please verify the details.";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={ASSETS.CLASSES.AUTH_CONTAINER}>
|
||||
<div className="min-h-screen bg-[#F7F7F7] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg shadow-sm ring-1 ring-gray-200 overflow-hidden">
|
||||
<div className="px-7 py-8 border-b border-gray-200 bg-[#F2ECCF] text-center">
|
||||
<img src={ASSETS.IMAGES.LOGO} alt="FCSC" className="w-[188px] h-[58px] mx-auto mb-2" />
|
||||
<img src={logoSrc} alt="FCSC" className="w-[188px] h-[58px] mx-auto mb-2" />
|
||||
<h1 className="text-base font-semibold text-[#92722A]">Reset Password</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Enter the verification code sent to your registered email and set a new password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-7 py-8 space-y-4" autoComplete="off">
|
||||
<form onSubmit={handleSubmit} className="px-7 py-8 space-y-4">
|
||||
{error && (
|
||||
<div className="text-sm text-red-700 bg-red-50 border border-red-200 rounded px-3 py-2">
|
||||
{error}
|
||||
@ -200,71 +186,40 @@ if (newPassword !== confirmPassword) {
|
||||
type="email"
|
||||
value={registeredEmail}
|
||||
disabled
|
||||
autoComplete="username"
|
||||
className="block w-full h-10 rounded-md border-2 border-gray-300 px-4 text-sm bg-gray-50 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
User Type
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
value={userType === 'admin_user' ? 'Admin' : 'Establishment'}
|
||||
className="block w-full h-10 rounded-md border-2 border-gray-300 px-4 text-sm bg-gray-50 cursor-not-allowed"
|
||||
/>
|
||||
<input type="hidden" name="user_type" value={userType} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToggleableInput
|
||||
label="Verification Code"
|
||||
required
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value)}
|
||||
placeholder="Enter 6-digit code"
|
||||
error={otpError}
|
||||
autoComplete="one-time-code"
|
||||
type="text"
|
||||
/>
|
||||
label="Verification Code"
|
||||
required
|
||||
type="password"
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value)}
|
||||
show={showOtp}
|
||||
toggleShow={() => setShowOtp((v) => !v)}
|
||||
placeholder="Enter 6-digit code"
|
||||
/>
|
||||
|
||||
<ToggleableInput
|
||||
label="New Password"
|
||||
required
|
||||
value={newPassword}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setNewPassword(value);
|
||||
const passwordError = validatePassword(value);
|
||||
setNewPasswordError(passwordError);
|
||||
}}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
show={showNewPassword}
|
||||
toggleShow={() => setShowNewPassword((v) => !v)}
|
||||
placeholder="Enter new password"
|
||||
error={newPasswordError}
|
||||
/>
|
||||
<ToggleableInput
|
||||
label="New Password"
|
||||
required
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
show={showNewPassword}
|
||||
toggleShow={() => setShowNewPassword((v) => !v)}
|
||||
placeholder="Enter new password"
|
||||
/>
|
||||
|
||||
<ToggleableInput
|
||||
label="Confirm New Password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setConfirmPassword(value);
|
||||
if (newPassword === value) setConfirmPasswordError("");
|
||||
}}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
show={showConfirmPassword}
|
||||
toggleShow={() => setShowConfirmPassword((v) => !v)}
|
||||
placeholder="Re-enter new password"
|
||||
error={confirmPasswordError}
|
||||
/>
|
||||
<ToggleableInput
|
||||
label="Confirm New Password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
show={showConfirmPassword}
|
||||
toggleShow={() => setShowConfirmPassword((v) => !v)}
|
||||
placeholder="Re-enter new password"
|
||||
/>
|
||||
{newPassword && (
|
||||
<ul className="text-xs text-gray-600 list-disc pl-5 space-y-1">
|
||||
<li className={/.{8,}/.test(newPassword) ? "text-green-600" : ""}>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { requestPasswordReset } from '@/services/auth/authService.js';
|
||||
import { ASSETS } from '@/constants/assets';
|
||||
const CONTAINER_CLASS = "min-h-screen bg-[#F7F7F7] flex items-center justify-center px-4";
|
||||
|
||||
const logoSrc = '/assets/images/FCSCLogo.svg';
|
||||
|
||||
const ForgotPassword = () => {
|
||||
const navigate = useNavigate();
|
||||
@ -38,11 +38,11 @@ const ForgotPassword = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={CONTAINER_CLASS}>
|
||||
<div className="min-h-screen bg-[#F7F7F7] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg shadow-sm ring-1 ring-gray-200 overflow-hidden">
|
||||
<div className="px-7 py-8 border-b border-gray-200 bg-[#F2ECCF] text-center">
|
||||
<img src={ASSETS.IMAGES.LOGO} alt="FCSC" className="w-[188px] h-[58px] mx-auto mb-3" />
|
||||
<img src={logoSrc} alt="FCSC" className="w-[188px] h-[58px] mx-auto mb-3" />
|
||||
<h1 className="text-base font-semibold text-[#92722A]">Reset Password</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">Enter your email and we will send you instructions to reset your password.</p>
|
||||
</div>
|
||||
|
||||
@ -1,50 +1,22 @@
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Table from '@/components/common/Table';
|
||||
import HeaderBar from '@/components/layout/HeaderBar';
|
||||
import { resolveEstablishmentId } from '@/services/utils/establishment';
|
||||
import { fetchProducts } from '@/services/masters/masterService';
|
||||
import { fetchSubmissionHistory, getSubmissionAuditHistory } from '@/services/submissions/submissionService';
|
||||
import { fetchSubmissionHistory } from '@/services/submissions/submissionService.js';
|
||||
|
||||
const searchIconSrc = '/assets/images/material-symbols_search-rounded.svg';
|
||||
const BreadcrumbVector = '/assets/images/Breadcrumb-vector.svg';
|
||||
const caretDownSrc = '/assets/images/CaretDown.svg';
|
||||
|
||||
const Badge = ({ variant = 'default', children }) => {
|
||||
// Define all possible status variants and their styles
|
||||
const styles = {
|
||||
// Approved status
|
||||
approved: 'text-green-700 bg-green-50',
|
||||
|
||||
// Submitted status
|
||||
submitted: 'text-green-700 bg-green-50',
|
||||
|
||||
// Under Review status (includes pending and in-progress)
|
||||
'under review': 'text-blue-600 bg-blue-50',
|
||||
pending: 'text-blue-600 bg-blue-50',
|
||||
'in-progress': 'text-blue-600 bg-blue-50',
|
||||
|
||||
// Rejected status (includes returned)
|
||||
rejected: 'text-red-600 bg-red-50',
|
||||
returned: 'text-red-600 bg-red-50',
|
||||
|
||||
// Resubmitted status
|
||||
resubmitted: 'text-[#7C5E24] bg-[#F9F7ED]',
|
||||
|
||||
// Default fallback
|
||||
default: 'text-gray-600 bg-gray-50',
|
||||
approved: 'bg-green-50 text-green-700 ring-1 ring-green-200',
|
||||
submitted: 'bg-blue-50 text-blue-700 ring-1 ring-blue-200',
|
||||
rejected: 'bg-red-50 text-red-600 ring-1 ring-red-200',
|
||||
pending: 'bg-yellow-50 text-yellow-700 ring-1 ring-yellow-200',
|
||||
default: 'bg-gray-50 text-gray-700 ring-1 ring-gray-200',
|
||||
};
|
||||
|
||||
// Normalize the variant key
|
||||
const normalizedVariant = String(variant || '').toLowerCase().trim();
|
||||
|
||||
// Get the appropriate style, fallback to default if not found
|
||||
const style = styles[normalizedVariant] || styles.default;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap ${style}`}
|
||||
>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${styles[variant] || styles.default}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
@ -56,118 +28,14 @@ const History = () => {
|
||||
const [error, setError] = React.useState('');
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
const [statusFilter, setStatusFilter] = React.useState('all');
|
||||
const [yearFilter, setYearFilter] = React.useState('');
|
||||
const [quarterFilter, setQuarterFilter] = React.useState('');
|
||||
const [hscodeFilter, setHscodeFilter] = React.useState('');
|
||||
const [hscodeOptions, setHscodeOptions] = React.useState([]);
|
||||
const [isLoadingHscodes, setIsLoadingHscodes] = React.useState(false);
|
||||
const [auditHistory, setAuditHistory] = useState([]);
|
||||
const [isLoadingAudit, setIsLoadingAudit] = useState(false);
|
||||
const [actorOptions, setActorOptions] = useState([]);
|
||||
const [selectedActor, setSelectedActor] = useState('');
|
||||
|
||||
const [pagination, setPagination] = React.useState({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
totalItems: 0,
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Fetch HS Code options on component mount
|
||||
React.useEffect(() => {
|
||||
const loadHscodeOptions = async () => {
|
||||
setIsLoadingHscodes(true);
|
||||
try {
|
||||
const products = await fetchProducts();
|
||||
setHscodeOptions(products);
|
||||
} catch (error) {
|
||||
console.error('Failed to load HS Codes:', error);
|
||||
} finally {
|
||||
setIsLoadingHscodes(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadHscodeOptions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAuditHistory = async () => {
|
||||
setIsLoadingAudit(true);
|
||||
try {
|
||||
const establishmentId = resolveEstablishmentId();
|
||||
if (!establishmentId) {
|
||||
console.error('No establishment ID found');
|
||||
setIsLoadingAudit(false);
|
||||
showToast('error', 'No establishment ID found. Please log in again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract product_name from selected value ("HS - Product" → "Product")
|
||||
const productName = hscodeFilter.includes(" - ")
|
||||
? hscodeFilter.split(" - ")[1].trim()
|
||||
: hscodeFilter.trim();
|
||||
|
||||
|
||||
const params = {
|
||||
year: yearFilter || undefined,
|
||||
quarter: quarterFilter || undefined,
|
||||
product_name: productName || undefined,
|
||||
};
|
||||
|
||||
const response = await getSubmissionAuditHistory(establishmentId, params);
|
||||
|
||||
const data = response?.data || [];
|
||||
setAuditHistory(data);
|
||||
|
||||
const actors = [...new Set(data.map(item => item.actor).filter(Boolean))];
|
||||
setActorOptions(actors.map(actor => ({
|
||||
value: actor,
|
||||
label: actor
|
||||
})));
|
||||
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
totalItems: data.length,
|
||||
currentPage: 1,
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
console.error(" Error loading audit history:", error);
|
||||
setAuditHistory([]);
|
||||
} finally {
|
||||
setIsLoadingAudit(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAuditHistory();
|
||||
}, [yearFilter, quarterFilter, hscodeFilter , searchTerm , selectedActor]);
|
||||
|
||||
|
||||
const formatExportDateTime = (value) => {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
|
||||
// Format as DD/MM/YYYY, HH:MM AM/PM
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
let hours = date.getHours();
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const ampm = hours >= 12 ? 'PM' : 'AM';
|
||||
hours = hours % 12;
|
||||
hours = hours ? hours : 12; // the hour '0' should be '12'
|
||||
|
||||
return `${day}/${month}/${year}, ${hours}:${minutes} ${ampm}`;
|
||||
};
|
||||
|
||||
const toVariant = React.useCallback((status) => {
|
||||
const text = String(status || '').toLowerCase();
|
||||
if (text === 'approved') return 'approved';
|
||||
if (text === 'submitted') return 'submitted';
|
||||
if (text === 'resubmitted') return 'resubmitted';
|
||||
if (text === 'rejected' || text === 'returned') return 'rejected';
|
||||
if (text === 'pending' || text === 'in-progress' || text === 'under review') return 'under review';
|
||||
if (text === 'rejected') return 'rejected';
|
||||
if (text === 'pending' || text === 'in-progress' || text === 'under review') return 'pending';
|
||||
return 'default';
|
||||
}, []);
|
||||
|
||||
@ -190,20 +58,6 @@ const formatExportDateTime = (value) => {
|
||||
return date.toLocaleDateString('en-GB');
|
||||
}, []);
|
||||
|
||||
const formatDateTime = React.useCallback((value) => {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return date.toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadHistory = async () => {
|
||||
@ -231,337 +85,62 @@ const formatExportDateTime = (value) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const headers = [
|
||||
{ name: 'Submission Date & Time', className: 'text-left whitespace-nowrap w-[200px]' },
|
||||
{ name: 'Year', className: 'text-left w-[60px]' },
|
||||
{ name: 'Quarter', className: 'text-left w-[70px]' },
|
||||
{ name: 'HS Code', className: 'text-left w-[110px]' },
|
||||
{ name: 'Product', className: 'text-left w-[120px]' },
|
||||
{ name: 'Status', className: 'text-center w-[100px]' },
|
||||
{ name: 'Actors', className: 'text-left w-[150px]' },
|
||||
{ name: 'Details', className: 'text-left flex-1 min-w-[260px]' }
|
||||
];
|
||||
|
||||
const headers = ['Year', 'Quarter', 'Submission Window', 'Total Products', 'Products Submitted', 'Total Cost (AED)', 'Status', 'Date Submitted', 'Actions'];
|
||||
|
||||
const statusOptions = React.useMemo(
|
||||
() => [
|
||||
{ label: 'All Status', value: 'all' },
|
||||
{ label: 'Approved', value: 'approved' },
|
||||
{ label: 'Submitted', value: 'submitted' },
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
{ label: 'Rejected', value: 'rejected' },
|
||||
],
|
||||
[]
|
||||
);
|
||||
const handleViewDetails = React.useCallback((submission) => {
|
||||
if (!submission) return;
|
||||
navigate('/survey', { state: { submission, initialStep: 3 } });
|
||||
}, [navigate]);
|
||||
|
||||
// Years from 2015 to 2030 in descending order
|
||||
const yearOptions = React.useMemo(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const startYear = 2015;
|
||||
const endYear = 2030;
|
||||
const years = [];
|
||||
for (let year = endYear; year >= startYear; year--) {
|
||||
years.push(year);
|
||||
}
|
||||
return years;
|
||||
}, []);
|
||||
const filteredRows = React.useMemo(() => {
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
const statusValue = statusFilter.toLowerCase();
|
||||
return rows.filter((entry) => {
|
||||
const matchesStatus =
|
||||
statusValue === 'all' || toVariant(entry.status) === statusValue || formatStatus(entry.status).toLowerCase() === statusValue;
|
||||
if (!term) return matchesStatus;
|
||||
const haystack = [
|
||||
entry.year,
|
||||
entry.quarter,
|
||||
entry.submission_window,
|
||||
entry.total_products,
|
||||
entry.products_submitted,
|
||||
entry.total_cost,
|
||||
formatStatus(entry.status),
|
||||
formatDate(entry.created_at),
|
||||
]
|
||||
.map((val) => String(val || '').toLowerCase())
|
||||
.join(' ');
|
||||
return matchesStatus && haystack.includes(term);
|
||||
});
|
||||
}, [rows, searchTerm, statusFilter, toVariant, formatStatus, formatDate]);
|
||||
|
||||
const quarterOptions = [
|
||||
{ label: 'Quarter', value: '' },
|
||||
{ label: 'Q1', value: 'Q1' },
|
||||
{ label: 'Q2', value: 'Q2' },
|
||||
{ label: 'Q3', value: 'Q3' },
|
||||
{ label: 'Q4', value: 'Q4' }
|
||||
];
|
||||
|
||||
// Status options for the filter dropdown
|
||||
const statusOptions = [
|
||||
{ value: 'all', label: 'All Status' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'rejected', label: 'Rejected' },
|
||||
{ value: 'submitted', label: 'Submitted' },
|
||||
{ value: 'resubmitted', label: 'Resubmitted' },
|
||||
];
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
|
||||
const filtered = auditHistory.filter((entry) => {
|
||||
const matchesSearch =
|
||||
!term ||
|
||||
Object.values(entry)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(term);
|
||||
|
||||
const matchesYear = !yearFilter || String(entry.year) === yearFilter;
|
||||
const matchesQuarter = !quarterFilter || entry.quarter?.toUpperCase() === quarterFilter.toUpperCase();
|
||||
|
||||
const matchesHscode = (() => {
|
||||
if (!hscodeFilter) return true;
|
||||
const productName = hscodeFilter.includes(' - ')
|
||||
? hscodeFilter.split(' - ')[1].trim()
|
||||
: hscodeFilter.trim();
|
||||
return entry.product_name?.toLowerCase().includes(productName.toLowerCase());
|
||||
})();
|
||||
const matchesStatus = statusFilter === 'all' ||
|
||||
entry.status?.toLowerCase() === statusFilter.toLowerCase();
|
||||
|
||||
return matchesSearch && matchesYear && matchesQuarter && matchesHscode && matchesStatus;
|
||||
});
|
||||
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
totalItems: filtered.length,
|
||||
currentPage: 1,
|
||||
}));
|
||||
|
||||
return filtered;
|
||||
}, [auditHistory, searchTerm, yearFilter, quarterFilter, hscodeFilter,statusFilter]);
|
||||
|
||||
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num === null || num === undefined) return '—';
|
||||
return Number(num).toLocaleString('en-IN');
|
||||
};
|
||||
|
||||
// Added for expand/collapse logic
|
||||
const [expandedRow, setExpandedRow] = React.useState(null);
|
||||
const handleToggleDetails = (rowIndex) => {
|
||||
setExpandedRow(expandedRow === rowIndex ? null : rowIndex);
|
||||
};
|
||||
|
||||
// Updated tableRows with expandable details
|
||||
const handlePageChange = React.useCallback((page) => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
currentPage: page,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handlePageSizeChange = React.useCallback((newSize) => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
pageSize: newSize,
|
||||
currentPage: 1, // Reset to first page when page size changes
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Calculate pagination range text
|
||||
const getPaginationRangeText = () => {
|
||||
if (filteredRows.length === 0) {
|
||||
return '0-0 of 0';
|
||||
}
|
||||
const start = (pagination.currentPage - 1) * pagination.pageSize + 1;
|
||||
const end = Math.min(start + pagination.pageSize - 1, pagination.totalItems);
|
||||
return `${start}-${end} of ${pagination.totalItems}`;
|
||||
};
|
||||
|
||||
const downloadCsv = () => {
|
||||
if (!filteredRows.length) return;
|
||||
|
||||
// Define CSV headers
|
||||
const headers = [
|
||||
'Submission Date & Time',
|
||||
'Year',
|
||||
'Quarter',
|
||||
'HS Code',
|
||||
'Product',
|
||||
{ name: 'Status', className: 'text-center' },
|
||||
'Actors',
|
||||
'Details'
|
||||
];
|
||||
|
||||
// Helper function to safely get field values
|
||||
const getFieldValue = (value) => {
|
||||
if (value === null || value === undefined || value === '') return '-';
|
||||
return String(value);
|
||||
};
|
||||
|
||||
// Convert data to CSV rows
|
||||
const csvRows = [
|
||||
headers.join(','),
|
||||
...filteredRows.map(row => {
|
||||
// Get actor(s) - handle both array and single value
|
||||
let actors = '';
|
||||
if (Array.isArray(row.actor)) {
|
||||
actors = row.actor.filter(a => a && a.trim() !== '').join(', ');
|
||||
} else if (row.actor) {
|
||||
actors = row.actor;
|
||||
} else {
|
||||
actors = '-';
|
||||
}
|
||||
|
||||
// Calculate quantity and cost changes from details
|
||||
const details = row.details || [];
|
||||
const quantityChange = details
|
||||
.filter(d => d.column.includes('quantity'))
|
||||
.reduce((sum, d) => sum + (Number(d.new) - Number(d.old)), 0);
|
||||
|
||||
const costChange = details
|
||||
.filter(d => d.column.includes('cost'))
|
||||
.reduce((sum, d) => sum + (Number(d.new) - Number(d.old)), 0);
|
||||
|
||||
// Format the summary line with actual values
|
||||
const summaryParts = [];
|
||||
|
||||
if (details.some(d => d.column.includes('quantity'))) {
|
||||
const change = details
|
||||
.filter(d => d.column.includes('quantity'))
|
||||
.reduce((sum, d) => sum + (Number(d.new) - Number(d.old)), 0);
|
||||
|
||||
if (change !== 0) {
|
||||
summaryParts.push(`Quantity updated: ${change > 0 ? '+' : ''}${change.toLocaleString('en-IN')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (details.some(d => d.column.includes('cost'))) {
|
||||
const change = details
|
||||
.filter(d => d.column.includes('cost'))
|
||||
.reduce((sum, d) => sum + (Number(d.new) - Number(d.old)), 0);
|
||||
|
||||
if (change !== 0) {
|
||||
const absChange = Math.abs(change);
|
||||
const sign = change > 0 ? '+' : '-';
|
||||
summaryParts.push(`Cost updated: ${sign}AED ${absChange.toLocaleString('en-IN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})}`);
|
||||
}
|
||||
}
|
||||
|
||||
const displayText = summaryParts.length > 0
|
||||
? summaryParts.join('; ')
|
||||
: 'No changes';
|
||||
|
||||
// Create the row with proper escaping
|
||||
const rowData = [
|
||||
formatExportDateTime(row.created_at || row.submission_date) || '-',
|
||||
row.year || '-',
|
||||
row.quarter || '-',
|
||||
row.hs_code || '-',
|
||||
row.product_name || '-',
|
||||
formatStatus(row.status) || '-',
|
||||
actors,
|
||||
displayText // Use the summary line here instead of detailsText
|
||||
];
|
||||
|
||||
// Properly escape and quote CSV fields
|
||||
return rowData
|
||||
.map(field => `"${String(field || '').replace(/"/g, '""')}"`)
|
||||
.join(',');
|
||||
})
|
||||
];
|
||||
|
||||
// Create CSV content
|
||||
const csvContent = csvRows.join('\n');
|
||||
|
||||
// Create download link
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `submission-history-${new Date().toISOString().split('T')[0]}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const tableRows = auditHistory.map((entry, index) => {
|
||||
const isExpanded = expandedRow === index;
|
||||
const details = entry.details || [];
|
||||
|
||||
// Check if there are quantity or cost changes
|
||||
const hasQuantityChanges = details.some(d => d.column.includes('quantity'));
|
||||
const hasCostChanges = details.some(d => d.column.includes('cost'));
|
||||
|
||||
return [
|
||||
formatDateTime(entry.submission_date || entry.created_at),
|
||||
const tableRows = filteredRows.map((entry) => [
|
||||
entry.year || '—',
|
||||
entry.quarter || '—',
|
||||
entry.hs_code || '—',
|
||||
entry.product_name || '—',
|
||||
<div className="flex justify-center">
|
||||
<Badge variant={toVariant(entry.status)}>
|
||||
{formatStatus(entry.status)}
|
||||
</Badge>
|
||||
</div>,
|
||||
entry.actor || '—',
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex items-center justify-between text-sm pt-2 text-gray-800">
|
||||
<span>
|
||||
{hasQuantityChanges ? 'Quantity updated' : ''}
|
||||
{hasQuantityChanges && hasCostChanges ? '; ' : ''}
|
||||
{hasCostChanges ? 'Cost updated' : ''}
|
||||
{!hasQuantityChanges && !hasCostChanges ? 'No changes' : ''}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleDetails(index);
|
||||
}}
|
||||
className="flex items-center justify-center w-5 h-5 border border-gray-300 rounded hover:bg-gray-100"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<path
|
||||
d="M6 9L12 15L18 9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && details.length > 0 && (
|
||||
<div className="mt-2 ml-1 border border-gray-200 rounded p-2 bg-gray-50 text-sm text-gray-700">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="font-medium">Quantity Updated: </div>
|
||||
<div>
|
||||
{details
|
||||
.filter(d => d.column.includes('quantity'))
|
||||
.reduce((sum, d) => sum + (Number(d.new) - Number(d.old)), 0)
|
||||
.toLocaleString('en-IN')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">Cost Updated:</div>
|
||||
<div>
|
||||
{details
|
||||
.filter(d => d.column.includes('cost'))
|
||||
.reduce((sum, d) => sum + (Number(d.new) - Number(d.old)), 0)
|
||||
.toLocaleString('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'AED',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">Reason for variations: -</div>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
];
|
||||
});
|
||||
entry.submission_window || '—',
|
||||
Number.isFinite(Number(entry.total_products)) ? entry.total_products : '—',
|
||||
Number.isFinite(Number(entry.products_submitted)) ? entry.products_submitted : '—',
|
||||
entry.total_cost ? Intl.NumberFormat('en-US').format(entry.total_cost) : '—',
|
||||
formatStatus(entry.status),
|
||||
formatDate(entry.created_at),
|
||||
'View Details',
|
||||
]);
|
||||
|
||||
const toolbar = (
|
||||
<div className="px-6 py-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-[18px] leading-[28px] font-medium text-[#232528]">Submission History</h2>
|
||||
<h2 className="text-[18px] leading-[28px] font-medium text-[#232528]">Quarter Overview</h2>
|
||||
<div className="flex gap-3 text-sm text-gray-500">
|
||||
{loading && <span>Loading…</span>}
|
||||
{error && <span className="text-red-600">{error}</span>}
|
||||
@ -571,15 +150,11 @@ const tableRows = auditHistory.map((entry, index) => {
|
||||
<div className="relative w-full md:w-56">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSearchTerm(value);
|
||||
}}
|
||||
className="w-64 h-10 border border-gray-300 rounded-md px-3 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search submissions"
|
||||
className="w-full h-10 rounded-md border border-[#E5E7EB] pl-10 pr-3 text-sm focus:outline-none"
|
||||
/>
|
||||
|
||||
<img src={searchIconSrc} alt="Search" className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5" />
|
||||
</div>
|
||||
<div className="relative w-full md:w-40">
|
||||
@ -633,223 +208,36 @@ const tableRows = auditHistory.map((entry, index) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
// Calculate total products and average cost for the card
|
||||
const totalProducts = rows.length;
|
||||
const averageCost = rows.length > 0
|
||||
? Math.round(rows.reduce((sum, row) => sum + (parseFloat(row.total_cost) || 0), 0) / rows.length)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F7F7F7]">
|
||||
<HeaderBar />
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-2 mt-3">
|
||||
<nav className="flex items-center text-sm text-gray-500 space-x-2">
|
||||
<a href="/" className="hover:text-[#92722A] transition-colors flex items-center gap-1">
|
||||
Dashboard
|
||||
<img src={BreadcrumbVector} alt="" className="h-3 w-3 ml-3" />
|
||||
</a>
|
||||
<span className="hover:text-[#92722A] transition-colors flex items-center ml-2">
|
||||
History
|
||||
<img src={BreadcrumbVector} alt="" className="h-3 w-3 ml-3" />
|
||||
</span>
|
||||
<span className="text-[#92722A] ml-2">Submission History</span>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="mx-auto max-w-7xl px-6 py-2 ">
|
||||
{/* Card Section */}
|
||||
<div className="bg-white p-4 rounded-t-lg border-b border-[#E2E8F0] mb-6 mt-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2 pr-6">
|
||||
<p className="text-sm text-[#232528] font-medium">Establishment ID:</p>
|
||||
<p className="text-sm text-[#232528]">{totalProducts}</p>
|
||||
</div>
|
||||
{/* <div className="flex items-center space-x-2 pl-6">
|
||||
<p className="text-sm text-[#232528] font-medium">Average Cost (AED):</p>
|
||||
<p className="text-sm text-[#232528]">{averageCost.toLocaleString('en-IN')}</p>
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative">
|
||||
<select
|
||||
value={yearFilter}
|
||||
onChange={(e) => setYearFilter(e.target.value)}
|
||||
className="h-10 w-32 rounded-md border border-[#E2E8F0] bg-white pl-3 pr-8 text-sm text-[#232528] focus:outline-none cursor-pointer appearance-none"
|
||||
>
|
||||
<option value="">Year</option>
|
||||
{yearOptions.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<img src={caretDownSrc} alt="Toggle" className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={quarterFilter}
|
||||
onChange={(e) => setQuarterFilter(e.target.value)}
|
||||
className="h-10 w-32 rounded-md border border-[#E2E8F0] bg-white pl-3 pr-8 text-sm text-[#232528] focus:outline-none cursor-pointer appearance-none"
|
||||
>
|
||||
{quarterOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<svg className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#6B7280]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
{/* Replace both HS Code and Product dropdowns with this */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={hscodeFilter}
|
||||
onChange={(e) => {
|
||||
setHscodeFilter(e.target.value);
|
||||
}}
|
||||
className="h-10 w-48 rounded-md border border-[#E2E8F0] bg-white pl-3 pr-8 text-sm text-[#232528] focus:outline-none cursor-pointer appearance-none"
|
||||
>
|
||||
<option value="">HS Codes & Products</option>
|
||||
{hscodeOptions.map((option) => (
|
||||
<option
|
||||
key={option.id}
|
||||
value={option.product_name} //this is the fix
|
||||
>
|
||||
{option.hs_code
|
||||
? `${option.hs_code} - ${option.product_name || ""}`.trim()
|
||||
: option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{isLoadingHscodes ? (
|
||||
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 border-2 border-t-[#6B7280] border-r-transparent border-b-transparent border-l-transparent rounded-full animate-spin"></div>
|
||||
) : (
|
||||
<svg
|
||||
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#6B7280]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedActor}
|
||||
onChange={(e) => setSelectedActor(e.target.value)}
|
||||
className="h-10 rounded-md border border-[#E2E8F0] bg-white pl-3 pr-8 text-sm text-[#232528] focus:outline-none cursor-pointer appearance-none"
|
||||
>
|
||||
<option value="">Actors</option>
|
||||
{actorOptions.map((actor) => (
|
||||
<option key={actor.value} value={actor.value}>
|
||||
{actor.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<svg className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#6B7280]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="h-10 w-32 rounded-md border border-[#E2E8F0] bg-white pl-3 pr-8 text-sm text-[#232528] focus:outline-none cursor-pointer appearance-none"
|
||||
>
|
||||
<option value="all">Status</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="submitted">Submitted</option>
|
||||
<option value="resubmitted">Resubmitted</option>
|
||||
</select>
|
||||
<svg className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#6B7280]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white w-full rounded-lg shadow-sm ring-1 ring-gray-200 mt-1">
|
||||
<div className="px-6 pt-6 pb-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-[18px] font-medium text-[#232528]">Submission History</h2>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-3">
|
||||
<div className="relative w-full md:w-72">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search Product"
|
||||
className="w-full h-10 rounded-md border border-[#E2E8F0] pl-10 pr-3 text-sm text-[#232528] focus:outline-none"
|
||||
/>
|
||||
<img
|
||||
src={searchIconSrc}
|
||||
alt="Search"
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadCsv}
|
||||
disabled={!filteredRows.length}
|
||||
className={`inline-flex items-center gap-2 h-10 rounded-md border px-4 text-sm font-medium ${filteredRows.length ? 'border-[#92722A] text-[#92722A] hover:bg-[#F2ECCF]' : 'border-gray-200 text-gray-400 cursor-not-allowed'}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{filteredRows.length === 0 && !loading ? (
|
||||
<div className="w-full flex flex-col h-5items-center justify-start pt-1 pb-7 text-center">
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<h3 className="text-lg font-medium text-[#92722A] pt-4 text-center">No submissions History found</h3>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
headers={headers}
|
||||
rows={tableRows}
|
||||
loading={loading}
|
||||
error={error}
|
||||
className="mt-4"
|
||||
columnWidths={[
|
||||
{ width: 180, style: { whiteSpace: 'normal', wordWrap: 'break-word' } }, // Submission Date
|
||||
{ width: 80, style: { whiteSpace: 'normal', wordWrap: 'break-word' } }, // Year
|
||||
{ width: 80, style: { whiteSpace: 'normal', wordWrap: 'break-word' } }, // Quarter
|
||||
{ width: 120, style: { whiteSpace: 'normal', wordWrap: 'break-word' } }, // HS Code
|
||||
{ width: 200, style: { whiteSpace: 'normal', wordWrap: 'break-word' } }, // Product
|
||||
{ width: 150, align: 'center', style: { whiteSpace: 'normal', wordWrap: 'break-word' } }, // Status
|
||||
{ width: 200, style: { whiteSpace: 'normal', wordWrap: 'break-word' } }, // Actors
|
||||
{ width: 270, style: { whiteSpace: 'normal', wordWrap: 'break-word' } } // Details
|
||||
]}
|
||||
pagination={{
|
||||
currentPage: pagination.currentPage,
|
||||
onPageChange: handlePageChange,
|
||||
pageSize: pagination.pageSize,
|
||||
totalItems: pagination.totalItems,
|
||||
pageSizeOptions: [10, 25, 50, 100],
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
}}
|
||||
<div className="mx-auto max-w-7xl px-6 py-10">
|
||||
<div className="bg-white w-full rounded-lg shadow-sm ring-1 ring-gray-200">
|
||||
<Table
|
||||
headers={headers}
|
||||
rows={tableRows}
|
||||
beforeHeader={toolbar}
|
||||
renderCell={(value, ri, ci) => {
|
||||
if (ci === 5) { // Status column
|
||||
return <Badge variant={toVariant(filteredRows[ri]?.status)}>{formatStatus(filteredRows[ri]?.status)}</Badge>;
|
||||
if (ci === 6) {
|
||||
return <Badge variant={toVariant(filteredRows[ri]?.status)}>{value}</Badge>;
|
||||
}
|
||||
if (ci === headers.length - 1) {
|
||||
const submission = filteredRows[ri];
|
||||
const disabled = !submission;
|
||||
return (
|
||||
<button
|
||||
className={`text-[#92722A] hover:underline ${disabled ? 'cursor-not-allowed opacity-50 hover:no-underline' : ''}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => handleViewDetails(submission)}
|
||||
>
|
||||
{String(value)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const Banner = () => {
|
||||
return (
|
||||
<section className="w-full py-8 md:py-14 lg:py-14 -mt-4 md:-mt-4 bg-gradient-to-b from-[#F2ECCF] to-[#F2ECCF] flex flex-col items-center justify-center text-center px-4">
|
||||
{/* Tagline */}
|
||||
<div className=" inline-block bg-white border border-gray-200 text-gray-700 text-sm px-4 py-2 rounded-full mb-6 shadow-sm">
|
||||
Official survey by{" "}
|
||||
<span className="font-medium text-[#4b5563]">
|
||||
Federal Competitiveness and Statistics Centre (FCSC)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-2xl md:text-3xl font-semibold text-gray-800 mb-6">
|
||||
IIP (Index of Industrial Production) – UAE
|
||||
</h1>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-gray-700 text-[15px] max-w-2xl mx-auto leading-relaxed mb-10">
|
||||
A secure <span className="font-semibold">quarterly survey</span> for manufacturing establishments — with{" "}
|
||||
<span className="font-semibold">monthly breakdowns</span> inside each quarter.
|
||||
Your data powers official national indicators.
|
||||
</p>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex flex-col md:flex-row justify-center gap-4">
|
||||
<a
|
||||
href="/login"
|
||||
className="bg-[#92722A] w-48 text-white text-md px-5 py-2 rounded-md hover:bg-[#a07f30] transition-all shadow-sm inline-flex items-center justify-center no-underline"
|
||||
>
|
||||
Sign in to submit
|
||||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Scroll to How it works section with offset for fixed header
|
||||
const howItWorksSection = document.getElementById('how-it-works');
|
||||
if (howItWorksSection) {
|
||||
const headerOffset = 80; // Same as in Navbar.jsx
|
||||
const elementPosition = howItWorksSection.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="border-2 border-[#92722A] text-[#92722A] text-md px-5 py-2 rounded-md hover:bg-[#f9f6ec] transition-all shadow-sm"
|
||||
>
|
||||
How the survey works
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Banner;
|
||||
@ -1,75 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const steps = [
|
||||
{
|
||||
number: 1,
|
||||
title: "Notification",
|
||||
description: "You receive an email/SMS when a new quarter opens.",
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "Sign in",
|
||||
description:
|
||||
"Use your FCSC-provided account. If needed, use Forgot password.",
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: "Enter data",
|
||||
description:
|
||||
"Provide monthly quantities and values for each product in the open quarter (e.g., Jan, Feb, Mar for Q1).",
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
title: "Validate",
|
||||
description:
|
||||
"Inline checks highlight missing or unusual values across months.",
|
||||
},
|
||||
{
|
||||
number: 5,
|
||||
title: "Submit",
|
||||
description:
|
||||
"Certify accuracy for the quarter and submit.",
|
||||
},
|
||||
{
|
||||
number: 6,
|
||||
title: "Review",
|
||||
description:
|
||||
"FCSC may contact you for clarifications or revisions.",
|
||||
},
|
||||
];
|
||||
|
||||
const Cards = () => {
|
||||
return (
|
||||
<section className="bg-gray-50 py-6 md:py-8 lg:py-10 px-6 md:px-12 lg:px-24">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">How the survey works</h2>
|
||||
<p className="text-gray-500 mt-1">A simple quarterly cycle</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.number}
|
||||
className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition"
|
||||
>
|
||||
<div className="flex items-center mb-3">
|
||||
<div className="w-8 h-8 flex items-center justify-center rounded-full border border-gray-400 text-gray-700 font-medium mr-3">
|
||||
{step.number}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">
|
||||
{step.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">{step.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-10 mb-15">
|
||||
Accounts are issued by FCSC. There is no self-registration.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cards;
|
||||
@ -1,47 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const Collect = () => {
|
||||
return (
|
||||
<section className="mx-auto flex flex-col md:flex-row justify-between px-6 md:px-10 lg:px-24 py-6 md:py-12 gap-10">
|
||||
{/* Left Section */}
|
||||
<div className=" flex flex-col item-cemter justify-center md:w-1/2">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
Why we collect this data
|
||||
</h2>
|
||||
<ul className="space-y-2 text-gray-700">
|
||||
<li>
|
||||
To produce timely and reliable statistics on manufacturing trends.
|
||||
</li>
|
||||
<li>
|
||||
To support evidence-based decisions by public and private stakeholders.
|
||||
</li>
|
||||
<li>
|
||||
To contribute to national accounts and short-term economic indicators.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Right Section */}
|
||||
<div className="md:w-1/2 bg-[#F1EBCF] rounded-2xl border border-gray-200 p-8 flex items-center justify-center">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{[
|
||||
{ q: "Q1", period: "Jan–Mar" },
|
||||
{ q: "Q2", period: "Apr–Jun" },
|
||||
{ q: "Q3", period: "Jul–Sep" },
|
||||
{ q: "Q4", period: "Oct–Dec" },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.q}
|
||||
className="bg-white shadow-sm border border-gray-100 rounded-xl px-6 lg:px-18 py-6 text-center"
|
||||
>
|
||||
<p className="font-semibold text-gray-900">{item.q}</p>
|
||||
<p className="text-sm text-gray-600">{item.period}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Collect;
|
||||
@ -1,35 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const Cta = () => {
|
||||
return (
|
||||
<div className=" md:px-10 lg:px-24 mx-auto my-10 px-6">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between bg-white border border-gray-200 rounded-2xl shadow-sm p-8 md:p-10">
|
||||
{/* Left content */}
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold text-gray-900 mb-2">
|
||||
We’re here to help
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-1">
|
||||
Our support team can assist with access, questions, or clarifications.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Support hours: Monday–Friday, 8:00–16:00 GST
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mt-6 md:mt-0">
|
||||
<button className="bg-[#92722A] w-42 text-white text-md px-5 py-2 rounded-md hover:bg-[#a07f30] transition-all shadow-sm">
|
||||
|
||||
Contact Support
|
||||
</button>
|
||||
<button className="border-2 border-[#92722A] text-[#92722A] text-md px-5 py-2 rounded-md hover:bg-[#f9f6ec] transition-all shadow-sm">
|
||||
Read the guidance
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cta;
|
||||
@ -1,172 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
const Faq = () => {
|
||||
const [openIndexLeft, setOpenIndexLeft] = useState(null);
|
||||
const [openIndexRight, setOpenIndexRight] = useState(null);
|
||||
|
||||
// FULL FAQ DATA
|
||||
const leftFaqs = [
|
||||
{
|
||||
question: "Do I need an account to respond?",
|
||||
answer:
|
||||
"Yes. FCSC provisions accounts to selected establishments. Use Login to access the survey.",
|
||||
},
|
||||
{
|
||||
question: "Is my data public?",
|
||||
answer: "No. Results are published only in aggregate form.",
|
||||
},
|
||||
{
|
||||
question: "Can I update after submission?",
|
||||
answer:
|
||||
"Yes, if a revision window is open or if FCSC requests a correction.",
|
||||
},
|
||||
{
|
||||
question: "How long does it take?",
|
||||
answer:
|
||||
"Most respondents complete within minutes each quarter, depending on product lines.",
|
||||
},
|
||||
];
|
||||
|
||||
const rightFaqs = [
|
||||
{
|
||||
question: "Can I register myself?",
|
||||
answer:
|
||||
"No. There is no self-registration. If you were contacted but can’t access your account, use Forgot password or Contact support.",
|
||||
},
|
||||
{
|
||||
question: "What if production was zero in a month?",
|
||||
answer: "Enter 0 for the month and provide a reason when prompted.",
|
||||
},
|
||||
{
|
||||
question: "Which browser is supported?",
|
||||
answer: "Latest Chrome, Edge, Safari.",
|
||||
},
|
||||
{
|
||||
question: "Who can I contact?",
|
||||
answer: "See Need help below.",
|
||||
},
|
||||
];
|
||||
|
||||
// Auto height expand component
|
||||
const Expandable = ({ isOpen, children }) => {
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
ref.current.style.maxHeight = ref.current.scrollHeight + "px";
|
||||
} else {
|
||||
ref.current.classList.remove('max-h-md');
|
||||
ref.current.classList.add('max-h-0');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`overflow-hidden transition-[max-height] duration-300 max-h-0 ${
|
||||
isOpen ? "mt-3" : ""
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-[#fafafa] py-12 px-6 md:px-16 lg:px-24">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-8">
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
|
||||
{/* TWO FULLY INDEPENDENT COLUMNS */}
|
||||
<div className="grid md:grid-cols-2 gap-10">
|
||||
|
||||
{/* LEFT COLUMN */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{leftFaqs.map((faq, index) => {
|
||||
const isOpen = openIndexLeft === index;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white rounded-xl p-5 shadow-sm transition-all duration-300"
|
||||
>
|
||||
<button
|
||||
onClick={() =>
|
||||
setOpenIndexLeft(isOpen ? null : index)
|
||||
}
|
||||
className="w-full flex justify-between items-center text-left"
|
||||
>
|
||||
<span
|
||||
className={`font-medium ${
|
||||
isOpen ? "text-yellow-800" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{faq.question}
|
||||
</span>
|
||||
|
||||
{isOpen ? (
|
||||
<ChevronUp size={22} className="text-yellow-700" />
|
||||
) : (
|
||||
<ChevronDown size={22} className="text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Expandable isOpen={isOpen}>
|
||||
<p className="text-gray-700 text-sm leading-relaxed">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</Expandable>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{rightFaqs.map((faq, index) => {
|
||||
const isOpen = openIndexRight === index;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white rounded-xl p-5 shadow-sm transition-all duration-300"
|
||||
>
|
||||
<button
|
||||
onClick={() =>
|
||||
setOpenIndexRight(isOpen ? null : index)
|
||||
}
|
||||
className="w-full flex justify-between items-center text-left"
|
||||
>
|
||||
<span
|
||||
className={`font-medium ${
|
||||
isOpen ? "text-yellow-800" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{faq.question}
|
||||
</span>
|
||||
|
||||
{isOpen ? (
|
||||
<ChevronUp size={22} className="text-yellow-700" />
|
||||
) : (
|
||||
<ChevronDown size={22} className="text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Expandable isOpen={isOpen}>
|
||||
<p className="text-gray-700 text-sm leading-relaxed">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</Expandable>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Faq;
|
||||
@ -1,99 +0,0 @@
|
||||
import React from "react";
|
||||
import logo from "/assets/images/FCSCLogo.png";
|
||||
|
||||
import {
|
||||
Phone,
|
||||
PhoneCall,
|
||||
Facebook,
|
||||
Instagram,
|
||||
Linkedin,
|
||||
Youtube,
|
||||
Mail,
|
||||
} from "lucide-react";
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer className="bg-white border-t-3 border-[#92722A]">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 px-6 md:px-12 lg:px-24 py-8 md:py-10 lg:py-12">
|
||||
{/* Column 1 */}
|
||||
<div>
|
||||
<h4 className="text-[#92722A] font-semibold mb-3">
|
||||
The Ministry
|
||||
</h4>
|
||||
<ul className="space-y-2 text-gray-700 text-sm">
|
||||
<p>The Federal Competitiveness and Statistics Centre (FCSC) provides reliable data and insights to enhance the UAE’s competitiveness and support sustainable national development.</p>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Column 2 */}
|
||||
<div>
|
||||
<h4 className="text-[#92722A] font-semibold mb-3">
|
||||
Using the website
|
||||
</h4>
|
||||
<ul className="space-y-2 text-gray-700 text-sm">
|
||||
<li><a >Disclaimer</a></li>
|
||||
<li><a >Privacy policy</a></li>
|
||||
<li><a >Terms and conditions</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Column 3 */}
|
||||
<div>
|
||||
<h4 className="text-[#92722A] font-semibold mb-3">
|
||||
Information and support
|
||||
</h4>
|
||||
<ul className="space-y-2 text-gray-700 text-sm">
|
||||
<li><a >Contact us</a></li>
|
||||
<li><a >FAQ’s</a></li>
|
||||
<li><a >Feedback and complaints</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Column 4 – Logo & Contact */}
|
||||
<div className="flex flex-col items-start space-y-2">
|
||||
<img
|
||||
src={logo}
|
||||
alt="Tawasol Logo"
|
||||
className="w-44 "
|
||||
/>
|
||||
<div className=" mt-2 text-gray-700 text-sm space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Phone className="w-4 h-4 text-green-600" />
|
||||
<span>+971506491429</span>
|
||||
</div>
|
||||
{/* <div className="flex items-center space-x-2">
|
||||
<PhoneCall className="w-4 h-4 text-green-600" />
|
||||
<span>+971 4 327 3535</span>
|
||||
</div> */}
|
||||
<p className="text-xs text-gray-600">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Mail className="w-4 h-4 text-green-600" />
|
||||
<span>
|
||||
iip.survey@fcsc.gov.ae</span>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 mt-6">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex flex-col md:flex-row justify-between items-center text-sm text-gray-600">
|
||||
<p>
|
||||
Copyright © 2025 Federal Competitiveness and Statistics Centre, All Rights Reserved{" "}
|
||||
{/* <span className="font-medium">All rights reserved.</span> Last updated on 24/04/2023 at 15:45 */}
|
||||
</p>
|
||||
|
||||
{/* Social Icons */}
|
||||
<div className="flex space-x-4 mt-3 md:mt-0">
|
||||
{/* <a href="#"><Facebook size={18} /></a>
|
||||
<a href="#"><Instagram size={18} /></a>
|
||||
<a href="#"><Linkedin size={18} /></a>
|
||||
<a href="#"><Youtube size={18} /></a> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@ -1,49 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const Industrial = () => {
|
||||
return (
|
||||
<section className="mx-auto px-6 md:px-10 lg:px-24 py-6 md:py-10 lg:py-14 grid md:grid-cols-2 gap-6 md:gap-8 lg:gap-12 items-start">
|
||||
{/* Left Side: Content */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
What is the Index of Industrial Production (IIP)?
|
||||
</h2>
|
||||
<p className="text-gray-700 leading-relaxed text-justify">
|
||||
The Index of Industrial Production (IIP) measures changes in the volume
|
||||
of industrial output over time. It tracks production across
|
||||
manufacturing activities classified by ISIC and is a key official
|
||||
indicator used to monitor economic activity and inform policy.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Quick Facts Box */}
|
||||
<div className="bg-white shadow-sm border border-gray-200 rounded-2xl p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Quick facts
|
||||
</h3>
|
||||
<ul className="space-y-3 text-gray-700">
|
||||
<li>
|
||||
<strong>Frequency:</strong> Quarterly (enter monthly figures for the
|
||||
quarter)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Reference area:</strong> United Arab Emirates
|
||||
</li>
|
||||
<li>
|
||||
<strong>Respondents:</strong> Representatives from selected manufacturing establishments.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Classification:</strong> ISIC Rev 4 , HS 2022 8dgt for products.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="mt-6 bg-gray-100 border border-gray-100 rounded-lg p-4 text-sm text-gray-600">
|
||||
<strong>Tip:</strong> Keep monthly records ready (e.g., invoices, ERP
|
||||
extracts).
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Industrial;
|
||||
@ -1,155 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import logo from "/assets/images/FCSCLogo.png";
|
||||
import Banner from "./Banner";
|
||||
import Industrial from "./Industrial";
|
||||
import Collect from "./Collect";
|
||||
import Survey from "./Survey";
|
||||
import Cards from "./Cards";
|
||||
import Protected from "./Protected";
|
||||
import Participation from "./Participation";
|
||||
import Timeline from "./Timeline";
|
||||
import Faq from "./Faq";
|
||||
import Footer from "./Footer";
|
||||
import Resources from "./Resources";
|
||||
import Cta from "./Cta";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const Navbar = () => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState("about-ipi");
|
||||
|
||||
const closeMenu = () => setMenuOpen(false);
|
||||
|
||||
// Scroll detection for active section
|
||||
useEffect(() => {
|
||||
const sections = document.querySelectorAll("section[id]");
|
||||
const options = {
|
||||
root: null,
|
||||
threshold: 0.5,
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveSection(entry.target.id);
|
||||
}
|
||||
});
|
||||
}, options);
|
||||
|
||||
sections.forEach((section) => observer.observe(section));
|
||||
return () => sections.forEach((section) => observer.unobserve(section));
|
||||
}, []);
|
||||
|
||||
// Active & hover styling
|
||||
const linkClass = (id) =>
|
||||
`relative pb-1 transition-all duration-300 ${
|
||||
activeSection === id
|
||||
? "text-[#b9933b] after:content-[''] after:absolute after:left-0 after:bottom-0 after:w-full after:h-[2px] after:bg-[#b9933b]"
|
||||
: "text-gray-800 hover:text-[#b9933b]"
|
||||
}`;
|
||||
|
||||
// Scroll and highlight on click with offset for fixed header
|
||||
const handleClick = (id) => {
|
||||
setActiveSection(id);
|
||||
closeMenu();
|
||||
const section = document.getElementById(id);
|
||||
if (section) {
|
||||
const headerOffset = 80; // Height of your fixed header
|
||||
const elementPosition = section.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: "smooth"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Navbar */}
|
||||
<header className="w-full border-t-4 border-[#b9933b] bg-white shadow-sm fixed top-0 left-0 z-50">
|
||||
<div className="flex items-center justify-between px-4 md:px-10 py-3">
|
||||
<img src={logo} alt="FCSC Logo" className="h-10 w-auto" />
|
||||
|
||||
{/* Desktop Menu */}
|
||||
<nav className="hidden md:flex items-center space-x-8 text-[15px] font-medium">
|
||||
<button onClick={() => handleClick("about-ipi")} className={linkClass("about-ipi")}>
|
||||
About IIP
|
||||
</button>
|
||||
<button onClick={() => handleClick("how-it-works")} className={linkClass("how-it-works")}>
|
||||
How it works
|
||||
</button>
|
||||
<button onClick={() => handleClick("eligibility")} className={linkClass("eligibility")}>
|
||||
Eligibility
|
||||
</button>
|
||||
<button onClick={() => handleClick("faqs")} className={linkClass("faqs")}>
|
||||
FAQs
|
||||
</button>
|
||||
<button onClick={() => handleClick("resources")} className={linkClass("resources")}>
|
||||
Resources
|
||||
</button>
|
||||
<Link
|
||||
to="/login"
|
||||
className="bg-[#b9933b] text-white text-sm px-5 py-1.5 rounded-md hover:bg-[#a07f30] transition-all"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu Toggle */}
|
||||
<button
|
||||
className="md:hidden text-[#b9933b]"
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
>
|
||||
{menuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{menuOpen && (
|
||||
<div className="md:hidden bg-gray-50 border-t border-gray-200 px-6 py-4 space-y-3 text-gray-800">
|
||||
{["about-ipi", "how-it-works", "eligibility", "faqs", "resources"].map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => handleClick(id)}
|
||||
className={`${linkClass(id)} block w-full text-left`}
|
||||
>
|
||||
{id === "about-ipi" && "About IIP"}
|
||||
{id === "how-it-works" && "How it works"}
|
||||
{id === "eligibility" && "Eligibility"}
|
||||
{id === "faqs" && "FAQs"}
|
||||
{id === "resources" && "Resources"}
|
||||
</button>
|
||||
))}
|
||||
<Link
|
||||
to="/login" className="w-full bg-[#b9933b] text-white text-sm px-5 py-1.5 rounded-md hover:bg-[#a07f30] transition-all">
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Sections with proper spacing */}
|
||||
<div className="scroll-smooth">
|
||||
<div className="pt-24">
|
||||
<Banner />
|
||||
<section id="about-ipi"><Industrial /></section>
|
||||
<section id="how-it-works"><Cards /></section>
|
||||
<section id="eligibility"><Survey /></section>
|
||||
<Collect />
|
||||
<Protected />
|
||||
<Participation />
|
||||
<Timeline />
|
||||
<section id="resources"><Resources /></section>
|
||||
<section id="faqs"><Faq /></section>
|
||||
<Cta/>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
@ -1,40 +0,0 @@
|
||||
import React from "react";
|
||||
import sampleForm from "/assets/images/product-image.jpg";
|
||||
|
||||
const Participation = () => {
|
||||
return (
|
||||
|
||||
<section className="bg-white py-12 px-6 md:px-12 lg:px-20">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 items-center">
|
||||
{/* Left Side - Image / Form Box */}
|
||||
<div className="flex justify-center">
|
||||
<div className="border border-gray-200 rounded-2xl shadow-sm bg-gradient-to-br from-gray-50 to-white p-4">
|
||||
<img
|
||||
src={sampleForm}
|
||||
alt="Participation form example"
|
||||
className="rounded-xl w-full h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Text Content */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
Why your <span className="text-gray-900 font-bold">participation</span> matters
|
||||
</h2>
|
||||
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>Accurate indicators help calibrate policies that affect industry.</li>
|
||||
<li>Timely inputs reduce follow-up calls and queries.</li>
|
||||
<li>Access to historical submissions and receipts for your records.</li>
|
||||
<li>
|
||||
Contributes to a complete national picture of manufacturing performance.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Participation;
|
||||
@ -1,88 +0,0 @@
|
||||
import React from "react";
|
||||
import Lock from "/assets/images/lock.jpg"
|
||||
|
||||
|
||||
const Protected = () => {
|
||||
return (
|
||||
<section className="bg-gray-50 py-6 md:py-8 lg:py-12 px-6 md:px-12 lg:px-24">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 items-center">
|
||||
|
||||
{/* Left Side - Text Content */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
How your data is protected and used
|
||||
</h2>
|
||||
|
||||
<div className="space-y-5 text-gray-700">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">Confidentiality</h3>
|
||||
<p className="text-justify">
|
||||
Your establishment’s data is confidential and used only for official
|
||||
statistics. Published results are aggregated; no individual
|
||||
establishment’s data is disclosed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">Legal basis</h3>
|
||||
<p className="text-justify">
|
||||
|
||||
Collected under the national statistics mandate to compile official
|
||||
indicators. Participation is compulsory for selected establishments
|
||||
under applicable statistical regulations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">Data security</h3>
|
||||
<p className="text-justify">
|
||||
|
||||
Role-based access, encryption in transit and at rest, and monitored
|
||||
environments. Regular audits and adherence to government security
|
||||
guidelines.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">Retention</h3>
|
||||
<p className="text-justify">
|
||||
|
||||
Data is retained only as long as necessary for statistical purposes per
|
||||
policy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 mt-6">
|
||||
See{" "}
|
||||
<a href="#" className="text-blue-600 hover:underline">
|
||||
Privacy Policy
|
||||
</a>
|
||||
,{" "}
|
||||
<a href="#" className="text-blue-600 hover:underline">
|
||||
Terms of Use
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="#" className="text-blue-600 hover:underline">
|
||||
Data Protection
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Image / Icon Box */}
|
||||
<div className="bg-white from-yellow-50 to-gray-50 rounded-2xl flex justify-center items-center border border-gray-200 shadow-sm">
|
||||
<div className="bg-[#F1EBCF]">
|
||||
<img
|
||||
src={Lock}
|
||||
alt="Data Protection Illustration"
|
||||
className=" h-[380px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Protected;
|
||||
@ -1,37 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const Resources = () => {
|
||||
const resources = [
|
||||
{ title: "FCSC – Official website", link: "#" },
|
||||
{ title: "Sample questionnaire (PDF)", link: "#" },
|
||||
{ title: "Item classification guide (ISIC) (PDF)", link: "#" },
|
||||
{ title: "Data definitions & unit guidelines (PDF)", link: "#" },
|
||||
{ title: "Browser and system requirements", link: "#" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-white py-12 px-6 md:px-16 lg:px-24">
|
||||
<div className="mx-auto">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-6">
|
||||
Resources & downloads
|
||||
</h2>
|
||||
<ul className="space-y-3">
|
||||
{resources.map((item, index) => (
|
||||
<li key={index}>
|
||||
<a
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 hover:underline text-[16px]"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Resources;
|
||||
@ -1,35 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const Survey = () => {
|
||||
return (
|
||||
<section className="mx-auto px-6 md:px-10 lg:px-24 py-12">
|
||||
{/* Heading */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
Who should complete the IIP <span className="text-gray-900">survey</span>?
|
||||
</h2>
|
||||
|
||||
{/* Content */}
|
||||
<div className="text-gray-700 space-y-3 leading-relaxed">
|
||||
<p>
|
||||
Manufacturing establishments operating in the UAE that are selected/notified by FCSC.
|
||||
</p>
|
||||
<p>
|
||||
The survey is address-based: if your establishment is contacted, you are required
|
||||
to respond for the reference quarter.
|
||||
</p>
|
||||
<p>
|
||||
If your activities paused or changed, you must still complete the survey and indicate
|
||||
reasons where relevant.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
If you believe you were contacted in error, please see{" "}
|
||||
<a href="#" className="text-blue-600 underline hover:text-blue-800">
|
||||
Contact
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Survey;
|
||||
@ -1,54 +0,0 @@
|
||||
import React from "react";
|
||||
import timelineImage from "/assets/images/timeline-image.jpg";
|
||||
|
||||
const Timeline = () => {
|
||||
return (
|
||||
<section className="bg-white py-12 px-6 md:px-12 lg:px-20">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 items-center">
|
||||
|
||||
{/* Left Side - Text Content */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
Key dates & timeline
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3 text-gray-700">
|
||||
<p>
|
||||
<strong>Survey frequency: Quarterly</strong> — each survey collects{" "}
|
||||
<em>monthly</em> figures for the months in that quarter.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Quarter mapping:</strong> Q1 (Jan–Mar), Q2 (Apr–Jun), Q3 (Jul–Sep),
|
||||
Q4 (Oct–Dec).
|
||||
</p>
|
||||
<p>
|
||||
<strong>Windows:</strong> Open and deadline dates are communicated in
|
||||
notifications; a short grace period may apply.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Revisions:</strong> If requested, please update promptly; indices are
|
||||
re-computed when revisions arrive.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
<strong>Tip:</strong> Keep your monthly records ready; you can save drafts
|
||||
before final submission.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Image / Timeline Box */}
|
||||
<div className="flex justify-center">
|
||||
<div className="border border-gray-200 rounded-2xl shadow-sm bg-gradient-to-br from-gray-50 to-white p-4">
|
||||
<img
|
||||
src={timelineImage}
|
||||
alt="Participation form example"
|
||||
className="rounded-xl w-full h-auto md:h-60 object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Timeline;
|
||||
@ -1,308 +1,180 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { login, requestOtp } from '@/services/auth/authService.js';
|
||||
import { AlertCircle, Clock } from "lucide-react";
|
||||
|
||||
import { login } from '@/services/auth/authService.js';
|
||||
import { RefreshCw } from "lucide-react";
|
||||
const logoSrc = '/assets/images/FCSCLogo.svg';
|
||||
|
||||
const MAX_FAILED_ATTEMPTS = 3;
|
||||
const LOCK_DURATION_MINUTES = 2;
|
||||
|
||||
|
||||
const Login = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [email, setEmail] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
const [rememberMe, setRememberMe] = React.useState(true);
|
||||
const [rememberMe, setRememberMe] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState('');
|
||||
const [toast, setToast] = React.useState(null);
|
||||
|
||||
const [emailError, setEmailError] = React.useState('');
|
||||
const [passwordError, setPasswordError] = React.useState('');
|
||||
|
||||
const [isLocked, setIsLocked] = React.useState(false);
|
||||
const [lockExpiry, setLockExpiry] = React.useState(null);
|
||||
const [failedAttempts, setFailedAttempts] = React.useState(0);
|
||||
const [remainingTime, setRemainingTime] = React.useState(0);
|
||||
|
||||
|
||||
const [captcha, setCaptcha] = React.useState('');
|
||||
const [userCaptcha, setUserCaptcha] = React.useState('');
|
||||
const [captchaError, setCaptchaError] = React.useState('');
|
||||
|
||||
const toastTimeoutRef = React.useRef(null);
|
||||
const navigateTimeoutRef = React.useRef(null);
|
||||
const countdownIntervalRef = React.useRef(null);
|
||||
|
||||
|
||||
const generateCaptcha = React.useCallback(() => {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
let code = '';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
setCaptcha(code);
|
||||
}, []);
|
||||
|
||||
const closeToast = React.useCallback(() => {
|
||||
if (toastTimeoutRef.current) {
|
||||
clearTimeout(toastTimeoutRef.current);
|
||||
window.clearTimeout(toastTimeoutRef.current);
|
||||
toastTimeoutRef.current = null;
|
||||
}
|
||||
setToast(null);
|
||||
}, []);
|
||||
|
||||
|
||||
const showToast = React.useCallback((type, message) => {
|
||||
if (!message) return;
|
||||
if (toastTimeoutRef.current) {
|
||||
clearTimeout(toastTimeoutRef.current);
|
||||
window.clearTimeout(toastTimeoutRef.current);
|
||||
toastTimeoutRef.current = null;
|
||||
}
|
||||
setToast({ type, message });
|
||||
toastTimeoutRef.current = setTimeout(() => {
|
||||
toastTimeoutRef.current = window.setTimeout(() => {
|
||||
setToast(null);
|
||||
toastTimeoutRef.current = null;
|
||||
}, 4000);
|
||||
}, []);
|
||||
|
||||
|
||||
const scheduleNavigate = React.useCallback(
|
||||
(path) => {
|
||||
if (!path) return;
|
||||
if (navigateTimeoutRef.current) {
|
||||
clearTimeout(navigateTimeoutRef.current);
|
||||
window.clearTimeout(navigateTimeoutRef.current);
|
||||
navigateTimeoutRef.current = null;
|
||||
}
|
||||
navigateTimeoutRef.current = setTimeout(() => {
|
||||
navigateTimeoutRef.current = window.setTimeout(() => {
|
||||
navigate(path);
|
||||
navigateTimeoutRef.current = null;
|
||||
}, 500);
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const startCountdown = React.useCallback((lockUntil) => {
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
}
|
||||
|
||||
const updateTimer = () => {
|
||||
const now = Date.now();
|
||||
const remaining = Math.max(0, Math.ceil((parseInt(lockUntil, 10) - now) / 1000));
|
||||
|
||||
setRemainingTime(remaining);
|
||||
|
||||
if (remaining <= 0) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
countdownIntervalRef.current = null;
|
||||
setIsLocked(false);
|
||||
setLockExpiry(null);
|
||||
localStorage.removeItem('lock_until');
|
||||
showToast('success', 'Account unlocked. You can try logging in again.');
|
||||
}
|
||||
};
|
||||
|
||||
updateTimer();
|
||||
countdownIntervalRef.current = setInterval(updateTimer, 1000);
|
||||
}, [showToast]);
|
||||
|
||||
// Check lock status when email changes
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!email) return;
|
||||
|
||||
// Get the stored attempts and lock status for the current email
|
||||
const storedAttempts = JSON.parse(localStorage.getItem('login_attempts') || '{}');
|
||||
const emailAttempts = storedAttempts[email] || { attempts: 0, lockUntil: null };
|
||||
|
||||
setFailedAttempts(emailAttempts.attempts);
|
||||
|
||||
if (emailAttempts.lockUntil && Date.now() < emailAttempts.lockUntil) {
|
||||
setIsLocked(true);
|
||||
setLockExpiry(emailAttempts.lockUntil);
|
||||
startCountdown(emailAttempts.lockUntil);
|
||||
} else if (emailAttempts.lockUntil) {
|
||||
// Clear expired lock
|
||||
const updatedAttempts = { ...storedAttempts };
|
||||
delete updatedAttempts[email];
|
||||
localStorage.setItem('login_attempts', JSON.stringify(updatedAttempts));
|
||||
setIsLocked(false);
|
||||
} else {
|
||||
setIsLocked(false);
|
||||
generateCaptcha();
|
||||
}, [generateCaptcha]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (location.state?.showSuccessToast && location.state?.successMessage) {
|
||||
showToast('success', location.state.successMessage);
|
||||
|
||||
window.history.replaceState({}, document.title);
|
||||
}
|
||||
}, [email, startCountdown]);
|
||||
|
||||
}, [location.state, showToast]);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (toastTimeoutRef.current) clearTimeout(toastTimeoutRef.current);
|
||||
if (navigateTimeoutRef.current) clearTimeout(navigateTimeoutRef.current);
|
||||
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
|
||||
if (toastTimeoutRef.current) {
|
||||
window.clearTimeout(toastTimeoutRef.current);
|
||||
}
|
||||
if (navigateTimeoutRef.current) {
|
||||
window.clearTimeout(navigateTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
const validateForm = () => {
|
||||
let valid = true;
|
||||
setEmailError('');
|
||||
setPasswordError('');
|
||||
|
||||
const trimmedEmail = email.trim();
|
||||
const trimmedPassword = password.trim();
|
||||
|
||||
if (!trimmedEmail) {
|
||||
setEmailError('Email is required');
|
||||
valid = false;
|
||||
} else if (!emailRegex.test(trimmedEmail)) {
|
||||
setEmailError('Enter a valid email address.');
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!trimmedPassword) {
|
||||
setPasswordError('Password is required');
|
||||
valid = false;
|
||||
}
|
||||
|
||||
return valid;
|
||||
|
||||
const handleCaptchaChange = (e) => {
|
||||
setUserCaptcha(e.target.value.toUpperCase());
|
||||
setCaptchaError('');
|
||||
};
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isLocked) {
|
||||
const minutes = Math.floor(remainingTime / 60);
|
||||
const seconds = remainingTime % 60;
|
||||
showToast('error', `Account locked. Please wait ${minutes}m ${seconds}s or contact support.`);
|
||||
return;
|
||||
}
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setCaptchaError('');
|
||||
setLoading(true);
|
||||
|
||||
if (!validateForm()) return;
|
||||
if (userCaptcha !== captcha) {
|
||||
setCaptchaError('Incorrect CAPTCHA. Please try again.');
|
||||
generateCaptcha();
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await login({ email, password });
|
||||
|
||||
// Check if login was successful
|
||||
if (response?.status === 'success' && response?.data) {
|
||||
const userData = response.data;
|
||||
|
||||
// Clear any existing failed attempts
|
||||
const storedAttempts = JSON.parse(localStorage.getItem("login_attempts") || "{}");
|
||||
if (storedAttempts[email]) {
|
||||
delete storedAttempts[email];
|
||||
localStorage.setItem("login_attempts", JSON.stringify(storedAttempts));
|
||||
try {
|
||||
const response = await login({ email, password });
|
||||
if (response?.status !== 'success' || !response?.data) {
|
||||
throw new Error(response?.message || 'Invalid response');
|
||||
}
|
||||
|
||||
// Store user data in localStorage
|
||||
localStorage.setItem('user_role', userData.role);
|
||||
|
||||
// Store establishment_id if user is an EstablishmentUser
|
||||
if (userData.role === 'EstablishmentUser' && userData.establishment_id) {
|
||||
localStorage.setItem('establishment_id', userData.establishment_id);
|
||||
}
|
||||
|
||||
// Store user profile
|
||||
const userProfile = {
|
||||
id: userData.id,
|
||||
name: userData.name,
|
||||
email: userData.email
|
||||
};
|
||||
localStorage.setItem('user_profile', JSON.stringify(userProfile));
|
||||
|
||||
// Reset login attempt counters
|
||||
setFailedAttempts(0);
|
||||
setIsLocked(false);
|
||||
setRemainingTime(0);
|
||||
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
countdownIntervalRef.current = null;
|
||||
}
|
||||
const token = response.data;
|
||||
sessionStorage.setItem('auth_token', token);
|
||||
|
||||
let payload;
|
||||
try {
|
||||
// Request OTP after successful login
|
||||
const otpResponse = await requestOtp(email);
|
||||
|
||||
if (otpResponse.status === 'success' && otpResponse.message === 'OTP sent successfully to your registered email') {
|
||||
// Store user data in localStorage
|
||||
localStorage.setItem('user_role', userData.role);
|
||||
localStorage.setItem('user_profile', JSON.stringify({
|
||||
id: userData.id,
|
||||
name: userData.name,
|
||||
email: userData.email
|
||||
}));
|
||||
|
||||
// Store establishment_id if user is an EstablishmentUser
|
||||
if (userData.role === 'EstablishmentUser' && userData.establishment_id) {
|
||||
localStorage.setItem('establishment_id', userData.establishment_id);
|
||||
}
|
||||
|
||||
// Show success message
|
||||
showToast('success', 'OTP sent successfully to your registered email');
|
||||
|
||||
// Add a small delay before navigation to allow toast to be visible
|
||||
setTimeout(() => {
|
||||
// Calculate OTP expiration time (3 minutes from now)
|
||||
const otpExpiryTime = Date.now() + (3 * 60 * 1000);
|
||||
|
||||
// Navigate to OTP verification with email and expiry time
|
||||
navigate('/verify-otp', {
|
||||
state: {
|
||||
email,
|
||||
otpExpiryTime,
|
||||
from: location.state?.from || (userData.role === 'Admin' ? '/admin/dashboard' : '/dashboard')
|
||||
}
|
||||
});
|
||||
}, 1000); // 1 second delay
|
||||
} else {
|
||||
throw new Error('Failed to send OTP');
|
||||
}
|
||||
} catch (otpError) {
|
||||
console.error('OTP request failed:', otpError);
|
||||
showToast('error', 'Login successful but failed to send OTP. Please try again.');
|
||||
// Still navigate to OTP page but show error
|
||||
navigate('/verify-otp', {
|
||||
state: {
|
||||
email,
|
||||
from: location.state?.from || (userData.role === 'Admin' ? '/admin/dashboard' : '/dashboard'),
|
||||
error: 'Failed to send OTP. Please request a new one.'
|
||||
}
|
||||
});
|
||||
const [, base64Payload] = token.split('.');
|
||||
const json = atob(base64Payload);
|
||||
payload = JSON.parse(json);
|
||||
} catch {
|
||||
throw new Error('Received invalid token.');
|
||||
}
|
||||
} else {
|
||||
throw new Error(response?.message || 'Invalid credentials');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login failed', err);
|
||||
const errorMessage = err.response?.data?.message ||
|
||||
err.message ||
|
||||
'An error occurred during login. Please try again.';
|
||||
|
||||
// Handle failed login attempts
|
||||
const storedAttempts = JSON.parse(localStorage.getItem("login_attempts") || "{}");
|
||||
const attempts = (storedAttempts[email]?.attempts || 0) + 1;
|
||||
const role = payload?.role ?? (payload?.is_admin ? 'Admin' : undefined);
|
||||
if (role) sessionStorage.setItem('user_role', role);
|
||||
|
||||
const updatedAttempts = {
|
||||
...storedAttempts,
|
||||
[email]: {
|
||||
attempts,
|
||||
lockUntil: attempts >= MAX_FAILED_ATTEMPTS
|
||||
? Date.now() + LOCK_DURATION_MINUTES * 60 * 1000
|
||||
: null
|
||||
const profile = {
|
||||
name: payload?.name || payload?.username || '',
|
||||
email: payload?.email || '',
|
||||
};
|
||||
sessionStorage.setItem('user_profile', JSON.stringify(profile));
|
||||
|
||||
const establishmentId = payload?.establishment_id ?? payload?.establishmentId;
|
||||
const isAdmin =
|
||||
String(role || '').toLowerCase() === 'admin' || payload?.is_admin === true;
|
||||
const successMessage = response?.message || 'Login successful.';
|
||||
|
||||
if (isAdmin) {
|
||||
sessionStorage.removeItem('establishment_id');
|
||||
showToast('success', successMessage);
|
||||
scheduleNavigate('/admin/dashboard');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
localStorage.setItem("login_attempts", JSON.stringify(updatedAttempts));
|
||||
setFailedAttempts(attempts);
|
||||
if (rememberMe) {
|
||||
sessionStorage.setItem('remember_me', 'true');
|
||||
sessionStorage.setItem('remembered_email', email);
|
||||
sessionStorage.setItem('remembered_password', password);
|
||||
} else {
|
||||
sessionStorage.removeItem('remember_me');
|
||||
sessionStorage.removeItem('remembered_email');
|
||||
sessionStorage.removeItem('remembered_password');
|
||||
}
|
||||
|
||||
if (attempts >= MAX_FAILED_ATTEMPTS) {
|
||||
const lockUntil = Date.now() + LOCK_DURATION_MINUTES * 60 * 1000;
|
||||
setIsLocked(true);
|
||||
setLockExpiry(lockUntil);
|
||||
startCountdown(lockUntil);
|
||||
showToast('error', `Too many failed attempts. Account locked for ${LOCK_DURATION_MINUTES} minutes.`);
|
||||
} else {
|
||||
const remainingAttempts = MAX_FAILED_ATTEMPTS - attempts;
|
||||
showToast('error', `Email or password is incorrect. ${remainingAttempts} attempt${remainingAttempts !== 1 ? 's' : ''} remaining.`);
|
||||
if (establishmentId) {
|
||||
sessionStorage.setItem('establishment_id', String(establishmentId));
|
||||
showToast('success', successMessage);
|
||||
scheduleNavigate('/dashboard');
|
||||
} else {
|
||||
setError('No establishment associated with this account.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login failed', err);
|
||||
const apiMessage = err.response?.data?.message || err.response?.data?.error;
|
||||
const fallbackMessage = err.message || 'Unable to sign in. Please try again.';
|
||||
setError(apiMessage || fallbackMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F7F7F7] flex items-center justify-center px-4">
|
||||
{loading && (
|
||||
@ -310,18 +182,17 @@ const submit = async (e) => {
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-[3px] border-white border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{toast && (
|
||||
<div className="fixed top-6 inset-x-0 z-50 flex justify-center px-4">
|
||||
<div
|
||||
className={`text-sm rounded-lg px-4 py-3 border flex items-center justify-between gap-3 shadow-lg max-w-md ${
|
||||
className={`text-sm rounded px-3 py-2 border flex items-start justify-between gap-3 shadow ${
|
||||
toast.type === 'success'
|
||||
? 'bg-[#F3FAF4] border-[#C6E7D8] text-[#2F663C]'
|
||||
: 'bg-[#FEF2F2] border-[#FECACA] text-[#B91C1C]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{toast.type === 'error' && <AlertCircle className="h-4 w-4 flex-shrink-0" />}
|
||||
<div className="flex items-start gap-2">
|
||||
<span>{toast.message}</span>
|
||||
</div>
|
||||
<button
|
||||
@ -334,120 +205,115 @@ const submit = async (e) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg shadow-sm ring-1 ring-gray-200 overflow-hidden">
|
||||
<div className="px-7 py-6 border-b border-gray-200 bg-[#F2ECCF] text-center">
|
||||
<div className="mb-2">
|
||||
<img src={logoSrc} alt="FCSC Logo" className="mx-auto h-16 w-auto" />
|
||||
</div>
|
||||
<h1 className="text-[17px] font-semibold text-[#1F2937] mt-0">
|
||||
IIP (Index of Industrial Production)
|
||||
</h1>
|
||||
<div className="px-7 py-8 border-b border-gray-200 bg-[#F2ECCF] text-center">
|
||||
<img src={logoSrc} alt="FCSC Logo" className="mx-auto h-16 w-auto" />
|
||||
<h1 className="text-base font-semibold text-[#92722A]">IPI Survey Platform</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="px-7 py-8 space-y-4" autoComplete="off">
|
||||
{isLocked && remainingTime > 0 && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded">
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock className="h-5 w-5 text-red-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-red-800 mb-1">
|
||||
Account Temporarily Locked
|
||||
</h3>
|
||||
<p className="text-sm text-red-700 mb-2">
|
||||
Too many failed login attempts. Please wait before trying again.
|
||||
</p>
|
||||
<div className="text-lg font-mono font-bold text-red-900">
|
||||
{formatTime(remainingTime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="px-7 py-8 space-y-4">
|
||||
{error && (
|
||||
<div className="text-sm text-red-700 bg-red-50 border border-red-200 rounded px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLocked && failedAttempts > 0 && failedAttempts < MAX_FAILED_ATTEMPTS && (
|
||||
<div className="bg-yellow-50 border-l-4 border-yellow-500 p-3 rounded">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>{MAX_FAILED_ATTEMPTS - failedAttempts}</strong> login attempt{MAX_FAILED_ATTEMPTS - failedAttempts !== 1 ? 's' : ''} remaining before account lock.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email address
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
type="text"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
if (emailError) setEmailError('');
|
||||
}}
|
||||
placeholder="name@company.com"
|
||||
disabled={isLocked}
|
||||
autoComplete="username"
|
||||
className={`block w-full h-10 rounded-md border-2 px-4 text-sm focus:ring-0 ${
|
||||
emailError
|
||||
? 'border-red-500 bg-red-50'
|
||||
: isLocked
|
||||
? 'border-gray-300 bg-gray-100 cursor-not-allowed'
|
||||
: 'border-[#92722A] bg-white'
|
||||
}`}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Enter Email"
|
||||
className="block w-full h-10 rounded-md border-2 border-[#92722A] focus:border-[#92722A] focus:ring-0 px-4 text-sm bg-white"
|
||||
required
|
||||
/>
|
||||
{emailError && <p className="text-xs text-red-600 mt-1">{emailError}</p>}
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
if (passwordError) setPasswordError('');
|
||||
}}
|
||||
disabled={isLocked}
|
||||
autoComplete="current-password"
|
||||
className={`block w-full h-10 rounded-md border-2 px-4 pr-11 text-sm focus:ring-0 ${
|
||||
passwordError
|
||||
? 'border-red-500 bg-red-50'
|
||||
: isLocked
|
||||
? 'border-gray-300 bg-gray-100 cursor-not-allowed'
|
||||
: 'border-[#92722A] bg-white'
|
||||
}`}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
className="block w-full h-10 rounded-md border-2 border-[#92722A] focus:border-[#92722A] focus:ring-0 px-4 pr-11 text-sm bg-white"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 px-3 flex items-center cursor-pointer"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
disabled={isLocked}
|
||||
aria-label="Toggle password visibility"
|
||||
>
|
||||
{showPassword ? (
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-[#92722A]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z" />
|
||||
<circle cx="12" cy="12" r="3" strokeWidth="2" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-[#92722A]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0 1 12 19c-5.523 0-10-4.477-10-10a9.96 9.96 0 0 1 2.122-6.21m3.086-1.955A9.953 9.953 0 0 1 12 3c5.523 0 10 4.477 10 10 0 1.591-.372 3.093-1.034 4.432M9.88 9.88a3 3 0 1 0 4.24 4.24"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="m3 3 18 18"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-[#92722A]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.875 18.825A10.05 10.05 0 0 1 12 19c-5.523 0-10-4.477-10-10a9.96 9.96 0 0 1 2.122-6.21m3.086-1.955A9.953 9.953 0 0 1 12 3c5.523 0 10 4.477 10 10 0 1.591-.372 3.093-1.034 4.432M9.88 9.88a3 3 0 1 0 4.24 4.24" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m3 3 18 18" />
|
||||
</svg>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z" />
|
||||
<circle cx="12" cy="12" r="3" strokeWidth="2" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{passwordError && <p className="text-xs text-red-600 mt-1">{passwordError}</p>}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
CAPTCHA Verification <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex justify-center items-center font-mono text-lg tracking-widest bg-gray-100 border-2 border-[#92722A] rounded-md px-4 py-2 select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{captcha}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateCaptcha}
|
||||
className="flex items-center gap-1 text-sm text-[#92722A] hover:underline"
|
||||
>
|
||||
<RefreshCw size={14} className="text-[#92722A]" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
If you cannot read the code, click "Refresh" or type:{" "}
|
||||
<span className="font-mono">{captcha}</span>
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
name="captcha"
|
||||
value={userCaptcha}
|
||||
onChange={handleCaptchaChange}
|
||||
placeholder="Enter the code shown above"
|
||||
className={`mt-2 block w-full h-10 rounded-md border-2 ${
|
||||
captchaError ? 'border-red-500' : 'border-[#92722A]'
|
||||
} focus:border-[#92722A] focus:ring-0 px-4 text-sm bg-white`}
|
||||
/>
|
||||
{captchaError && (
|
||||
<p className="text-xs text-red-600 mt-1">{captchaError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
@ -455,11 +321,8 @@ const submit = async (e) => {
|
||||
className="h-4 w-4 text-[#92722A] border-gray-300 rounded cursor-pointer"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
disabled={isLocked}
|
||||
/>
|
||||
<span className={`${isLocked ? 'text-gray-400' : 'text-gray-700'}`}>
|
||||
Keep me signed in on this device
|
||||
</span>
|
||||
<span className="text-gray-700">Remember me</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@ -469,26 +332,17 @@ const submit = async (e) => {
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
loading ||
|
||||
isLocked
|
||||
}
|
||||
className={`inline-flex items-center justify-center w-full h-12 rounded-lg text-white font-medium ${
|
||||
isLocked
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: loading
|
||||
disabled={loading || !userCaptcha}
|
||||
className={`inline-flex items-center justify-center w-full h-12 rounded-lg text-white ${
|
||||
loading || !userCaptcha
|
||||
? 'bg-[#bfa06a] cursor-not-allowed'
|
||||
: 'bg-[#92722A] hover:bg-[#7b5c1f]'
|
||||
} transition-colors`}
|
||||
>
|
||||
{isLocked
|
||||
? `Locked (${formatTime(remainingTime)})`
|
||||
: loading
|
||||
? 'Signing in…'
|
||||
: 'Sign in'}
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@ -496,6 +350,5 @@ const submit = async (e) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
|
||||
|
||||
export default Login;
|
||||
@ -1,916 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import AdminHeader from '@/components/admin/AdminHeader';
|
||||
import {
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import { SelectField as BaseSelectField } from '@/components/common/FormControls';
|
||||
import Table from '@/components/common/Table';
|
||||
import {
|
||||
FiberManualRecord as FiberManualRecordIcon,
|
||||
KeyboardArrowRight as KeyboardArrowRightIcon,
|
||||
InfoOutlined as InfoOutlinedIcon,
|
||||
NavigateNext as NavigateNextIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { Breadcrumbs } from '@mui/material';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Card from '@/components/common/Card';
|
||||
import triggerIcon from '@/assets/trigger.png';
|
||||
import { getManufacturingIndex, getManufacturingMonthlyOverview, autoSubmitSurvey, getManufacturingIndexFiltered } from '@/services/manufacturingIndex/ManufacturingIndex';
|
||||
import apiClient from '@/services/api/apiClient';
|
||||
|
||||
// Import popup components
|
||||
import ManufacturingTotal from './ManufacturingIndexPopup/ManufacturingTotal';
|
||||
import ISIC2Digit from './ManufacturingIndexPopup/ISIC2Digit';
|
||||
import ISIC3Digit from './ManufacturingIndexPopup/ISIC3Digit';
|
||||
import ISIC4Digit from './ManufacturingIndexPopup/ISIC4Digit';
|
||||
|
||||
|
||||
// Custom components
|
||||
const SelectField = (props) => (
|
||||
<BaseSelectField
|
||||
{...props}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
'&.Mui-focused fieldset, &.Mui-focused:hover fieldset': {
|
||||
borderColor: '#8E5B2D',
|
||||
},
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: props.value ? '#8E5B2D' : 'rgba(0, 0, 0, 0.23)',
|
||||
},
|
||||
},
|
||||
...props.sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const ManufacturingIndex = () => {
|
||||
// State management
|
||||
const [year, setYear] = useState('All');
|
||||
const [searchMonth, setSearchMonth] = useState('');
|
||||
const [selectedMonth, setSelectedMonth] = useState(null);
|
||||
const [allMonths, setAllMonths] = useState([]); // Store all months data
|
||||
const [filteredMonths, setFilteredMonths] = useState([]); // Store filtered months
|
||||
const [activeTab, setActiveTab] = useState('manufacturing');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [monthlyOverviewData, setMonthlyOverviewData] = useState(null);
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
||||
const [apiLoading, setApiLoading] = useState(false);
|
||||
const [quarter, setQuarter] = useState('All');
|
||||
const [surveySubmitLoading, setSurveySubmitLoading] = useState(false);
|
||||
const [surveySubmitStatus, setSurveySubmitStatus] = useState('');
|
||||
const [surveySubmitError, setSurveySubmitError] = useState(null);
|
||||
const [baseYearLoading, setBaseYearLoading] = useState(false);
|
||||
const [baseYearStatus, setBaseYearStatus] = useState('');
|
||||
const [baseYearError, setBaseYearError] = useState('');
|
||||
const [nextScheduledRun, setNextScheduledRun] = useState(''); // Default value
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
totalItems: 0
|
||||
});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
applyFilters(year, searchMonth, allMonths, quarter);
|
||||
}, [year, quarter, searchMonth, allMonths]);
|
||||
|
||||
// Fetch data from API
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await getManufacturingIndex();
|
||||
|
||||
// Handle different response structures
|
||||
let dataArray = [];
|
||||
|
||||
// Case 1: Response is already an array
|
||||
if (Array.isArray(response)) {
|
||||
dataArray = response;
|
||||
}
|
||||
// Case 2: Response has data property which is array
|
||||
else if (response && response.data && Array.isArray(response.data)) {
|
||||
dataArray = response.data;
|
||||
}
|
||||
// Case 3: Response.data is object with nested data
|
||||
else if (response && response.data && typeof response.data === 'object') {
|
||||
// Try to extract array from common structures
|
||||
if (Array.isArray(response.data.manufacturingIndex) || Array.isArray(response.data.data)) {
|
||||
dataArray = response.data.manufacturingIndex || response.data.data || [];
|
||||
} else {
|
||||
// Convert object values to array
|
||||
dataArray = Object.values(response.data);
|
||||
}
|
||||
}
|
||||
// Case 4: Response has nested structure
|
||||
else if (response && response.data && response.data.data && Array.isArray(response.data.data)) {
|
||||
dataArray = response.data.data;
|
||||
}
|
||||
else {
|
||||
console.error('Unexpected API response structure:', response);
|
||||
// Try to extract any array from response
|
||||
const findArray = (obj) => {
|
||||
if (Array.isArray(obj)) return obj;
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
for (const key in obj) {
|
||||
if (Array.isArray(obj[key])) {
|
||||
return obj[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
dataArray = findArray(response);
|
||||
}
|
||||
|
||||
// If no data found
|
||||
if (!dataArray || dataArray.length === 0) {
|
||||
console.warn('No data array found, using empty array');
|
||||
dataArray = [];
|
||||
}
|
||||
|
||||
// Debug: Log the actual data structure
|
||||
|
||||
|
||||
// Format the data
|
||||
const formattedData = dataArray.map((item, index) => {
|
||||
// Create a date object from reference_date or other date fields
|
||||
let dateObj = new Date();
|
||||
let year = 2025;
|
||||
let monthNumber = 1;
|
||||
|
||||
if (item.reference_date) {
|
||||
dateObj = new Date(item.reference_date);
|
||||
} else if (item.date) {
|
||||
dateObj = new Date(item.date);
|
||||
} else if (item.referenceDate) {
|
||||
dateObj = new Date(item.referenceDate);
|
||||
} else if (item.year && item.month) {
|
||||
// If year and month are separate fields
|
||||
year = item.year;
|
||||
monthNumber = item.month;
|
||||
dateObj = new Date(year, monthNumber - 1, 1);
|
||||
}
|
||||
|
||||
const monthName = dateObj.toLocaleString('en-US', { month: 'short' });
|
||||
year = item.year || dateObj.getFullYear() || 2025;
|
||||
monthNumber = item.month || (dateObj.getMonth() + 1) || 1;
|
||||
// Normalize quarter value to form 'Q1'..'Q4'
|
||||
let quarterVal = item.quarter || item.quarter_name || item.quarterName || '-';
|
||||
if (quarterVal !== '-' && quarterVal !== null && quarterVal !== undefined) {
|
||||
quarterVal = String(quarterVal).trim();
|
||||
// If it's numeric like '2' or 2 -> convert to 'Q2'
|
||||
if (/^\d+$/.test(quarterVal)) quarterVal = `Q${quarterVal}`;
|
||||
quarterVal = quarterVal.toUpperCase();
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id || `${year}-${monthNumber}` || `item-${index}`,
|
||||
month: `${monthName} ${year}`,
|
||||
monthName: monthName,
|
||||
year: year,
|
||||
monthNumber: monthNumber,
|
||||
index: item.manufacturing_index?.toString() || item.index?.toString() || item.manufacturingIndex?.toString() || '-',
|
||||
mom: item.mom_change || item.mom || item.momChange ?
|
||||
(parseFloat(item.mom_change || item.mom || item.momChange) > 0 ?
|
||||
`+${item.mom_change || item.mom || item.momChange}` :
|
||||
(item.mom_change || item.mom || item.momChange).toString()) :
|
||||
'-',
|
||||
yoy: item.yoy_change || item.yoy || item.yoyChange ?
|
||||
(parseFloat(item.yoy_change || item.yoy || item.yoyChange) > 0 ?
|
||||
`+${item.yoy_change || item.yoy || item.yoyChange}` :
|
||||
(item.yoy_change || item.yoy || item.yoyChange).toString()) :
|
||||
'-',
|
||||
generated: item.generated_on || item.generatedOn || item.generated_at ?
|
||||
new Date(item.generated_on || item.generatedOn || item.generated_at).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Asia/Dubai',
|
||||
timeZoneName: 'short'
|
||||
}) : '-',
|
||||
quarter: quarterVal || '-',
|
||||
totalWeight: item.total_weight || '-',
|
||||
status: item.status || 'Completed',
|
||||
rawData: item
|
||||
};
|
||||
});
|
||||
|
||||
setAllMonths(formattedData);
|
||||
applyFilters(year, searchMonth, formattedData, quarter);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
totalItems: formattedData.length
|
||||
}));
|
||||
|
||||
// Log the full response for debugging
|
||||
|
||||
// Extract next scheduled run date from response
|
||||
// The API response has the date in response.data.next_scheduled_run
|
||||
const nextRunDateString = response?.data?.nextScheduleDate;
|
||||
|
||||
if (nextRunDateString) {
|
||||
const nextRunDate = new Date(nextRunDateString);
|
||||
if (!isNaN(nextRunDate.getTime())) {
|
||||
const formattedDate = nextRunDate.toLocaleString('en-US', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Asia/Dubai',
|
||||
timeZoneName: 'short'
|
||||
}).replace(',', ''); // Remove comma after the day for cleaner format
|
||||
|
||||
setNextScheduledRun(formattedDate);
|
||||
} else {
|
||||
console.error('Invalid date format from API:', nextRunDateString);
|
||||
// Set a default value or empty string if date is invalid
|
||||
setNextScheduledRun('');
|
||||
}
|
||||
} else {
|
||||
console.warn('No next_scheduled_run found in response.data');
|
||||
// Set a default value or empty string if no date is found
|
||||
setNextScheduledRun('');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching manufacturing index:', err);
|
||||
console.error('Error details:', {
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
response: err.response
|
||||
});
|
||||
setError('Failed to load manufacturing data. Please try again later.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [refreshKey]);
|
||||
|
||||
// ARROW CLICK
|
||||
const handleMonthSelect = async (row) => {
|
||||
try {
|
||||
|
||||
const yearNumber = row.year;
|
||||
const monthNumber = row.monthNumber;
|
||||
|
||||
setSelectedMonth(row);
|
||||
setIsPopupOpen(true);
|
||||
setApiLoading(true);
|
||||
|
||||
// API CALL
|
||||
const apiResponse = await getManufacturingMonthlyOverview(yearNumber, row.quarter);
|
||||
|
||||
// Extract data from the response structure
|
||||
// Note: Your API returns {status, message, manufacturing_total, isic_2digit, isic_3digit, isic_4digit}
|
||||
const responseData = apiResponse.data || apiResponse;
|
||||
|
||||
// Transform data based on YOUR API structure
|
||||
const transformedData = {
|
||||
manufacturing: responseData?.manufacturing_total || {},
|
||||
isic2: Array.isArray(responseData?.isic_2digit) ? responseData.isic_2digit : [],
|
||||
isic3: Array.isArray(responseData?.isic_3digit) ? responseData.isic_3digit : [],
|
||||
isic4: Array.isArray(responseData?.isic_4digit) ? responseData.isic_4digit : []
|
||||
};
|
||||
|
||||
// 4 popups-க்கும் data set பண்ணு
|
||||
setMonthlyOverviewData(transformedData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in handleMonthSelect:', error);
|
||||
setError(`API Error: ${error.message || 'Failed to load detailed data'}`);
|
||||
|
||||
// Fallback: empty data set
|
||||
setMonthlyOverviewData({
|
||||
manufacturing: {},
|
||||
isic2: [],
|
||||
isic3: [],
|
||||
isic4: []
|
||||
});
|
||||
} finally {
|
||||
setApiLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClosePopup = () => {
|
||||
setIsPopupOpen(false);
|
||||
setMonthlyOverviewData(null);
|
||||
};
|
||||
|
||||
// Apply year, quarter and search filters
|
||||
const applyFilters = (selectedYear, searchTerm, monthsData = allMonths, selectedQuarter = quarter) => {
|
||||
|
||||
const filtered = monthsData.filter(month => {
|
||||
const matchesYear = selectedYear === 'All' || month.year.toString() === selectedYear;
|
||||
const matchesSearch = searchTerm === '' || month.month.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesQuarter = !selectedQuarter || selectedQuarter === 'All' || selectedQuarter === '-' ? true : (month.quarter || '').toUpperCase() === String(selectedQuarter).toUpperCase();
|
||||
return matchesYear && matchesSearch && matchesQuarter;
|
||||
});
|
||||
|
||||
setFilteredMonths(filtered);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
currentPage: 1,
|
||||
totalItems: filtered.length
|
||||
}));
|
||||
};
|
||||
|
||||
const handleYearChange = (e) => {
|
||||
setYear(e.target.value);
|
||||
};
|
||||
|
||||
const handleQuarterChange = (e) => {
|
||||
setQuarter(e.target.value);
|
||||
};
|
||||
|
||||
const handleSearchMonth = (e) => {
|
||||
setSearchMonth(e.target.value);
|
||||
};
|
||||
|
||||
const handleBaseYear = async () => {
|
||||
setSurveySubmitLoading(true);
|
||||
setSurveySubmitStatus('');
|
||||
setSurveySubmitError('');
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/calculate_base_year', {
|
||||
baseYear: 2022,
|
||||
forceRecalculate: true,
|
||||
});
|
||||
|
||||
setSurveySubmitStatus(
|
||||
response?.data?.message ||
|
||||
'Base year calculation completed successfully.'
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
setSurveySubmitError(
|
||||
error?.response?.data?.message ||
|
||||
error.message ||
|
||||
'Base year calculation failed.'
|
||||
);
|
||||
|
||||
console.error('Error:', error);
|
||||
} finally {
|
||||
setSurveySubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleTriggerData = async () => {
|
||||
setError(null);
|
||||
setSurveySubmitStatus('');
|
||||
setSurveySubmitError(null);
|
||||
|
||||
if (year === 'All' || !quarter) {
|
||||
setSurveySubmitError('Please select a valid year and quarter.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSurveySubmitLoading(true);
|
||||
|
||||
try {
|
||||
// Trigger backend calculate/auto-submit
|
||||
const response = await autoSubmitSurvey(parseInt(year, 10), quarter);
|
||||
|
||||
setSurveySubmitStatus(
|
||||
response?.data?.message || 'Survey auto-submit request sent successfully.'
|
||||
);
|
||||
|
||||
// After auto-submit, fetch the filtered manufacturing index for selected year and quarter/all
|
||||
try {
|
||||
const filteredResp = await getManufacturingIndexFiltered(
|
||||
parseInt(year, 10),
|
||||
quarter === 'All' ? undefined : quarter
|
||||
);
|
||||
|
||||
// Extract array similar to initial fetch
|
||||
let dataArray = [];
|
||||
if (Array.isArray(filteredResp)) {
|
||||
dataArray = filteredResp;
|
||||
} else if (filteredResp && filteredResp.data && Array.isArray(filteredResp.data)) {
|
||||
dataArray = filteredResp.data;
|
||||
} else if (filteredResp && filteredResp.data && typeof filteredResp.data === 'object') {
|
||||
if (Array.isArray(filteredResp.data.manufacturingIndex) || Array.isArray(filteredResp.data.data)) {
|
||||
dataArray = filteredResp.data.manufacturingIndex || filteredResp.data.data || [];
|
||||
} else {
|
||||
dataArray = Object.values(filteredResp.data);
|
||||
}
|
||||
} else {
|
||||
const findArray = (obj) => {
|
||||
if (Array.isArray(obj)) return obj;
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
for (const key in obj) {
|
||||
if (Array.isArray(obj[key])) return obj[key];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
dataArray = findArray(filteredResp);
|
||||
}
|
||||
|
||||
const formattedData = dataArray.map((item, index) => {
|
||||
let dateObj = new Date();
|
||||
let yearVal = 2025;
|
||||
let monthNumber = 1;
|
||||
|
||||
if (item.reference_date) {
|
||||
dateObj = new Date(item.reference_date);
|
||||
} else if (item.date) {
|
||||
dateObj = new Date(item.date);
|
||||
} else if (item.referenceDate) {
|
||||
dateObj = new Date(item.referenceDate);
|
||||
} else if (item.year && item.month) {
|
||||
yearVal = item.year;
|
||||
monthNumber = item.month;
|
||||
dateObj = new Date(yearVal, monthNumber - 1, 1);
|
||||
}
|
||||
|
||||
const monthName = dateObj.toLocaleString('en-US', { month: 'short' });
|
||||
yearVal = item.year || dateObj.getFullYear() || 2025;
|
||||
monthNumber = item.month || (dateObj.getMonth() + 1) || 1;
|
||||
// Normalize quarter value to form 'Q1'..'Q4'
|
||||
let quarterVal = item.quarter || item.quarter_name || item.quarterName || '-';
|
||||
if (quarterVal !== '-' && quarterVal !== null && quarterVal !== undefined) {
|
||||
quarterVal = String(quarterVal).trim();
|
||||
if (/^\d+$/.test(quarterVal)) quarterVal = `Q${quarterVal}`;
|
||||
quarterVal = quarterVal.toUpperCase();
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id || `${yearVal}-${monthNumber}` || `item-${index}`,
|
||||
month: `${monthName} ${yearVal}`,
|
||||
monthName: monthName,
|
||||
year: yearVal,
|
||||
monthNumber: monthNumber,
|
||||
index: item.manufacturing_index?.toString() || item.index?.toString() || item.manufacturingIndex?.toString() || '-',
|
||||
mom: item.mom_change || item.mom || item.momChange ?
|
||||
(parseFloat(item.mom_change || item.mom || item.momChange) > 0 ?
|
||||
`+${item.mom_change || item.mom || item.momChange}` :
|
||||
(item.mom_change || item.mom || item.momChange).toString()) :
|
||||
'-',
|
||||
yoy: item.yoy_change || item.yoy || item.yoyChange ?
|
||||
(parseFloat(item.yoy_change || item.yoy || item.yoyChange) > 0 ?
|
||||
`+${item.yoy_change || item.yoy || item.yoyChange}` :
|
||||
(item.yoy_change || item.yoy || item.yoyChange).toString()) :
|
||||
'-',
|
||||
generated: item.generated_on || item.generatedOn || item.generated_at ?
|
||||
new Date(item.generated_on || item.generatedOn || item.generated_at).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Asia/Dubai',
|
||||
timeZoneName: 'short'
|
||||
}) : '-',
|
||||
quarter: quarterVal || '-',
|
||||
totalWeight: item.total_weight || '-',
|
||||
status: item.status || 'Completed',
|
||||
rawData: item
|
||||
};
|
||||
});
|
||||
|
||||
setAllMonths(formattedData);
|
||||
applyFilters(year, searchMonth, formattedData, quarter);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
totalItems: formattedData.length,
|
||||
currentPage: 1
|
||||
}));
|
||||
} catch (fetchErr) {
|
||||
console.error('Error fetching filtered manufacturing index after trigger:', fetchErr);
|
||||
}
|
||||
} catch (error) {
|
||||
setSurveySubmitError(
|
||||
error?.response?.data?.message || error.message || 'Survey auto-submit failed.'
|
||||
);
|
||||
} finally {
|
||||
setSurveySubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCSV = () => {
|
||||
const headers = ['Month', 'Quarter', 'Index', 'Total Weight', 'MoM Change', 'YoY Change', 'Generated On', 'Status'];
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...filteredMonths.map(month => [
|
||||
`"${month.month}"`,
|
||||
`"${month.quarter}"`,
|
||||
month.index,
|
||||
month.totalWeight,
|
||||
month.mom,
|
||||
month.yoy,
|
||||
`"${month.generated}"`,
|
||||
`"${month.status}"`
|
||||
].join(','))
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `manufacturing_index_${year}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const downloadIconSrc = '/assets/images/DownloadSimple.svg';
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years = [
|
||||
'All',
|
||||
...Array.from(
|
||||
{ length: currentYear - 2021 },
|
||||
(_, i) => (currentYear - i).toString()
|
||||
)
|
||||
];
|
||||
const selectedMonthData = selectedMonth || {};
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#92722A] mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Loading manufacturing data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="text-center p-6 max-w-md mx-auto bg-white rounded-lg shadow-md">
|
||||
<div className="text-red-500 mb-4">
|
||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Error Loading Data</h3>
|
||||
<p className="text-gray-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-[#92722A] text-white rounded-md hover:bg-[#7a5e22] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#92722A]"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-gray-50">
|
||||
<AdminHeader />
|
||||
<div className="max-w-[1280px] mx-auto px-4 py-6">
|
||||
{/* Popup Dialog */}
|
||||
<Dialog
|
||||
open={isPopupOpen}
|
||||
onClose={handleClosePopup}
|
||||
maxWidth="lg"
|
||||
fullWidth={true}
|
||||
className="relative"
|
||||
>
|
||||
<DialogTitle className="bg-[#F9F5E8] text-[#92722A] flex justify-between items-center pr-2">
|
||||
<span>Quarterly ISIC breakdown {selectedMonth?.quarter} - {selectedMonth?.month}</span>
|
||||
<IconButton
|
||||
onClick={handleClosePopup}
|
||||
size="small"
|
||||
className="absolute right-2 top-2 text-gray-500 hover:text-gray-700"
|
||||
sx={{
|
||||
'&:hover': {
|
||||
backgroundColor: 'transparent'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent className="p-0">
|
||||
<div className="p-6">
|
||||
{apiLoading ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-[#92722A]"></div>
|
||||
<span className="ml-3">Loading detailed data...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0 w-[1000px] max-w-[95vw] min-w-[800px]">
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Start from the manufacturing total, then drill down into ISIC 2-digit and 3 & 4-digit groups for the selected quarter.
|
||||
</p>
|
||||
{/* Tabs Header */}
|
||||
<div className="sticky top-0 z-10 bg-white pt-2">
|
||||
<div className="flex justify-between items-center border-b border-gray-200 pb-2">
|
||||
<div className="flex space-x-1">
|
||||
{['manufacturing', 'isic2', 'isic3', 'isic4'].map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 text-sm font-medium ${
|
||||
activeTab === tab
|
||||
? 'text-[#92722A] border-b-2 border-[#92722A]'
|
||||
: 'text-medium-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab === 'manufacturing' ? 'Manufacturing total' :
|
||||
tab === 'isic2' ? 'ISIC 2-digit' :
|
||||
tab === 'isic3' ? 'ISIC 3-digit' : 'ISIC 4-digit'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-5"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="bg-white mt-2 rounded-lg shadow p-6 w-full">
|
||||
{activeTab === 'manufacturing' && (
|
||||
<ManufacturingTotal
|
||||
selectedMonthData={selectedMonthData}
|
||||
setActiveTab={setActiveTab}
|
||||
apiData={monthlyOverviewData?.manufacturing}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'isic2' && (
|
||||
<ISIC2Digit
|
||||
setActiveTab={setActiveTab}
|
||||
apiData={monthlyOverviewData?.isic2}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'isic3' && (
|
||||
<ISIC3Digit
|
||||
setActiveTab={setActiveTab}
|
||||
apiData={monthlyOverviewData?.isic3}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'isic4' && (
|
||||
<ISIC4Digit
|
||||
setActiveTab={setActiveTab}
|
||||
apiData={monthlyOverviewData?.isic4}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Main UI */}
|
||||
<div className="w-full py-6">
|
||||
{/* Breadcrumbs */}
|
||||
<nav className="mb-6 text-sm text-[#8F9299] flex items-center gap-2">
|
||||
<Link
|
||||
to="/admin/dashboard"
|
||||
className="font-bold flex items-center gap-1 !text-black no-underline hover:underline hover:!text-black cursor-pointer"
|
||||
>
|
||||
Dashboard
|
||||
<img src="/assets/images/Breadcrumb-vector.svg" alt="" className="ml-1 w-3 h-3" />
|
||||
</Link>
|
||||
<span className="text-[#92722A] font-medium">Manufacturing Index</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-6">
|
||||
<Card className="p-6 bg-white rounded-lg shadow">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-start gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Manufacturing Index - Quarterly overview
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 max-w-3xl">
|
||||
Quarterly manufacturing indices are automatically generated on the 11th of each quarter using the finalized Laspeyres item indices. This view enables users to monitor trends and drill down into ISIC group–level details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="mt-5 flex flex-col items-end gap-2">
|
||||
<div className="flex items-center text-sm text-green-600">
|
||||
<FiberManualRecordIcon className="text-xs mr-1" />
|
||||
Next scheduled run: {nextScheduledRun}
|
||||
</div>
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<Tooltip title="Manufacturing index calculation method" arrow>
|
||||
<InfoOutlinedIcon className="text-gray-400 text-sm mr-1 align-middle flex-shrink-0 mt-[-0.2rem]" />
|
||||
</Tooltip>
|
||||
<span className="whitespace-nowrap">Formula: Manufacturing index<sub>t</sub> = Σ (sector weight<sub>i</sub> × group index<sub>i,t</sub>)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Table */}
|
||||
<div className="space-y-6">
|
||||
<Card className="p-6 bg-white rounded-lg shadow">
|
||||
<div className="space-y-4">
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
{/* Filters */}
|
||||
<div className="bg-white px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">
|
||||
Manufacturing Index
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Filter by year and quickly jump to a quarter to see its ISIC breakdown.
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<div className="text-sm text-gray-600 bg-gray-100 px-3 py-1 rounded-full">
|
||||
Base year: 2022 = 100
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 bg-gray-100 px-3 py-1 rounded-full">
|
||||
Coverage: Manufacturing (ISIC C)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-[180px]">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<img src="/assets/images/material-symbols_search-rounded.svg" alt="Search" className="h-5 w-5" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search Quarter"
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md h-[40px]"
|
||||
value={searchMonth}
|
||||
onChange={handleSearchMonth}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center w-[99px]">
|
||||
<SelectField
|
||||
value={year}
|
||||
onChange={handleYearChange}
|
||||
className="w-full h-[40px]"
|
||||
size="small"
|
||||
options={years.map(y => ({ value: y, label: y }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center w-[89px]">
|
||||
<SelectField
|
||||
value={quarter}
|
||||
onChange={handleQuarterChange}
|
||||
className="w-full h-[40px]"
|
||||
size="small"
|
||||
options={['All', 'Q1', 'Q2', 'Q3', 'Q4'].map(q => ({ value: q, label: q }))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="h-[40px] px-4 rounded-[6px] bg-[#F7F7F7] border border-[#C3C6CB] text-sm inline-flex items-center gap-2 hover:bg-gray-50 whitespace-nowrap font-medium text-[#232528]"
|
||||
onClick={handleTriggerData}
|
||||
>
|
||||
<img src={triggerIcon} alt="Trigger" className="h-5 w-5" />
|
||||
Calculate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h-[40px] px-4 rounded-[6px] bg-[#F7F7F7] border border-[#C3C6CB] text-sm inline-flex items-center gap-2 hover:bg-gray-50 whitespace-nowrap font-medium text-[#232528]"
|
||||
onClick={handleBaseYear}
|
||||
>
|
||||
<img src={triggerIcon} alt="Trigger" className="h-5 w-5" />
|
||||
Base Year
|
||||
</button>
|
||||
<button
|
||||
className="h-10 px-3 xl:px-4 rounded-[6px] bg-[#F7F7F7] border border-[#C3C6CB] text-sm inline-flex items-center gap-2 hover:bg-gray-50 whitespace-nowrap"
|
||||
onClick={handleExportCSV}
|
||||
>
|
||||
<img src={downloadIconSrc} alt="Download" className="h-5 w-5" />
|
||||
<span className="hidden xl:inline font-medium text-[#232528]">Export CSV</span>
|
||||
<span className="xl:hidden font-medium text-[#232528]">Export</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{surveySubmitLoading || surveySubmitStatus || surveySubmitError ? (
|
||||
<div className="px-6 pb-4 pt-2 text-sm text-gray-700">
|
||||
{surveySubmitLoading ? (
|
||||
<span>Submitting survey request...</span>
|
||||
) : surveySubmitError ? (
|
||||
<span className="text-red-600">{surveySubmitError}</span>
|
||||
) : (
|
||||
<span className="text-green-600">{surveySubmitStatus}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="relative">
|
||||
<div className="w-full">
|
||||
<Table
|
||||
headers={[
|
||||
'Reference Year',
|
||||
'Quarter',
|
||||
'Manufacturing Index',
|
||||
'Total Weight',
|
||||
'QOQ Change',
|
||||
'YOY Change',
|
||||
'Generated On',
|
||||
'Status',
|
||||
'Actions'
|
||||
]}
|
||||
rows={filteredMonths.map((row) => {
|
||||
const isSelected = selectedMonth?.month === row.month;
|
||||
return [
|
||||
<div key="month" className={`${isSelected ? 'font-medium' : ''}`}>
|
||||
{row.year}
|
||||
</div>,
|
||||
<span className={isSelected ? 'font-medium' : ''} key="quarter">
|
||||
{row.quarter}
|
||||
</span>,
|
||||
<span className={isSelected ? 'font-medium' : ''} key="index">
|
||||
{row.index} (2022=100)
|
||||
</span>,
|
||||
<span className={isSelected ? 'font-medium' : ''} key="totalWeight">
|
||||
{row.totalWeight}
|
||||
</span>,
|
||||
<span className={`${parseFloat(row.mom) >= 0 ? 'text-green-600' : 'text-red-600'} ${isSelected ? 'font-medium' : ''}`} key="mom">
|
||||
{row.mom}%
|
||||
</span>,
|
||||
<span className={`${parseFloat(row.yoy) >= 0 ? 'text-green-600' : 'text-red-600'} ${isSelected ? 'font-medium' : ''}`} key="yoy">
|
||||
{row.yoy}%
|
||||
</span>,
|
||||
<span className={isSelected ? 'font-medium' : ''} key="generated">
|
||||
{row.generated}
|
||||
</span>,
|
||||
<span className={isSelected ? 'font-medium' : ''} key="status">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
row.status === 'Completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{row.status}
|
||||
</span>
|
||||
</span>,
|
||||
<div
|
||||
key="arrow"
|
||||
className={`cursor-pointer flex items-center justify-end pr-4 ${
|
||||
isSelected ? 'text-gray-900' : 'text-gray-400'
|
||||
}`}
|
||||
onClick={() => handleMonthSelect(row)}
|
||||
>
|
||||
<KeyboardArrowRightIcon />
|
||||
</div>
|
||||
];
|
||||
})}
|
||||
columnWidths={['180px', '100px', '190px', '120px', '130px', '130px', '200px', '120px', '80px']}
|
||||
getRowClass={(index) => {
|
||||
const isSelected = selectedMonth?.month === filteredMonths[index]?.month;
|
||||
return isSelected ? 'bg-[#F5F0E1]' : 'hover:bg-gray-50';
|
||||
}}
|
||||
pagination={{
|
||||
currentPage: pagination.currentPage,
|
||||
onPageChange: (page) => setPagination(prev => ({ ...prev, currentPage: page })),
|
||||
pageSize: pagination.pageSize,
|
||||
totalItems: filteredMonths.length,
|
||||
pageSizeOptions: [10, 25, 50, 100],
|
||||
onPageSizeChange: (size) => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
pageSize: size,
|
||||
currentPage: 1
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Note: Indices shown here are headline manufacturing totals. Use the drill-down actions to move from manufacturing total to ISIC 2-digit, then to ISIC 3 & 4-digit groups.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManufacturingIndex;
|
||||
@ -1,247 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import Table from '@/components/common/Table';
|
||||
|
||||
const ISIC2Digit = ({ setActiveTab, apiData }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
|
||||
// Your API returns array of objects with: isic_2digit_code, isic_description, total_weight, weighted_index_sum, isic_2digit_index
|
||||
const isicData = Array.isArray(apiData) ? apiData : [];
|
||||
|
||||
// Filter ISIC 2-digit data based on search term
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchTerm.trim()) return isicData;
|
||||
|
||||
const term = searchTerm.toLowerCase().trim();
|
||||
return isicData.filter(item => {
|
||||
const code = item.isic_2digit_code || '';
|
||||
const name = item.isic_description || '';
|
||||
const index = item.isic_2digit_index || '';
|
||||
const weight = item.total_weight || '';
|
||||
|
||||
return (
|
||||
code.toString().toLowerCase().includes(term) ||
|
||||
name.toString().toLowerCase().includes(term) ||
|
||||
index.toString().toLowerCase().includes(term) ||
|
||||
weight.toString().toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
}, [searchTerm, isicData]);
|
||||
|
||||
// Handle CSV export
|
||||
const handleExport = () => {
|
||||
if (filteredData.length === 0) return;
|
||||
|
||||
// Create CSV content
|
||||
const headers = ['ISIC Code', 'Index', 'Weight (%)', 'Weighted Index'];
|
||||
const csvRows = [
|
||||
headers.join(','),
|
||||
...filteredData.map(item =>
|
||||
[
|
||||
`"${item.isic_2digit_code || ''}"`,
|
||||
item.isic_2digit_index || '',
|
||||
item.total_weight || '',
|
||||
item.weighted_index_sum || ''
|
||||
].join(',')
|
||||
)
|
||||
];
|
||||
|
||||
// Create and trigger download
|
||||
const csvContent = csvRows.join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `isic_2digit_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
// Format number with 2 decimal places if needed
|
||||
const formatNumber = (value) => {
|
||||
if (value === null || value === undefined || value === '' || value === '-') return '-';
|
||||
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num)) return value;
|
||||
|
||||
// If it's a whole number, show as is, else show 2 decimal places
|
||||
return num % 1 === 0 ? num.toString() : num.toFixed(2);
|
||||
};
|
||||
|
||||
// Get total weight for display
|
||||
const totalWeight = useMemo(() => {
|
||||
return isicData.reduce((sum, item) => {
|
||||
const weight = parseFloat(item.total_weight || 0);
|
||||
return sum + (isNaN(weight) ? 0 : weight);
|
||||
}, 0);
|
||||
}, [isicData]);
|
||||
|
||||
return (
|
||||
<div className="-mt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">ISIC 2-digit breakdown</h3>
|
||||
{/* <div className="bg-gray-100 px-3 py-1 rounded-full">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Quarter: {apiData?.[0]?.quarter || '-'}
|
||||
</span>
|
||||
</div> */}
|
||||
</div>
|
||||
<p className="text-sm mt-2 text-gray-600 mb-4">
|
||||
The manufacturing index is calculated as a weighted average of ISIC 2-digit groups,
|
||||
using their relative weights in the manufacturing sector.
|
||||
{totalWeight > 0 && ` Total weight: ${formatNumber(totalWeight)}%`}
|
||||
</p>
|
||||
|
||||
{/* Data Summary */}
|
||||
{isicData.length === 0 ? (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800">No data available</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700">
|
||||
<p>No ISIC 2-digit data found for this month.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Search and Filter Bar */}
|
||||
<div className="flex flex-col sm:flex-row justify-end items-center gap-4 mb-4">
|
||||
<div className="relative w-full max-w-md">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by code or index..."
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#92722A] focus:border-[#92722A] sm:text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={filteredData.length === 0}
|
||||
className={`inline-flex items-center gap-2 h-9 px-4 rounded-md border text-sm font-medium ${
|
||||
filteredData.length > 0
|
||||
? 'border-[#92722A] text-[#92722A] hover:bg-[#F2ECCF]'
|
||||
: 'border-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="-mx-6">
|
||||
<Table
|
||||
headers={['ISIC Code', 'Index', 'Weight (%)', 'Weighted Index']}
|
||||
rows={filteredData.map((item, index) => [
|
||||
<span className="font-medium" key={`code-${item.isic_2digit_code || index}`}>
|
||||
{item.isic_2digit_code || '-'}
|
||||
</span>,
|
||||
<span
|
||||
key={`index-${item.isic_2digit_code || index}`}
|
||||
className={parseFloat(item.isic_2digit_index) >= 0 ? 'text-green-600' : 'text-red-600'}
|
||||
>
|
||||
{formatNumber(item.isic_2digit_index)}
|
||||
</span>,
|
||||
<span key={`weight-${item.isic_2digit_code || index}`}>
|
||||
{formatNumber(item.total_weight)}%
|
||||
</span>,
|
||||
<span key={`weighted-index-${item.isic_2digit_code || index}`}>
|
||||
{formatNumber(item.weighted_index_sum)}
|
||||
</span>
|
||||
])}
|
||||
className="w-full"
|
||||
columnWidths={['15%', '30%', '25%', '30%']}
|
||||
getCellClass={(rowIndex, colIndex) => {
|
||||
const baseClasses = 'px-6 py-4 whitespace-nowrap text-sm';
|
||||
return colIndex === 0
|
||||
? `${baseClasses} text-gray-900 font-medium`
|
||||
: baseClasses;
|
||||
}}
|
||||
getRowClass={(rowIndex) => {
|
||||
const baseClasses = 'border-b border-gray-200';
|
||||
return rowIndex % 2 === 0
|
||||
? `${baseClasses} bg-white`
|
||||
: `${baseClasses} bg-gray-50`;
|
||||
}}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
onPageChange: setCurrentPage,
|
||||
pageSize: pageSize,
|
||||
totalItems: filteredData.length,
|
||||
pageSizeOptions: [10, 25, 50, 100],
|
||||
onPageSizeChange: setPageSize
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Data Stats */}
|
||||
{filteredData.length > 0 && (
|
||||
<div className="mt-4 text-sm text-gray-500 flex justify-between items-center">
|
||||
{/* <span>Showing {filteredData.length} of {isicData.length} sectors</span> */}
|
||||
{/* {totalWeight > 0 && (
|
||||
<span>Total weight: <span className="font-medium">{formatNumber(totalWeight)}%</span></span>
|
||||
)} */}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Operational Notes */}
|
||||
<div className="mt-6 ml-2 p-4 bg-yellow-50 rounded-md text-sm text-gray-700 w-full -mx-6 px-6">
|
||||
<p className="font-medium mb-2">Operational notes</p>
|
||||
<ul className="text-xs text-gray-600 space-y-1.5 list-disc pl-5">
|
||||
<li>Manufacturing indices are auto-generated on the 11th using the latest validated item-level data for the previous reference quarter.</li>
|
||||
<li>If an automated run fails, the status for that quarter will show as Error; admins can re-trigger the calculation from the technical tools page.</li>
|
||||
<li>All drill-down views (ISIC 2-digit and ISIC 3 & 4-digit) use the same Laspeyres weights and base year as the headline manufacturing index.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Back and Next Buttons */}
|
||||
<div className="mt-8 flex flex-col sm:flex-row justify-between gap-3">
|
||||
<button
|
||||
onClick={() => setActiveTab('manufacturing')}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md border border-[#92722A] text-[#92722A] bg-transparent hover:bg-[#92722A]/10"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('isic3')}
|
||||
disabled={isicData.length === 0}
|
||||
className={`inline-flex items-center gap-2 h-9 px-5 rounded-md ${
|
||||
isicData.length > 0
|
||||
? 'bg-[#92722A] text-white hover:bg-[#7b5c1f] cursor-pointer'
|
||||
: 'bg-gray-200 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<span>Next</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ISIC2Digit;
|
||||
@ -1,222 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import Table from '@/components/common/Table';
|
||||
|
||||
const ISIC3Digit = ({ setActiveTab, apiData }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
|
||||
const isicData = Array.isArray(apiData) ? apiData : [];
|
||||
|
||||
// Filter data based on search term
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchTerm.trim()) return isicData;
|
||||
|
||||
const term = searchTerm.toLowerCase().trim();
|
||||
return isicData.filter(item => {
|
||||
const code = item.isic_3digit_code || '';
|
||||
const name = item.isic_description || '';
|
||||
const index = item.isic_3digit_index || '';
|
||||
const weight = item.total_weight || '';
|
||||
|
||||
return (
|
||||
code.toString().toLowerCase().includes(term) ||
|
||||
name.toString().toLowerCase().includes(term) ||
|
||||
index.toString().toLowerCase().includes(term) ||
|
||||
weight.toString().toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
}, [searchTerm, isicData]);
|
||||
|
||||
// Handle CSV export
|
||||
const handleExport = () => {
|
||||
if (filteredData.length === 0) return;
|
||||
|
||||
// Create CSV content
|
||||
const headers = ['ISIC Code', 'Index', 'Weight (%)', 'Weighted Index'];
|
||||
const csvRows = [
|
||||
headers.join(','),
|
||||
...filteredData.map(item =>
|
||||
[
|
||||
`"${item.isic_3digit_code || ''}"`,
|
||||
item.isic_3digit_index || '',
|
||||
item.total_weight || '',
|
||||
item.weighted_index_sum || ''
|
||||
].join(',')
|
||||
)
|
||||
];
|
||||
|
||||
// Create and trigger download
|
||||
const csvContent = csvRows.join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `isic_3digit_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
// Format number with 2 decimal places if needed
|
||||
const formatNumber = (value) => {
|
||||
if (value === null || value === undefined || value === '' || value === '') return '-';
|
||||
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num)) return value;
|
||||
|
||||
return num % 1 === 0 ? num.toString() : num.toFixed(2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="-mt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">ISIC 3-digit breakdown</h3>
|
||||
|
||||
</div>
|
||||
<p className="text-sm mt-2 text-gray-600 mb-4">
|
||||
Detailed breakdown of manufacturing index by ISIC 3-digit classification.
|
||||
</p>
|
||||
|
||||
{/* Data Summary */}
|
||||
{isicData.length === 0 ? (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800">No data available</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700">
|
||||
<p>No ISIC 3-digit data found for this month.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Search and Filter Bar */}
|
||||
<div className="flex flex-col sm:flex-row justify-end items-center gap-4 mb-4">
|
||||
<div className="relative w-full max-w-md">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by code or index..."
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#92722A] focus:border-[#92722A] sm:text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={filteredData.length === 0}
|
||||
className={`inline-flex items-center gap-2 h-9 px-4 rounded-md border text-sm font-medium ${
|
||||
filteredData.length > 0
|
||||
? 'border-[#92722A] text-[#92722A] hover:bg-[#F2ECCF]'
|
||||
: 'border-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="-mx-6">
|
||||
<Table
|
||||
headers={['ISIC Code', 'Index', 'Weight (%)', 'Weighted Index']}
|
||||
rows={filteredData.map((item, index) => [
|
||||
<span className="font-medium" key={`code-${item.isic_3digit_code || index}`}>
|
||||
{item.isic_3digit_code || '-'}
|
||||
</span>,
|
||||
<span
|
||||
key={`index-${item.isic_3digit_code || index}`}
|
||||
className={parseFloat(item.isic_3digit_index) >= 0 ? 'text-green-600' : 'text-red-600'}
|
||||
>
|
||||
{formatNumber(item.isic_3digit_index)}
|
||||
</span>,
|
||||
<span key={`weight-${item.isic_3digit_code || index}`}>
|
||||
{formatNumber(item.total_weight)}%
|
||||
</span>,
|
||||
<span key={`weighted-index-${item.isic_3digit_code || index}`}>
|
||||
{formatNumber(item.weighted_index_sum)}
|
||||
</span>
|
||||
])}
|
||||
className="w-full"
|
||||
columnWidths={['15%', '30%', '25%', '30%']}
|
||||
getCellClass={(rowIndex, colIndex) => {
|
||||
const baseClasses = 'px-6 py-4 whitespace-nowrap text-sm text-gray-900';
|
||||
return colIndex === 0
|
||||
? `${baseClasses} font-medium`
|
||||
: baseClasses;
|
||||
}}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
onPageChange: setCurrentPage,
|
||||
pageSize: pageSize,
|
||||
totalItems: filteredData.length,
|
||||
pageSizeOptions: [10, 25, 50, 100],
|
||||
onPageSizeChange: setPageSize
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Data Stats */}
|
||||
{filteredData.length > 0 && (
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
{/* <span>Showing {filteredData.length} of {isicData.length} sectors</span> */}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Operational Notes */}
|
||||
<div className="mt-6 ml-2 p-4 bg-yellow-50 rounded-md text-sm text-gray-700 w-full -mx-6 px-6">
|
||||
<p className="font-medium mb-2">Operational notes</p>
|
||||
<ul className="text-xs text-gray-600 space-y-1.5 list-disc pl-5">
|
||||
<li>Manufacturing indices are auto-generated on the 11th using the latest validated item-level data for the previous reference quarter.</li>
|
||||
<li>If an automated run fails, the status for that quarter will show as Error; admins can re-trigger the calculation from the technical tools page.</li>
|
||||
<li>All drill-down views (ISIC 2-digit and ISIC 3 & 4-digit) use the same Laspeyres weights and base year as the headline manufacturing index.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Back and Next Buttons */}
|
||||
<div className="mt-8 flex flex-col sm:flex-row justify-between gap-3">
|
||||
<button
|
||||
onClick={() => setActiveTab('isic2')}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md border border-[#92722A] text-[#92722A] bg-transparent hover:bg-[#92722A]/10"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('isic4')}
|
||||
disabled={isicData.length === 0}
|
||||
className={`inline-flex items-center gap-2 h-9 px-5 rounded-md ${
|
||||
isicData.length > 0
|
||||
? 'bg-[#92722A] text-white hover:bg-[#7b5c1f] cursor-pointer'
|
||||
: 'bg-gray-200 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<span>Next</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ISIC3Digit;
|
||||
@ -1,208 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import Table from '@/components/common/Table';
|
||||
|
||||
const ISIC4Digit = ({ setActiveTab, apiData }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
// API
|
||||
const isicData = Array.isArray(apiData) ? apiData : [];
|
||||
|
||||
// Filter data based on search term
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchTerm.trim()) return isicData;
|
||||
|
||||
const term = searchTerm.toLowerCase().trim();
|
||||
return isicData.filter(item => {
|
||||
const code = item.isic_4digit_code || '';
|
||||
const name = item.isic_description || '';
|
||||
const index = item.isic_4digit_index || '';
|
||||
const weight = item.total_weight || '';
|
||||
|
||||
return (
|
||||
code.toString().toLowerCase().includes(term) ||
|
||||
name.toString().toLowerCase().includes(term) ||
|
||||
index.toString().toLowerCase().includes(term) ||
|
||||
weight.toString().toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
}, [searchTerm, isicData]);
|
||||
|
||||
// Handle CSV export
|
||||
const handleExport = () => {
|
||||
if (filteredData.length === 0) return;
|
||||
|
||||
// Create CSV content
|
||||
const headers = ['ISIC Code', 'Index', 'Weight (%)', 'Weighted Index'];
|
||||
const csvRows = [
|
||||
headers.join(','),
|
||||
...filteredData.map(item =>
|
||||
[
|
||||
`"${item.isic_4digit_code || ''}"`,
|
||||
item.isic_4digit_index || '',
|
||||
item.total_weight || '',
|
||||
item.weighted_index_sum || ''
|
||||
].join(',')
|
||||
)
|
||||
];
|
||||
|
||||
// Create and trigger download
|
||||
const csvContent = csvRows.join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `isic_4digit_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
// Format number with 2 decimal places if needed
|
||||
const formatNumber = (value) => {
|
||||
if (value === null || value === undefined || value === '' || value === '-') return '-';
|
||||
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num)) return value;
|
||||
|
||||
return num % 1 === 0 ? num.toString() : num.toFixed(2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="-mt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">ISIC 4-digit breakdown</h3>
|
||||
|
||||
</div>
|
||||
<p className="text-sm mt-2 text-gray-600 mb-4">
|
||||
Detailed breakdown of manufacturing index by ISIC 4-digit classification.
|
||||
</p>
|
||||
|
||||
{/* Data Summary */}
|
||||
{isicData.length === 0 ? (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800">No data available</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700">
|
||||
<p>No ISIC 4-digit data found for this month.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Search and Filter Bar */}
|
||||
<div className="flex flex-col sm:flex-row justify-end items-center gap-4 mb-4">
|
||||
<div className="relative w-full max-w-md">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by code or index..."
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#92722A] focus:border-[#92722A] sm:text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={filteredData.length === 0}
|
||||
className={`inline-flex items-center gap-2 h-9 px-4 rounded-md border text-sm font-medium ${
|
||||
filteredData.length > 0
|
||||
? 'border-[#92722A] text-[#92722A] hover:bg-[#F2ECCF]'
|
||||
: 'border-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="-mx-6">
|
||||
<Table
|
||||
headers={['ISIC Code', 'Index', 'Weight (%)', 'Weighted Index']}
|
||||
rows={filteredData.map((item, index) => [
|
||||
<span className="font-medium" key={`code-${item.isic_4digit_code || index}`}>
|
||||
{item.isic_4digit_code || '-'}
|
||||
</span>,
|
||||
<span
|
||||
key={`index-${item.isic_4digit_code || index}`}
|
||||
className={parseFloat(item.isic_4digit_index) >= 0 ? 'text-green-600' : 'text-red-600'}
|
||||
>
|
||||
{formatNumber(item.isic_4digit_index)}
|
||||
</span>,
|
||||
<span key={`weight-${item.isic_4digit_code || index}`}>
|
||||
{formatNumber(item.total_weight)}%
|
||||
</span>,
|
||||
<span key={`weighted-index-${item.isic_4digit_code || index}`}>
|
||||
{formatNumber(item.weighted_index_sum)}
|
||||
</span>
|
||||
])}
|
||||
className="w-full"
|
||||
columnWidths={['15%', '30%', '25%', '30%']}
|
||||
getCellClass={(rowIndex, colIndex) => {
|
||||
const baseClasses = 'px-6 py-4 whitespace-nowrap text-sm text-gray-900';
|
||||
return colIndex === 0
|
||||
? `${baseClasses} font-medium`
|
||||
: baseClasses;
|
||||
}}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
onPageChange: setCurrentPage,
|
||||
pageSize: pageSize,
|
||||
totalItems: filteredData.length,
|
||||
pageSizeOptions: [10, 25, 50, 100],
|
||||
onPageSizeChange: setPageSize
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Data Stats */}
|
||||
{filteredData.length > 0 && (
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
{/* <span>Showing {filteredData.length} of {isicData.length} sectors</span> */}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Operational Notes */}
|
||||
<div className="mt-6 ml-2 p-4 bg-yellow-50 rounded-md text-sm text-gray-700 w-full -mx-6 px-6">
|
||||
<p className="font-medium mb-2">Operational notes</p>
|
||||
<ul className="text-xs text-gray-600 space-y-1.5 list-disc pl-5">
|
||||
<li>Manufacturing indices are auto-generated on the 11th using the latest validated item-level data for the previous reference quarter.</li>
|
||||
<li>If an automated run fails, the status for that quarter will show as Error; admins can re-trigger the calculation from the technical tools page.</li>
|
||||
<li>All drill-down views (ISIC 2-digit and ISIC 3 & 4-digit) use the same Laspeyres weights and base year as the headline manufacturing index.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Only Back Button for ISIC 4-digit (no Next button) */}
|
||||
<div className="mt-8 flex flex-col sm:flex-row justify-start gap-3">
|
||||
<button
|
||||
onClick={() => setActiveTab('isic3')}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md border border-[#92722A] text-[#92722A] bg-transparent hover:bg-[#92722A]/10"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ISIC4Digit;
|
||||
@ -1,139 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const ManufacturingTotal = ({ selectedMonthData, setActiveTab, monthlyOverviewData }) => {
|
||||
// Use API data if available, otherwise use table data
|
||||
const apiData = monthlyOverviewData?.manufacturing;
|
||||
|
||||
// Determine which data source to use
|
||||
const dataSource = apiData || selectedMonthData;
|
||||
|
||||
if (!dataSource) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-medium-500">
|
||||
Manufacturing Index (Headline)
|
||||
</p>
|
||||
<p className="text-gray-500">No data available for this quarter.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Extract values from the selected data source
|
||||
const getValue = (key) => {
|
||||
if (apiData) {
|
||||
// API data has different structure
|
||||
switch(key) {
|
||||
case 'index': return apiData.index || apiData.manufacturing_index || '-';
|
||||
case 'mom':
|
||||
const momVal = apiData.mom || apiData.mom_change || 0;
|
||||
return momVal > 0 ? `+${momVal}%` : `${momVal}%`;
|
||||
case 'yoy':
|
||||
const yoyVal = apiData.yoy || apiData.yoy_change || 0;
|
||||
return yoyVal > 0 ? `+${yoyVal}%` : `${yoyVal}%`;
|
||||
default: return '-';
|
||||
}
|
||||
} else {
|
||||
// Table data
|
||||
return dataSource[key] || '-';
|
||||
}
|
||||
};
|
||||
|
||||
const indexValue = getValue('index');
|
||||
const momValue = getValue('mom');
|
||||
const yoyValue = getValue('yoy');
|
||||
|
||||
// Parse numeric values for calculations
|
||||
const momNumeric = parseFloat(momValue);
|
||||
const yoyNumeric = parseFloat(yoyValue);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-medium-500">
|
||||
Manufacturing Index (Headline)
|
||||
</p>
|
||||
{/* <div className="bg-gray-100 px-3 py-1 rounded-full"> */}
|
||||
{/* <span className="text-sm font-medium text-gray-700">
|
||||
Quarter: {selectedMonthData?.quarter || '-'}
|
||||
</span> */}
|
||||
{/* </div> */}
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h3 className="text-3xl font-semibold text-gray-900">
|
||||
{indexValue}
|
||||
</h3>
|
||||
<span className="text-sm text-gray-500">(2022 = 100)</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<div className="text-sm text-gray-700">
|
||||
<span className="text-medium-500">QoQ:</span>{' '}
|
||||
<span
|
||||
className={`font-medium ${
|
||||
momNumeric >= 0
|
||||
? 'text-green-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{momValue}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-700">
|
||||
<span className="text-medium-500">YoY:</span>{' '}
|
||||
<span
|
||||
className={`font-medium ${
|
||||
yoyNumeric >= 0
|
||||
? 'text-green-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{yoyValue}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-700 leading-relaxed max-w-3xl">
|
||||
The manufacturing index{' '}
|
||||
{momNumeric >= 0 ? 'increased' : 'decreased'} by{' '}
|
||||
<strong>{Math.abs(momNumeric)}%</strong> compared to
|
||||
the previous quarter and{' '}
|
||||
{yoyNumeric >= 0 ? 'increased' : 'decreased'} by{' '}
|
||||
<strong>{Math.abs(yoyNumeric)}%</strong> compared to
|
||||
the same quarter last year.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 p-4 bg-yellow-50 rounded-md text-sm text-gray-700 max-w-3xl">
|
||||
<p className="font-medium mb-2">Laspeyres reminder</p>
|
||||
<p className="font mb-2">Manufacturing index<sub>t</sub> = Σ (weight<sub>j</sub> × group_index<sub>j,t</sub>)</p>
|
||||
<p className="text-xs text-gray-600 mb-4">
|
||||
Weights are fixed at base year (2022) and reflect each group's share in total manufacturing value added.
|
||||
</p>
|
||||
|
||||
<div className="border-t border-gray-200 pt-3 mt-3">
|
||||
<p className="font-medium mb-2">Operational notes</p>
|
||||
<ul className="text-xs text-gray-600 space-y-1.5 list-disc pl-5">
|
||||
<li>Manufacturing indices are auto-generated on the 11th using the latest validated item-level data for the previous reference quarter.</li>
|
||||
<li>If an automated run fails, the status for that quarter will show as Error; admins can re-trigger the calculation from the technical tools page.</li>
|
||||
<li>All drill-down views (ISIC 2-digit and ISIC 3 & 4-digit) use the same Laspeyres weights and base year as the headline manufacturing index.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-4 border-gray-200 flex justify-end">
|
||||
<button
|
||||
onClick={() => setActiveTab('isic2')}
|
||||
className="inline-flex items-center gap-2 h-9 px-5 rounded-md bg-[#92722A] text-white hover:bg-[#7b5c1f]"
|
||||
>
|
||||
<span>Next</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManufacturingTotal;
|
||||
@ -1,464 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { requestOtp, verifyOtp } from '@/services/auth/authService';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { AlertCircle, Clock, CheckCircle } from "lucide-react";
|
||||
|
||||
const logoSrc = '/assets/images/FCSCLogo.svg';
|
||||
|
||||
const OTPVerification = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [otp, setOtp] = useState(['', '', '', '', '', '']);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [toast, setToast] = useState(null);
|
||||
const [resendDisabled, setResendDisabled] = useState(false); // Resend is enabled by default
|
||||
const [otpExpiryCountdown, setOtpExpiryCountdown] = useState(600); // 10 minutes countdown
|
||||
const [resendCooldown, setResendCooldown] = useState(0); // No initial cooldown
|
||||
const [showResendCooldown, setShowResendCooldown] = useState(false); // Only show cooldown after first click
|
||||
const [attemptsRemaining, setAttemptsRemaining] = useState(3);
|
||||
|
||||
const inputRefs = useRef(Array(6).fill(null).map(() => React.createRef()));
|
||||
const toastTimeoutRef = useRef(null);
|
||||
const countdownRef = useRef(null);
|
||||
|
||||
// Get email from location state or use a default
|
||||
const maskedEmail = (() => {
|
||||
const email = location.state?.email;
|
||||
if (!email) return 'na***@company.com';
|
||||
|
||||
const [local, domain] = email.split('@');
|
||||
|
||||
// local part masking
|
||||
const maskedLocal =
|
||||
local.length > 3
|
||||
? `${local.slice(0, 2)}*****${local.slice(-1)}`
|
||||
: `${local.slice(0, 1)}***`;
|
||||
|
||||
// domain name + extension
|
||||
const domainParts = domain.split('.');
|
||||
const domainName = domainParts[0];
|
||||
const extension = domainParts.slice(1).join('.');
|
||||
|
||||
const maskedDomain =
|
||||
domainName.length > 3
|
||||
? `${domainName.slice(0, 2)}****${domainName.slice(-1)}`
|
||||
: `${domainName.slice(0, 1)}***`;
|
||||
|
||||
return `${maskedLocal}@${maskedDomain}.${extension}`;
|
||||
})();
|
||||
|
||||
|
||||
// Toast handling
|
||||
const closeToast = useCallback(() => {
|
||||
if (toastTimeoutRef.current) {
|
||||
clearTimeout(toastTimeoutRef.current);
|
||||
toastTimeoutRef.current = null;
|
||||
}
|
||||
setToast(null);
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback((type, message) => {
|
||||
if (!message) return;
|
||||
if (toastTimeoutRef.current) {
|
||||
clearTimeout(toastTimeoutRef.current);
|
||||
toastTimeoutRef.current = null;
|
||||
}
|
||||
setToast({ type, message });
|
||||
toastTimeoutRef.current = setTimeout(() => {
|
||||
setToast(null);
|
||||
toastTimeoutRef.current = null;
|
||||
}, 4000);
|
||||
}, []);
|
||||
|
||||
// Handle OTP input change
|
||||
const handleOtpChange = (e, index) => {
|
||||
const value = e.target.value;
|
||||
|
||||
// Only allow numbers and limit to 1 character
|
||||
if (value && !/^\d*$/.test(value)) return;
|
||||
|
||||
const newOtp = [...otp];
|
||||
newOtp[index] = value.slice(-1);
|
||||
setOtp(newOtp);
|
||||
|
||||
// Move to next input if there's a value and we're not on the last input
|
||||
if (value && index < 5 && inputRefs.current[index + 1]?.current) {
|
||||
inputRefs.current[index + 1].current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle backspace
|
||||
const handleKeyDown = (e, index) => {
|
||||
if (e.key === 'Backspace' && !otp[index] && index > 0 && inputRefs.current[index - 1]?.current) {
|
||||
// Move to previous input on backspace
|
||||
inputRefs.current[index - 1].current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle paste
|
||||
const handlePaste = (e) => {
|
||||
e.preventDefault();
|
||||
const pasteData = e.clipboardData.getData('text/plain').trim();
|
||||
if (/^\d{6}$/.test(pasteData)) {
|
||||
const newOtp = pasteData.split('').slice(0, 6);
|
||||
setOtp([...newOtp, ...Array(6 - newOtp.length).fill('')]);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle verify OTP
|
||||
const handleVerify = async (e) => {
|
||||
e.preventDefault();
|
||||
const otpValue = otp.join('');
|
||||
|
||||
if (otpValue.length !== 6) {
|
||||
setError('Please enter a valid 6-digit code');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await verifyOtp({
|
||||
registered_email: location.state?.email,
|
||||
otp: otpValue
|
||||
});
|
||||
|
||||
if (response?.status === 'success') {
|
||||
// Show success message immediately
|
||||
showToast('success', 'Verified! You are signed in. Redirecting to your dashboard.');
|
||||
|
||||
// Get the user role from the most reliable source
|
||||
const userRole = response?.data?.user?.role ||
|
||||
response?.data?.role ||
|
||||
localStorage.getItem('user_role');
|
||||
|
||||
// Set the role in localStorage if not already set
|
||||
if (userRole && !localStorage.getItem('user_role')) {
|
||||
localStorage.setItem('user_role', userRole);
|
||||
}
|
||||
|
||||
// Update user profile in local storage with OTP verification status
|
||||
try {
|
||||
const userProfile = JSON.parse(localStorage.getItem('user_profile') || '{}');
|
||||
localStorage.setItem('user_profile', JSON.stringify({
|
||||
...userProfile,
|
||||
isOtpVerified: true,
|
||||
email: location.state?.email || userProfile.email,
|
||||
role: userRole || userProfile.role
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to update user profile:', error);
|
||||
}
|
||||
|
||||
// Determine the appropriate dashboard based on user role
|
||||
let redirectPath = '/dashboard';
|
||||
|
||||
// Check if user is admin (case-insensitive check)
|
||||
if (userRole && userRole.toLowerCase() === 'admin') {
|
||||
redirectPath = '/admin/dashboard';
|
||||
}
|
||||
|
||||
// If there's a saved path from before login, use that
|
||||
const savedPath = location.state?.from?.pathname;
|
||||
if (savedPath) {
|
||||
redirectPath = savedPath;
|
||||
}
|
||||
|
||||
// Redirect after a short delay
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
navigate(redirectPath, { replace: true });
|
||||
}, 1000);
|
||||
} else {
|
||||
throw new Error(response?.message || 'Invalid OTP');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('OTP verification failed', err);
|
||||
const errorMessage = err.response?.data?.message ||
|
||||
err.message ||
|
||||
'An error occurred during OTP verification. Please try again.';
|
||||
|
||||
const newAttempts = attemptsRemaining - 1;
|
||||
setAttemptsRemaining(newAttempts);
|
||||
|
||||
// Set the error message for incorrect OTP
|
||||
const displayError = errorMessage || 'Invalid verification code';
|
||||
setError(displayError);
|
||||
|
||||
if (newAttempts <= 0) {
|
||||
setOtp(Array(6).fill(''));
|
||||
// Show error message and redirect to login after a short delay
|
||||
setError('Maximum attempts reached. Redirecting to login...');
|
||||
setTimeout(() => {
|
||||
navigate('/login', { replace: true });
|
||||
}, 2000);
|
||||
} else {
|
||||
setOtp(Array(6).fill(''));
|
||||
// Focus the first input if it exists
|
||||
if (inputRefs.current[0]) {
|
||||
inputRefs.current[0].focus();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle OTP expiry countdown
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setOtpExpiryCountdown(prev => {
|
||||
if (prev <= 0) {
|
||||
clearInterval(timer);
|
||||
// Redirect to login when OTP expires
|
||||
navigate('/login', {
|
||||
state: {
|
||||
message: 'OTP has expired. Please try again.',
|
||||
messageType: 'error'
|
||||
},
|
||||
replace: true
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [navigate]);
|
||||
|
||||
// Handle resend cooldown timer
|
||||
useEffect(() => {
|
||||
if (resendCooldown <= 0) {
|
||||
setResendDisabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setResendDisabled(true);
|
||||
const cooldownTimer = setInterval(() => {
|
||||
setResendCooldown(prev => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(cooldownTimer);
|
||||
setResendDisabled(false);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(cooldownTimer);
|
||||
}, [resendCooldown]);
|
||||
|
||||
// Handle resend OTP
|
||||
const handleResend = async () => {
|
||||
if (resendDisabled) return;
|
||||
|
||||
// Show loading state immediately
|
||||
setResendDisabled(true);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Call the API to resend OTP
|
||||
const response = await requestOtp(location.state?.email);
|
||||
|
||||
// Reset timers
|
||||
setOtpExpiryCountdown(600); // Reset to 10 minutes
|
||||
setResendCooldown(60); // Start 60 seconds cooldown
|
||||
setShowResendCooldown(true); // Show the cooldown timer
|
||||
setAttemptsRemaining(3);
|
||||
setError('');
|
||||
setOtp(Array(6).fill(''));
|
||||
|
||||
// Show success message immediately
|
||||
showToast('success', response.message || 'A new OTP has been sent to your email.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error resending OTP:', error);
|
||||
showToast('error', 'An error occurred while sending OTP. Please try again.');
|
||||
setResendDisabled(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (toastTimeoutRef.current) clearTimeout(toastTimeoutRef.current);
|
||||
if (countdownRef.current) clearInterval(countdownRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const expiryMinutes = Math.floor(otpExpiryCountdown / 60);
|
||||
const expirySeconds = otpExpiryCountdown % 60;
|
||||
|
||||
// Format time for display (MM:SS)
|
||||
const formatTime = (time) => time < 10 ? `0${time}` : time;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F7F7F7] flex items-center justify-center px-4">
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<div className="fixed top-6 inset-x-0 z-50 flex justify-center px-4">
|
||||
<div
|
||||
className={`text-sm rounded-lg px-4 py-3 border flex items-center justify-between gap-3 shadow-lg max-w-md ${
|
||||
toast.type === 'success'
|
||||
? 'bg-green-100 border-green-400 text-green-800'
|
||||
: toast.type === 'error'
|
||||
? 'bg-[#FEF2F2] border-[#FECACA] text-[#B91C1C]'
|
||||
: 'bg-blue-100 border-blue-200 text-blue-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{toast.type === 'error' ? (
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||
) : toast.type === 'success' ? (
|
||||
<CheckCircle className="h-5 w-5 flex-shrink-0 text-green-600" />
|
||||
) : null}
|
||||
<span>{toast.message}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded hover:bg-black/5"
|
||||
onClick={closeToast}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg shadow-sm ring-1 ring-gray-200 overflow-hidden">
|
||||
{/* Logo Section */}
|
||||
<div className="px-7 py-6 border-b border-gray-200 bg-[#F2ECCF] text-center">
|
||||
<div className="mb-2">
|
||||
<img src={logoSrc} alt="FCSC Logo" className="mx-auto h-16 w-auto" />
|
||||
</div>
|
||||
<h1 className="text-[17px] font-semibold text-[#1F2937] mt-0">
|
||||
IIP (Index of Industrial Production)
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="p-7">
|
||||
{/* <div className="text-center mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-800">Email OTP</h2>
|
||||
<p className="text-gray-600 mt-1">Step 2 of 2</p>
|
||||
</div> */}
|
||||
|
||||
<form onSubmit={handleVerify}>
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium text-gray-800">Verify your sign-in</h3>
|
||||
<p className="text-gray-600 mt-1">
|
||||
We sent a 6-digit authentication code to <span className="font-medium">{maskedEmail}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* OTP Inputs */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center space-x-3">
|
||||
{otp.map((digit, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(el) => { inputRefs.current[index] = { current: el } }}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={(e) => handleOtpChange(e, index)}
|
||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||
onPaste={handlePaste}
|
||||
className={`w-12 h-12 text-center text-xl bg-transparent border-2 rounded focus:outline-none focus:ring-0 ${
|
||||
error ? 'border-red-500' :
|
||||
digit ? 'border-[#92722A]' :
|
||||
'border-gray-300 hover:border-gray-400 focus:border-[#92722A] focus:border-b-2 focus:border-b-[#92722A]'
|
||||
}`}
|
||||
autoFocus={index === 0}
|
||||
disabled={loading || attemptsRemaining <= 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm flex items-center justify-center">
|
||||
<AlertCircle className="h-4 w-4 mr-1" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center text-sm text-gray-600">
|
||||
<div className="flex items-center space-x-2 leading-none">
|
||||
<Clock className="h-4 w-4 flex-shrink-0 -mt-0.5" />
|
||||
<span className="text-sm -ml-1">
|
||||
Code expires in {formatTime(expiryMinutes)}:{formatTime(expirySeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<span>Attempts used: {attemptsRemaining}/3</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || otp.join('').length !== 6 || attemptsRemaining <= 0}
|
||||
className={`w-full py-3 px-4 rounded-md font-medium bg-[#92722A] text-white ${
|
||||
loading || attemptsRemaining <= 0 || otp.join('').length !== 6
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-[#7a5e23] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#92722A]'
|
||||
}`}
|
||||
>
|
||||
{loading ? 'Verifying...' : 'Verify'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/login')}
|
||||
className="w-full py-3 px-4 rounded-md font-medium border border-[#92722A] bg-white text-[#92722A] hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#92722A]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={resendDisabled}
|
||||
className={`text-sm font-medium ${
|
||||
resendDisabled ? 'text-gray-400 cursor-not-allowed' : 'text-[#92722A] hover:text-[#7a5e23] cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{showResendCooldown && resendCooldown > 0
|
||||
? `Resend code (${formatTime(Math.floor(resendCooldown / 60))}:${formatTime(resendCooldown % 60)})`
|
||||
: 'Resend code'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 text-center">
|
||||
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Having trouble?{' '}
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="text-[#92722A] hover:text-[#7a5e23] font-medium focus:outline-none"
|
||||
>
|
||||
Contact support
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OTPVerification;
|
||||
@ -1,589 +0,0 @@
|
||||
// src/pages/Overview/Overview.jsx
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { getSubmissions, getSubmissionHistoryByEstablishment, fetchSubmissionDetail } from '@/services/submissions/submissionService';
|
||||
import HeaderBar from '@/components/layout/HeaderBar';
|
||||
import Table from '@/components/common/Table';
|
||||
import DetailedOverview from '@/components/overview/DetailedOverview';
|
||||
const searchIconSrc = '/assets/images/material-symbols_search-rounded.svg';
|
||||
const caretDownSrc = '/assets/images/CaretDown.svg';
|
||||
const downloadIconSrc = '/assets/images/DownloadSimple.svg';
|
||||
const pencilActiveSrc = '/assets/images/PencilSimple.svg';
|
||||
const pencilInactiveSrc = '/assets/images/pencilsimple-inactive.svg';
|
||||
import CustomToast from '@/components/common/CustomToast';
|
||||
const trashActiveSrc = '/assets/images/Trash - active.svg';
|
||||
const trashInactiveSrc = '/assets/images/Trash - Inactive.svg';
|
||||
import { deleteDraftSubmission } from '@/services/submissions/submissionService';
|
||||
import { HiMiniArrowPathRoundedSquare } from 'react-icons/hi2';
|
||||
const statusStyles = {
|
||||
approved: 'text-[#2F663C] bg-[#F3FAF4]',
|
||||
submitted: 'text-[#003CFF] bg-[#E7F5FF]',
|
||||
pending: 'text-[#7C5E24] bg-[#F9F7ED]',
|
||||
rejected: 'text-[#B52520] bg-[#FEF2F2]',
|
||||
resubmitted: 'text-[#7C5E24] bg-[#F9F7ED]',
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-GB');
|
||||
};
|
||||
|
||||
const isResubmitDisabled = (record) => {
|
||||
// Only apply this logic for rejected records
|
||||
if (record.approve_reject_status !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rejectedAt = record.rejected_at;
|
||||
if (!rejectedAt) {
|
||||
return false; // If no rejection date, keep enabled
|
||||
}
|
||||
|
||||
const currentDate = new Date();
|
||||
const rejectionDate = new Date(rejectedAt);
|
||||
const blockOnDate = new Date(rejectionDate);
|
||||
blockOnDate.setDate(blockOnDate.getDate() + 14);
|
||||
|
||||
return currentDate >= blockOnDate;
|
||||
};
|
||||
|
||||
const Overview = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedStatus, setSelectedStatus] = useState('');
|
||||
const [selectedSubmission, setSelectedSubmission] = useState(null);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [toast, setToast] = useState({ show: false, message: '', type: 'success' });
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [draftToDelete, setDraftToDelete] = useState(null);
|
||||
const [hoveredDelete, setHoveredDelete] = useState(null);
|
||||
const establishmentId = localStorage.getItem('establishment_id');
|
||||
const [pagination, setPagination] = useState({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
totalItems: 0,
|
||||
pageSizeOptions: [10, 20, 50, 100]
|
||||
});
|
||||
|
||||
// Set selected submission from route state if available
|
||||
useEffect(() => {
|
||||
if (location.state?.selectedSubmission) {
|
||||
setSelectedSubmission(location.state.selectedSubmission);
|
||||
}
|
||||
}, [location.state]);
|
||||
const handleDeleteDraft = async (e, record) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (!record?.id) {
|
||||
throw new Error('No submission ID provided');
|
||||
}
|
||||
setDraftToDelete(record);
|
||||
setShowDeleteConfirm(true);
|
||||
} catch (error) {
|
||||
console.error('Error preparing to delete submission:', error);
|
||||
showToast(error.message || 'Error preparing to delete draft', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteDraft = async () => {
|
||||
if (!draftToDelete?.id) return;
|
||||
|
||||
try {
|
||||
await deleteDraftSubmission(draftToDelete.id);
|
||||
|
||||
// Update the submissions list by removing the deleted item
|
||||
setSubmissions(prev =>
|
||||
prev.filter(item => item.id !== draftToDelete.id)
|
||||
);
|
||||
|
||||
// Show success toast
|
||||
showToast('Draft deleted successfully', 'error');
|
||||
|
||||
// Close the confirmation modal
|
||||
setShowDeleteConfirm(false);
|
||||
setDraftToDelete(null);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting submission:', error);
|
||||
showToast(error.message || 'Failed to delete draft', 'error');
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Add this helper function
|
||||
const showToast = (message, type = 'success') => {
|
||||
setToast({ show: true, message, type });
|
||||
setTimeout(() => {
|
||||
setToast({ ...toast, show: false });
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSubmissions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Use getSubmissionHistoryByEstablishment instead of getSubmissions
|
||||
const response = await getSubmissionHistoryByEstablishment(establishmentId);
|
||||
setSubmissions(response.data || []);
|
||||
} catch (err) {
|
||||
setError('Failed to load submission history');
|
||||
console.error('Error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSubmissions();
|
||||
}, [establishmentId]);
|
||||
|
||||
const filteredRows = React.useMemo(() => {
|
||||
const normalizedTerm = searchTerm.trim().toLowerCase();
|
||||
const filtered = submissions.filter((record) => {
|
||||
const matchesSearch =
|
||||
!normalizedTerm ||
|
||||
String(record.year).toLowerCase().includes(normalizedTerm) ||
|
||||
record.quarter.toLowerCase().includes(normalizedTerm) ||
|
||||
`${record.quarter} ${record.year}`.toLowerCase().includes(normalizedTerm) ||
|
||||
record.status.toLowerCase().includes(normalizedTerm) ||
|
||||
record.establishment?.factory_name?.toLowerCase().includes(normalizedTerm);
|
||||
|
||||
const matchesStatus =
|
||||
!selectedStatus ||
|
||||
(selectedStatus === 'approved' && record.status === 'Approved') ||
|
||||
(selectedStatus === 'submitted' && record.status === 'Submitted') ||
|
||||
(selectedStatus === 'rejected' && record.status === 'Rejected') ||
|
||||
(selectedStatus === 'resubmitted' && record.status === 'Resubmitted');
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
// Update total items in pagination
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
totalItems: filtered.length,
|
||||
currentPage: 1 // Reset to first page when filters change
|
||||
}));
|
||||
|
||||
return filtered;
|
||||
}, [searchTerm, selectedStatus, submissions]);
|
||||
|
||||
const uae_currency = '/assets/images/UAE_Dirham_Symbol.svg';
|
||||
|
||||
const rows = useMemo(() => {
|
||||
return filteredRows.map((record) => [
|
||||
record.year,
|
||||
record.quarter,
|
||||
`${record.quarter} ${record.year}`,
|
||||
// 10, // Always show 10 for Total Products
|
||||
record.product_count || 0, // Show actual submitted products count
|
||||
record.total_cost !== undefined ? (
|
||||
<span className="flex items-center">
|
||||
<img src={uae_currency} alt="AED" className="inline-block w-6 h-6 -mt-0.5 opacity-80 mr-1" />
|
||||
{record.total_cost || 0}
|
||||
</span>
|
||||
) : '-',
|
||||
record.status,
|
||||
formatDate(record.created_at),
|
||||
record.status === 'Draft' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
localStorage.removeItem('autoSaveData');
|
||||
try {
|
||||
const submissionData = await fetchSubmissionDetail(record.id);
|
||||
navigate('/survey', {
|
||||
state: {
|
||||
submission: submissionData,
|
||||
isEdit: true,
|
||||
fromDraft: true
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching submission details:', error);
|
||||
}
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
title="Edit"
|
||||
|
||||
>
|
||||
<img
|
||||
src={pencilActiveSrc}
|
||||
alt="Edit"
|
||||
className="h-5 w-5 opacity-60 hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleDeleteDraft(e, record)}
|
||||
className="focus:outline-none group"
|
||||
title="Delete"
|
||||
onMouseEnter={() => setHoveredDelete(record.id)}
|
||||
onMouseLeave={() => setHoveredDelete(null)}
|
||||
>
|
||||
<img
|
||||
src={hoveredDelete === record.id ? trashInactiveSrc : trashActiveSrc}
|
||||
alt="Delete"
|
||||
className="h-5 w-5 transition-colors"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) :
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedSubmission(record);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<img
|
||||
src="/assets/images/Eye.svg"
|
||||
alt="View"
|
||||
// className="w-5 h-5 opacity-60 hover:opacity-100 transition-opacity"
|
||||
className="w-5 h-5 text-[#92722A] opacity-60 hover:opacity-100 transition-opacity"
|
||||
style={{ filter: 'invert(36%) sepia(49%) saturate(680%) hue-rotate(1deg) brightness(89%) contrast(91%)' }}
|
||||
/>
|
||||
</button>
|
||||
{record.status === 'Rejected' && (
|
||||
<HiMiniArrowPathRoundedSquare
|
||||
className={`w-5 h-5 transition-opacity ${
|
||||
isResubmitDisabled(record)
|
||||
? 'text-[#A88632] opacity-30 cursor-not-allowed'
|
||||
: 'text-[#92722A] opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
title={isResubmitDisabled(record) ? 'Resubmission deadline passed' : 'Resubmit'}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!isResubmitDisabled(record)) {
|
||||
try {
|
||||
const response = await fetchSubmissionDetail(record.id);
|
||||
const submissionData = response.data;
|
||||
|
||||
// Structure the data in the format expected by the component
|
||||
const formattedData = {
|
||||
...submissionData,
|
||||
establishment: {
|
||||
...submissionData.establishment,
|
||||
establishment_contact_email: submissionData.establishment.establishment_contact_email,
|
||||
emirate: submissionData.establishment.establishment_emirate_id,
|
||||
establishment_emirate_id: submissionData.establishment.establishment_emirate_id
|
||||
},
|
||||
employeeInfo: {
|
||||
emiratiMale: submissionData.establishment.emirati_male,
|
||||
emiratiFemale: submissionData.establishment.emirati_female,
|
||||
nonEmiratiMale: submissionData.establishment.non_emirati_male,
|
||||
nonEmiratiFemale: submissionData.establishment.non_emirati_female,
|
||||
totalEmirati: submissionData.establishment.total_emirati,
|
||||
totalEmployees: submissionData.establishment.total_employees
|
||||
},
|
||||
products: submissionData.products || [],
|
||||
quarterWindow: submissionData.quarter_window || {}
|
||||
};
|
||||
|
||||
// Save the complete data to localStorage
|
||||
localStorage.setItem('resubmissionData', JSON.stringify(formattedData));
|
||||
|
||||
// Save products separately
|
||||
if (submissionData.products && submissionData.products.length > 0) {
|
||||
localStorage.setItem('submissionProducts', JSON.stringify(submissionData.products));
|
||||
}
|
||||
|
||||
// Set resubmission flags
|
||||
localStorage.setItem('isResubmission', 'true');
|
||||
|
||||
// Navigate to survey with resubmission state
|
||||
navigate('/survey', {
|
||||
state: {
|
||||
submission: formattedData,
|
||||
isResubmit: true,
|
||||
fromResubmit: true
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error preparing resubmission:', error);
|
||||
// Optionally show error to user
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
]);
|
||||
}, [filteredRows, uae_currency]);
|
||||
|
||||
const handlePageChange = (newPage) => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
currentPage: newPage
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (newSize) => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
pageSize: newSize,
|
||||
currentPage: 1
|
||||
}));
|
||||
};
|
||||
|
||||
const downloadCsv = React.useCallback(() => {
|
||||
if (!filteredRows.length) return;
|
||||
|
||||
const headers = ['Year', 'Quarter', 'Submission Window', 'Total Products', 'Products Submitted', 'Total Value (AED)', 'Status', 'Date Submitted', 'Action'];
|
||||
const csvRows = [headers.join(',')];
|
||||
|
||||
filteredRows.forEach((record) => {
|
||||
csvRows.push([
|
||||
record.year,
|
||||
record.quarter,
|
||||
`${record.quarter} ${record.year}`,
|
||||
10,
|
||||
record.product_count || 0,
|
||||
record.total_cost !== undefined ? record.total_cost : '-',
|
||||
formatDate(record.created_at)
|
||||
].map((value) => `"${String(value).replace(/"/g, '""')}"`).join(','));
|
||||
});
|
||||
|
||||
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', 'quarter-overview.csv');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [filteredRows]);
|
||||
|
||||
const toolbar = (
|
||||
<div className="px-6 pt-6 pb-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-[18px] font-medium text-[#232528]">Quarter Overview ({filteredRows.length})</h2>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-3">
|
||||
<div className="relative w-full md:w-72">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder="Search submissions..."
|
||||
className="w-full h-10 rounded-md border border-[#E2E8F0] pl-10 pr-3 text-sm text-[#232528] focus:outline-none"
|
||||
/>
|
||||
<img
|
||||
src={searchIconSrc}
|
||||
alt="Search"
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative w-full md:w-40">
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(event) => setSelectedStatus(event.target.value)}
|
||||
className="h-10 w-full appearance-none rounded-md border border-[#E2E8F0] bg-white pl-3 pr-9 text-sm text-[#232528] focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="submitted">Submitted</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="resubmitted">Resubmitted</option>
|
||||
</select>
|
||||
<img
|
||||
src={caretDownSrc}
|
||||
alt="Open"
|
||||
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadCsv}
|
||||
disabled={!filteredRows.length}
|
||||
className={`inline-flex items-center gap-2 h-10 rounded-md border px-4 text-sm font-medium ${
|
||||
filteredRows.length
|
||||
? 'border-[#92722A] text-[#92722A] hover:bg-[#F2ECCF]'
|
||||
: 'border-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<img src={downloadIconSrc} alt="Export" className="h-4 w-4" />
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleViewDetails = (rowIndex) => {
|
||||
setSelectedSubmission(filteredRows[rowIndex]);
|
||||
};
|
||||
|
||||
// If we've navigated here with a selected submission, update the state
|
||||
useEffect(() => {
|
||||
if (location.state?.selectedSubmission) {
|
||||
setSelectedSubmission(location.state.selectedSubmission);
|
||||
// Clear the location state to prevent re-triggering on re-renders
|
||||
window.history.replaceState({}, document.title);
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
// If we have a selected submission, show the detailed view
|
||||
if (selectedSubmission) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<HeaderBar />
|
||||
<main className="max-w-[1280px] mx-auto px-4 py-6">
|
||||
<DetailedOverview submission={selectedSubmission} onBack={() => setSelectedSubmission(null)} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<HeaderBar />
|
||||
<main className="max-w-[1280px] mx-auto px-4 py-6">
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-[#92722A]"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<HeaderBar />
|
||||
<main className="max-w-[1280px] mx-auto px-4 py-6">
|
||||
<div className="bg-red-50 border-l-4 border-red-400 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedSubmission) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<HeaderBar />
|
||||
<main className="max-w-[1280px] mx-auto px-4 py-6">
|
||||
<DetailedOverview
|
||||
submission={selectedSubmission}
|
||||
onBack={() => setSelectedSubmission(null)}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<HeaderBar />
|
||||
<main className="max-w-[1280px] mx-auto px-4 py-6 overflow-x-auto">
|
||||
<div className="flex items-center justify-between text-sm text-[#6B7280] mb-4">
|
||||
<h1 className="text-xl font-semibold text-[#232528]">Overview</h1>
|
||||
</div>
|
||||
<section className="bg-white rounded-lg border border-[#E2E8F0] shadow-sm">
|
||||
<Table
|
||||
headers={['Year', 'Quarter', 'Submission Window', 'Products Submitted', 'Total Value (AED)', 'Status', 'Date Submitted', 'Action']}
|
||||
rows={rows}
|
||||
beforeHeader={toolbar}
|
||||
separated
|
||||
rowGapClass="border-spacing-y-2"
|
||||
className="w-full min-w-[1200px] [&_td]:whitespace-nowrap [&_th]:whitespace-nowrap"
|
||||
renderCell={(value, rowIndex, columnIndex) => {
|
||||
if (columnIndex === 6) { // Status column
|
||||
const normalized = String(value).toLowerCase();
|
||||
const style = statusStyles[normalized] || 'text-[#232528] bg-[#F8FAFC]';
|
||||
return (
|
||||
<span className={`inline-flex items-center justify-center rounded-md px-3 py-1 text-xs font-medium ${style}`}>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (columnIndex === 7) { // Action column
|
||||
return (
|
||||
<button
|
||||
className="text-[#92722A] hover:underline text-sm font-medium focus:outline-none"
|
||||
onClick={() => handleViewDetails(rowIndex)}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}}
|
||||
pagination={{
|
||||
currentPage: pagination.currentPage,
|
||||
onPageChange: handlePageChange,
|
||||
pageSize: pagination.pageSize,
|
||||
totalItems: pagination.totalItems,
|
||||
pageSizeOptions: pagination.pageSizeOptions,
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="bg-white w-full max-w-md rounded-lg shadow-2xl border border-gray-200">
|
||||
<div className="p-6 text-center flex flex-col gap-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900">Confirm Deletion</h3>
|
||||
<p className="text-gray-600">Are you sure you want to delete this draft?</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
confirmDeleteDraft();
|
||||
}}
|
||||
className="px-4 py-2 bg-[#92722A] text-white rounded-md hover:bg-[#A88632] focus:outline-none"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowDeleteConfirm(false);
|
||||
setDraftToDelete(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-[#92722A] text-[#92722A] font-medium rounded-md hover:bg-[#F5F0E1] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Toast Notification */}
|
||||
{toast.show && (
|
||||
<div className="fixed top-4 right-4 z-[10000]">
|
||||
<CustomToast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ ...toast, show: false })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default Overview;
|
||||
@ -1,240 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import HeaderBar from '@/components/layout/HeaderBar';
|
||||
import Table from '@/components/common/Table';
|
||||
import DetailedOverview from '@/components/overview/DetailedOverview';
|
||||
|
||||
const searchIconSrc = '/assets/images/material-symbols_search-rounded.svg';
|
||||
const caretDownSrc = '/assets/images/CaretDown.svg';
|
||||
const downloadIconSrc = '/assets/images/DownloadSimple.svg';
|
||||
|
||||
const statusStyles = {
|
||||
approved: 'text-[#2F663C] bg-[#F3FAF4] ',
|
||||
submitted: 'text-[#003CFF] bg-[#E7F5FF]',
|
||||
pending: 'text-[#B45309] bg-[#FEF2F2]',
|
||||
rejected: 'text-[#B52520] bg-[#FEF2F2]',
|
||||
};
|
||||
|
||||
const mockOverview = [
|
||||
{
|
||||
year: '2025',
|
||||
quarter: 'Q4',
|
||||
window: 'Oct-Dec 2025',
|
||||
totalProducts: 10,
|
||||
submittedProducts: 4,
|
||||
totalCost: 195000,
|
||||
status: 'Approved',
|
||||
submittedOn: '18/10/2025',
|
||||
},
|
||||
{
|
||||
year: '2025',
|
||||
quarter: 'Q3',
|
||||
window: 'Jul-Sep 2025',
|
||||
totalProducts: 10,
|
||||
submittedProducts: 6,
|
||||
totalCost: 295000,
|
||||
status: 'Submitted',
|
||||
submittedOn: '28/08/2025',
|
||||
},
|
||||
{
|
||||
year: '2025',
|
||||
quarter: 'Q2',
|
||||
window: 'Apr-Jun 2025',
|
||||
totalProducts: 10,
|
||||
submittedProducts: 6,
|
||||
totalCost: 395000,
|
||||
status: 'Rejected',
|
||||
submittedOn: '24/05/2025',
|
||||
},
|
||||
{
|
||||
year: '2025',
|
||||
quarter: 'Q1',
|
||||
window: 'Jan-Mar 2025',
|
||||
totalProducts: 10,
|
||||
submittedProducts: 6,
|
||||
totalCost: 495000,
|
||||
status: 'Rejected',
|
||||
submittedOn: '21/03/2025',
|
||||
},
|
||||
];
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
if (amount === null || amount === undefined) return '—';
|
||||
return new Intl.NumberFormat('en-AE').format(amount);
|
||||
};
|
||||
|
||||
const Overview = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedYear, setSelectedYear] = useState('');
|
||||
const [selectedQuarter, setSelectedQuarter] = useState('');
|
||||
const [selectedStatus, setSelectedStatus] = useState('');
|
||||
const [selectedSubmission, setSelectedSubmission] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const filteredRows = React.useMemo(() => {
|
||||
const normalizedTerm = searchTerm.trim().toLowerCase();
|
||||
return mockOverview.filter((record) => {
|
||||
const matchesSearch =
|
||||
!normalizedTerm ||
|
||||
record.year.toLowerCase().includes(normalizedTerm) ||
|
||||
record.quarter.toLowerCase().includes(normalizedTerm) ||
|
||||
record.window.toLowerCase().includes(normalizedTerm) ||
|
||||
record.status.toLowerCase().includes(normalizedTerm);
|
||||
const matchesStatus =
|
||||
selectedStatus === '' || record.status.toLowerCase() === selectedStatus;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [searchTerm, selectedStatus]);
|
||||
|
||||
const rows = filteredRows.map((record) => [
|
||||
record.year,
|
||||
record.quarter,
|
||||
record.window,
|
||||
record.totalProducts,
|
||||
record.submittedProducts,
|
||||
`AED ${formatCurrency(record.totalCost)}`,
|
||||
record.status,
|
||||
record.submittedOn,
|
||||
'View Details',
|
||||
]);
|
||||
|
||||
const downloadCsv = React.useCallback(() => {
|
||||
if (!filteredRows.length) return;
|
||||
const headers = ['Year', 'Quarter', 'Submission Window', 'Total Products', 'Products Submitted', 'Total Value (AED)', 'Status', 'Date Submitted'];
|
||||
const csvRows = [headers.join(',')];
|
||||
filteredRows.forEach((record) => {
|
||||
csvRows.push([
|
||||
record.year,
|
||||
record.quarter,
|
||||
record.window,
|
||||
record.totalProducts,
|
||||
record.submittedProducts,
|
||||
record.totalCost,
|
||||
record.status,
|
||||
record.submittedOn,
|
||||
].map((value) => `"${String(value).replace(/"/g, '""')}"`).join(','));
|
||||
});
|
||||
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', 'quarter-overview.csv');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [filteredRows]);
|
||||
|
||||
const toolbar = (
|
||||
<div className="px-6 pt-6 pb-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-[18px] font-medium text-[#232528]">Quarter Overview</h2>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-3">
|
||||
<div className="relative w-full md:w-72">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder="Search establishment"
|
||||
className="w-full h-10 rounded-md border border-[#E2E8F0] pl-10 pr-3 text-sm text-[#232528] focus:outline-none"
|
||||
/>
|
||||
<img
|
||||
src={searchIconSrc}
|
||||
alt="Search"
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative w-full md:w-40">
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(event) => setSelectedStatus(event.target.value)}
|
||||
className="h-10 w-full appearance-none rounded-md border border-[#E2E8F0] bg-white pl-3 pr-9 text-sm text-[#232528] focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="submitted">Submitted</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
<img
|
||||
src={caretDownSrc}
|
||||
alt="Open"
|
||||
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadCsv}
|
||||
disabled={!filteredRows.length}
|
||||
className={`inline-flex items-center gap-2 h-10 rounded-md border px-4 text-sm font-medium ${filteredRows.length ? 'border-[#92722A] text-[#92722A] hover:bg-[#F2ECCF]' : 'border-gray-200 text-gray-400 cursor-not-allowed'}`}
|
||||
>
|
||||
<img src={downloadIconSrc} alt="Export" className="h-4 w-4" />
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleViewDetails = (rowIndex) => {
|
||||
setSelectedSubmission(filteredRows[rowIndex]);
|
||||
};
|
||||
|
||||
if (selectedSubmission) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<HeaderBar />
|
||||
<main className="max-w-[1280px] mx-auto px-4 py-6">
|
||||
<DetailedOverview
|
||||
submission={selectedSubmission}
|
||||
onBack={() => setSelectedSubmission(null)}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<HeaderBar />
|
||||
<main className="max-w-[1280px] mx-auto px-4 py-6 overflow-x-auto">
|
||||
<div className="flex items-center justify-between text-sm text-[#6B7280] mb-4">
|
||||
<h1 className="text-xl font-semibold text-[#232528]">Overview</h1>
|
||||
</div>
|
||||
<section className="bg-white rounded-lg border border-[#E2E8F0] shadow-sm">
|
||||
<Table
|
||||
headers={['Year', 'Quarter', 'Submission Window', 'Total Products', 'Products Submitted', 'Total Value (AED)', 'Status', 'Date Submitted', 'Action']}
|
||||
rows={rows}
|
||||
beforeHeader={toolbar}
|
||||
separated
|
||||
rowGapClass="border-spacing-y-2"
|
||||
className="w-full min-w-[1200px] [&_td]:whitespace-nowrap [&_th]:whitespace-nowrap"
|
||||
renderCell={(value, rowIndex, columnIndex) => {
|
||||
if (columnIndex === 6) {
|
||||
const normalized = String(value).toLowerCase();
|
||||
const style = statusStyles[normalized] || 'text-[#232528] bg-[#F8FAFC] ';
|
||||
return (
|
||||
<span className={`inline-flex items-center justify-center rounded-md px-3 py-1 text-xs font-medium ${style}`}>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (columnIndex === 8) {
|
||||
return (
|
||||
<button
|
||||
className="text-[#92722A] hover:underline text-sm font-medium"
|
||||
onClick={() => handleViewDetails(rowIndex)}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <span className="inline-block">{value}</span>;
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Overview;
|
||||