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
+71 -15
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useContext } from 'react'
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload, Tag, Plus, Pencil, Trash2, X } from 'lucide-react'
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload, Tag, Plus, Pencil, Trash2, X, Mail } from 'lucide-react'
import { api } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
import { useToast } from '../components/ToastContainer'
@@ -23,9 +23,15 @@ export default function Settings() {
const [maxSizeMB, setMaxSizeMB] = useState(50)
const [sizeSaving, setSizeSaving] = useState(false)
const [sizeSaved, setSizeSaved] = useState(false)
const [ceoEmail, setCeoEmail] = useState('')
const [ceoSaving, setCeoSaving] = useState(false)
const [ceoSaved, setCeoSaved] = useState(false)
useEffect(() => {
api.get('/settings/app').then(s => setMaxSizeMB(s.uploadMaxSizeMB || 50)).catch(() => {})
api.get('/settings/app').then(s => {
setMaxSizeMB(s.uploadMaxSizeMB || 50)
if (s.ceoEmail) setCeoEmail(s.ceoEmail)
}).catch(() => {})
}, [])
const handleSaveMaxSize = async () => {
@@ -65,9 +71,9 @@ export default function Settings() {
<p className="text-sm text-text-tertiary">{t('settings.preferences')}</p>
{/* General Settings */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary">{t('settings.general')}</h2>
<h3 className="font-semibold text-text-primary">{t('settings.general')}</h3>
</div>
<div className="p-6 space-y-4">
{/* Language Selector */}
@@ -79,7 +85,7 @@ export default function Settings() {
<select
value={lang}
onChange={(e) => setLang(e.target.value)}
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
>
<option value="en">{t('settings.english')}</option>
<option value="ar">{t('settings.arabic')}</option>
@@ -95,7 +101,7 @@ export default function Settings() {
<select
value={currency}
onChange={(e) => setCurrency(e.target.value)}
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
>
{CURRENCIES.map(c => (
<option key={c.code} value={c.code}>
@@ -109,12 +115,12 @@ export default function Settings() {
</div>
{/* Uploads Section */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<Upload className="w-5 h-5 text-brand-primary" />
{t('settings.uploads')}
</h2>
</h3>
</div>
<div className="p-6 space-y-4">
<div>
@@ -128,7 +134,7 @@ export default function Settings() {
max="500"
value={maxSizeMB}
onChange={(e) => setMaxSizeMB(Number(e.target.value))}
className="w-24 px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
className="w-24 px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
/>
<span className="text-sm text-text-secondary">{t('settings.mb')}</span>
<button
@@ -147,9 +153,9 @@ export default function Settings() {
</div>
{/* Tutorial Section */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h2>
<h3 className="font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h3>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-text-secondary">
@@ -180,6 +186,56 @@ export default function Settings() {
</div>
</div>
{/* Budget Approval (Superadmin only) */}
{user?.role === 'superadmin' && (
<div className="bg-surface rounded-xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<Mail className="w-5 h-5 text-brand-primary" />
{t('settings.budgetApproval') || 'Budget Approval'}
</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
{t('settings.ceoEmail')}
</label>
<div className="flex items-center gap-3">
<input
type="email"
value={ceoEmail}
onChange={(e) => setCeoEmail(e.target.value)}
placeholder="ceo@company.com"
className="flex-1 max-w-sm px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
/>
<button
onClick={async () => {
setCeoSaving(true)
setCeoSaved(false)
try {
await api.patch('/settings/app', { ceoEmail })
setCeoSaved(true)
setTimeout(() => setCeoSaved(false), 2000)
} catch (err) {
toast.error(err.message || t('settings.saveFailed'))
} finally {
setCeoSaving(false)
}
}}
disabled={ceoSaving}
className="px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
>
{ceoSaved ? (
<span className="flex items-center gap-1.5"><CheckCircle className="w-4 h-4" />{t('settings.saved')}</span>
) : ceoSaving ? '...' : t('common.save')}
</button>
</div>
<p className="text-xs text-text-tertiary mt-1.5">{t('settings.ceoEmailHint')}</p>
</div>
</div>
</div>
)}
{/* Roles Management (Superadmin only) */}
{user?.role === 'superadmin' && <RolesSection roles={roles} loadRoles={loadRoles} t={t} toast={toast} />}
</div>
@@ -235,12 +291,12 @@ function RolesSection({ roles, loadRoles, t, toast }) {
return (
<>
<div className="bg-white dark:bg-surface-primary rounded-xl border border-border overflow-hidden">
<div className="bg-surface dark:bg-surface-primary rounded-xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<Tag className="w-5 h-5 text-brand-primary" />
{t('settings.roles')}
</h2>
</h3>
<button
onClick={openAddModal}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"