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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user