From e1d1c392ebbf559cfd23ed4523146307627c39dc Mon Sep 17 00:00:00 2001 From: fahed Date: Sun, 15 Mar 2026 15:36:19 +0300 Subject: [PATCH] feat: comprehensive UI overhaul + budget allocation redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- client/index.html | 2 +- client/src/App.jsx | 4 +- client/src/components/ApproverMultiSelect.jsx | 4 +- client/src/components/ArtefactDetailPanel.jsx | 450 +--------- .../components/ArtefactDetailVersionsTab.jsx | 429 +++++++++ .../components/ArtefactVersionTimeline.jsx | 1 + client/src/components/AssetCard.jsx | 3 +- client/src/components/CampaignCalendar.jsx | 6 +- client/src/components/CampaignDetailPanel.jsx | 6 +- client/src/components/CommentsSection.jsx | 4 +- client/src/components/DatePresetPicker.jsx | 2 +- client/src/components/EmptyState.jsx | 6 +- client/src/components/FormInput.jsx | 6 +- client/src/components/Header.jsx | 16 +- client/src/components/InteractiveTimeline.jsx | 24 +- client/src/components/KanbanCard.jsx | 4 +- client/src/components/MemberCard.jsx | 4 +- client/src/components/Modal.jsx | 81 +- client/src/components/PostCard.jsx | 4 +- client/src/components/PostDetailApproval.jsx | 109 +++ .../src/components/PostDetailAttachments.jsx | 247 ++++++ client/src/components/PostDetailPanel.jsx | 833 ++---------------- client/src/components/PostDetailPlatforms.jsx | 92 ++ client/src/components/PostDetailVersions.jsx | 391 ++++++++ client/src/components/ProjectCard.jsx | 4 +- client/src/components/ProjectEditPanel.jsx | 6 +- client/src/components/Sidebar.jsx | 17 +- client/src/components/SkeletonLoader.jsx | 12 +- client/src/components/SlidePanel.jsx | 37 +- client/src/components/StatCard.jsx | 12 +- client/src/components/TabbedModal.jsx | 49 +- client/src/components/TaskCalendarView.jsx | 12 +- client/src/components/TaskCard.jsx | 2 +- client/src/components/TaskDetailPanel.jsx | 22 +- client/src/components/TeamMemberPanel.jsx | 14 +- client/src/components/TeamPanel.jsx | 4 +- client/src/components/ThemeToggle.jsx | 2 +- client/src/components/ToastContainer.jsx | 2 +- client/src/components/TrackDetailPanel.jsx | 2 +- .../src/components/TranslationDetailPanel.jsx | 4 +- client/src/components/Tutorial.jsx | 4 +- client/src/i18n/ar.json | 74 +- client/src/i18n/en.json | 80 +- client/src/index.css | 213 ++--- client/src/pages/Artefacts.jsx | 38 +- client/src/pages/Assets.jsx | 16 +- client/src/pages/Brands.jsx | 12 +- client/src/pages/Budgets.jsx | 36 +- client/src/pages/CampaignDetail.jsx | 163 ++-- client/src/pages/Campaigns.jsx | 22 +- client/src/pages/Dashboard.jsx | 335 +++---- client/src/pages/Finance.jsx | 298 ++++++- client/src/pages/ForgotPassword.jsx | 23 +- client/src/pages/Issues.jsx | 38 +- client/src/pages/Login.jsx | 54 +- client/src/pages/PostCalendar.jsx | 20 +- client/src/pages/PostProduction.jsx | 42 +- client/src/pages/ProjectDetail.jsx | 38 +- client/src/pages/Projects.jsx | 8 +- client/src/pages/PublicBudgetApproval.jsx | 246 ++++++ client/src/pages/PublicIssueTracker.jsx | 14 +- client/src/pages/PublicPostReview.jsx | 4 +- client/src/pages/PublicReview.jsx | 4 +- client/src/pages/PublicTranslationReview.jsx | 2 +- client/src/pages/ResetPassword.jsx | 29 +- client/src/pages/Settings.jsx | 86 +- client/src/pages/Tasks.jsx | 46 +- client/src/pages/Team.jsx | 54 +- client/src/pages/Translations.jsx | 26 +- .../2026-03-15-budget-allocation-redesign.md | 635 +++++++++++++ .../2026-03-15-budget-allocation-redesign.md | 245 ++++++ server/app-settings.json | 3 +- server/budget-helpers.js | 48 + server/budget-mutex.js | 13 + server/notifications.js | 75 +- server/server.js | 480 +++++++++- server/setup-tables.js | 6 +- 77 files changed, 4351 insertions(+), 2108 deletions(-) create mode 100644 client/src/components/ArtefactDetailVersionsTab.jsx create mode 100644 client/src/components/PostDetailApproval.jsx create mode 100644 client/src/components/PostDetailAttachments.jsx create mode 100644 client/src/components/PostDetailPlatforms.jsx create mode 100644 client/src/components/PostDetailVersions.jsx create mode 100644 client/src/pages/PublicBudgetApproval.jsx create mode 100644 docs/superpowers/plans/2026-03-15-budget-allocation-redesign.md create mode 100644 docs/superpowers/specs/2026-03-15-budget-allocation-redesign.md create mode 100644 server/budget-helpers.js create mode 100644 server/budget-mutex.js diff --git a/client/index.html b/client/index.html index dc9efea..f331249 100644 --- a/client/index.html +++ b/client/index.html @@ -7,7 +7,7 @@ - Digital Hub + Rawaj
diff --git a/client/src/App.jsx b/client/src/App.jsx index f6acf08..42dacd9 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -37,6 +37,7 @@ const PublicIssueSubmit = lazy(() => import('./pages/PublicIssueSubmit')) const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker')) const Translations = lazy(() => import('./pages/Translations')) const PublicTranslationReview = lazy(() => import('./pages/PublicTranslationReview')) +const PublicBudgetApproval = lazy(() => import('./pages/PublicBudgetApproval')) const ForgotPassword = lazy(() => import('./pages/ForgotPassword')) const ResetPassword = lazy(() => import('./pages/ResetPassword')) @@ -161,7 +162,7 @@ function AppContent() { {/* Profile completion prompt */} {showProfilePrompt && ( -
+
⚠️ @@ -298,6 +299,7 @@ function AppContent() { } /> } /> } /> + } /> : }> } /> {hasModule('marketing') && <> diff --git a/client/src/components/ApproverMultiSelect.jsx b/client/src/components/ApproverMultiSelect.jsx index ffb9bfb..6f9758b 100644 --- a/client/src/components/ApproverMultiSelect.jsx +++ b/client/src/components/ApproverMultiSelect.jsx @@ -64,7 +64,7 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang ))} - +
{open && (
@@ -76,7 +76,7 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang key={uid} type="button" onClick={() => toggle(uid)} - className={`w-full text-left px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between ${ + className={`w-full text-start px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between ${ isSelected ? 'text-brand-primary font-medium' : 'text-text-primary' }`} > diff --git a/client/src/components/ArtefactDetailPanel.jsx b/client/src/components/ArtefactDetailPanel.jsx index 5c65623..d66a9e5 100644 --- a/client/src/components/ArtefactDetailPanel.jsx +++ b/client/src/components/ArtefactDetailPanel.jsx @@ -1,13 +1,13 @@ import { useState, useEffect, useContext } from 'react' -import { Plus, Copy, Check, ExternalLink, Upload, Globe, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save, FileEdit, Layers, ShieldCheck } from 'lucide-react' +import { Copy, Check, ExternalLink, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save, FileEdit, Layers, ShieldCheck } from 'lucide-react' import { AppContext } from '../App' import { useLanguage } from '../i18n/LanguageContext' import { api } from '../utils/api' import Modal from './Modal' import TabbedModal from './TabbedModal' import { useToast } from './ToastContainer' -import ArtefactVersionTimeline from './ArtefactVersionTimeline' import ApproverMultiSelect from './ApproverMultiSelect' +import { ArtefactDetailVersionsTab } from './ArtefactDetailVersionsTab' const STATUS_COLORS = { draft: 'bg-surface-tertiary text-text-secondary', @@ -17,13 +17,6 @@ const STATUS_COLORS = { revision_requested: 'bg-orange-100 text-orange-700', } -const AVAILABLE_LANGUAGES = [ - { code: 'AR', label: '\u0627\u0644\u0639\u0631\u0628\u064A\u0629' }, - { code: 'EN', label: 'English' }, - { code: 'FR', label: 'Fran\u00E7ais' }, - { code: 'ID', label: 'Bahasa Indonesia' }, -] - const TYPE_ICONS = { copy: FileText, design: ImageIcon, @@ -55,27 +48,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel const reviewUrl = freshReviewUrl || (artefact.approval_token ? `${window.location.origin}/review/${artefact.approval_token}` : '') const [savingDraft, setSavingDraft] = useState(false) const [deleting, setDeleting] = useState(false) - const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null) - const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null) const [showDeleteArtefactConfirm, setShowDeleteArtefactConfirm] = useState(false) - // Language management (for copy type) - const [showLanguageModal, setShowLanguageModal] = useState(false) - const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' }) - const [savingLanguage, setSavingLanguage] = useState(false) - - // New version modal - const [showNewVersionModal, setShowNewVersionModal] = useState(false) - const [newVersionNotes, setNewVersionNotes] = useState('') - const [copyFromPrevious, setCopyFromPrevious] = useState(false) - const [creatingVersion, setCreatingVersion] = useState(false) - // File upload (for design/video) const [uploading, setUploading] = useState(false) - - // Video inline (Drive link input) - const [driveUrl, setDriveUrl] = useState('') - const [dragOver, setDragOver] = useState(false) const [uploadProgress, setUploadProgress] = useState(0) // Comments @@ -137,57 +113,23 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel loadVersionData(version.Id) } - const handleCreateVersion = async () => { - setCreatingVersion(true) - try { - await api.post(`/artefacts/${artefact.Id}/versions`, { - notes: newVersionNotes || `Version ${(versions[versions.length - 1]?.version_number || 0) + 1}`, - copy_from_previous: artefact.type === 'copy' ? copyFromPrevious : false, - }) - - toast.success(t('artefacts.versionCreated')) - setShowNewVersionModal(false) - setNewVersionNotes('') - setCopyFromPrevious(false) - loadVersions() - onUpdate() - } catch (err) { - console.error('Create version failed:', err) - toast.error(t('artefacts.failedCreateVersion')) - } finally { - setCreatingVersion(false) - } + const handleCreateVersion = async ({ notes, copy_from_previous }) => { + await api.post(`/artefacts/${artefact.Id}/versions`, { notes, copy_from_previous }) + toast.success(t('artefacts.versionCreated')) + loadVersions() + onUpdate() } - const handleAddLanguage = async () => { - if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) { - toast.error(t('artefacts.allFieldsRequired')) - return - } - - setSavingLanguage(true) - try { - await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm) - toast.success(t('artefacts.languageAdded')) - setShowLanguageModal(false) - setLanguageForm({ language_code: '', language_label: '', content: '' }) - loadVersionData(selectedVersion.Id) - } catch (err) { - console.error('Add language failed:', err) - toast.error(t('artefacts.failedAddLanguage')) - } finally { - setSavingLanguage(false) - } + const handleAddLanguage = async (languageForm) => { + await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm) + toast.success(t('artefacts.languageAdded')) + loadVersionData(selectedVersion.Id) } const handleDeleteLanguage = async (textId) => { - try { - await api.delete(`/artefact-version-texts/${textId}`) - toast.success(t('artefacts.languageDeleted')) - loadVersionData(selectedVersion.Id) - } catch (err) { - toast.error(t('artefacts.failedDeleteLanguage')) - } + await api.delete(`/artefact-version-texts/${textId}`) + toast.success(t('artefacts.languageDeleted')) + loadVersionData(selectedVersion.Id) } const handleFileUpload = async (fileOrEvent) => { @@ -215,16 +157,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel } } - const handleVideoDrop = (e) => { - e.preventDefault() - setDragOver(false) - const file = e.dataTransfer.files?.[0] - if (file && file.type.startsWith('video/')) { - handleFileUpload(file) - } - } - - const handleAddDriveVideo = async () => { + const handleAddDriveVideo = async (driveUrl) => { if (!driveUrl.trim()) { toast.error(t('artefacts.enterDriveUrl')) return @@ -236,7 +169,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel drive_url: driveUrl, }) toast.success(t('artefacts.videoLinkAdded')) - setDriveUrl('') loadVersionData(selectedVersion.Id) } catch (err) { console.error('Add Drive link failed:', err) @@ -247,13 +179,9 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel } const handleDeleteAttachment = async (attId) => { - try { - await api.delete(`/artefact-attachments/${attId}`) - toast.success(t('artefacts.attachmentDeleted')) - loadVersionData(selectedVersion.Id) - } catch (err) { - toast.error(t('artefacts.failedDeleteAttachment')) - } + await api.delete(`/artefact-attachments/${attId}`) + toast.success(t('artefacts.attachmentDeleted')) + loadVersionData(selectedVersion.Id) } const handleSubmitReview = async () => { @@ -501,213 +429,22 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel {/* Versions Tab */} {activeTab === 'versions' && ( -
- {/* Version Timeline */} -
-
-

{t('artefacts.versions')}

- -
- -
- - {/* Type-specific content */} - {versionData && selectedVersion && ( -
- {/* COPY TYPE: Language entries */} - {artefact.type === 'copy' && ( -
-
-

{t('artefacts.languages')}

- -
- - {versionData.texts && versionData.texts.length > 0 ? ( -
- {versionData.texts.map(text => ( -
-
-
- - {text.language_code} - - {text.language_label} -
- -
-
- {text.content} -
-
- ))} -
- ) : ( -
- -

{t('artefacts.noLanguages')}

-
- )} -
- )} - - {/* DESIGN TYPE: Image gallery */} - {artefact.type === 'design' && ( -
-
-

{t('artefacts.imagesLabel')}

- -
- - {versionData.attachments && versionData.attachments.length > 0 ? ( -
- {versionData.attachments.map(att => ( -
- {att.original_name} -
- -
-
- {att.original_name} -
-
- ))} -
- ) : ( -
- -

{t('artefacts.noImages')}

-
- )} -
- )} - - {/* VIDEO TYPE: Files and Drive links — all inline */} - {artefact.type === 'video' && ( -
-

{t('artefacts.videosLabel')}

- - {/* Existing attachments */} - {versionData.attachments && versionData.attachments.length > 0 && ( -
- {versionData.attachments.map(att => ( -
- {att.drive_url ? ( -
-
- {t('artefacts.googleDriveVideo')} - -
-