Compare commits

..

No commits in common. "master" and "admin_dashboard" have entirely different histories.

119 changed files with 4633 additions and 29858 deletions

View File

@ -1,2 +0,0 @@
VITE_API_BASE_URL=https://ipi.venbait.in/api
VITE_API_APP_SIGNATURE=fcsc.ipi.v3f4b8fcb261i73b6c5198f4e7ajd22c8d13e4a6f0e4c8d82b7c6yf8a9b17.2025

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

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

View File

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 18 KiB

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 803 B

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

View File

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

View File

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

View File

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

View File

@ -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"
>
&times;
</button>
</div>
);
};
export default CustomToast;

View File

@ -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>FAQs</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">FAQs</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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: '1520 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: '1520 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: '1520 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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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" : ""}>

View File

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

View File

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

View File

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

View File

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

View File

@ -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: "JanMar" },
{ q: "Q2", period: "AprJun" },
{ q: "Q3", period: "JulSep" },
{ q: "Q4", period: "OctDec" },
].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;

View File

@ -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">
Were 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: MondayFriday, 8:0016: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;

View File

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

View File

@ -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 UAEs 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 >FAQs</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;

View File

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

View File

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

View File

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

View File

@ -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 establishments data is confidential and used only for official
statistics. Published results are aggregated; no individual
establishments 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;

View File

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

View File

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

View File

@ -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 (JanMar), Q2 (AprJun), Q3 (JulSep),
Q4 (OctDec).
</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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More