From e76be7849816f1e5d139cccc75f88f36c3e71ea8 Mon Sep 17 00:00:00 2001 From: fahed Date: Sun, 15 Feb 2026 15:49:28 +0300 Subject: [PATCH] Dashboard fix, expense system, currency settings, visual upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Dashboard stat card: show "Budget Remaining" instead of "Budget Spent" with correct remaining value accounting for campaign allocations - Add expense system: budget entries now have income/expense type with server-side split, per-campaign and per-project expense tracking, colored amounts, type filters, and summary bar in Budgets page - Add configurable currency in Settings (SAR ⃁ default, supports 10 currencies) replacing all hardcoded SAR references across the app - Replace PiggyBank icon with Landmark (culturally appropriate for KSA) - Visual upgrade: mesh background, gradient text, premium stat cards with accent bars, section-card containers, sidebar active glow - UX polish: consistent text-2xl headers, skeleton loaders for Finance and Budgets pages - Finance page: expenses column in campaign/project breakdown tables, ROI accounts for expenses, expense stat card Co-Authored-By: Claude Opus 4.6 --- client/src/components/BudgetBar.jsx | 7 +- client/src/components/CampaignDetailPanel.jsx | 427 +++++++++++ client/src/components/Layout.jsx | 2 +- client/src/components/Sidebar.jsx | 164 ++++- client/src/components/StatCard.jsx | 22 +- client/src/components/TrackDetailPanel.jsx | 307 ++++++++ client/src/i18n/LanguageContext.jsx | 29 +- client/src/i18n/ar.json | 192 ++++- client/src/i18n/en.json | 192 ++++- client/src/index.css | 79 ++ client/src/pages/Budgets.jsx | 487 ++++++++++++ client/src/pages/CampaignDetail.jsx | 691 +++--------------- client/src/pages/Campaigns.jsx | 411 +---------- client/src/pages/Dashboard.jsx | 289 ++++++-- client/src/pages/Finance.jsx | 395 ++++------ client/src/pages/Settings.jsx | 25 +- server/server.js | 477 +++++++++++- 17 files changed, 2817 insertions(+), 1379 deletions(-) create mode 100644 client/src/components/CampaignDetailPanel.jsx create mode 100644 client/src/components/TrackDetailPanel.jsx create mode 100644 client/src/pages/Budgets.jsx diff --git a/client/src/components/BudgetBar.jsx b/client/src/components/BudgetBar.jsx index 65d9eac..fb685fa 100644 --- a/client/src/components/BudgetBar.jsx +++ b/client/src/components/BudgetBar.jsx @@ -1,4 +1,7 @@ +import { useLanguage } from '../i18n/LanguageContext' + export default function BudgetBar({ budget, spent, height = 'h-1.5' }) { + const { currencySymbol } = useLanguage() if (!budget || budget <= 0) return null const pct = Math.min((spent / budget) * 100, 100) @@ -9,8 +12,8 @@ export default function BudgetBar({ budget, spent, height = 'h-1.5' }) { return (
- {(spent || 0).toLocaleString()} SAR spent - {budget.toLocaleString()} SAR + {(spent || 0).toLocaleString()} {currencySymbol} spent + {budget.toLocaleString()} {currencySymbol}
diff --git a/client/src/components/CampaignDetailPanel.jsx b/client/src/components/CampaignDetailPanel.jsx new file mode 100644 index 0000000..0308c02 --- /dev/null +++ b/client/src/components/CampaignDetailPanel.jsx @@ -0,0 +1,427 @@ +import { useState, useEffect } from 'react' +import { X, Trash2, DollarSign, Eye, MousePointer, Target } from 'lucide-react' +import { useLanguage } from '../i18n/LanguageContext' +import { PLATFORMS, getBrandColor } from '../utils/api' +import CommentsSection from './CommentsSection' +import Modal from './Modal' +import SlidePanel from './SlidePanel' +import CollapsibleSection from './CollapsibleSection' +import BudgetBar from './BudgetBar' + +export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelete, brands, permissions }) { + const { t, lang, currencySymbol } = useLanguage() + const [form, setForm] = useState({}) + const [dirty, setDirty] = useState(false) + const [saving, setSaving] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + const campaignId = campaign?._id || campaign?.id + const isCreateMode = !campaignId + + useEffect(() => { + if (campaign) { + setForm({ + name: campaign.name || '', + description: campaign.description || '', + brand_id: campaign.brandId || campaign.brand_id || '', + status: campaign.status || 'planning', + start_date: campaign.startDate ? new Date(campaign.startDate).toISOString().slice(0, 10) : (campaign.start_date || ''), + end_date: campaign.endDate ? new Date(campaign.endDate).toISOString().slice(0, 10) : (campaign.end_date || ''), + budget: campaign.budget || '', + goals: campaign.goals || '', + platforms: campaign.platforms || [], + budget_spent: campaign.budgetSpent || campaign.budget_spent || '', + revenue: campaign.revenue || '', + impressions: campaign.impressions || '', + clicks: campaign.clicks || '', + conversions: campaign.conversions || '', + notes: campaign.notes || '', + }) + setDirty(isCreateMode) + } + }, [campaign]) + + if (!campaign) return null + + const statusOptions = [ + { value: 'planning', label: 'Planning' }, + { value: 'active', label: 'Active' }, + { value: 'paused', label: 'Paused' }, + { value: 'completed', label: 'Completed' }, + { value: 'cancelled', label: 'Cancelled' }, + ] + + const update = (field, value) => { + setForm(f => ({ ...f, [field]: value })) + setDirty(true) + } + + const handleSave = async () => { + setSaving(true) + try { + const data = { + name: form.name, + description: form.description, + brand_id: form.brand_id ? Number(form.brand_id) : null, + status: form.status, + start_date: form.start_date, + end_date: form.end_date, + budget: form.budget ? Number(form.budget) : null, + goals: form.goals, + platforms: form.platforms || [], + budget_spent: form.budget_spent ? Number(form.budget_spent) : 0, + revenue: form.revenue ? Number(form.revenue) : 0, + impressions: form.impressions ? Number(form.impressions) : 0, + clicks: form.clicks ? Number(form.clicks) : 0, + conversions: form.conversions ? Number(form.conversions) : 0, + notes: form.notes || '', + } + await onSave(isCreateMode ? null : campaignId, data) + setDirty(false) + if (isCreateMode) onClose() + } finally { + setSaving(false) + } + } + + const confirmDelete = async () => { + setShowDeleteConfirm(false) + await onDelete(campaignId) + onClose() + } + + const brandName = (() => { + if (form.brand_id) { + const b = brands?.find(b => String(b._id || b.id) === String(form.brand_id)) + return b ? (lang === 'ar' && b.name_ar ? b.name_ar : b.name) : null + } + return campaign.brand_name || campaign.brandName || null + })() + + const header = ( +
+
+
+ update('name', e.target.value)} + className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0" + placeholder={t('campaigns.name')} + /> +
+ + {statusOptions.find(s => s.value === form.status)?.label} + + {brandName && ( + + {brandName} + + )} +
+
+ +
+
+ ) + + return ( + <> + + {/* Details Section */} + +
+
+ +