Files
ProductDevelopment/src/pages/admin/AdminDashboard.js
T
Sizzy e22a8dc248 chnages
chnages
2026-07-01 01:10:13 +05:45

268 lines
11 KiB
JavaScript

// Admin dashboard — the sole back-office job: review Contact Us
// enquiries. Headline stats + a searchable, filterable, paginated
// table, with a full-detail modal per enquiry.
import { useEffect, useState, useCallback } from 'react';
import { getJSON, postJSON, apiFetch } from '../../api';
import Modal from '../../components/Modal';
import Icon from '../../components/Icon';
import { Loading, ErrorState, EmptyState } from '../../components/States';
function StatCard({ num, label }) {
return (
<div className="stat-card">
<div className="stat">
<span className="stat__num">{num}</span>
<span className="stat__label">{label}</span>
</div>
</div>
);
}
function fmtDateTime(d) {
return new Date(d).toLocaleString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function AdminDashboard() {
const [stats, setStats] = useState(null);
const [search, setSearch] = useState('');
const [debounced, setDebounced] = useState('');
const [country, setCountry] = useState('');
const [status, setStatus] = useState('');
const [page, setPage] = useState(1);
const [list, setList] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [active, setActive] = useState(null); // open enquiry detail
const [detailLoading, setDetailLoading] = useState(false);
const limit = 10;
// Debounce the free-text search.
useEffect(() => {
const t = setTimeout(() => { setDebounced(search); setPage(1); }, 350);
return () => clearTimeout(t);
}, [search]);
const loadStats = useCallback(() => {
getJSON('/api/admin/stats').then(setStats).catch(() => {});
}, []);
const loadList = useCallback(() => {
setLoading(true);
setError('');
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (debounced) params.set('search', debounced);
if (country) params.set('country', country);
if (status) params.set('status', status);
getJSON(`/api/admin/inquiries?${params.toString()}`)
.then(setList)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [page, debounced, country, status]);
useEffect(() => { loadStats(); }, [loadStats]);
useEffect(() => { loadList(); }, [loadList]);
const openDetail = async (id) => {
setDetailLoading(true);
try {
const data = await getJSON(`/api/admin/inquiries/${id}`);
setActive(data.inquiry);
loadStats(); // unread count may change (viewing marks read)
loadList();
} catch (e) {
setError(e.message);
} finally {
setDetailLoading(false);
}
};
const removeInquiry = async (id) => {
if (!window.confirm('Delete this enquiry? This cannot be undone.')) return;
try {
await apiFetch(`/api/admin/inquiries/${id}`, { method: 'DELETE' });
setActive(null);
loadStats();
loadList();
} catch (e) {
setError(e.message);
}
};
const toggleRead = async (inq) => {
try {
await postJSON(`/api/admin/inquiries/${inq.id}/read`, { is_read: !inq.is_read }, 'PATCH');
loadStats();
loadList();
setActive((a) => (a && a.id === inq.id ? { ...a, is_read: !inq.is_read } : a));
} catch (e) {
setError(e.message);
}
};
const inquiries = list?.inquiries || [];
const pg = list?.pagination;
return (
<>
<div className="admin__topbar">
<div className="admin__title">
<h1>Customer Enquiries</h1>
<p>Every submission from the public Contact Us form.</p>
</div>
</div>
{/* Stats */}
<div className="grid grid--4" style={{ marginBottom: 28 }}>
<StatCard num={stats ? stats.totalInquiries : '—'} label="Total enquiries" />
<StatCard num={stats ? stats.unreadInquiries : '—'} label="Unread" />
<StatCard num={stats ? stats.last7Days : '—'} label="Last 7 days" />
<StatCard num={stats ? stats.today : '—'} label="Today" />
</div>
{/* Toolbar */}
<div className="toolbar">
<div className="toolbar__grow" style={{ position: 'relative', minWidth: 220 }}>
<span style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--ink-dim)' }}>
<Icon name="search" size={16} />
</span>
<input
className="input"
style={{ paddingLeft: 38, width: '100%' }}
placeholder="Search name, email, company, details…"
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Search enquiries"
/>
</div>
<label className="sr-only" htmlFor="f-country">Filter by country</label>
<select id="f-country" className="select" value={country} onChange={(e) => { setCountry(e.target.value); setPage(1); }}>
<option value="">All countries</option>
{(stats?.countries || []).map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<label className="sr-only" htmlFor="f-status">Filter by status</label>
<select id="f-status" className="select" value={status} onChange={(e) => { setStatus(e.target.value); setPage(1); }}>
<option value="">All statuses</option>
<option value="unread">Unread</option>
<option value="read">Read</option>
</select>
{(search || country || status) && (
<button type="button" className="btn btn--ghost" onClick={() => { setSearch(''); setCountry(''); setStatus(''); setPage(1); }}>
Clear
</button>
)}
</div>
{/* Table */}
{loading && <Loading label="Loading enquiries…" />}
{error && !loading && <ErrorState message={error} onRetry={loadList} />}
{!loading && !error && inquiries.length === 0 && (
<EmptyState
title="No enquiries found"
message={search || country || status ? 'Try clearing your filters.' : 'New Contact Us submissions will appear here.'}
/>
)}
{!loading && !error && inquiries.length > 0 && (
<>
<div className="table-wrap">
<table className="data">
<caption className="sr-only">Customer enquiries</caption>
<thead>
<tr>
<th scope="col">Status</th>
<th scope="col">Name</th>
<th scope="col">Company</th>
<th scope="col">Country</th>
<th scope="col">Received</th>
<th scope="col"><span className="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
{inquiries.map((inq) => (
<tr
key={inq.id}
className={inq.is_read ? '' : 'row-unread'}
onClick={() => openDetail(inq.id)}
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') openDetail(inq.id); }}
>
<td><span className={`badge ${inq.is_read ? 'badge--read' : 'badge--new'}`}>{inq.is_read ? 'Read' : 'New'}</span></td>
<td>
<div style={{ fontWeight: 600 }}>{inq.full_name}</div>
<div className="muted" style={{ fontSize: 12.5 }}>{inq.email}</div>
</td>
<td>{inq.company || <span className="muted">—</span>}</td>
<td>{inq.country || <span className="muted">—</span>}</td>
<td className="mono" style={{ fontSize: 12, color: 'var(--ink-dim)' }}>{fmtDateTime(inq.created_at)}</td>
<td onClick={(e) => e.stopPropagation()}>
<button type="button" className="btn btn--ghost" onClick={() => openDetail(inq.id)} aria-label={`View enquiry from ${inq.full_name}`}>
View
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{pg && (
<div className="pagination">
<span className="pagination__info">
Page {pg.page} of {pg.totalPages} · {pg.totalCount} enquir{pg.totalCount === 1 ? 'y' : 'ies'}
</span>
<div className="flex gap-8">
<button type="button" className="btn" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Previous</button>
<button type="button" className="btn" disabled={page >= pg.totalPages} onClick={() => setPage((p) => p + 1)}>Next</button>
</div>
</div>
)}
</>
)}
{/* Detail modal */}
{(active || detailLoading) && (
<Modal title={active ? `Enquiry from ${active.full_name}` : 'Loading…'} onClose={() => setActive(null)}>
{!active ? (
<Loading label="Loading enquiry…" />
) : (
<>
<dl className="dl">
<dt>Full name</dt><dd>{active.full_name}</dd>
<dt>Email</dt><dd><a href={`mailto:${active.email}`}>{active.email}</a></dd>
<dt>Phone</dt><dd>{active.phone || <span className="muted">Not provided</span>}</dd>
<dt>Company</dt><dd>{active.company || <span className="muted">Not provided</span>}</dd>
<dt>Country</dt><dd>{active.country || <span className="muted">Not provided</span>}</dd>
<dt>Job title</dt><dd>{active.job_title || <span className="muted">Not provided</span>}</dd>
<dt>Received</dt><dd>{fmtDateTime(active.created_at)}</dd>
<dt>Job details</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{active.job_details}</dd>
</dl>
<div className="flex between wrap gap-12 mt-24">
<div className="flex gap-8 wrap">
<a className="btn btn--primary" href={`mailto:${active.email}?subject=Re: your enquiry to AI-Solutions`}>
<Icon name="mail" size={15} /> Reply by email
</a>
<button type="button" className="btn" onClick={() => toggleRead(active)}>
Mark as {active.is_read ? 'unread' : 'read'}
</button>
</div>
<button type="button" className="btn btn--ghost" onClick={() => removeInquiry(active.id)} style={{ color: 'var(--danger)' }}>
<Icon name="trash" size={15} /> Delete
</button>
</div>
</>
)}
</Modal>
)}
</>
);
}
export default AdminDashboard;