Merged in admin_dashboard (pull request #1)
Admin & Establishment dashboard, Manage Submission & its view
This commit is contained in:
commit
02da3fedb0
BIN
ipi-survey-platform/public/assets/images/TawasolLogo.png
Normal file
BIN
ipi-survey-platform/public/assets/images/TawasolLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
@ -78,7 +78,7 @@ const AdminHeader = () => {
|
||||
<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="Manage Submissions" iconActive={checkCircleActiveSrc} iconInactive={manageSubmissionInactiveSrc} />
|
||||
<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>
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
const StatCard = ({ icon = null, label, value, width = 200.4 }) => {
|
||||
const StatCard = ({ icon = null, label, value, tooltipText, width = 200.4 }) => {
|
||||
const resolvedWidth = typeof width === 'number' ? `${width}px` : width;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="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)]"
|
||||
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">
|
||||
<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-[15px] font-medium text-[#5F646D] leading-[22px] text-center">
|
||||
<span className="text-[15px] font-medium text-[#5F646D] leading-[22px] text-center truncate">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
@ -18,6 +18,13 @@ const StatCard = ({ icon = null, label, value, width = 200.4 }) => {
|
||||
<span className="mt-2 text-[24px] font-semibold text-[#232528] leading-[30px] text-center">
|
||||
{value}
|
||||
</span>
|
||||
|
||||
{tooltipText && (
|
||||
<div className="absolute bottom-[-58px] left-1/2 -translate-x-1/2 z-20 hidden w-[220px] text-center rounded-md bg-black text-white text-xs px-3 py-2 group-hover:block">
|
||||
{tooltipText}
|
||||
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-black rotate-45"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,125 +1,68 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import Table from '@/components/common/Table';
|
||||
import apiClient from '../../services/api/apiClient.js';
|
||||
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);
|
||||
useEffect(() => {
|
||||
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 searchIconSrc = '/assets/images/material-symbols_search-rounded.svg';
|
||||
const checkCircleSrc = '/assets/images/check-circle-fill 1.svg';
|
||||
const xCircleSrc = '/assets/images/warning-circle-fill 1.svg';
|
||||
const eyeIconSrc = '/assets/images/User-active.svg';
|
||||
|
||||
const submissions = [
|
||||
{
|
||||
establishment: 'CASCADE MARINE FOODS LLC',
|
||||
emirate: 'Sharjah',
|
||||
productCount: '5',
|
||||
year: '2025',
|
||||
quarter: 'Q4',
|
||||
lastSubmission: '10/12/2024',
|
||||
deadline: '20/12/2024',
|
||||
},
|
||||
{
|
||||
establishment: 'Khazan meat factory',
|
||||
emirate: 'Sharjah',
|
||||
productCount: '8',
|
||||
year: '2025',
|
||||
quarter: 'Q3',
|
||||
lastSubmission: '15/11/2024',
|
||||
deadline: '15/12/2024',
|
||||
},
|
||||
{
|
||||
establishment: 'SPINNEYS FRESH FOOD INDUSTRIES L.L.C',
|
||||
emirate: 'Dubai',
|
||||
productCount: '6',
|
||||
year: '2025',
|
||||
quarter: 'Q2',
|
||||
lastSubmission: '12/12/2024',
|
||||
deadline: '10/12/2024',
|
||||
},
|
||||
{
|
||||
establishment: 'FRESHLY FROZEN FOODS FACTORY L.L.C',
|
||||
emirate: 'Dubai',
|
||||
productCount: '8',
|
||||
year: '2025',
|
||||
quarter: 'Q1',
|
||||
lastSubmission: '08/12/2024',
|
||||
deadline: '18/12/2024',
|
||||
},
|
||||
{
|
||||
establishment: 'SAHAR FOOD INDUSTRY (L.L.C)',
|
||||
emirate: 'Dubai',
|
||||
productCount: '8',
|
||||
year: '2025',
|
||||
quarter: 'Q4',
|
||||
lastSubmission: '08/12/2024',
|
||||
deadline: '18/12/2024',
|
||||
},
|
||||
{
|
||||
establishment: 'CONTINENTAL FOOD PROCESSING LLC',
|
||||
emirate: 'Ajman',
|
||||
productCount: '8',
|
||||
year: '2025',
|
||||
quarter: 'Q3',
|
||||
lastSubmission: '08/12/2024',
|
||||
deadline: '18/12/2024',
|
||||
},
|
||||
{
|
||||
establishment: 'AL KHAZNA POULTRY FARM',
|
||||
emirate: 'Abu Dhabi',
|
||||
productCount: '8',
|
||||
year: '2025',
|
||||
quarter: 'Q2',
|
||||
lastSubmission: '08/12/2024',
|
||||
deadline: '18/12/2024',
|
||||
},
|
||||
{
|
||||
establishment: 'DIAMOND MEAT PROCESSING L.L.C',
|
||||
emirate: 'Abu Dhabi',
|
||||
productCount: '8',
|
||||
year: '2025',
|
||||
quarter: 'Q2',
|
||||
lastSubmission: '08/12/2024',
|
||||
deadline: '18/12/2024',
|
||||
},
|
||||
{
|
||||
establishment: 'ALLIANCE FOODS CO L.L.C',
|
||||
emirate: 'Abu Dhabi',
|
||||
productCount: '8',
|
||||
year: '2025',
|
||||
quarter: 'Q2',
|
||||
lastSubmission: '08/12/2024',
|
||||
deadline: '18/12/2024',
|
||||
},
|
||||
{
|
||||
establishment: 'KRUSTASIA FOODS L.L.C',
|
||||
emirate: 'Dubai',
|
||||
productCount: '4',
|
||||
year: '2025',
|
||||
quarter: 'Q1',
|
||||
lastSubmission: '14/12/2024',
|
||||
deadline: '21/12/2024',
|
||||
},
|
||||
];
|
||||
|
||||
const SubmissionTable = () => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedYear, setSelectedYear] = useState('2025');
|
||||
const [selectedQuarter, setSelectedQuarter] = useState('Q4');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Filter submissions by selected year and quarter
|
||||
const filteredByQuarterYear = useMemo(() => {
|
||||
return submissions.filter(
|
||||
(item) => item.year === selectedYear && item.quarter === selectedQuarter
|
||||
);
|
||||
}, [selectedYear, selectedQuarter]);
|
||||
|
||||
// Apply search filter to the filteredByQuarterYear results
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return filteredByQuarterYear;
|
||||
const normalized = search.trim().toLowerCase();
|
||||
return filteredByQuarterYear.filter((item) =>
|
||||
Object.values(item).join(' ').toLowerCase().includes(normalized)
|
||||
);
|
||||
}, [search, filteredByQuarterYear]);
|
||||
let list = [...data];
|
||||
if (selectedQuarter !== 'None') {
|
||||
list = list.filter((item) => item.quarter === selectedQuarter);
|
||||
}
|
||||
if (selectedYear !== 'None') {
|
||||
list = list.filter((item) => String(item.year) === String(selectedYear));
|
||||
}
|
||||
return list;
|
||||
}, [data, selectedQuarter, selectedYear]);
|
||||
|
||||
const titleText = useMemo(() => {
|
||||
const count = filtered.length;
|
||||
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 10 Submissions (${count})`;
|
||||
}
|
||||
}, [selectedQuarter, selectedYear, filtered]);
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
const formatted = date.toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
return formatted.replace(/\s?(am|pm)$/i, (match) => match.toUpperCase());
|
||||
};
|
||||
|
||||
const headers = [
|
||||
'Establishment',
|
||||
@ -139,25 +82,25 @@ const SubmissionTable = () => {
|
||||
const normalized = status?.toLowerCase();
|
||||
if (normalized === 'approved') {
|
||||
return (
|
||||
<span className="px-3 py-1 rounded-md text-green-700 bg-green-50 font-medium text-sm">
|
||||
<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 === 'rejected') {
|
||||
return (
|
||||
<span className="px-3 py-1 rounded-md text-red-600 bg-red-50 font-medium text-sm">
|
||||
<span className="px-3 py-1 rounded-md text-red-600 bg-red-50 font-medium text-sm whitespace-nowrap">
|
||||
Rejected
|
||||
</span>
|
||||
);
|
||||
} else if (normalized === 'pending') {
|
||||
return (
|
||||
<span className="px-3 py-1 rounded-md text-blue-600 bg-blue-50 font-medium text-sm">
|
||||
<span className="px-3 py-1 rounded-md text-blue-600 bg-blue-50 font-medium text-sm whitespace-nowrap">
|
||||
Pending
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="px-3 py-1 rounded-md text-gray-600 bg-gray-50 font-medium text-sm">
|
||||
<span className="px-3 py-1 rounded-md text-gray-600 bg-gray-50 font-medium text-sm whitespace-nowrap">
|
||||
{status || '-'}
|
||||
</span>
|
||||
);
|
||||
@ -165,69 +108,59 @@ const SubmissionTable = () => {
|
||||
};
|
||||
|
||||
const tableRows = filtered.map((item) => [
|
||||
item.establishment,
|
||||
item.emirate,
|
||||
item.year,
|
||||
item.quarter,
|
||||
item.productCount,
|
||||
item.submittedBy,
|
||||
item.submissionDateTime,
|
||||
renderStatusBadge(item.status),
|
||||
item.reviewer,
|
||||
item.reviewedOn,
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<img src={checkCircleSrc} alt="Approve" className="text-green-600 w-5 h-5 cursor-pointer hover:scale-110 transition-transform" />
|
||||
<img src={xCircleSrc} alt="Reject" className="text-red-500 w-5 h-5 cursor-pointer hover:scale-110 transition-transform" />
|
||||
<img src={eyeIconSrc} alt="View" className="text-gray-500 w-5 h-5 cursor-pointer hover:scale-110 transition-transform" />
|
||||
</div>,
|
||||
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 columnWidths = [240, 120, 100, 100, 130, 150, 180, 150, 150, 180, 120];
|
||||
|
||||
const titleText = useMemo(() => {
|
||||
const count = filtered.length;
|
||||
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 10 Submissions (${count})`;
|
||||
}
|
||||
}, [selectedQuarter, selectedYear, filtered]);
|
||||
|
||||
const toolbar = (
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<div className="mx-auto flex h-10 w-full max-w-[1280px] items-center justify-between gap-4">
|
||||
<h2 className="text-[18px] leading-[28px] font-medium text-[#232528]">
|
||||
{titleText}
|
||||
</h2>
|
||||
<div className="relative w-[280px]">
|
||||
<img
|
||||
src={searchIconSrc}
|
||||
alt="Search"
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search Establishment or Status"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="border border-[#E5E7EB] bg-white shadow-[0_16px_32px_rgba(15,23,42,0.06)] rounded-[8px]"
|
||||
style={{ width: '1250px', height: '706px' }}
|
||||
style={{ width: '1250px', minHeight: '780px' }}
|
||||
>
|
||||
<div className="space-y-[1px] overflow-x-auto">
|
||||
{toolbar}
|
||||
<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...
|
||||
@ -245,16 +178,18 @@ const SubmissionTable = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end px-6 pb-6">
|
||||
<div className="flex justify-end px-6 pb-14 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-[44px] w-[168px] 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]"
|
||||
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>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubmissionTable
|
||||
export default SubmissionTable;
|
||||
71
ipi-survey-platform/src/components/common/Footer.jsx
Normal file
71
ipi-survey-platform/src/components/common/Footer.jsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { Phone, Smartphone, Headphones, Instagram, Facebook, Linkedin, Youtube } from 'lucide-react';
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer className="bg-white border-t border-gray-200 mt-10">
|
||||
<div className="h-[3px] bg-[#92722A]" />
|
||||
|
||||
<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-2">The Ministry</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li><a href="#" className="hover:underline">About us</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<h3 className="text-[#92722A] font-semibold mb-2">Information and support</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li><a href="#" className="hover:underline">Contact us</a></li>
|
||||
<li><a href="#" className="hover:underline">FAQ’s</a></li>
|
||||
<li><a href="#" className="hover:underline">Feedback and complaints</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<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>
|
||||
|
||||
<div className="border-t border-gray-200 my-2" />
|
||||
|
||||
<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>
|
||||
© 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;
|
||||
@ -1,53 +1,50 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Table from '@/components/common/Table';
|
||||
import Footer from '../common/Footer';
|
||||
|
||||
const searchIconSrc = '/assets/images/material-symbols_search-rounded.svg';
|
||||
const caretDownSrc = '/assets/images/CaretDown.svg';
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
const formatted = date.toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
return formatted.replace(/\s?(am|pm)$/i, (match) => match.toUpperCase());
|
||||
};
|
||||
|
||||
const getStatusClass = (status) => {
|
||||
const normalized = String(status || '').toLowerCase();
|
||||
if (normalized === 'approved') {
|
||||
return 'inline-flex items-center rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-800';
|
||||
}
|
||||
if (normalized === 'pending' || normalized === 'under review' || normalized === 'in-progress') {
|
||||
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 (normalized === 'rejected' || normalized === 'returned') {
|
||||
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 formatQuarter = (quarter, year) => {
|
||||
if (!quarter && !year) return '—';
|
||||
return [quarter || '', year || ''].filter(Boolean).join(' ');
|
||||
};
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return date.toLocaleDateString('en-GB');
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'All Status', value: 'all' },
|
||||
{ label: 'Approved', value: 'approved' },
|
||||
{ label: 'Under Review', value: 'under review' },
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
{ label: 'Returned', value: 'returned' },
|
||||
{ label: 'Rejected', value: 'rejected' },
|
||||
];
|
||||
|
||||
const normalizeStatus = (status) => String(status || '').toLowerCase();
|
||||
|
||||
const downloadCsv = (rows) => {
|
||||
if (!rows.length) return;
|
||||
const headers = ['Quarter', 'Status', 'Submission Date', 'Products'];
|
||||
const headers = ['Survey Name', 'Year', 'Quarter', 'Status', 'Submission On', 'Products'];
|
||||
const csvRows = [headers.join(',')];
|
||||
rows.forEach((row) => {
|
||||
csvRows.push(row.slice(0, 4).map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','));
|
||||
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);
|
||||
@ -62,57 +59,72 @@ const downloadCsv = (rows) => {
|
||||
|
||||
export const SubmissionHistory = ({ history = [], loading = false, error = '' }) => {
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
const [statusFilter, setStatusFilter] = React.useState('all');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const normalizedHistory = React.useMemo(() => {
|
||||
if (!Array.isArray(history)) return [];
|
||||
return history.map((entry, index) => ({
|
||||
id: entry?.id ?? index,
|
||||
quarter: formatQuarter(entry?.quarter, entry?.year),
|
||||
survey_name: '—',
|
||||
year: entry?.year || '—',
|
||||
quarter: entry?.quarter || '—',
|
||||
status: entry?.status || 'Pending',
|
||||
date: formatDate(entry?.created_at || entry?.submitted_on),
|
||||
products: entry?.product_count,
|
||||
submission_on: formatDateTime(entry?.created_at || entry?.submitted_on),
|
||||
products: entry?.product_count ?? 0,
|
||||
}));
|
||||
}, [history]);
|
||||
|
||||
const filteredRows = normalizedHistory.filter((entry) => {
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
const matchesSearch =
|
||||
return (
|
||||
!term ||
|
||||
entry.year.toString().includes(term) ||
|
||||
entry.quarter.toLowerCase().includes(term) ||
|
||||
entry.status.toLowerCase().includes(term) ||
|
||||
entry.date.toLowerCase().includes(term);
|
||||
const matchesStatus =
|
||||
statusFilter === 'all' || normalizeStatus(entry.status) === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
const rowsData = filteredRows.map((s) => {
|
||||
const count = Number.isFinite(Number(s.products)) ? Number(s.products) : null;
|
||||
const display = count === null ? '—' : `${count} ${count === 1 ? 'product' : 'products'}`;
|
||||
return [s.quarter, s.status, s.date, display, 'View'];
|
||||
entry.status.toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
|
||||
const handleViewSubmission = React.useCallback(() => {
|
||||
navigate('/history');
|
||||
}, [navigate]);
|
||||
const navigateToView = (id) => navigate(`/history/${id}`);
|
||||
|
||||
const rowsData = filteredRows.map((item) => {
|
||||
const productCount = Number(item.products) || 0;
|
||||
const productLabel = `${productCount} ${productCount === 1 ? 'product' : 'products'}`;
|
||||
|
||||
return [
|
||||
item.survey_name,
|
||||
item.year,
|
||||
item.quarter,
|
||||
<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>,
|
||||
];
|
||||
});
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
<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 submissions"
|
||||
placeholder="Search Establishment"
|
||||
className="w-full h-10 rounded-md border border-[#E5E7EB] pl-10 pr-3 text-sm focus:outline-none"
|
||||
/>
|
||||
<img
|
||||
@ -121,29 +133,16 @@ export const SubmissionHistory = ({ history = [], loading = false, error = '' })
|
||||
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">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="h-10 w-full appearance-none rounded-md border border-[#E5E7EB] bg-white pl-3 pr-9 text-sm text-[#232528] focus:outline-none cursor-pointer"
|
||||
>
|
||||
{statusOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</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(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'}`}
|
||||
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"
|
||||
@ -152,7 +151,12 @@ export const SubmissionHistory = ({ history = [], loading = false, error = '' })
|
||||
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" />
|
||||
<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>
|
||||
@ -161,32 +165,28 @@ export const SubmissionHistory = ({ history = [], loading = false, error = '' })
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="max-w-[1280px] mx-auto px-0">
|
||||
<Table
|
||||
headers={['Quarter', 'Status', 'Submission Date', 'Products', 'Actions']}
|
||||
rows={rowsData}
|
||||
beforeHeader={toolbar}
|
||||
separated
|
||||
rowGapClass="border-spacing-y-2"
|
||||
renderCell={(value, ri, ci) => {
|
||||
if (ci === 1) {
|
||||
return <span className={getStatusClass(String(value))}>{value}</span>;
|
||||
}
|
||||
if (ci === 4) {
|
||||
const disabled = !normalizedHistory[ri];
|
||||
return (
|
||||
<button
|
||||
onClick={handleViewSubmission}
|
||||
className={`text-amber-700 hover:text-amber-800 ${disabled ? 'cursor-not-allowed opacity-50 hover:text-amber-700' : ''}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
<>
|
||||
<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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubmissionHistory;
|
||||
@ -0,0 +1,94 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Pause } from 'lucide-react';
|
||||
|
||||
const SurveyCarousel = ({
|
||||
surveys = [],
|
||||
autoRotate = true,
|
||||
rotateInterval = 5000,
|
||||
onStartSurvey,
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRotate || isPaused) return;
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % surveys.length);
|
||||
}, rotateInterval);
|
||||
return () => clearInterval(timer);
|
||||
}, [autoRotate, isPaused, rotateInterval, surveys.length]);
|
||||
|
||||
if (!surveys.length) return null;
|
||||
const current = surveys[currentIndex];
|
||||
|
||||
const handlePrev = () =>
|
||||
setCurrentIndex((prev) => (prev === 0 ? surveys.length - 1 : prev - 1));
|
||||
|
||||
const handleNext = () =>
|
||||
setCurrentIndex((prev) => (prev === surveys.length - 1 ? 0 : prev + 1));
|
||||
|
||||
return (
|
||||
<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-[16px] leading-[24px] font-medium text-[#232528]">
|
||||
{current?.subtitle || ''}
|
||||
</p>
|
||||
<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={() => 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-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"
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="p-1 rounded-full hover:bg-gray-100 transition"
|
||||
>
|
||||
<ChevronRight size={20} className="text-gray-700" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
{surveys.map((_, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
onClick={() => setCurrentIndex(idx)}
|
||||
className={`h-[8px] w-[8px] rounded-full cursor-pointer transition ${
|
||||
idx === currentIndex ? 'bg-[#92722A]' : 'bg-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SurveyCarousel;
|
||||
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import SurveyCarousel from './SurveyCarousel';
|
||||
|
||||
const formatQuarterYear = (quarter, year) => {
|
||||
if (!quarter || !year) return '—';
|
||||
@ -10,7 +11,11 @@ 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' });
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatRelativeDays = (value) => {
|
||||
@ -21,106 +26,80 @@ const formatRelativeDays = (value) => {
|
||||
const diffMs = deadline.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (!Number.isFinite(diffDays)) return '—';
|
||||
if (diffDays < 0) return `${Math.abs(diffDays)} days overdue`;
|
||||
if (diffDays < 0) return `${Math.abs(diffDays)} day${Math.abs(diffDays) === 1 ? '' : 's'} overdue`;
|
||||
if (diffDays === 0) return 'Due today';
|
||||
return `${diffDays} day${diffDays === 1 ? '' : 's'}`;
|
||||
return `Due in ${diffDays} day${diffDays === 1 ? '' : 's'}`;
|
||||
};
|
||||
|
||||
export const SurveyStatus = ({ data = null, loading = false, error = '' }) => {
|
||||
const navigate = useNavigate();
|
||||
const handleStartSurvey = () => {
|
||||
navigate('/survey', {
|
||||
state: {
|
||||
currentQuarter: data?.current_quarter,
|
||||
currentYear: data?.current_quarter_year
|
||||
}
|
||||
});
|
||||
|
||||
const handleStartSurvey = (survey) => {
|
||||
console.log('Starting survey:', survey.title);
|
||||
};
|
||||
|
||||
const submissionStatus = data?.submission_status || '—';
|
||||
const currentQuarter = formatQuarterYear(data?.current_quarter, data?.current_quarter_year);
|
||||
const nextDeadlineDate = formatDate(data?.next_deadline);
|
||||
const nextDeadlineRelative = formatRelativeDays(data?.next_deadline);
|
||||
const lastSubmissionQuarter = formatQuarterYear(data?.last_submission?.quarter, data?.last_submission?.year);
|
||||
const lastSubmissionDate = formatDate(data?.last_submission?.submitted_on);
|
||||
const surveyData = data || {};
|
||||
const nextDeadline = surveyData?.next_deadline;
|
||||
const formattedDueDate = formatDate(nextDeadline);
|
||||
const relativeDue = formatRelativeDays(nextDeadline);
|
||||
|
||||
const surveys = [
|
||||
{
|
||||
title: `${formatQuarterYear(surveyData.previous_quarter, surveyData.previous_quarter_year)} Survey Ready`,
|
||||
subtitle: 'Complete your quarterly industrial production data submission',
|
||||
dueDate: formattedDueDate,
|
||||
relativeDue,
|
||||
estTime: '15–20 minutes',
|
||||
status: surveyData?.submission_status || 'Pending',
|
||||
},
|
||||
{
|
||||
title: `${formatQuarterYear(surveyData.current_quarter, surveyData.current_quarter_year)} Survey Ready`,
|
||||
subtitle: 'Complete your quarterly industrial production data submission',
|
||||
dueDate: formattedDueDate,
|
||||
relativeDue,
|
||||
estTime: '15–20 minutes',
|
||||
status: surveyData?.submission_status || 'Pending',
|
||||
},
|
||||
{
|
||||
title: `${formatQuarterYear(surveyData.next_quarter, surveyData.next_quarter_year)} Survey Ready`,
|
||||
subtitle: 'Complete your quarterly industrial production data submission',
|
||||
dueDate: formattedDueDate,
|
||||
relativeDue,
|
||||
estTime: '15–20 minutes',
|
||||
status: surveyData?.submission_status || 'Pending',
|
||||
},
|
||||
];
|
||||
|
||||
const heroTitle = loading
|
||||
? 'Loading survey status…'
|
||||
: error
|
||||
? 'Unable to load survey status'
|
||||
: currentQuarter !== '—'
|
||||
? `${currentQuarter} Survey`
|
||||
: 'Survey status unavailable';
|
||||
? 'Unable to load survey status'
|
||||
: 'Survey Overview';
|
||||
|
||||
const heroSubtitle = loading
|
||||
? 'Fetching the latest reporting information.'
|
||||
: error
|
||||
? error
|
||||
: 'Complete your quarterly industrial production data submission';
|
||||
? error
|
||||
: 'Complete your quarterly industrial production data submission';
|
||||
|
||||
return (
|
||||
<section className="max-w-[1280px] mx-auto px-0">
|
||||
<div className="bg-white rounded-lg shadow-sm ring-1 ring-gray-200 mb-8 overflow-hidden">
|
||||
<div className="p-0">
|
||||
<div className="rounded-none bg-[#F2ECCF] px-6 py-4 min-h-[148px] flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className={`text-[18px] leading-[28px] font-medium ${error ? 'text-red-700' : 'text-[#232528]'}`}>{heroTitle}</h3>
|
||||
<p className="mt-1 text-[16px] leading-[24px] font-medium text-[#232528]">{heroSubtitle}</p>
|
||||
<div className="mt-2 flex items-center gap-6 text-[12px] leading-[16px] font-medium text-[#232528]">
|
||||
<span>Due: {nextDeadlineDate}</span>
|
||||
<span>{nextDeadlineRelative}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleStartSurvey}
|
||||
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"
|
||||
disabled={loading}
|
||||
>
|
||||
Start Survey Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="hidden sm:flex gap-5">
|
||||
<div className="w-[413px] h-[134px] bg-white rounded-[8px] ring-1 ring-gray-200 px-6 py-5">
|
||||
<p className="text-[16px] leading-[24px] font-medium text-[#232528]">Submission Status</p>
|
||||
<p className="mt-2 text-[18px] leading-[28px] font-medium text-[#232528]">{loading ? 'Loading…' : submissionStatus}</p>
|
||||
<p className="mt-2 text-[12px] leading-[16px] font-medium text-[#5F646D]">{currentQuarter !== '—' ? `${currentQuarter} submission required` : 'Awaiting reporting period data.'}</p>
|
||||
</div>
|
||||
|
||||
<div className="w-[413px] h-[134px] bg-white rounded-[8px] ring-1 ring-gray-200 px-6 py-5">
|
||||
<p className="text-[16px] leading-[24px] font-medium text-[#232528]">Next Deadline</p>
|
||||
<p className="mt-2 text-[18px] leading-[28px] font-medium text-[#232528]">{loading ? 'Loading…' : nextDeadlineRelative}</p>
|
||||
<p className="mt-2 text-[12px] leading-[16px] font-medium text-[#5F646D]">{`Due on ${nextDeadlineDate}`}</p>
|
||||
</div>
|
||||
|
||||
<div className="w-[413px] h-[134px] bg-white rounded-[8px] ring-1 ring-gray-200 px-6 py-5">
|
||||
<p className="text-[16px] leading-[24px] font-medium text-[#232528]">Last Submission</p>
|
||||
<p className="mt-2 text-[18px] leading-[28px] font-medium text-[#232528]">{loading ? 'Loading…' : lastSubmissionQuarter}</p>
|
||||
<p className="mt-2 text-[12px] leading-[16px] font-medium text-[#5F646D]">{`Submitted ${lastSubmissionDate}`}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-4">
|
||||
<div className="bg-white rounded-[8px] ring-1 ring-gray-200 px-6 py-5">
|
||||
<p className="text-[16px] leading-[24px] font-medium text-[#232528]">Submission Status</p>
|
||||
<p className="mt-2 text-[18px] leading-[28px] font-medium text-[#232528]">{loading ? 'Loading…' : submissionStatus}</p>
|
||||
<p className="mt-2 text-[12px] leading-[16px] font-medium text-[#5F646D]">{currentQuarter !== '—' ? `${currentQuarter} submission required` : 'Awaiting reporting period data.'}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-[8px] ring-1 ring-gray-200 px-6 py-5">
|
||||
<p className="text-[16px] leading-[24px] font-medium text-[#232528]">Next Deadline</p>
|
||||
<p className="mt-2 text-[18px] leading-[28px] font-medium text-[#232528]">{loading ? 'Loading…' : nextDeadlineRelative}</p>
|
||||
<p className="mt-2 text-[12px] leading-[16px] font-medium text-[#5F646D]">{`Due on ${nextDeadlineDate}`}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-[8px] ring-1 ring-gray-200 px-6 py-5">
|
||||
<p className="text-[16px] leading-[24px] font-medium text-[#232528]">Last Submission</p>
|
||||
<p className="mt-2 text-[18px] leading-[28px] font-medium text-[#232528]">{loading ? 'Loading…' : lastSubmissionQuarter}</p>
|
||||
<p className="mt-2 text-[12px] leading-[16px] font-medium text-[#5F646D]">{`Submitted ${lastSubmissionDate}`}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<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-10">{error}</div>
|
||||
) : (
|
||||
<SurveyCarousel
|
||||
surveys={surveys}
|
||||
autoRotate
|
||||
rotateInterval={7000}
|
||||
onStartSurvey={handleStartSurvey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SurveyStatus;
|
||||
@ -29,7 +29,18 @@ export const WelcomeSection = ({ data = null, loading = false, error = '' }) =>
|
||||
|
||||
<div className="max-w-[1280px] mx-auto px-4 pt-4 pb-2">
|
||||
<div className="space-y-1">
|
||||
<h2 className="w-[360px] text-[18px] leading-[28px] font-medium text-[#232528]">Welcome Back</h2>
|
||||
<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>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { FileSearch } from 'lucide-react';
|
||||
const logoSrc = '/assets/images/FCSCLogo.svg';
|
||||
const surveyIconActiveSrc = '/assets/images/Vector.svg';
|
||||
const surveyIconInactiveSrc = '/assets/images/Vectorblack.svg';
|
||||
@ -90,6 +90,29 @@ const HeaderBar = () => {
|
||||
</>
|
||||
)}
|
||||
</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'}`}
|
||||
|
||||
@ -98,36 +98,42 @@ const AdminDashboard = () => {
|
||||
value={loading ? '--' : summary.total_establishments}
|
||||
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={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={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={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={190}
|
||||
icon={<img src={overdueIconSrc} alt="Not Started" className="h-5 w-5" />}
|
||||
tooltipText="Establishments past submission deadline."
|
||||
/>
|
||||
<StatCard
|
||||
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>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import AdminHeader from '@/components/admin/AdminHeader';
|
||||
import { getSubmissionById } from '@/services/admin/submission';
|
||||
|
||||
const caretUpSrc = '/assets/images/caret-up.svg';
|
||||
const establishmentIconSrc = '/assets/images/Establishment.svg';
|
||||
@ -13,21 +14,6 @@ const femaleEmployeesIconSrc = '/assets/images/femaleemployess.svg';
|
||||
const uaeIconSrc = '/assets/images/uae.svg';
|
||||
const nonUaeIconSrc = '/assets/images/nonuae.svg';
|
||||
|
||||
const establishmentDetails = [
|
||||
{ label: 'Establishment Name', value: 'ABC Manufacturing LLC' },
|
||||
{ label: 'Establishment ID', value: 'MSCensus-23-05-002829' },
|
||||
{ label: 'Email', value: 'info@abcmg.com' },
|
||||
{ label: 'Emirate', value: 'Abu Dhabi' },
|
||||
];
|
||||
|
||||
const employmentDetails = [
|
||||
{ label: 'Total Employees', value: '120' },
|
||||
{ label: 'Male Employees', value: '80' },
|
||||
{ label: 'Female Employees', value: '40' },
|
||||
{ label: 'UAE Nationals', value: '30' },
|
||||
{ label: 'Non-UAE Nationals', value: '90' },
|
||||
];
|
||||
|
||||
const employmentIcons = {
|
||||
'Total Employees': totalEmployeesIconSrc,
|
||||
'Male Employees': maleEmployeesIconSrc,
|
||||
@ -36,75 +22,233 @@ const employmentIcons = {
|
||||
'Non-UAE Nationals': nonUaeIconSrc,
|
||||
};
|
||||
|
||||
const quarterLegend = [
|
||||
{ label: 'Previous Quarter', color: '#1D4ED8' },
|
||||
{ label: 'Current Quarter', color: '#1E7C34' },
|
||||
{ label: 'Next Quarter', color: '#D97706' },
|
||||
];
|
||||
|
||||
const productDetails = [
|
||||
{
|
||||
code: '04012000',
|
||||
name: 'Milk',
|
||||
submittedOn: '28/03/2025',
|
||||
unit: 'Liters',
|
||||
capacity: '20000',
|
||||
quarters: [
|
||||
{ label: 'Q1-2025', color: '#0C64F6', cellColor: '#F4F8FF' },
|
||||
{ label: 'Q2-2025', color: '#1E7C34', cellColor: '#F3FAF4' },
|
||||
{ label: 'Q3-2025', color: '#D97706', cellColor: '#FFF6EC' },
|
||||
],
|
||||
rows: [
|
||||
{ label: 'Quantity (liters)', values: ['500', '200', '50'] },
|
||||
{ label: 'Cost (AED)', values: ['40,000', '20,000', '20,000'] },
|
||||
{ label: 'Reason', values: ['--', 'Seasonal production surge', 'Higher demand expected'] },
|
||||
],
|
||||
remarks: 'NA',
|
||||
},
|
||||
{
|
||||
code: '07028000',
|
||||
name: 'Iron',
|
||||
submittedOn: '18/08/2025',
|
||||
unit: 'Kilogram',
|
||||
capacity: '50000',
|
||||
quarters: [
|
||||
{ label: 'Q1-2025', color: '#0C64F6', cellColor: '#F4F8FF' },
|
||||
{ label: 'Q2-2025', color: '#1E7C34', cellColor: '#F3FAF4' },
|
||||
{ label: 'Q3-2025', color: '#D97706', cellColor: '#FFF6EC' },
|
||||
],
|
||||
rows: [
|
||||
{ label: 'Quantity (kilograms)', values: ['800', '400', '80'] },
|
||||
{ label: 'Cost (AED)', values: ['70,000', '40,000', '30,000'] },
|
||||
{ label: 'Reason', values: ['--', 'Seasonal procurement', 'Higher demand expected'] },
|
||||
],
|
||||
remarks: 'NA',
|
||||
},
|
||||
];
|
||||
|
||||
const infoBadgeClass = 'inline-flex items-center rounded-[10px] border border-[#E5D7AA] bg-[#FDF9EB] px-3 py-1 text-sm font-medium text-[#92722A]';
|
||||
|
||||
const ValidationReview = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const [toast, setToast] = React.useState(null);
|
||||
const [isRejectOpen, setIsRejectOpen] = React.useState(false);
|
||||
const [rejectReason, setRejectReason] = React.useState('');
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState(null);
|
||||
const [submissionData, setSubmissionData] = React.useState(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchSubmission = async () => {
|
||||
if (!id) {
|
||||
setError('No submission ID provided');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await getSubmissionById(id);
|
||||
|
||||
let data = null;
|
||||
if (result?.status === 'success' && result?.data) {
|
||||
data = result.data;
|
||||
} else if (result?.data) {
|
||||
data = result.data;
|
||||
} else {
|
||||
data = result;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error('No data received from API');
|
||||
}
|
||||
|
||||
setSubmissionData(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load submission details:', err);
|
||||
setError(err.message || 'Unable to load submission details. Please try again later.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSubmission();
|
||||
}, [id]);
|
||||
|
||||
const showApproveToast = () => {
|
||||
setToast({ type: 'success', text: 'ABC Manufacturing LLC has been approved successfully.' });
|
||||
window.clearTimeout((showApproveToast._t));
|
||||
// Auto-hide after 4 seconds
|
||||
const establishmentName = submissionData?.establishment?.factory_name || 'Establishment';
|
||||
setToast({ type: 'success', text: `${establishmentName} has been approved successfully.` });
|
||||
window.clearTimeout(showApproveToast._t);
|
||||
showApproveToast._t = window.setTimeout(() => setToast(null), 4000);
|
||||
};
|
||||
|
||||
const handleRejectSubmit = () => {
|
||||
setToast({ type: 'info', text: rejectReason ? `Submission rejected. Reason: ${rejectReason}` : 'Submission rejected.' });
|
||||
window.clearTimeout((showApproveToast._t));
|
||||
setToast({
|
||||
type: 'info',
|
||||
text: rejectReason ? `Submission rejected. Reason: ${rejectReason}` : 'Submission rejected.'
|
||||
});
|
||||
window.clearTimeout(showApproveToast._t);
|
||||
showApproveToast._t = window.setTimeout(() => setToast(null), 4000);
|
||||
setIsRejectOpen(false);
|
||||
setRejectReason('');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F6F5F1]">
|
||||
<AdminHeader />
|
||||
<div className="flex items-center justify-center h-[calc(100vh-200px)] 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">
|
||||
<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>
|
||||
Loading submission details...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !submissionData) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F6F5F1]">
|
||||
<AdminHeader />
|
||||
<div className="max-w-[1280px] mx-auto px-4 py-6">
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="h-5 w-5 text-red-600 mt-0.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<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 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-red-800">Error Loading Submission</h3>
|
||||
<p className="mt-1 text-sm text-red-600">{error || 'Submission not found'}</p>
|
||||
<button
|
||||
onClick={() => navigate('/admin/validations')}
|
||||
className="mt-3 inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700"
|
||||
>
|
||||
Back to Submissions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const establishment = submissionData?.establishment || {};
|
||||
|
||||
const establishmentDetails = [
|
||||
{
|
||||
label: 'Establishment Name',
|
||||
value: establishment.factory_name || '—'
|
||||
},
|
||||
{
|
||||
label: 'Establishment ID',
|
||||
value: establishment.establishment_code || '—'
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
value: establishment.establishment_contact_email || '—'
|
||||
},
|
||||
{
|
||||
label: 'Emirate',
|
||||
value: establishment.establishment_address || '—'
|
||||
},
|
||||
];
|
||||
|
||||
const totalEmiratiMale = establishment.emirati_male || 0;
|
||||
const totalEmiratiFemale = establishment.emirati_female || 0;
|
||||
const totalNonEmiratiMale = establishment.non_emirati_male || 0;
|
||||
const totalNonEmiratiFemale = establishment.non_emirati_female || 0;
|
||||
const totalMale = totalEmiratiMale + totalNonEmiratiMale;
|
||||
const totalFemale = totalEmiratiFemale + totalNonEmiratiFemale;
|
||||
const totalEmirati = totalEmiratiMale + totalEmiratiFemale;
|
||||
const totalNonEmirati = totalNonEmiratiMale + totalNonEmiratiFemale;
|
||||
|
||||
const employmentDetails = [
|
||||
{
|
||||
label: 'Total Employees',
|
||||
value: establishment.total_employees?.toString() || (totalMale + totalFemale).toString() || '—'
|
||||
},
|
||||
{
|
||||
label: 'Male Employees',
|
||||
value: totalMale.toString() || '—'
|
||||
},
|
||||
{
|
||||
label: 'Female Employees',
|
||||
value: totalFemale.toString() || '—'
|
||||
},
|
||||
{
|
||||
label: 'UAE Nationals',
|
||||
value: totalEmirati.toString() || '—'
|
||||
},
|
||||
{
|
||||
label: 'Non-UAE Nationals',
|
||||
value: totalNonEmirati.toString() || '—'
|
||||
},
|
||||
];
|
||||
|
||||
const quarterPeriods = submissionData?.quarter_periods || {};
|
||||
const previousQuarter = quarterPeriods.previous_quarter || 'Q1';
|
||||
const currentQuarter = quarterPeriods.current_quarter || 'Q2';
|
||||
const forecastQuarter = quarterPeriods.forecast_quarter || 'Q3';
|
||||
const year = submissionData?.year || new Date().getFullYear();
|
||||
|
||||
const products = submissionData?.products || [];
|
||||
const productDetails = Array.isArray(products) ? products.map((product) => {
|
||||
const quarters = [
|
||||
{ label: `${previousQuarter}-${year}`, color: '#0C64F6', cellColor: '#F4F8FF', type: 'previous' },
|
||||
{ label: `${currentQuarter}-${year}`, color: '#1E7C34', cellColor: '#F3FAF4', type: 'current' },
|
||||
{ label: `${forecastQuarter}-${year}`, color: '#D97706', cellColor: '#FFF6EC', type: 'forecast' },
|
||||
];
|
||||
|
||||
const unitValue = product.unit?.uom || 'units';
|
||||
const unit = String(unitValue);
|
||||
|
||||
const previousQuantity = product.previous_quantity || '—';
|
||||
const previousCost = product.previous_cost || '—';
|
||||
const currentQuantity = product.current_quantity || '—';
|
||||
const currentCost = product.current_cost || '—';
|
||||
const forecastQuantity = product.forecast_quantity || '—';
|
||||
const forecastCost = product.forecast_cost || '—';
|
||||
|
||||
const variationReason = product.variation_reason?.reason || product.other_variation_reason || '—';
|
||||
const zeroTargetReason = product.zero_target_reason?.reason || product.other_zero_target_reason || '—';
|
||||
|
||||
return {
|
||||
code: product.product?.hs_code || '—',
|
||||
name: product.product?.product_name || '—',
|
||||
submittedBy: submissionData?.created_by || 'System',
|
||||
submittedOn: submissionData?.created_at ? new Date(submissionData.created_at).toLocaleDateString('en-GB') : '—',
|
||||
unit: unit,
|
||||
capacity: product.annual_installed_capacity?.toString() || '—',
|
||||
quarters,
|
||||
rows: [
|
||||
{
|
||||
label: `Quantity (${unit.toLowerCase()})`,
|
||||
values: [
|
||||
previousQuantity.toString(),
|
||||
currentQuantity.toString(),
|
||||
forecastQuantity.toString()
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Cost (AED)',
|
||||
values: [
|
||||
previousCost !== '—' ? Number(previousCost).toLocaleString() : '—',
|
||||
currentCost !== '—' ? Number(currentCost).toLocaleString() : '—',
|
||||
forecastCost !== '—' ? Number(forecastCost).toLocaleString() : '—'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Reason',
|
||||
values: [
|
||||
'—',
|
||||
variationReason,
|
||||
zeroTargetReason
|
||||
]
|
||||
},
|
||||
],
|
||||
remarks: product.remarks || 'NA',
|
||||
};
|
||||
}) : [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F6F5F1]">
|
||||
<AdminHeader />
|
||||
@ -139,20 +283,6 @@ const ValidationReview = () => {
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
{/* <div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-[24px] leading-[32px] font-semibold text-[#232528]">Submission Details</h1>
|
||||
<p className="text-sm text-[#7A7F87]">Submission #{id || '—'}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/admin/validations')}
|
||||
className="inline-flex h-10 items-center justify-center rounded-[10px] border border-[#C3C6CB] bg-white px-4 text-sm font-medium text-[#5F646D] transition-colors hover:bg-[#F7F7F7]"
|
||||
>
|
||||
Back to Submissions
|
||||
</button>
|
||||
</div> */}
|
||||
|
||||
<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)]">
|
||||
<div className="grid gap-1 lg:grid-cols-2">
|
||||
<div className="space-y-6 lg:pr-6 lg:pl-15">
|
||||
@ -210,79 +340,88 @@ const ValidationReview = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{productDetails.map((product) => (
|
||||
<section key={product.code} className="rounded-[16px] bg-white p-6 shadow-[0_16px_32px_rgba(15,23,42,0.08)] ring-1 ring-[#E5E7EB] space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-base font-semibold text-[#232528]">Product: {product.code} - {product.name}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className={infoBadgeClass}>Submitted By: <span className="ml-1 font-semibold text-[#232528]">{product.submittedOn}</span></span>
|
||||
<span className={infoBadgeClass}>Unit: <span className="ml-1 font-semibold text-[#232528]">{product.unit}</span></span>
|
||||
<span className={infoBadgeClass}>Capacity: <span className="ml-1 font-semibold text-[#232528]">{product.capacity}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-[#4B5563]">
|
||||
{quarterLegend.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<span>{item.label}</span>
|
||||
{productDetails.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{productDetails.map((product, idx) => (
|
||||
<section key={product.code + idx} className="rounded-[16px] bg-white p-6 shadow-[0_16px_32px_rgba(15,23,42,0.08)] ring-1 ring-[#E5E7EB] space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between w-full">
|
||||
<div className="text-sm font-semibold text-[#232528]">
|
||||
Product: <span className="font-normal">{product.code} - {product.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[12px] border border-[#E5E7EB]">
|
||||
<table className="w-full table-fixed text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-[160px] bg-[#F2ECCF] px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-[#92722A]">Description</th>
|
||||
{product.quarters.map((quarter) => (
|
||||
<th
|
||||
key={quarter.label}
|
||||
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-white"
|
||||
style={{ backgroundColor: quarter.color }}
|
||||
>
|
||||
{quarter.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{product.rows.map((row) => (
|
||||
<tr key={row.label} className="border-t border-[#E5E7EB]">
|
||||
<td className="bg-[#FFFCF2] px-4 py-3 font-medium text-[#232528]">{row.label}</td>
|
||||
{row.values.map((value, index) => (
|
||||
<td
|
||||
key={`${row.label}-${product.quarters[index].label}`}
|
||||
className="px-4 py-3 text-[#232528]"
|
||||
style={{ backgroundColor: product.quarters[index].cellColor }}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-1 border border-[#D0D5DD] rounded-md px-2 py-[2px] text-xs text-[#344054]">
|
||||
<span className="text-[#475467] font-medium">Submitted By:</span>
|
||||
<span className="font-semibold text-[#232528]">{product.submittedBy}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 border border-[#D0D5DD] rounded-md px-2 py-[2px] text-xs text-[#344054]">
|
||||
<span className="text-[#475467] font-medium">Unit:</span>
|
||||
<span className="font-semibold text-[#232528]">{product.unit}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 border border-[#D0D5DD] rounded-md px-2 py-[2px] text-xs text-[#344054]">
|
||||
<span className="text-[#475467] font-medium">Capacity:</span>
|
||||
<span className="font-semibold text-[#232528]">{product.capacity}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[12px] border border-[#E5E7EB]">
|
||||
<table className="w-full table-fixed text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-[160px] bg-[#F2ECCF] px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-[#92722A]">Description</th>
|
||||
{product.quarters.map((quarter) => (
|
||||
<th
|
||||
key={quarter.label}
|
||||
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-white"
|
||||
style={{ backgroundColor: quarter.color }}
|
||||
>
|
||||
{value}
|
||||
</td>
|
||||
{quarter.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{product.rows.map((row) => (
|
||||
<tr key={row.label} className="border-t border-[#E5E7EB]">
|
||||
<td className="bg-[#FFFCF2] px-4 py-3 font-medium text-[#232528]">{row.label}</td>
|
||||
{row.values.map((value, index) => (
|
||||
<td
|
||||
key={`${row.label}-${product.quarters[index].label}`}
|
||||
className="px-4 py-3 text-[#232528]"
|
||||
style={{ backgroundColor: product.quarters[index].cellColor }}
|
||||
>
|
||||
{value}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-[#4B5563]">
|
||||
<span className="font-semibold text-[#232528]">Remarks:</span> {product.remarks}
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-[#4B5563]">
|
||||
<span className="font-semibold text-[#232528]">Remarks:</span> {product.remarks}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold text-[#232528]">Admin Remarks</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="w-full rounded-[10px] border border-[#D1D5DB] bg-[#FFFCF2] px-4 py-2 text-sm text-[#232528] placeholder:text-[#9CA3AF] focus:outline-none focus:ring-2 focus:ring-[#92722A]"
|
||||
placeholder="Add short note..."
|
||||
defaultValue=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold text-[#232528]">Admin Remarks</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="w-full rounded-[10px] border border-[#D1D5DB] bg-[#FFFCF2] px-4 py-2 text-sm text-[#232528] placeholder:text-[#9CA3AF] focus:outline-none focus:ring-2 focus:ring-[#92722A]"
|
||||
placeholder="Add short note..."
|
||||
defaultValue=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-200 p-6 text-center">
|
||||
<p className="text-amber-800">No products found for this submission.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="rounded-[16px] bg-white p-6 shadow-[0_16px_32px_rgba(15,23,42,0.08)] ring-1 ring-[#E5E7EB]">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
@ -359,4 +498,4 @@ const ValidationReview = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidationReview;
|
||||
export default ValidationReview;
|
||||
@ -4,9 +4,9 @@ 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 } from 'lucide-react';
|
||||
import Footer from '@/components/common/Footer';
|
||||
const searchIconSrc = '/assets/images/material-symbols_search-rounded.svg';
|
||||
const downloadIconSrc = '/assets/images/downloadsimple.svg';
|
||||
const caretUpSrc = '/assets/images/caret-up.svg';
|
||||
|
||||
const STATUS_VARIANTS = {
|
||||
@ -22,6 +22,21 @@ const SUMMARY_VARIANTS = {
|
||||
rejected: { background: '#FEF2F2', label: '#B52520', value: '#232528' },
|
||||
};
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
const formatted = date.toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
return formatted.replace(/\s?(am|pm)$/i, (m) => m.toUpperCase());
|
||||
};
|
||||
|
||||
const Badge = ({ children, status }) => {
|
||||
const variant = STATUS_VARIANTS[status] || { background: '#F2F4F7', text: '#475467', border: '#EAECF0' };
|
||||
return (
|
||||
@ -39,7 +54,6 @@ const ManageSubmissions = () => {
|
||||
const [submissions, setSubmissions] = React.useState([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState(null);
|
||||
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [year, setYear] = React.useState('');
|
||||
const [quarter, setQuarter] = React.useState('');
|
||||
@ -48,31 +62,26 @@ const ManageSubmissions = () => {
|
||||
const [currentPage, setCurrentPage] = React.useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
// 🔹 Fetch data from backend
|
||||
React.useEffect(() => {
|
||||
const fetchSubmissions = async () => {
|
||||
try {
|
||||
const result = await getSubmissions();
|
||||
|
||||
// Handle success or direct data array
|
||||
const data = result?.status === 'success' ? result.data : result;
|
||||
|
||||
// Map API fields to table fields
|
||||
|
||||
const mapped = (data || []).map((item) => ({
|
||||
id: item.id,
|
||||
establishment: '', // blank
|
||||
emirate: '', // blank
|
||||
year: String(item.year ?? ''),
|
||||
quarter: item.quarter ?? '',
|
||||
products: '', // blank
|
||||
submittedBy: item.created_by ?? '',
|
||||
submittedAt: item.created_at
|
||||
? new Date(item.created_at).toLocaleString()
|
||||
: '',
|
||||
status: item.status ?? '',
|
||||
reviewer: '', // blank
|
||||
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 ?? '-',
|
||||
submittedAt: formatDateTime(item.created_at),
|
||||
status: item.status ?? '-',
|
||||
reviewer: item?.updated_by || '-',
|
||||
reviewerOn: formatDateTime(item?.updated_at),
|
||||
}));
|
||||
|
||||
|
||||
setSubmissions(mapped);
|
||||
} catch (err) {
|
||||
console.error('Failed to load submissions', err);
|
||||
@ -81,9 +90,8 @@ const ManageSubmissions = () => {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSubmissions();
|
||||
}, []);
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
@ -92,10 +100,10 @@ const ManageSubmissions = () => {
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
return submissions.filter((item) => {
|
||||
const matchSearch = search
|
||||
? item.establishment?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.submittedBy?.toLowerCase().includes(search.toLowerCase())
|
||||
: true;
|
||||
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;
|
||||
@ -104,12 +112,15 @@ const ManageSubmissions = () => {
|
||||
});
|
||||
}, [submissions, search, year, quarter, emirate, status]);
|
||||
|
||||
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 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',
|
||||
@ -121,6 +132,7 @@ const ManageSubmissions = () => {
|
||||
'Submission Date & Time',
|
||||
'Status',
|
||||
'Reviewer',
|
||||
'Reviewer On',
|
||||
'Actions',
|
||||
];
|
||||
|
||||
@ -134,30 +146,11 @@ const ManageSubmissions = () => {
|
||||
item.submittedAt,
|
||||
item.status,
|
||||
item.reviewer,
|
||||
item.id,
|
||||
item.reviewerOn,
|
||||
item,
|
||||
]);
|
||||
|
||||
const columnWidths = [302, 105, 83, 91, 100, 152, 208, 120, 104, 119];
|
||||
|
||||
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">
|
||||
<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>
|
||||
Loading submissions...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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">
|
||||
@ -184,7 +177,21 @@ const ManageSubmissions = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
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">
|
||||
<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>
|
||||
Loading submissions...
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error) return <div className="flex items-center justify-center h-screen text-red-600">{error}</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-[#F6F5F1]">
|
||||
<AdminHeader />
|
||||
<div className="max-w-[1280px] mx-auto px-4 py-6 flex flex-col gap-6">
|
||||
@ -192,34 +199,27 @@ const ManageSubmissions = () => {
|
||||
<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="flex items-center gap-2 text-[#92722A] font-medium">Manage Submissions</span>
|
||||
<span className="text-[#92722A] font-medium">Manage Submissions</span>
|
||||
</nav>
|
||||
|
||||
{/* Summary bar */}
|
||||
<div className="flex flex-wrap gap-[6px] rounded-[12px] bg-white px-5 py-3 shadow">
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto inline-flex h-10 items-center justify-center gap-3 rounded-[6px] border border-[#C3C6CB] bg-white px-5 text-sm font-medium text-[#5F646D] hover:bg-[#F7F7F7]"
|
||||
>
|
||||
<img src={downloadIconSrc} alt="Export" className="h-4 w-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
<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]" 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>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white shadow overflow-hidden">
|
||||
<Table
|
||||
headers={headers}
|
||||
@ -233,24 +233,53 @@ const ManageSubmissions = () => {
|
||||
pageSize,
|
||||
totalItems: filtered.length,
|
||||
}}
|
||||
renderCell={(value, rowIndex, columnIndex) => {
|
||||
renderCell={(value, _, columnIndex) => {
|
||||
if (columnIndex === 7)
|
||||
return <div className="flex justify-start"><Badge status={String(value)}>{value}</Badge></div>;
|
||||
if (columnIndex === 9)
|
||||
return (
|
||||
<button
|
||||
onClick={() => navigate(`/admin/validations/${value}`)}
|
||||
className="text-sm font-medium text-[#92722A] hover:underline"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
<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>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { login } from '@/services/auth/authService.js';
|
||||
|
||||
import { RefreshCw } from "lucide-react";
|
||||
const logoSrc = '/assets/images/FCSCLogo.svg';
|
||||
|
||||
const Login = () => {
|
||||
@ -277,12 +277,13 @@ const Login = () => {
|
||||
{captcha}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateCaptcha}
|
||||
className="text-sm text-[#92722A] hover:underline"
|
||||
>
|
||||
Refresh
|
||||
</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:{" "}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import apiClient from '@/services/api/apiClient';
|
||||
|
||||
import { RefreshCw } from "lucide-react";
|
||||
const logoSrc = '/assets/images/FCSCLogo.svg';
|
||||
|
||||
const PublicContactForm = () => {
|
||||
@ -266,14 +266,18 @@ const PublicContactForm = () => {
|
||||
{captcha}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateCaptcha}
|
||||
className="text-sm text-[#92722A] hover:underline"
|
||||
>
|
||||
Refresh
|
||||
</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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user