feat: comprehensive UI overhaul + budget allocation redesign

Audit & Quality:
- RTL: replaced 121 LTR-only utilities (text-left, pl-, left-) with logical properties
- A11y: focus traps + ARIA on Modal/SlidePanel/TabbedModal, clickable divs→buttons
- Theming: bg-white→bg-surface (170 instances), text-gray→semantic tokens
- Performance: useMemo on filters, loading="lazy" on 24 images
- CSS: prefers-reduced-motion, removed dead animations

Component Splits:
- PostDetailPanel: 1332→623 lines + 4 sub-components
- ArtefactDetailPanel: 972→590 lines + 1 sub-component

Brand Identity — Rawaj (رواج):
- New name, DM Sans font, deep teal palette (#0d9488)
- Custom SVG logo, forest-tinted dark mode
- All emails branded with app name in subject line

Design Refinement:
- Dashboard: merged posts+deadlines into tabbed ActivityFeed, inline stats
- Quieter: removed card lift, brand glow, gradient text, mesh backgrounds
- CampaignDetail: prominent budget card, compact team avatars, Lucide icons
- Consistent page titles via Header.jsx, standardized section headers
- Finance page fully i18n'd (20+ hardcoded strings replaced)

Budget Allocation Redesign:
- Single source of truth: BudgetEntries (Campaign.budget deprecated)
- Validation at all levels: main→campaign→track, expenses blocked if insufficient
- Budget request workflow with CEO approval via public link
- BudgetRequests table, CRUD routes, public approval page
- Budget mutex for race condition prevention
- Idempotent migration for existing campaign budgets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-15 15:36:19 +03:00
parent 3c857856c5
commit e1d1c392eb
77 changed files with 4351 additions and 2108 deletions
+246
View File
@@ -0,0 +1,246 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { CheckCircle, XCircle, DollarSign, User, FileText, Clock, Sparkles } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
export default function PublicBudgetApproval() {
const { token } = useParams()
const { t, currencySymbol } = useLanguage()
const [request, setRequest] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [expired, setExpired] = useState(false)
const [success, setSuccess] = useState('')
const [note, setNote] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => { loadRequest() }, [token])
const loadRequest = async () => {
try {
const res = await fetch(`/api/budget-approval/${token}`)
if (!res.ok) {
const err = await res.json()
if (res.status === 410 || err.error?.toLowerCase().includes('expired')) {
setExpired(true)
} else {
setError(err.error || t('budgetApproval.loadFailed') || 'Failed to load request')
}
setLoading(false)
return
}
const data = await res.json()
setRequest(data)
} catch {
setError(t('budgetApproval.loadFailed') || 'Failed to load request')
} finally {
setLoading(false)
}
}
const handleAction = async (action) => {
setSubmitting(true)
try {
const res = await fetch(`/api/budget-approval/${token}/respond`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, note: note.trim() || undefined }),
})
if (!res.ok) {
const err = await res.json()
setError(err.error || t('budgetApproval.actionFailed') || 'Action failed')
setSubmitting(false)
return
}
setSuccess(action === 'approve'
? (t('budgetApproval.approved') || 'Budget request approved')
: (t('budgetApproval.rejected') || 'Budget request rejected'))
} catch {
setError(t('budgetApproval.actionFailed') || 'Action failed')
} finally {
setSubmitting(false)
}
}
// Loading state
if (loading) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-brand-primary border-t-transparent rounded-full animate-spin" />
</div>
)
}
// Expired state
if (expired) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl p-8 text-center">
<div className="w-16 h-16 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-4">
<Clock className="w-8 h-8 text-amber-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">{t('budgetApproval.expired') || 'Request Expired'}</h2>
<p className="text-gray-500">{t('budgetApproval.expiredDesc') || 'This budget approval request has expired.'}</p>
</div>
</div>
)
}
// Error state
if (error) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl p-8 text-center">
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<XCircle className="w-8 h-8 text-red-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">{t('budgetApproval.error') || 'Error'}</h2>
<p className="text-gray-500">{error}</p>
</div>
</div>
)
}
// Success state
if (success) {
const isApproved = success.toLowerCase().includes('approved') || success.toLowerCase().includes('approve')
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl p-8 text-center">
<div className={`w-16 h-16 rounded-full ${isApproved ? 'bg-emerald-100' : 'bg-red-100'} flex items-center justify-center mx-auto mb-4`}>
{isApproved
? <CheckCircle className="w-8 h-8 text-emerald-600" />
: <XCircle className="w-8 h-8 text-red-600" />
}
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">{t('budgetApproval.thankYou') || 'Thank You'}</h2>
<p className="text-gray-500">{success}</p>
</div>
</div>
)
}
if (!request) return null
// Already handled (not pending)
if (request.status && request.status !== 'pending') {
const statusLabel = request.status.charAt(0).toUpperCase() + request.status.slice(1)
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl p-8 text-center">
<div className="w-16 h-16 rounded-full bg-blue-100 flex items-center justify-center mx-auto mb-4">
<FileText className="w-8 h-8 text-blue-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">{t('budgetApproval.alreadyHandled') || 'Already Handled'}</h2>
<p className="text-gray-500">
{t('budgetApproval.statusIs') || 'This request has been'}: <span className="font-semibold">{statusLabel}</span>
</p>
</div>
</div>
)
}
// Active state — show request details + approve/reject
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4 py-12">
<div className="max-w-lg w-full">
{/* Header card */}
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden">
<div className="bg-brand-primary px-8 py-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center">
<Sparkles className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-white">{t('budgetApproval.title') || 'Budget Approval'}</h1>
<p className="text-white/80 text-sm">Rawaj</p>
</div>
</div>
</div>
<div className="p-8 space-y-6">
{/* Amount */}
<div className="text-center">
<div className="inline-flex items-center gap-2 bg-emerald-50 px-6 py-4 rounded-2xl">
<DollarSign className="w-6 h-6 text-emerald-600" />
<span className="text-3xl font-bold text-emerald-700">
{Number(request.amount).toLocaleString()} {currencySymbol}
</span>
</div>
</div>
{/* Requested by */}
{request.requested_by_name && (
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center shrink-0">
<User className="w-4 h-4 text-slate-500" />
</div>
<div>
<p className="text-xs text-gray-400 uppercase tracking-wider">{t('budgetApproval.requestedBy') || 'Requested by'}</p>
<p className="text-sm font-semibold text-gray-900">{request.requested_by_name}</p>
</div>
</div>
)}
{/* Justification */}
<div>
<p className="text-xs text-gray-400 uppercase tracking-wider mb-1">{t('budgetApproval.justification') || 'Justification'}</p>
<p className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 rounded-xl p-4 border border-gray-100">
{request.justification}
</p>
</div>
{/* Earmarked for */}
{request.earmark_name && (
<div>
<p className="text-xs text-gray-400 uppercase tracking-wider mb-1">{t('budgetApproval.earmarkedFor') || 'Earmarked for'}</p>
<p className="text-sm font-medium text-gray-700">
{request.earmark_type && <span className="text-gray-400 capitalize">{request.earmark_type}: </span>}
{request.earmark_name}
</p>
</div>
)}
{/* Note textarea */}
<div>
<label className="block text-xs text-gray-400 uppercase tracking-wider mb-1">
{t('budgetApproval.note') || 'Note'} ({t('common.optional') || 'optional'})
</label>
<textarea
value={note}
onChange={e => setNote(e.target.value)}
rows={3}
placeholder={t('budgetApproval.notePlaceholder') || 'Add a note...'}
className="w-full px-4 py-2.5 text-sm border border-gray-200 rounded-xl bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
{/* Action buttons */}
<div className="grid grid-cols-2 gap-3 pt-2">
<button
onClick={() => handleAction('approve')}
disabled={submitting}
className="flex items-center justify-center gap-2 px-4 py-3.5 bg-emerald-600 text-white rounded-xl font-medium hover:bg-emerald-700 transition-colors disabled:opacity-50 shadow-sm"
>
<CheckCircle className="w-5 h-5" />
{t('budgetApproval.approve') || 'Approve'}
</button>
<button
onClick={() => handleAction('reject')}
disabled={submitting}
className="flex items-center justify-center gap-2 px-4 py-3.5 bg-red-600 text-white rounded-xl font-medium hover:bg-red-700 transition-colors disabled:opacity-50 shadow-sm"
>
<XCircle className="w-5 h-5" />
{t('budgetApproval.reject') || 'Reject'}
</button>
</div>
</div>
</div>
<div className="text-center text-slate-500 text-sm mt-6">
<p>{t('review.poweredBy') || 'Powered by Rawaj'}</p>
</div>
</div>
</div>
)
}