e1d1c392eb
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>
247 lines
10 KiB
React
247 lines
10 KiB
React
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>
|
|
)
|
|
}
|