Spaces:
Sleeping
Sleeping
| import { useState, useEffect, useCallback } from 'react'; | |
| import { motion } from 'framer-motion'; | |
| import { HiSearch } from 'react-icons/hi'; | |
| import { HiArrowPath } from 'react-icons/hi2'; | |
| import api from '../../../services/api'; | |
| import { formatDate, formatCurrency } from '../../../utils/helpers'; | |
| import toast from 'react-hot-toast'; | |
| const STATUSES = ['', 'submitted', 'under_review', 'approved', 'rejected', 'fulfilled']; | |
| const STATUS_STYLE = { | |
| submitted: { bg: '#eff6ff', color: '#2563EB', label: 'New' }, | |
| under_review: { bg: '#fffbeb', color: '#D97706', label: 'Reviewing' }, | |
| approved: { bg: '#ecfdf5', color: '#059669', label: 'Approved' }, | |
| rejected: { bg: '#fef2f2', color: '#EF4444', label: 'Rejected' }, | |
| fulfilled: { bg: '#f5f3ff', color: '#7C3AED', label: 'Fulfilled' }, | |
| }; | |
| const URGENCY_STYLE = { | |
| critical: '#EF4444', high: '#F59E0B', medium: '#3B82F6', low: '#10B981', | |
| }; | |
| const NEXT_STATUS = { | |
| submitted: 'under_review', under_review: 'approved', approved: 'fulfilled', | |
| }; | |
| function ReviewModal({ request, onClose, onUpdate }) { | |
| const [status, setStatus] = useState(request.status); | |
| const [notes, setNotes] = useState(request.reviewNotes || ''); | |
| const [saving, setSaving] = useState(false); | |
| const handle = async () => { | |
| setSaving(true); | |
| try { | |
| await api.put(`/help-requests/${request.id}/status`, { status, reviewNotes: notes }); | |
| toast.success('Request updated!'); | |
| onUpdate(); | |
| onClose(); | |
| } catch (e) { toast.error(e.message || 'Update failed.'); } | |
| finally { setSaving(false); } | |
| }; | |
| return ( | |
| <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }} onClick={onClose}> | |
| <motion.div initial={{ scale: 0.9, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} | |
| style={{ background: 'white', borderRadius: 20, padding: '2rem', width: '100%', maxWidth: 560 }} onClick={e => e.stopPropagation()}> | |
| <h2 style={{ marginBottom: '0.25rem', fontSize: '1.1rem' }}>π Review: {request.title}</h2> | |
| <p style={{ fontSize: '0.82rem', color: '#9CA3AF', marginBottom: '1.25rem' }}> | |
| Submitted {formatDate(request.createdAt)} Β· Est. {formatCurrency(request.estimatedAmount || 0)} | |
| </p> | |
| <p style={{ fontSize: '0.88rem', color: '#374151', marginBottom: '1.25rem', background: '#f9fafb', padding: '0.75rem', borderRadius: 10 }}> | |
| {request.description} | |
| </p> | |
| <div style={{ marginBottom: '1rem' }}> | |
| <label className="form-label">Update Status</label> | |
| <select className="form-input" value={status} onChange={e => setStatus(e.target.value)}> | |
| {Object.entries(STATUS_STYLE).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)} | |
| </select> | |
| </div> | |
| <div style={{ marginBottom: '1.25rem' }}> | |
| <label className="form-label">Review Notes (optional)</label> | |
| <textarea className="form-input" rows={3} value={notes} onChange={e => setNotes(e.target.value)} placeholder="Add notes for the citizen..." /> | |
| </div> | |
| <div style={{ display: 'flex', gap: '0.75rem' }}> | |
| <button className="btn btn--ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button> | |
| <button className="btn btn--primary" style={{ flex: 1 }} onClick={handle} disabled={saving}>{saving ? 'Saving...' : 'Update Request'}</button> | |
| </div> | |
| </motion.div> | |
| </div> | |
| ); | |
| } | |
| export default function HelpRequestTriage() { | |
| const [requests, setRequests] = useState([]); | |
| const [stats, setStats] = useState(null); | |
| const [statusFilter, setStatusFilter] = useState(''); | |
| const [search, setSearch] = useState(''); | |
| const [loading, setLoading] = useState(true); | |
| const [reviewing, setReviewing] = useState(null); | |
| const [quickActioning, setQuickActioning] = useState(null); | |
| const load = useCallback(async () => { | |
| setLoading(true); | |
| try { | |
| const [reqs, st] = await Promise.allSettled([ | |
| api.get(`/help-requests/all?limit=100${statusFilter ? '&status=' + statusFilter : ''}`), | |
| api.get('/help-requests/stats'), | |
| ]); | |
| if (reqs.status === 'fulfilled') setRequests(reqs.value.requests || []); | |
| if (st.status === 'fulfilled') setStats(st.value); | |
| } finally { setLoading(false); } | |
| }, [statusFilter]); | |
| useEffect(() => { load(); }, [load]); | |
| const handleQuickAction = async (req, nextStatus) => { | |
| setQuickActioning(req.id); | |
| try { | |
| await api.put(`/help-requests/${req.id}/status`, { status: nextStatus }); | |
| toast.success(`Moved to ${STATUS_STYLE[nextStatus]?.label}`); | |
| await load(); | |
| } catch (e) { toast.error(e.message || 'Action failed.'); } | |
| finally { setQuickActioning(null); } | |
| }; | |
| const filtered = requests.filter(r => | |
| !search || r.title?.toLowerCase().includes(search.toLowerCase()) || | |
| r.requestType?.toLowerCase().includes(search.toLowerCase()) | |
| ); | |
| const statsItems = [ | |
| { label: 'Total', value: stats?.total || 0, color: '#374151' }, | |
| { label: 'New', value: stats?.submitted || 0, color: '#2563EB' }, | |
| { label: 'Reviewing', value: stats?.under_review || 0, color: '#D97706' }, | |
| { label: 'Approved', value: stats?.approved || 0, color: '#059669' }, | |
| { label: 'Fulfilled', value: stats?.fulfilled || 0, color: '#7C3AED' }, | |
| ]; | |
| return ( | |
| <div> | |
| {reviewing && <ReviewModal request={reviewing} onClose={() => setReviewing(null)} onUpdate={load} />} | |
| <div className="dash-page-header"> | |
| <h1>π Help Request Triage</h1> | |
| <p>Review, assign, and resolve citizen help requests.</p> | |
| </div> | |
| {/* Stats */} | |
| <div className="dash-kpi-grid" style={{ marginBottom: '1.25rem' }}> | |
| {statsItems.map(s => ( | |
| <div key={s.label} className="dash-kpi" style={{ padding: '1rem 1.25rem', cursor: 'pointer' }} | |
| onClick={() => setStatusFilter(s.label === 'Total' ? '' : s.label.toLowerCase().replace(' ', '_'))}> | |
| <div className="dash-kpi__body"> | |
| <div className="dash-kpi__value" style={{ color: s.color }}>{s.value}</div> | |
| <div className="dash-kpi__label">{s.label}</div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| {/* Filters */} | |
| <div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1.25rem', flexWrap: 'wrap', alignItems: 'center' }}> | |
| <div style={{ position: 'relative', flex: 1, minWidth: 200, maxWidth: 300 }}> | |
| <HiSearch style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: '#9CA3AF' }} /> | |
| <input className="form-input" style={{ paddingLeft: '2.25rem' }} placeholder="Search requests..." value={search} onChange={e => setSearch(e.target.value)} /> | |
| </div> | |
| <select className="form-input" style={{ width: 'auto', minWidth: 160 }} value={statusFilter} onChange={e => setStatusFilter(e.target.value)}> | |
| <option value="">All Status</option> | |
| {Object.entries(STATUS_STYLE).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)} | |
| </select> | |
| </div> | |
| {/* Requests */} | |
| {loading ? ( | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> | |
| {[1,2,3,4].map(i => <div key={i} className="dash-skeleton" style={{ height: 120 }} />)} | |
| </div> | |
| ) : filtered.length === 0 ? ( | |
| <div className="dash-empty"><div className="dash-empty__icon">β </div><p>No help requests found.</p></div> | |
| ) : ( | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}> | |
| {filtered.map((req, i) => { | |
| const sty = STATUS_STYLE[req.status] || STATUS_STYLE.submitted; | |
| const urgColor = URGENCY_STYLE[req.urgency] || URGENCY_STYLE.medium; | |
| const nextStatus = NEXT_STATUS[req.status]; | |
| return ( | |
| <motion.div key={req.id} className="dash-card" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.05 }}> | |
| <div style={{ display: 'flex', alignItems: 'flex-start', gap: '1rem', flexWrap: 'wrap' }}> | |
| <div style={{ flex: 1, minWidth: 220 }}> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.35rem' }}> | |
| <span style={{ fontWeight: 700, color: '#111827', fontSize: '0.95rem' }}>{req.title}</span> | |
| <span style={{ background: `${urgColor}15`, color: urgColor, padding: '0.15rem 0.5rem', borderRadius: 999, fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase' }}> | |
| {req.urgency} | |
| </span> | |
| <span style={{ background: sty.bg, color: sty.color, padding: '0.15rem 0.5rem', borderRadius: 999, fontSize: '0.7rem', fontWeight: 600 }}> | |
| {sty.label} | |
| </span> | |
| </div> | |
| <p style={{ fontSize: '0.83rem', color: '#6B7280', margin: '0 0 0.4rem' }}>{req.description?.slice(0, 120)}{req.description?.length > 120 ? '...' : ''}</p> | |
| <div style={{ display: 'flex', gap: '1rem', fontSize: '0.75rem', color: '#9CA3AF', flexWrap: 'wrap' }}> | |
| <span>π {req.requestType}</span> | |
| <span>π° Est. {formatCurrency(req.estimatedAmount || 0)}</span> | |
| {req.location?.city && <span>π {req.location.city}</span>} | |
| <span>π {formatDate(req.createdAt)}</span> | |
| </div> | |
| {req.reviewNotes && ( | |
| <div style={{ marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: '#f9fafb', borderRadius: 8, fontSize: '0.78rem', color: '#374151', borderLeft: `3px solid ${sty.color}` }}> | |
| π {req.reviewNotes} | |
| </div> | |
| )} | |
| </div> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', flexShrink: 0 }}> | |
| <button className="btn btn--primary btn--sm" onClick={() => setReviewing(req)} style={{ fontSize: '0.78rem' }}> | |
| π Review | |
| </button> | |
| {nextStatus && ( | |
| <button className="btn btn--ghost btn--sm" style={{ fontSize: '0.78rem' }} | |
| disabled={quickActioning === req.id} onClick={() => handleQuickAction(req, nextStatus)}> | |
| {quickActioning === req.id ? <HiArrowPath style={{ animation: 'spin 0.8s linear infinite' }} /> : `β ${STATUS_STYLE[nextStatus]?.label}`} | |
| </button> | |
| )} | |
| {req.status !== 'rejected' && req.status !== 'fulfilled' && ( | |
| <button className="btn btn--ghost btn--sm" style={{ fontSize: '0.78rem', color: '#EF4444', borderColor: '#fecaca' }} | |
| disabled={quickActioning === req.id} onClick={() => handleQuickAction(req, 'rejected')}> | |
| β Reject | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </motion.div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |