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
+23 -23
View File
@@ -325,16 +325,16 @@ export default function Tasks() {
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* Search */}
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder={t('tasks.search')}
className="w-full pl-9 pr-3 py-1.5 text-sm border border-border rounded-lg bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
className="w-full ps-9 pe-3 py-1.5 text-sm border border-border rounded-lg bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-tertiary hover:text-text-primary">
<button onClick={() => setSearchQuery('')} className="absolute end-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-tertiary hover:text-text-primary">
<X className="w-3.5 h-3.5" />
</button>
)}
@@ -350,7 +350,7 @@ export default function Tasks() {
onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode
? 'bg-white text-text-primary shadow-sm'
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
@@ -399,7 +399,7 @@ export default function Tasks() {
<select
value={filterProject}
onChange={e => setFilterProject(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allProjects')}</option>
{taskProjects.map(p => (
@@ -411,7 +411,7 @@ export default function Tasks() {
<select
value={filterBrand}
onChange={e => setFilterBrand(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allBrands')}</option>
{taskBrands.map(b => (
@@ -440,7 +440,7 @@ export default function Tasks() {
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
active
? 'bg-brand-primary/10 border-brand-primary/20 text-brand-primary'
: 'bg-white border-border text-text-tertiary'
: 'bg-surface border-border text-text-tertiary'
}`}
>
{t(`tasks.${s}`)}
@@ -453,7 +453,7 @@ export default function Tasks() {
<select
value={filterPriority}
onChange={e => setFilterPriority(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allPriorities')}</option>
<option value="low">{t('tasks.priority.low')}</option>
@@ -466,7 +466,7 @@ export default function Tasks() {
<select
value={filterAssignee}
onChange={e => setFilterAssignee(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allAssignees')}</option>
{(assignableUsers || []).map(m => (
@@ -479,7 +479,7 @@ export default function Tasks() {
<select
value={filterCreator}
onChange={e => setFilterCreator(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allCreators')}</option>
{users.map(m => (
@@ -501,7 +501,7 @@ export default function Tasks() {
type="date"
value={filterDateFrom}
onChange={e => { setFilterDateFrom(e.target.value); setActivePreset('') }}
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
title={t('posts.periodFrom')}
/>
<span className="text-text-tertiary text-xs">-</span>
@@ -509,7 +509,7 @@ export default function Tasks() {
type="date"
value={filterDateTo}
onChange={e => { setFilterDateTo(e.target.value); setActivePreset('') }}
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
title={t('posts.periodTo')}
/>
</div>
@@ -520,7 +520,7 @@ export default function Tasks() {
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
filterOverdue
? 'bg-red-50 border-red-200 text-red-600'
: 'bg-white border-border text-text-tertiary'
: 'bg-surface border-border text-text-tertiary'
}`}
>
{t('tasks.overdue')}
@@ -599,7 +599,7 @@ export default function Tasks() {
onDelete={() => setShowBulkDeleteConfirm(true)}
/>
)}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="bg-surface rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-secondary/50">
@@ -614,28 +614,28 @@ export default function Tasks() {
</th>
<th className="w-8 px-3 py-2.5"></th>
<th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('title')}
>
{t('tasks.taskTitle')} {sortBy === 'title' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.project')}</th>
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.brand')}</th>
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.project')}</th>
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.brand')}</th>
<th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('status')}
>
{t('tasks.status')} {sortBy === 'status' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.assignee')}</th>
<th className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.assignee')}</th>
<th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('due_date')}
>
{t('tasks.dueDate')} {sortBy === 'due_date' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
className="text-start px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('priority')}
>
{t('tasks.priority')} {sortBy === 'priority' && (sortDir === 'asc' ? '↑' : '↓')}
@@ -651,7 +651,7 @@ export default function Tasks() {
const brandName = task.brand_name || task.brandName
const assignedName = task.assigned_name || task.assignedName
const statusLabels = { todo: t('tasks.todo'), in_progress: t('tasks.in_progress'), done: t('tasks.done') }
const statusColors = { todo: 'bg-gray-100 text-gray-600', in_progress: 'bg-blue-100 text-blue-700', done: 'bg-emerald-100 text-emerald-700' }
const statusColors = { todo: 'bg-gray-100 text-text-secondary', in_progress: 'bg-blue-100 text-blue-700', done: 'bg-emerald-100 text-emerald-700' }
return (
<tr
@@ -675,7 +675,7 @@ export default function Tasks() {
{task.title}
</span>
{(task.comment_count || task.commentCount) > 0 && (
<span className="ml-2 text-[10px] text-text-tertiary">💬 {task.comment_count || task.commentCount}</span>
<span className="ms-2 text-[10px] text-text-tertiary">💬 {task.comment_count || task.commentCount}</span>
)}
</td>
<td className="px-3 py-2.5 text-text-tertiary text-xs">{projectName || '—'}</td>