Dashboard fix, expense system, currency settings, visual upgrade
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,7 @@
|
|||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
|
||||||
export default function BudgetBar({ budget, spent, height = 'h-1.5' }) {
|
export default function BudgetBar({ budget, spent, height = 'h-1.5' }) {
|
||||||
|
const { currencySymbol } = useLanguage()
|
||||||
if (!budget || budget <= 0) return null
|
if (!budget || budget <= 0) return null
|
||||||
const pct = Math.min((spent / budget) * 100, 100)
|
const pct = Math.min((spent / budget) * 100, 100)
|
||||||
|
|
||||||
@@ -9,8 +12,8 @@ export default function BudgetBar({ budget, spent, height = 'h-1.5' }) {
|
|||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex justify-between text-[10px] text-text-tertiary mb-0.5">
|
<div className="flex justify-between text-[10px] text-text-tertiary mb-0.5">
|
||||||
<span>{(spent || 0).toLocaleString()} SAR spent</span>
|
<span>{(spent || 0).toLocaleString()} {currencySymbol} spent</span>
|
||||||
<span>{budget.toLocaleString()} SAR</span>
|
<span>{budget.toLocaleString()} {currencySymbol}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${height} bg-surface-tertiary rounded-full overflow-hidden`}>
|
<div className={`${height} bg-surface-tertiary rounded-full overflow-hidden`}>
|
||||||
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
||||||
|
|||||||
427
client/src/components/CampaignDetailPanel.jsx
Normal file
427
client/src/components/CampaignDetailPanel.jsx
Normal file
@@ -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 = (
|
||||||
|
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => 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')}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${
|
||||||
|
form.status === 'active' ? 'bg-emerald-100 text-emerald-700' :
|
||||||
|
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
|
||||||
|
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
|
||||||
|
form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
|
||||||
|
'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{statusOptions.find(s => s.value === form.status)?.label}
|
||||||
|
</span>
|
||||||
|
{brandName && (
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
|
||||||
|
{brandName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
|
||||||
|
{/* Details Section */}
|
||||||
|
<CollapsibleSection title={t('campaigns.details')}>
|
||||||
|
<div className="px-5 pb-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.description')}</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={e => update('description', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||||
|
placeholder="Campaign description..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.brand')}</label>
|
||||||
|
<select
|
||||||
|
value={form.brand_id}
|
||||||
|
onChange={e => update('brand_id', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
<option value="">Select brand</option>
|
||||||
|
{(brands || []).map(b => <option key={b.id || b._id} value={b.id || b._id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.status')}</label>
|
||||||
|
<select
|
||||||
|
value={form.status}
|
||||||
|
onChange={e => update('status', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platforms */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.platforms')}</label>
|
||||||
|
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[38px]">
|
||||||
|
{Object.entries(PLATFORMS).map(([k, v]) => {
|
||||||
|
const checked = (form.platforms || []).includes(k)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={k}
|
||||||
|
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-full cursor-pointer border transition-colors ${
|
||||||
|
checked
|
||||||
|
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary font-medium'
|
||||||
|
: 'bg-surface-tertiary border-transparent text-text-secondary hover:bg-surface-tertiary/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => {
|
||||||
|
update('platforms', checked
|
||||||
|
? form.platforms.filter(p => p !== k)
|
||||||
|
: [...(form.platforms || []), k]
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
{v.label}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.startDate')} *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.start_date}
|
||||||
|
onChange={e => update('start_date', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.endDate')} *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.end_date}
|
||||||
|
onChange={e => update('end_date', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">
|
||||||
|
{t('campaigns.budget')} ({currencySymbol})
|
||||||
|
{!permissions?.canSetBudget && <span className="text-[10px] text-text-tertiary ml-1">(Superadmin only)</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.budget}
|
||||||
|
onChange={e => update('budget', e.target.value)}
|
||||||
|
disabled={!permissions?.canSetBudget}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
placeholder="e.g., 50000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.goals')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.goals}
|
||||||
|
onChange={e => update('goals', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
placeholder="Campaign goals"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
{dirty && (
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!form.name || !form.start_date || !form.end_date || saving}
|
||||||
|
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
|
||||||
|
>
|
||||||
|
{isCreateMode ? t('campaigns.createCampaign') : t('tasks.saveChanges')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && !isCreateMode && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title={t('common.delete')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* Performance Section (hidden in create mode) */}
|
||||||
|
{!isCreateMode && (
|
||||||
|
<CollapsibleSection title={t('campaigns.performance')}>
|
||||||
|
<div className="px-5 pb-4 space-y-3">
|
||||||
|
{(form.budget_spent || form.impressions || form.clicks) && (
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
||||||
|
<DollarSign className="w-3.5 h-3.5 mx-auto mb-0.5 text-amber-600" />
|
||||||
|
<div className="text-xs font-bold text-amber-600">{form.budget_spent ? Number(form.budget_spent).toLocaleString() : '—'}</div>
|
||||||
|
<div className="text-[10px] text-text-tertiary">{t('campaigns.budgetSpent')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
||||||
|
<Eye className="w-3.5 h-3.5 mx-auto mb-0.5 text-purple-600" />
|
||||||
|
<div className="text-xs font-bold text-purple-600">{form.impressions ? Number(form.impressions).toLocaleString() : '—'}</div>
|
||||||
|
<div className="text-[10px] text-text-tertiary">{t('campaigns.impressions')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
||||||
|
<MousePointer className="w-3.5 h-3.5 mx-auto mb-0.5 text-blue-600" />
|
||||||
|
<div className="text-xs font-bold text-blue-600">{form.clicks ? Number(form.clicks).toLocaleString() : '—'}</div>
|
||||||
|
<div className="text-[10px] text-text-tertiary">{t('campaigns.clicks')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
||||||
|
<Target className="w-3.5 h-3.5 mx-auto mb-0.5 text-emerald-600" />
|
||||||
|
<div className="text-xs font-bold text-emerald-600">{form.conversions ? Number(form.conversions).toLocaleString() : '—'}</div>
|
||||||
|
<div className="text-[10px] text-text-tertiary">{t('campaigns.conversions')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.budget && form.budget_spent && (
|
||||||
|
<div className="p-3 bg-surface-secondary rounded-lg">
|
||||||
|
<BudgetBar budget={Number(form.budget)} spent={Number(form.budget_spent)} />
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
{Number(form.budget_spent) > 0 && (
|
||||||
|
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${
|
||||||
|
((Number(form.revenue) - Number(form.budget_spent)) / Number(form.budget_spent) * 100) >= 0
|
||||||
|
? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'
|
||||||
|
}`}>
|
||||||
|
ROI {((Number(form.revenue || 0) - Number(form.budget_spent)) / Number(form.budget_spent) * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{Number(form.clicks) > 0 && Number(form.budget_spent) > 0 && (
|
||||||
|
<span className="text-[10px] text-text-tertiary">
|
||||||
|
CPC: {(Number(form.budget_spent) / Number(form.clicks)).toFixed(2)} {currencySymbol}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{Number(form.impressions) > 0 && Number(form.clicks) > 0 && (
|
||||||
|
<span className="text-[10px] text-text-tertiary">
|
||||||
|
CTR: {(Number(form.clicks) / Number(form.impressions) * 100).toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.budgetSpent')} ({currencySymbol})</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.budget_spent}
|
||||||
|
onChange={e => update('budget_spent', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.revenue')} ({currencySymbol})</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.revenue}
|
||||||
|
onChange={e => update('revenue', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.impressions')}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.impressions}
|
||||||
|
onChange={e => update('impressions', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.clicks')}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.clicks}
|
||||||
|
onChange={e => update('clicks', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.conversions')}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.conversions}
|
||||||
|
onChange={e => update('conversions', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.notes')}</label>
|
||||||
|
<textarea
|
||||||
|
value={form.notes}
|
||||||
|
onChange={e => update('notes', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||||
|
placeholder="Performance notes..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Discussion Section (hidden in create mode) */}
|
||||||
|
{!isCreateMode && (
|
||||||
|
<CollapsibleSection title={t('campaigns.discussion')} noBorder>
|
||||||
|
<div className="px-5 pb-5">
|
||||||
|
<CommentsSection entityType="campaign" entityId={campaignId} />
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
</SlidePanel>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showDeleteConfirm}
|
||||||
|
onClose={() => setShowDeleteConfirm(false)}
|
||||||
|
title={t('campaigns.deleteCampaign')}
|
||||||
|
isConfirm
|
||||||
|
danger
|
||||||
|
confirmText={t('common.delete')}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
>
|
||||||
|
{t('campaigns.deleteConfirm')}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ export default function Layout() {
|
|||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface-secondary">
|
<div className="min-h-screen bg-mesh">
|
||||||
<Sidebar collapsed={collapsed} setCollapsed={setCollapsed} />
|
<Sidebar collapsed={collapsed} setCollapsed={setCollapsed} />
|
||||||
<div
|
<div
|
||||||
className={`transition-all duration-300 ${
|
className={`transition-all duration-300 ${
|
||||||
|
|||||||
@@ -1,34 +1,99 @@
|
|||||||
import { useContext } from 'react'
|
import { useState, useContext } from 'react'
|
||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, FileEdit, Image, Calendar, Wallet,
|
LayoutDashboard, FileEdit, Image, Calendar, Wallet,
|
||||||
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, Sparkles, Shield, LogOut, User, Settings, Languages, Tag
|
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, ChevronDown,
|
||||||
|
Sparkles, Shield, LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
|
||||||
const navItems = [
|
// Standalone items (no category)
|
||||||
|
const standaloneTop = [
|
||||||
{ to: '/', icon: LayoutDashboard, labelKey: 'nav.dashboard', end: true, tutorial: 'dashboard' },
|
{ to: '/', icon: LayoutDashboard, labelKey: 'nav.dashboard', end: true, tutorial: 'dashboard' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Grouped items by module
|
||||||
|
const moduleGroups = [
|
||||||
|
{
|
||||||
|
module: 'marketing',
|
||||||
|
labelKey: 'modules.marketing',
|
||||||
|
icon: Calendar,
|
||||||
|
items: [
|
||||||
{ to: '/campaigns', icon: Calendar, labelKey: 'nav.campaigns', tutorial: 'campaigns' },
|
{ to: '/campaigns', icon: Calendar, labelKey: 'nav.campaigns', tutorial: 'campaigns' },
|
||||||
{ to: '/finance', icon: Wallet, labelKey: 'nav.finance', minRole: 'manager' },
|
|
||||||
{ to: '/posts', icon: FileEdit, labelKey: 'nav.posts', tutorial: 'posts' },
|
{ to: '/posts', icon: FileEdit, labelKey: 'nav.posts', tutorial: 'posts' },
|
||||||
{ to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' },
|
{ to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' },
|
||||||
{ to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
|
|
||||||
{ to: '/tasks', icon: CheckSquare, labelKey: 'nav.tasks', tutorial: 'tasks' },
|
|
||||||
{ to: '/team', icon: Users, labelKey: 'nav.team', tutorial: 'team' },
|
|
||||||
{ to: '/brands', icon: Tag, labelKey: 'nav.brands' },
|
{ to: '/brands', icon: Tag, labelKey: 'nav.brands' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: 'projects',
|
||||||
|
labelKey: 'modules.projects',
|
||||||
|
icon: FolderKanban,
|
||||||
|
items: [
|
||||||
|
{ to: '/projects', icon: LayoutList, labelKey: 'nav.projects' },
|
||||||
|
{ to: '/tasks', icon: CheckSquare, labelKey: 'nav.tasks', tutorial: 'tasks' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: 'finance',
|
||||||
|
labelKey: 'modules.finance',
|
||||||
|
icon: Wallet,
|
||||||
|
minRole: 'manager',
|
||||||
|
items: [
|
||||||
|
{ to: '/finance', icon: BarChart3, labelKey: 'nav.financeDashboard' },
|
||||||
|
{ to: '/budgets', icon: Receipt, labelKey: 'nav.budgets' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const standaloneBottom = [
|
||||||
|
{ to: '/team', icon: Users, labelKey: 'nav.team', tutorial: 'team' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const ROLE_LEVEL = { contributor: 0, manager: 1, superadmin: 2 }
|
const ROLE_LEVEL = { contributor: 0, manager: 1, superadmin: 2 }
|
||||||
|
|
||||||
export default function Sidebar({ collapsed, setCollapsed }) {
|
export default function Sidebar({ collapsed, setCollapsed }) {
|
||||||
const { user: currentUser, logout } = useAuth()
|
const { user: currentUser, logout, hasModule } = useAuth()
|
||||||
const { t, lang, setLang } = useLanguage()
|
const { t, lang, setLang } = useLanguage()
|
||||||
const userLevel = ROLE_LEVEL[currentUser?.role] ?? 0
|
const userLevel = ROLE_LEVEL[currentUser?.role] ?? 0
|
||||||
|
|
||||||
const visibleItems = navItems.filter(item => {
|
// Track expanded state for each module group
|
||||||
if (!item.minRole) return true
|
const [expandedGroups, setExpandedGroups] = useState(() => {
|
||||||
return userLevel >= (ROLE_LEVEL[item.minRole] ?? 0)
|
const initial = {}
|
||||||
|
moduleGroups.forEach(g => { initial[g.module] = true })
|
||||||
|
return initial
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleGroup = (module) => {
|
||||||
|
setExpandedGroups(prev => ({ ...prev, [module]: !prev[module] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const navLink = ({ to, icon: Icon, labelKey, end, tutorial }, { sub = false } = {}) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
end={end}
|
||||||
|
data-tutorial={tutorial}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 rounded-lg font-medium transition-all duration-200 group ${
|
||||||
|
sub ? 'px-3 py-1.5 ms-5 text-[13px]' : 'px-3 py-2 text-sm'
|
||||||
|
} ${
|
||||||
|
isActive
|
||||||
|
? 'bg-white/15 text-white shadow-sm sidebar-active-glow'
|
||||||
|
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className={`${sub ? 'w-3.5 h-3.5' : 'w-5 h-5'} shrink-0`} />
|
||||||
|
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t(labelKey)}</span>}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleGroups = moduleGroups.filter(group => {
|
||||||
|
if (!hasModule(group.module)) return false
|
||||||
|
if (group.minRole && userLevel < (ROLE_LEVEL[group.minRole] ?? 0)) return false
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -39,7 +104,7 @@ export default function Sidebar({ collapsed, setCollapsed }) {
|
|||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center gap-3 px-4 h-16 border-b border-white/10 shrink-0">
|
<div className="flex items-center gap-3 px-4 h-16 border-b border-white/10 shrink-0">
|
||||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center shrink-0">
|
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-indigo-400 via-purple-500 to-pink-500 flex items-center justify-center shrink-0 shadow-lg shadow-indigo-500/30">
|
||||||
<Sparkles className="w-5 h-5 text-white" />
|
<Sparkles className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
@@ -51,32 +116,53 @@ export default function Sidebar({ collapsed, setCollapsed }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 py-4 px-3 space-y-1 overflow-y-auto">
|
<nav className="flex-1 py-3 px-3 space-y-0.5 overflow-y-auto">
|
||||||
{visibleItems.map(({ to, icon: Icon, labelKey, end, tutorial }) => (
|
{/* Dashboard (always visible, standalone) */}
|
||||||
<NavLink
|
{standaloneTop.map(item => navLink(item))}
|
||||||
key={to}
|
|
||||||
to={to}
|
{/* Module groups */}
|
||||||
end={end}
|
{visibleGroups.map(group => {
|
||||||
data-tutorial={tutorial}
|
const GroupIcon = group.icon
|
||||||
className={({ isActive }) =>
|
const isExpanded = expandedGroups[group.module]
|
||||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group ${
|
|
||||||
isActive
|
if (collapsed) {
|
||||||
? 'bg-white/15 text-white shadow-sm'
|
// When collapsed, just show the sub-item icons
|
||||||
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
|
return group.items.map(item => navLink(item))
|
||||||
}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.module} className="mt-3">
|
||||||
|
{/* Category header */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleGroup(group.module)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-bold uppercase tracking-wide text-text-on-dark-muted hover:text-white transition-colors rounded-lg hover:bg-white/5"
|
||||||
>
|
>
|
||||||
<Icon className="w-5 h-5 shrink-0" />
|
<GroupIcon className="w-4 h-4 shrink-0 opacity-70" />
|
||||||
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t(labelKey)}</span>}
|
<span className="flex-1 text-start">{t(group.labelKey)}</span>
|
||||||
</NavLink>
|
<ChevronDown className={`w-3.5 h-3.5 opacity-60 transition-transform ${isExpanded ? '' : '-rotate-90'}`} />
|
||||||
))}
|
</button>
|
||||||
|
|
||||||
|
{/* Sub-items */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="space-y-0.5 mt-0.5">
|
||||||
|
{group.items.map(item => navLink(item, { sub: true }))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Team (always visible) */}
|
||||||
|
<div className="mt-3 pt-2 border-t border-white/8">
|
||||||
|
{standaloneBottom.map(item => navLink(item))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Superadmin Only: Users Management */}
|
{/* Superadmin Only: Users Management */}
|
||||||
{currentUser?.role === 'superadmin' && (
|
{currentUser?.role === 'superadmin' && (
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/users"
|
to="/users"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group ${
|
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 group ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-white/15 text-white shadow-sm'
|
? 'bg-white/15 text-white shadow-sm'
|
||||||
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
|
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
|
||||||
@@ -92,7 +178,7 @@ export default function Sidebar({ collapsed, setCollapsed }) {
|
|||||||
<NavLink
|
<NavLink
|
||||||
to="/settings"
|
to="/settings"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group ${
|
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 group ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-white/15 text-white shadow-sm'
|
? 'bg-white/15 text-white shadow-sm'
|
||||||
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
|
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
export default function StatCard({ icon: Icon, label, value, subtitle, color = 'brand-primary', trend }) {
|
export default function StatCard({ icon: Icon, label, value, subtitle, color = 'brand-primary', trend }) {
|
||||||
const colorMap = {
|
const accentMap = {
|
||||||
'brand-primary': 'from-indigo-500 to-indigo-600',
|
'brand-primary': 'accent-primary',
|
||||||
'brand-secondary': 'from-pink-500 to-pink-600',
|
'brand-secondary': 'accent-secondary',
|
||||||
'brand-tertiary': 'from-amber-500 to-amber-600',
|
'brand-tertiary': 'accent-tertiary',
|
||||||
'brand-quaternary': 'from-emerald-500 to-emerald-600',
|
'brand-quaternary': 'accent-quaternary',
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconBgMap = {
|
const iconBgMap = {
|
||||||
'brand-primary': 'bg-indigo-50 text-indigo-600',
|
'brand-primary': 'bg-indigo-50 text-indigo-600 shadow-lg shadow-indigo-500/20',
|
||||||
'brand-secondary': 'bg-pink-50 text-pink-600',
|
'brand-secondary': 'bg-pink-50 text-pink-600 shadow-lg shadow-pink-500/20',
|
||||||
'brand-tertiary': 'bg-amber-50 text-amber-600',
|
'brand-tertiary': 'bg-amber-50 text-amber-600 shadow-lg shadow-amber-500/20',
|
||||||
'brand-quaternary': 'bg-emerald-50 text-emerald-600',
|
'brand-quaternary': 'bg-emerald-50 text-emerald-600 shadow-lg shadow-emerald-500/20',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accentClass = accentMap[color] || 'accent-primary'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-border p-5 card-hover">
|
<div className={`stat-card-premium ${accentClass} bg-white rounded-xl border border-border p-5 card-hover`}>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-text-tertiary">{label}</p>
|
<p className="text-sm font-medium text-text-tertiary">{label}</p>
|
||||||
|
|||||||
307
client/src/components/TrackDetailPanel.jsx
Normal file
307
client/src/components/TrackDetailPanel.jsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { X, Trash2 } from 'lucide-react'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import { PLATFORMS } from '../utils/api'
|
||||||
|
import Modal from './Modal'
|
||||||
|
import SlidePanel from './SlidePanel'
|
||||||
|
import CollapsibleSection from './CollapsibleSection'
|
||||||
|
import BudgetBar from './BudgetBar'
|
||||||
|
|
||||||
|
const TRACK_TYPES = {
|
||||||
|
organic_social: { label: 'Organic Social' },
|
||||||
|
paid_social: { label: 'Paid Social' },
|
||||||
|
paid_search: { label: 'Paid Search (PPC)' },
|
||||||
|
seo_content: { label: 'SEO / Content' },
|
||||||
|
production: { label: 'Production' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRACK_STATUSES = ['planned', 'active', 'paused', 'completed']
|
||||||
|
|
||||||
|
export default function TrackDetailPanel({ track, campaignId, onClose, onSave, onDelete, scrollToMetrics }) {
|
||||||
|
const { t, currencySymbol } = useLanguage()
|
||||||
|
const [form, setForm] = useState({})
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
|
||||||
|
const trackId = track?._id || track?.id
|
||||||
|
const isCreateMode = !trackId
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (track) {
|
||||||
|
setForm({
|
||||||
|
name: track.name || '',
|
||||||
|
type: track.type || 'organic_social',
|
||||||
|
platform: track.platform || '',
|
||||||
|
budget_allocated: track.budget_allocated || '',
|
||||||
|
status: track.status || 'planned',
|
||||||
|
notes: track.notes || '',
|
||||||
|
budget_spent: track.budget_spent || '',
|
||||||
|
revenue: track.revenue || '',
|
||||||
|
impressions: track.impressions || '',
|
||||||
|
clicks: track.clicks || '',
|
||||||
|
conversions: track.conversions || '',
|
||||||
|
})
|
||||||
|
setDirty(isCreateMode)
|
||||||
|
}
|
||||||
|
}, [track])
|
||||||
|
|
||||||
|
if (!track) return null
|
||||||
|
|
||||||
|
const update = (field, value) => {
|
||||||
|
setForm(f => ({ ...f, [field]: value }))
|
||||||
|
setDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
name: form.name,
|
||||||
|
type: form.type,
|
||||||
|
platform: form.platform || null,
|
||||||
|
budget_allocated: form.budget_allocated ? Number(form.budget_allocated) : 0,
|
||||||
|
status: form.status,
|
||||||
|
notes: form.notes,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
await onSave(isCreateMode ? null : trackId, data)
|
||||||
|
setDirty(false)
|
||||||
|
if (isCreateMode) onClose()
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
await onDelete(trackId)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeInfo = TRACK_TYPES[form.type] || TRACK_TYPES.organic_social
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => 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('tracks.trackName')}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
|
||||||
|
{typeInfo.label}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${
|
||||||
|
form.status === 'active' ? 'bg-emerald-100 text-emerald-700' :
|
||||||
|
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
|
||||||
|
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
|
||||||
|
'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{form.status?.charAt(0).toUpperCase() + form.status?.slice(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||||
|
{/* Details Section */}
|
||||||
|
<CollapsibleSection title={t('tracks.details')}>
|
||||||
|
<div className="px-5 pb-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.type')}</label>
|
||||||
|
<select
|
||||||
|
value={form.type}
|
||||||
|
onChange={e => update('type', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||||
|
>
|
||||||
|
{Object.entries(TRACK_TYPES).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>{v.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.platform')}</label>
|
||||||
|
<select
|
||||||
|
value={form.platform}
|
||||||
|
onChange={e => update('platform', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">All / Multiple</option>
|
||||||
|
{Object.entries(PLATFORMS).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>{v.label}</option>
|
||||||
|
))}
|
||||||
|
<option value="google_ads">Google Ads</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.budgetAllocated')} ({currencySymbol})</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.budget_allocated}
|
||||||
|
onChange={e => update('budget_allocated', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||||
|
placeholder="0 for free/organic"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.status')}</label>
|
||||||
|
<select
|
||||||
|
value={form.status}
|
||||||
|
onChange={e => update('status', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||||
|
>
|
||||||
|
{TRACK_STATUSES.map(s => (
|
||||||
|
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.notes')}</label>
|
||||||
|
<textarea
|
||||||
|
value={form.notes}
|
||||||
|
onChange={e => update('notes', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none resize-none"
|
||||||
|
placeholder="Keywords, targeting details..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
{dirty && (
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
|
||||||
|
>
|
||||||
|
{isCreateMode ? t('tracks.addTrack') : t('tasks.saveChanges')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && !isCreateMode && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title={t('common.delete')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* Metrics Section (hidden in create mode) */}
|
||||||
|
{!isCreateMode && (
|
||||||
|
<CollapsibleSection title={t('tracks.metrics')} defaultOpen={!!scrollToMetrics} noBorder>
|
||||||
|
<div className="px-5 pb-4 space-y-3">
|
||||||
|
{Number(form.budget_allocated) > 0 && (
|
||||||
|
<div className="p-3 bg-surface-secondary rounded-lg">
|
||||||
|
<BudgetBar budget={Number(form.budget_allocated)} spent={Number(form.budget_spent) || 0} height="h-2" />
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
{Number(form.clicks) > 0 && Number(form.budget_spent) > 0 && (
|
||||||
|
<span className="text-[10px] text-text-tertiary">
|
||||||
|
CPC: {(Number(form.budget_spent) / Number(form.clicks)).toFixed(2)} {currencySymbol}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{Number(form.impressions) > 0 && Number(form.clicks) > 0 && (
|
||||||
|
<span className="text-[10px] text-text-tertiary">
|
||||||
|
CTR: {(Number(form.clicks) / Number(form.impressions) * 100).toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.budgetSpent')} ({currencySymbol})</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.budget_spent}
|
||||||
|
onChange={e => update('budget_spent', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.revenue')} ({currencySymbol})</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.revenue}
|
||||||
|
onChange={e => update('revenue', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.impressions')}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.impressions}
|
||||||
|
onChange={e => update('impressions', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.clicks')}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.clicks}
|
||||||
|
onChange={e => update('clicks', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.conversions')}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.conversions}
|
||||||
|
onChange={e => update('conversions', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
</SlidePanel>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showDeleteConfirm}
|
||||||
|
onClose={() => setShowDeleteConfirm(false)}
|
||||||
|
title={t('tracks.deleteTrack')}
|
||||||
|
isConfirm
|
||||||
|
danger
|
||||||
|
confirmText={t('common.delete')}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
>
|
||||||
|
{t('tracks.deleteConfirm')}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,19 @@ import ar from './ar.json'
|
|||||||
|
|
||||||
const translations = { en, ar }
|
const translations = { en, ar }
|
||||||
|
|
||||||
|
export const CURRENCIES = [
|
||||||
|
{ code: 'SAR', symbol: '', labelEn: 'Saudi Riyal (SAR)', labelAr: 'ريال سعودي' },
|
||||||
|
{ code: 'AED', symbol: 'د.إ', labelEn: 'UAE Dirham (AED)', labelAr: 'درهم إماراتي' },
|
||||||
|
{ code: 'USD', symbol: '$', labelEn: 'US Dollar (USD)', labelAr: 'دولار أمريكي' },
|
||||||
|
{ code: 'EUR', symbol: '€', labelEn: 'Euro (EUR)', labelAr: 'يورو' },
|
||||||
|
{ code: 'GBP', symbol: '£', labelEn: 'British Pound (GBP)', labelAr: 'جنيه إسترليني' },
|
||||||
|
{ code: 'KWD', symbol: 'د.ك', labelEn: 'Kuwaiti Dinar (KWD)', labelAr: 'دينار كويتي' },
|
||||||
|
{ code: 'QAR', symbol: 'ر.ق', labelEn: 'Qatari Riyal (QAR)', labelAr: 'ريال قطري' },
|
||||||
|
{ code: 'BHD', symbol: 'د.ب', labelEn: 'Bahraini Dinar (BHD)', labelAr: 'دينار بحريني' },
|
||||||
|
{ code: 'OMR', symbol: 'ر.ع', labelEn: 'Omani Rial (OMR)', labelAr: 'ريال عماني' },
|
||||||
|
{ code: 'EGP', symbol: 'ج.م', labelEn: 'Egyptian Pound (EGP)', labelAr: 'جنيه مصري' },
|
||||||
|
]
|
||||||
|
|
||||||
const LanguageContext = createContext()
|
const LanguageContext = createContext()
|
||||||
|
|
||||||
export function LanguageProvider({ children }) {
|
export function LanguageProvider({ children }) {
|
||||||
@@ -12,12 +25,26 @@ export function LanguageProvider({ children }) {
|
|||||||
return localStorage.getItem('digitalhub-lang') || 'en'
|
return localStorage.getItem('digitalhub-lang') || 'en'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [currency, setCurrencyState] = useState(() => {
|
||||||
|
return localStorage.getItem('digitalhub-currency') || 'SAR'
|
||||||
|
})
|
||||||
|
|
||||||
const setLang = (newLang) => {
|
const setLang = (newLang) => {
|
||||||
if (newLang !== 'en' && newLang !== 'ar') return
|
if (newLang !== 'en' && newLang !== 'ar') return
|
||||||
setLangState(newLang)
|
setLangState(newLang)
|
||||||
localStorage.setItem('digitalhub-lang', newLang)
|
localStorage.setItem('digitalhub-lang', newLang)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setCurrency = (code) => {
|
||||||
|
const valid = CURRENCIES.find(c => c.code === code)
|
||||||
|
if (!valid) return
|
||||||
|
setCurrencyState(code)
|
||||||
|
localStorage.setItem('digitalhub-currency', code)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencyObj = CURRENCIES.find(c => c.code === currency) || CURRENCIES[0]
|
||||||
|
const currencySymbol = currencyObj.symbol
|
||||||
|
|
||||||
const dir = lang === 'ar' ? 'rtl' : 'ltr'
|
const dir = lang === 'ar' ? 'rtl' : 'ltr'
|
||||||
|
|
||||||
// Update HTML dir attribute whenever language changes
|
// Update HTML dir attribute whenever language changes
|
||||||
@@ -32,7 +59,7 @@ export function LanguageProvider({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LanguageContext.Provider value={{ lang, setLang, t, dir }}>
|
<LanguageContext.Provider value={{ lang, setLang, t, dir, currency, setCurrency, currencySymbol }}>
|
||||||
{children}
|
{children}
|
||||||
</LanguageContext.Provider>
|
</LanguageContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
"nav.dashboard": "لوحة التحكم",
|
"nav.dashboard": "لوحة التحكم",
|
||||||
"nav.campaigns": "الحملات",
|
"nav.campaigns": "الحملات",
|
||||||
"nav.finance": "المالية والعائد",
|
"nav.finance": "المالية والعائد",
|
||||||
|
"nav.financeDashboard": "لوحة التحكم",
|
||||||
|
"nav.budgets": "الميزانيات",
|
||||||
"nav.posts": "إنتاج المحتوى",
|
"nav.posts": "إنتاج المحتوى",
|
||||||
"nav.assets": "الأصول",
|
"nav.assets": "الأصول",
|
||||||
"nav.projects": "المشاريع",
|
"nav.projects": "المشاريع",
|
||||||
"nav.tasks": "المهام",
|
"nav.tasks": "المهام",
|
||||||
"nav.team": "الفريق",
|
"nav.team": "الفرق",
|
||||||
"nav.settings": "الإعدادات",
|
"nav.settings": "الإعدادات",
|
||||||
"nav.users": "المستخدمين",
|
"nav.users": "المستخدمين",
|
||||||
"nav.logout": "تسجيل الخروج",
|
"nav.logout": "تسجيل الخروج",
|
||||||
@@ -46,6 +48,7 @@
|
|||||||
"dashboard.activeCampaigns": "الحملات النشطة",
|
"dashboard.activeCampaigns": "الحملات النشطة",
|
||||||
"dashboard.total": "إجمالي",
|
"dashboard.total": "إجمالي",
|
||||||
"dashboard.budgetSpent": "الميزانية المنفقة",
|
"dashboard.budgetSpent": "الميزانية المنفقة",
|
||||||
|
"dashboard.budgetRemaining": "الميزانية المتبقية",
|
||||||
"dashboard.of": "من",
|
"dashboard.of": "من",
|
||||||
"dashboard.noBudget": "لا توجد ميزانية بعد",
|
"dashboard.noBudget": "لا توجد ميزانية بعد",
|
||||||
"dashboard.overdueTasks": "مهام متأخرة",
|
"dashboard.overdueTasks": "مهام متأخرة",
|
||||||
@@ -170,6 +173,44 @@
|
|||||||
"tasks.deleted": "تم حذف المهمة بنجاح!",
|
"tasks.deleted": "تم حذف المهمة بنجاح!",
|
||||||
"tasks.statusUpdated": "تم تحديث حالة المهمة!",
|
"tasks.statusUpdated": "تم تحديث حالة المهمة!",
|
||||||
"tasks.canOnlyEditOwn": "يمكنك فقط تعديل مهامك الخاصة.",
|
"tasks.canOnlyEditOwn": "يمكنك فقط تعديل مهامك الخاصة.",
|
||||||
|
"tasks.search": "بحث في المهام...",
|
||||||
|
"tasks.board": "لوحة",
|
||||||
|
"tasks.list": "قائمة",
|
||||||
|
"tasks.calendar": "تقويم",
|
||||||
|
"tasks.filters": "الفلاتر",
|
||||||
|
"tasks.allProjects": "جميع المشاريع",
|
||||||
|
"tasks.allBrands": "جميع العلامات",
|
||||||
|
"tasks.allPriorities": "جميع الأولويات",
|
||||||
|
"tasks.allStatuses": "جميع الحالات",
|
||||||
|
"tasks.allAssignees": "جميع المُسندين",
|
||||||
|
"tasks.allCreators": "جميع المنشئين",
|
||||||
|
"tasks.overdue": "متأخر",
|
||||||
|
"tasks.clearFilters": "مسح الفلاتر",
|
||||||
|
"tasks.details": "التفاصيل",
|
||||||
|
"tasks.discussion": "النقاش",
|
||||||
|
"tasks.unscheduled": "غير مجدول",
|
||||||
|
"tasks.today": "اليوم",
|
||||||
|
"tasks.project": "المشروع",
|
||||||
|
"tasks.brand": "العلامة التجارية",
|
||||||
|
"tasks.status": "الحالة",
|
||||||
|
"tasks.creator": "المنشئ",
|
||||||
|
"tasks.assignee": "المُسند إليه",
|
||||||
|
"tasks.commentCount": "{n} تعليقات",
|
||||||
|
"tasks.dueDateRange": "تاريخ الاستحقاق",
|
||||||
|
"tasks.noProject": "بدون مشروع",
|
||||||
|
"tasks.createdBy": "أنشأها",
|
||||||
|
"tasks.startDate": "تاريخ البدء",
|
||||||
|
"tasks.attachments": "المرفقات",
|
||||||
|
"tasks.uploadFile": "رفع ملف",
|
||||||
|
"tasks.setAsThumbnail": "تعيين كصورة مصغرة",
|
||||||
|
"tasks.removeThumbnail": "إزالة الصورة المصغرة",
|
||||||
|
"tasks.thumbnail": "الصورة المصغرة",
|
||||||
|
"tasks.dropOrClick": "اسحب ملفاً أو انقر للرفع",
|
||||||
|
|
||||||
|
"projects.thumbnail": "الصورة المصغرة",
|
||||||
|
"projects.uploadThumbnail": "رفع صورة مصغرة",
|
||||||
|
"projects.changeThumbnail": "تغيير الصورة المصغرة",
|
||||||
|
"projects.removeThumbnail": "إزالة الصورة المصغرة",
|
||||||
|
|
||||||
"team.title": "الفريق",
|
"team.title": "الفريق",
|
||||||
"team.members": "أعضاء الفريق",
|
"team.members": "أعضاء الفريق",
|
||||||
@@ -250,6 +291,8 @@
|
|||||||
"settings.noBrands": "لا توجد علامات بعد. أضف أول علامة تجارية.",
|
"settings.noBrands": "لا توجد علامات بعد. أضف أول علامة تجارية.",
|
||||||
"settings.moreComingSoon": "المزيد من الإعدادات قريباً",
|
"settings.moreComingSoon": "المزيد من الإعدادات قريباً",
|
||||||
"settings.additionalSettings": "سيتم إضافة إعدادات إضافية للإشعارات وتفضيلات العرض والمزيد هنا.",
|
"settings.additionalSettings": "سيتم إضافة إعدادات إضافية للإشعارات وتفضيلات العرض والمزيد هنا.",
|
||||||
|
"settings.currency": "العملة",
|
||||||
|
"settings.currencyHint": "ستُستخدم هذه العملة في جميع الصفحات المالية.",
|
||||||
"settings.preferences": "إدارة تفضيلاتك وإعدادات التطبيق",
|
"settings.preferences": "إدارة تفضيلاتك وإعدادات التطبيق",
|
||||||
|
|
||||||
"tutorial.skip": "تخطي",
|
"tutorial.skip": "تخطي",
|
||||||
@@ -303,5 +346,148 @@
|
|||||||
"timeline.noItems": "لا توجد عناصر للعرض",
|
"timeline.noItems": "لا توجد عناصر للعرض",
|
||||||
"timeline.addItems": "أضف عناصر بتواريخ لعرض الجدول الزمني",
|
"timeline.addItems": "أضف عناصر بتواريخ لعرض الجدول الزمني",
|
||||||
"timeline.tracks": "المسارات",
|
"timeline.tracks": "المسارات",
|
||||||
"timeline.timeline": "الجدول الزمني"
|
"timeline.timeline": "الجدول الزمني",
|
||||||
|
|
||||||
|
"posts.details": "التفاصيل",
|
||||||
|
"posts.platformsLinks": "المنصات والروابط",
|
||||||
|
"posts.discussion": "النقاش",
|
||||||
|
|
||||||
|
"campaigns.details": "التفاصيل",
|
||||||
|
"campaigns.performance": "الأداء",
|
||||||
|
"campaigns.discussion": "النقاش",
|
||||||
|
"campaigns.name": "الاسم",
|
||||||
|
"campaigns.description": "الوصف",
|
||||||
|
"campaigns.brand": "العلامة التجارية",
|
||||||
|
"campaigns.status": "الحالة",
|
||||||
|
"campaigns.platforms": "المنصات",
|
||||||
|
"campaigns.startDate": "تاريخ البدء",
|
||||||
|
"campaigns.endDate": "تاريخ الانتهاء",
|
||||||
|
"campaigns.budget": "الميزانية",
|
||||||
|
"campaigns.goals": "الأهداف",
|
||||||
|
"campaigns.notes": "ملاحظات",
|
||||||
|
"campaigns.budgetSpent": "المنفق من الميزانية",
|
||||||
|
"campaigns.revenue": "الإيرادات",
|
||||||
|
"campaigns.impressions": "مرات الظهور",
|
||||||
|
"campaigns.clicks": "النقرات",
|
||||||
|
"campaigns.conversions": "التحويلات",
|
||||||
|
"campaigns.createCampaign": "إنشاء حملة",
|
||||||
|
"campaigns.editCampaign": "تعديل الحملة",
|
||||||
|
"campaigns.deleteCampaign": "حذف الحملة؟",
|
||||||
|
"campaigns.deleteConfirm": "هل أنت متأكد من حذف هذه الحملة؟ سيتم حذف جميع البيانات المرتبطة. لا يمكن التراجع.",
|
||||||
|
|
||||||
|
"tracks.details": "التفاصيل",
|
||||||
|
"tracks.metrics": "المقاييس",
|
||||||
|
"tracks.trackName": "اسم المسار",
|
||||||
|
"tracks.type": "النوع",
|
||||||
|
"tracks.platform": "المنصة",
|
||||||
|
"tracks.budgetAllocated": "الميزانية المخصصة",
|
||||||
|
"tracks.status": "الحالة",
|
||||||
|
"tracks.notes": "ملاحظات",
|
||||||
|
"tracks.budgetSpent": "المنفق من الميزانية",
|
||||||
|
"tracks.revenue": "الإيرادات",
|
||||||
|
"tracks.addTrack": "إضافة مسار",
|
||||||
|
"tracks.editTrack": "تعديل المسار",
|
||||||
|
"tracks.deleteTrack": "حذف المسار؟",
|
||||||
|
"tracks.deleteConfirm": "هل أنت متأكد من حذف هذا المسار؟ لا يمكن التراجع.",
|
||||||
|
|
||||||
|
"projects.details": "التفاصيل",
|
||||||
|
"projects.discussion": "النقاش",
|
||||||
|
"projects.name": "الاسم",
|
||||||
|
"projects.description": "الوصف",
|
||||||
|
"projects.brand": "العلامة التجارية",
|
||||||
|
"projects.owner": "المالك",
|
||||||
|
"projects.status": "الحالة",
|
||||||
|
"projects.startDate": "تاريخ البدء",
|
||||||
|
"projects.dueDate": "تاريخ الاستحقاق",
|
||||||
|
"projects.editProject": "تعديل المشروع",
|
||||||
|
"projects.deleteProject": "حذف المشروع؟",
|
||||||
|
"projects.deleteConfirm": "هل أنت متأكد من حذف هذا المشروع؟ لا يمكن التراجع.",
|
||||||
|
|
||||||
|
"team.details": "التفاصيل",
|
||||||
|
"team.workload": "عبء العمل",
|
||||||
|
"team.recentTasks": "المهام الأخيرة",
|
||||||
|
"team.recentPosts": "المنشورات الأخيرة",
|
||||||
|
"team.modules": "الوحدات",
|
||||||
|
"team.selectBrands": "اختر العلامات التجارية...",
|
||||||
|
"team.gridView": "عرض الشبكة",
|
||||||
|
"team.teamsView": "عرض الفرق",
|
||||||
|
"team.unassigned": "غير مُعيّن",
|
||||||
|
|
||||||
|
"modules.marketing": "التسويق",
|
||||||
|
"modules.projects": "المشاريع",
|
||||||
|
"modules.finance": "المالية",
|
||||||
|
|
||||||
|
"teams.title": "الفرق",
|
||||||
|
"teams.teams": "الفرق",
|
||||||
|
"teams.createTeam": "إنشاء فريق",
|
||||||
|
"teams.editTeam": "تعديل الفريق",
|
||||||
|
"teams.deleteTeam": "حذف الفريق؟",
|
||||||
|
"teams.deleteConfirm": "هل أنت متأكد من حذف هذا الفريق؟ لا يمكن التراجع.",
|
||||||
|
"teams.name": "اسم الفريق",
|
||||||
|
"teams.description": "الوصف",
|
||||||
|
"teams.members": "أعضاء",
|
||||||
|
"teams.details": "التفاصيل",
|
||||||
|
"teams.noTeams": "لا توجد فرق بعد",
|
||||||
|
"teams.selectMembers": "بحث عن أعضاء...",
|
||||||
|
|
||||||
|
"dates.today": "اليوم",
|
||||||
|
"dates.yesterday": "أمس",
|
||||||
|
"dates.thisWeek": "هذا الأسبوع",
|
||||||
|
"dates.lastWeek": "الأسبوع الماضي",
|
||||||
|
"dates.thisMonth": "هذا الشهر",
|
||||||
|
"dates.lastMonth": "الشهر الماضي",
|
||||||
|
"dates.thisQuarter": "هذا الربع",
|
||||||
|
"dates.thisYear": "هذا العام",
|
||||||
|
"dates.customRange": "نطاق مخصص",
|
||||||
|
"dates.clearDates": "مسح التواريخ",
|
||||||
|
|
||||||
|
"dashboard.myTasks": "مهامي",
|
||||||
|
"dashboard.projectProgress": "تقدم المشاريع",
|
||||||
|
"dashboard.noProjectsYet": "لا توجد مشاريع بعد",
|
||||||
|
|
||||||
|
"finance.project": "المشروع",
|
||||||
|
"finance.projectBudget": "ميزانية المشروع",
|
||||||
|
"finance.projectBreakdown": "توزيع المشاريع",
|
||||||
|
"finance.budgetFor": "ميزانية لـ",
|
||||||
|
|
||||||
|
"budgets.title": "الميزانيات",
|
||||||
|
"budgets.subtitle": "إضافة وإدارة سجلات الميزانية — تتبع المصدر والوجهة والتخصيص",
|
||||||
|
"budgets.addEntry": "إضافة سجل",
|
||||||
|
"budgets.editEntry": "تعديل السجل",
|
||||||
|
"budgets.deleteEntry": "حذف السجل؟",
|
||||||
|
"budgets.deleteConfirm": "هل أنت متأكد من حذف هذا السجل؟ لا يمكن التراجع.",
|
||||||
|
"budgets.searchEntries": "بحث في السجلات...",
|
||||||
|
"budgets.allCategories": "جميع الفئات",
|
||||||
|
"budgets.allDestinations": "جميع الوجهات",
|
||||||
|
"budgets.noEntries": "لا توجد سجلات ميزانية بعد. أضف أول سجل.",
|
||||||
|
"budgets.noMatch": "لا توجد سجلات تطابق الفلاتر.",
|
||||||
|
"budgets.label": "الوصف",
|
||||||
|
"budgets.labelPlaceholder": "مثال: ميزانية التسويق الربع الأول، شراء معدات...",
|
||||||
|
"budgets.amount": "المبلغ",
|
||||||
|
"budgets.dateReceived": "تاريخ الاستلام",
|
||||||
|
"budgets.source": "المصدر",
|
||||||
|
"budgets.sourcePlaceholder": "مثال: موافقة المدير، الميزانية السنوية...",
|
||||||
|
"budgets.destination": "الوجهة",
|
||||||
|
"budgets.selectDestination": "اختر الوجهة...",
|
||||||
|
"budgets.companyCard": "بطاقة الشركة",
|
||||||
|
"budgets.personalAccount": "حساب شخصي",
|
||||||
|
"budgets.corporateAccount": "حساب الشركة",
|
||||||
|
"budgets.otherDest": "أخرى",
|
||||||
|
"budgets.category": "الفئة",
|
||||||
|
"budgets.linkedTo": "مرتبط بـ",
|
||||||
|
"budgets.noCampaign": "بدون حملة",
|
||||||
|
"budgets.noProject": "بدون مشروع",
|
||||||
|
"budgets.general": "عام",
|
||||||
|
"budgets.notes": "ملاحظات",
|
||||||
|
"budgets.notesPlaceholder": "أي تفاصيل حول هذا السجل...",
|
||||||
|
"budgets.date": "التاريخ",
|
||||||
|
"budgets.type": "النوع",
|
||||||
|
"budgets.income": "دخل",
|
||||||
|
"budgets.expense": "مصروف",
|
||||||
|
"budgets.allTypes": "الكل",
|
||||||
|
"budgets.net": "صافي",
|
||||||
|
"budgets.dateExpensed": "التاريخ",
|
||||||
|
|
||||||
|
"dashboard.expenses": "المصروفات",
|
||||||
|
"finance.expenses": "إجمالي المصروفات"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
"nav.dashboard": "Dashboard",
|
"nav.dashboard": "Dashboard",
|
||||||
"nav.campaigns": "Campaigns",
|
"nav.campaigns": "Campaigns",
|
||||||
"nav.finance": "Finance & ROI",
|
"nav.finance": "Finance & ROI",
|
||||||
|
"nav.financeDashboard": "Dashboard",
|
||||||
|
"nav.budgets": "Budgets",
|
||||||
"nav.posts": "Post Production",
|
"nav.posts": "Post Production",
|
||||||
"nav.assets": "Assets",
|
"nav.assets": "Assets",
|
||||||
"nav.projects": "Projects",
|
"nav.projects": "Projects",
|
||||||
"nav.tasks": "Tasks",
|
"nav.tasks": "Tasks",
|
||||||
"nav.team": "Team",
|
"nav.team": "Teams",
|
||||||
"nav.settings": "Settings",
|
"nav.settings": "Settings",
|
||||||
"nav.users": "Users",
|
"nav.users": "Users",
|
||||||
"nav.logout": "Logout",
|
"nav.logout": "Logout",
|
||||||
@@ -46,6 +48,7 @@
|
|||||||
"dashboard.activeCampaigns": "Active Campaigns",
|
"dashboard.activeCampaigns": "Active Campaigns",
|
||||||
"dashboard.total": "total",
|
"dashboard.total": "total",
|
||||||
"dashboard.budgetSpent": "Budget Spent",
|
"dashboard.budgetSpent": "Budget Spent",
|
||||||
|
"dashboard.budgetRemaining": "Budget Remaining",
|
||||||
"dashboard.of": "of",
|
"dashboard.of": "of",
|
||||||
"dashboard.noBudget": "No budget yet",
|
"dashboard.noBudget": "No budget yet",
|
||||||
"dashboard.overdueTasks": "Overdue Tasks",
|
"dashboard.overdueTasks": "Overdue Tasks",
|
||||||
@@ -170,6 +173,44 @@
|
|||||||
"tasks.deleted": "Task deleted successfully!",
|
"tasks.deleted": "Task deleted successfully!",
|
||||||
"tasks.statusUpdated": "Task status updated!",
|
"tasks.statusUpdated": "Task status updated!",
|
||||||
"tasks.canOnlyEditOwn": "You can only edit your own tasks.",
|
"tasks.canOnlyEditOwn": "You can only edit your own tasks.",
|
||||||
|
"tasks.search": "Search tasks...",
|
||||||
|
"tasks.board": "Board",
|
||||||
|
"tasks.list": "List",
|
||||||
|
"tasks.calendar": "Calendar",
|
||||||
|
"tasks.filters": "Filters",
|
||||||
|
"tasks.allProjects": "All Projects",
|
||||||
|
"tasks.allBrands": "All Brands",
|
||||||
|
"tasks.allPriorities": "All Priorities",
|
||||||
|
"tasks.allStatuses": "All Statuses",
|
||||||
|
"tasks.allAssignees": "All Assignees",
|
||||||
|
"tasks.allCreators": "All Creators",
|
||||||
|
"tasks.overdue": "Overdue",
|
||||||
|
"tasks.clearFilters": "Clear Filters",
|
||||||
|
"tasks.details": "Details",
|
||||||
|
"tasks.discussion": "Discussion",
|
||||||
|
"tasks.unscheduled": "Unscheduled",
|
||||||
|
"tasks.today": "Today",
|
||||||
|
"tasks.project": "Project",
|
||||||
|
"tasks.brand": "Brand",
|
||||||
|
"tasks.status": "Status",
|
||||||
|
"tasks.creator": "Creator",
|
||||||
|
"tasks.assignee": "Assignee",
|
||||||
|
"tasks.commentCount": "{n} comments",
|
||||||
|
"tasks.dueDateRange": "Due Date",
|
||||||
|
"tasks.noProject": "No project",
|
||||||
|
"tasks.createdBy": "Created by",
|
||||||
|
"tasks.startDate": "Start Date",
|
||||||
|
"tasks.attachments": "Attachments",
|
||||||
|
"tasks.uploadFile": "Upload file",
|
||||||
|
"tasks.setAsThumbnail": "Set as thumbnail",
|
||||||
|
"tasks.removeThumbnail": "Remove thumbnail",
|
||||||
|
"tasks.thumbnail": "Thumbnail",
|
||||||
|
"tasks.dropOrClick": "Drop file or click to upload",
|
||||||
|
|
||||||
|
"projects.thumbnail": "Thumbnail",
|
||||||
|
"projects.uploadThumbnail": "Upload Thumbnail",
|
||||||
|
"projects.changeThumbnail": "Change Thumbnail",
|
||||||
|
"projects.removeThumbnail": "Remove Thumbnail",
|
||||||
|
|
||||||
"team.title": "Team",
|
"team.title": "Team",
|
||||||
"team.members": "Team Members",
|
"team.members": "Team Members",
|
||||||
@@ -250,6 +291,8 @@
|
|||||||
"settings.noBrands": "No brands yet. Add your first brand.",
|
"settings.noBrands": "No brands yet. Add your first brand.",
|
||||||
"settings.moreComingSoon": "More Settings Coming Soon",
|
"settings.moreComingSoon": "More Settings Coming Soon",
|
||||||
"settings.additionalSettings": "Additional settings for notifications, display preferences, and more will be added here.",
|
"settings.additionalSettings": "Additional settings for notifications, display preferences, and more will be added here.",
|
||||||
|
"settings.currency": "Currency",
|
||||||
|
"settings.currencyHint": "This currency will be used across all financial pages.",
|
||||||
"settings.preferences": "Manage your preferences and app settings",
|
"settings.preferences": "Manage your preferences and app settings",
|
||||||
|
|
||||||
"tutorial.skip": "Skip Tutorial",
|
"tutorial.skip": "Skip Tutorial",
|
||||||
@@ -303,5 +346,148 @@
|
|||||||
"timeline.noItems": "No items to display",
|
"timeline.noItems": "No items to display",
|
||||||
"timeline.addItems": "Add items with dates to see the timeline",
|
"timeline.addItems": "Add items with dates to see the timeline",
|
||||||
"timeline.tracks": "Tracks",
|
"timeline.tracks": "Tracks",
|
||||||
"timeline.timeline": "Timeline"
|
"timeline.timeline": "Timeline",
|
||||||
|
|
||||||
|
"posts.details": "Details",
|
||||||
|
"posts.platformsLinks": "Platforms & Links",
|
||||||
|
"posts.discussion": "Discussion",
|
||||||
|
|
||||||
|
"campaigns.details": "Details",
|
||||||
|
"campaigns.performance": "Performance",
|
||||||
|
"campaigns.discussion": "Discussion",
|
||||||
|
"campaigns.name": "Name",
|
||||||
|
"campaigns.description": "Description",
|
||||||
|
"campaigns.brand": "Brand",
|
||||||
|
"campaigns.status": "Status",
|
||||||
|
"campaigns.platforms": "Platforms",
|
||||||
|
"campaigns.startDate": "Start Date",
|
||||||
|
"campaigns.endDate": "End Date",
|
||||||
|
"campaigns.budget": "Budget",
|
||||||
|
"campaigns.goals": "Goals",
|
||||||
|
"campaigns.notes": "Notes",
|
||||||
|
"campaigns.budgetSpent": "Budget Spent",
|
||||||
|
"campaigns.revenue": "Revenue",
|
||||||
|
"campaigns.impressions": "Impressions",
|
||||||
|
"campaigns.clicks": "Clicks",
|
||||||
|
"campaigns.conversions": "Conversions",
|
||||||
|
"campaigns.createCampaign": "Create Campaign",
|
||||||
|
"campaigns.editCampaign": "Edit Campaign",
|
||||||
|
"campaigns.deleteCampaign": "Delete Campaign?",
|
||||||
|
"campaigns.deleteConfirm": "Are you sure you want to delete this campaign? All associated data will be removed. This action cannot be undone.",
|
||||||
|
|
||||||
|
"tracks.details": "Details",
|
||||||
|
"tracks.metrics": "Metrics",
|
||||||
|
"tracks.trackName": "Track Name",
|
||||||
|
"tracks.type": "Type",
|
||||||
|
"tracks.platform": "Platform",
|
||||||
|
"tracks.budgetAllocated": "Budget Allocated",
|
||||||
|
"tracks.status": "Status",
|
||||||
|
"tracks.notes": "Notes",
|
||||||
|
"tracks.budgetSpent": "Budget Spent",
|
||||||
|
"tracks.revenue": "Revenue",
|
||||||
|
"tracks.addTrack": "Add Track",
|
||||||
|
"tracks.editTrack": "Edit Track",
|
||||||
|
"tracks.deleteTrack": "Delete Track?",
|
||||||
|
"tracks.deleteConfirm": "Are you sure you want to delete this track? This action cannot be undone.",
|
||||||
|
|
||||||
|
"projects.details": "Details",
|
||||||
|
"projects.discussion": "Discussion",
|
||||||
|
"projects.name": "Name",
|
||||||
|
"projects.description": "Description",
|
||||||
|
"projects.brand": "Brand",
|
||||||
|
"projects.owner": "Owner",
|
||||||
|
"projects.status": "Status",
|
||||||
|
"projects.startDate": "Start Date",
|
||||||
|
"projects.dueDate": "Due Date",
|
||||||
|
"projects.editProject": "Edit Project",
|
||||||
|
"projects.deleteProject": "Delete Project?",
|
||||||
|
"projects.deleteConfirm": "Are you sure you want to delete this project? This action cannot be undone.",
|
||||||
|
|
||||||
|
"team.details": "Details",
|
||||||
|
"team.workload": "Workload",
|
||||||
|
"team.recentTasks": "Recent Tasks",
|
||||||
|
"team.recentPosts": "Recent Posts",
|
||||||
|
"team.modules": "Modules",
|
||||||
|
"team.selectBrands": "Select brands...",
|
||||||
|
"team.gridView": "Grid View",
|
||||||
|
"team.teamsView": "Teams View",
|
||||||
|
"team.unassigned": "Unassigned",
|
||||||
|
|
||||||
|
"modules.marketing": "Marketing",
|
||||||
|
"modules.projects": "Projects",
|
||||||
|
"modules.finance": "Finance",
|
||||||
|
|
||||||
|
"teams.title": "Teams",
|
||||||
|
"teams.teams": "Teams",
|
||||||
|
"teams.createTeam": "Create Team",
|
||||||
|
"teams.editTeam": "Edit Team",
|
||||||
|
"teams.deleteTeam": "Delete Team?",
|
||||||
|
"teams.deleteConfirm": "Are you sure you want to delete this team? This action cannot be undone.",
|
||||||
|
"teams.name": "Team Name",
|
||||||
|
"teams.description": "Description",
|
||||||
|
"teams.members": "members",
|
||||||
|
"teams.details": "Details",
|
||||||
|
"teams.noTeams": "No teams yet",
|
||||||
|
"teams.selectMembers": "Search members...",
|
||||||
|
|
||||||
|
"dates.today": "Today",
|
||||||
|
"dates.yesterday": "Yesterday",
|
||||||
|
"dates.thisWeek": "This Week",
|
||||||
|
"dates.lastWeek": "Last Week",
|
||||||
|
"dates.thisMonth": "This Month",
|
||||||
|
"dates.lastMonth": "Last Month",
|
||||||
|
"dates.thisQuarter": "This Quarter",
|
||||||
|
"dates.thisYear": "This Year",
|
||||||
|
"dates.customRange": "Custom Range",
|
||||||
|
"dates.clearDates": "Clear Dates",
|
||||||
|
|
||||||
|
"dashboard.myTasks": "My Tasks",
|
||||||
|
"dashboard.projectProgress": "Project Progress",
|
||||||
|
"dashboard.noProjectsYet": "No projects yet",
|
||||||
|
|
||||||
|
"finance.project": "Project",
|
||||||
|
"finance.projectBudget": "Project Budget",
|
||||||
|
"finance.projectBreakdown": "Project Breakdown",
|
||||||
|
"finance.budgetFor": "Budget for",
|
||||||
|
|
||||||
|
"budgets.title": "Budgets",
|
||||||
|
"budgets.subtitle": "Add and manage budget entries — track source, destination, and allocation",
|
||||||
|
"budgets.addEntry": "Add Entry",
|
||||||
|
"budgets.editEntry": "Edit Entry",
|
||||||
|
"budgets.deleteEntry": "Delete Entry?",
|
||||||
|
"budgets.deleteConfirm": "Are you sure you want to delete this budget entry? This action cannot be undone.",
|
||||||
|
"budgets.searchEntries": "Search entries...",
|
||||||
|
"budgets.allCategories": "All Categories",
|
||||||
|
"budgets.allDestinations": "All Destinations",
|
||||||
|
"budgets.noEntries": "No budget entries yet. Add your first entry.",
|
||||||
|
"budgets.noMatch": "No entries match your filters.",
|
||||||
|
"budgets.label": "Label",
|
||||||
|
"budgets.labelPlaceholder": "e.g., Q1 Marketing Budget, Equipment Purchase...",
|
||||||
|
"budgets.amount": "Amount",
|
||||||
|
"budgets.dateReceived": "Date Received",
|
||||||
|
"budgets.source": "Source",
|
||||||
|
"budgets.sourcePlaceholder": "e.g., CEO Approval, Annual Budget...",
|
||||||
|
"budgets.destination": "Destination",
|
||||||
|
"budgets.selectDestination": "Select destination...",
|
||||||
|
"budgets.companyCard": "Company Card",
|
||||||
|
"budgets.personalAccount": "Personal Account",
|
||||||
|
"budgets.corporateAccount": "Corporate Account",
|
||||||
|
"budgets.otherDest": "Other",
|
||||||
|
"budgets.category": "Category",
|
||||||
|
"budgets.linkedTo": "Linked To",
|
||||||
|
"budgets.noCampaign": "No campaign",
|
||||||
|
"budgets.noProject": "No project",
|
||||||
|
"budgets.general": "General",
|
||||||
|
"budgets.notes": "Notes",
|
||||||
|
"budgets.notesPlaceholder": "Any details about this budget entry...",
|
||||||
|
"budgets.date": "Date",
|
||||||
|
"budgets.type": "Type",
|
||||||
|
"budgets.income": "Income",
|
||||||
|
"budgets.expense": "Expense",
|
||||||
|
"budgets.allTypes": "All Types",
|
||||||
|
"budgets.net": "Net",
|
||||||
|
"budgets.dateExpensed": "Date",
|
||||||
|
|
||||||
|
"dashboard.expenses": "Expenses",
|
||||||
|
"finance.expenses": "Total Expenses"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,15 @@ textarea {
|
|||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-right {
|
||||||
|
from { transform: translateX(100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-right {
|
||||||
|
animation: slide-in-right 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
/* Stagger children */
|
/* Stagger children */
|
||||||
.stagger-children > * {
|
.stagger-children > * {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -217,6 +226,76 @@ textarea {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mesh background - subtle radial gradients */
|
||||||
|
.bg-mesh {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(at 20% 20%, rgba(79, 70, 229, 0.04) 0, transparent 50%),
|
||||||
|
radial-gradient(at 80% 40%, rgba(219, 39, 119, 0.03) 0, transparent 50%),
|
||||||
|
radial-gradient(at 40% 80%, rgba(5, 150, 105, 0.03) 0, transparent 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient text */
|
||||||
|
.text-gradient {
|
||||||
|
background: linear-gradient(135deg, var(--color-brand-primary) 0%, #7c3aed 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Premium stat card - always-visible gradient top bar */
|
||||||
|
.stat-card-premium {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.stat-card-premium::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.stat-card-premium.accent-primary::before {
|
||||||
|
background: linear-gradient(90deg, #4f46e5, #7c3aed);
|
||||||
|
}
|
||||||
|
.stat-card-premium.accent-secondary::before {
|
||||||
|
background: linear-gradient(90deg, #db2777, #ec4899);
|
||||||
|
}
|
||||||
|
.stat-card-premium.accent-tertiary::before {
|
||||||
|
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||||
|
}
|
||||||
|
.stat-card-premium.accent-quaternary::before {
|
||||||
|
background: linear-gradient(90deg, #059669, #34d399);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section card - premium container */
|
||||||
|
.section-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
.section-card:hover {
|
||||||
|
box-shadow: 0 4px 20px -4px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
.section-card-header {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: linear-gradient(180deg, rgba(249, 250, 251, 0.5) 0%, white 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar active glow */
|
||||||
|
.sidebar-active-glow {
|
||||||
|
box-shadow: inset 3px 0 0 rgba(129, 140, 248, 0.8);
|
||||||
|
}
|
||||||
|
[dir="rtl"] .sidebar-active-glow {
|
||||||
|
box-shadow: inset -3px 0 0 rgba(129, 140, 248, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
/* Refined button styles */
|
/* Refined button styles */
|
||||||
button {
|
button {
|
||||||
border-radius: 0.625rem;
|
border-radius: 0.625rem;
|
||||||
|
|||||||
487
client/src/pages/Budgets.jsx
Normal file
487
client/src/pages/Budgets.jsx
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
import { useState, useEffect, useContext } from 'react'
|
||||||
|
import { Plus, DollarSign, Edit2, Trash2, Search, CreditCard, User, Building2, TrendingUp, TrendingDown } from 'lucide-react'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { AppContext } from '../App'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import Modal from '../components/Modal'
|
||||||
|
import StatusBadge from '../components/StatusBadge'
|
||||||
|
import { SkeletonTable } from '../components/SkeletonLoader'
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ value: 'marketing', label: 'Marketing' },
|
||||||
|
{ value: 'production', label: 'Production' },
|
||||||
|
{ value: 'equipment', label: 'Equipment' },
|
||||||
|
{ value: 'travel', label: 'Travel' },
|
||||||
|
{ value: 'other', label: 'Other' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DESTINATIONS = [
|
||||||
|
{ value: 'company_card', labelKey: 'budgets.companyCard', icon: CreditCard },
|
||||||
|
{ value: 'personal_account', labelKey: 'budgets.personalAccount', icon: User },
|
||||||
|
{ value: 'corporate_account', labelKey: 'budgets.corporateAccount', icon: Building2 },
|
||||||
|
{ value: 'other', labelKey: 'budgets.otherDest', icon: DollarSign },
|
||||||
|
]
|
||||||
|
|
||||||
|
const EMPTY_ENTRY = {
|
||||||
|
label: '', amount: '', source: '', destination: '', campaign_id: '', project_id: '',
|
||||||
|
category: 'marketing', date_received: new Date().toISOString().slice(0, 10), notes: '',
|
||||||
|
type: 'income',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Budgets() {
|
||||||
|
const { t, currencySymbol } = useLanguage()
|
||||||
|
const { brands } = useContext(AppContext)
|
||||||
|
const { permissions } = useAuth()
|
||||||
|
const canManageFinance = permissions?.canManageFinance
|
||||||
|
const [entries, setEntries] = useState([])
|
||||||
|
const [campaigns, setCampaigns] = useState([])
|
||||||
|
const [projects, setProjects] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const [editing, setEditing] = useState(null)
|
||||||
|
const [form, setForm] = useState(EMPTY_ENTRY)
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
const [entryToDelete, setEntryToDelete] = useState(null)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [filterCategory, setFilterCategory] = useState('')
|
||||||
|
const [filterDestination, setFilterDestination] = useState('')
|
||||||
|
const [filterType, setFilterType] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => { loadAll() }, [])
|
||||||
|
|
||||||
|
const loadAll = async () => {
|
||||||
|
try {
|
||||||
|
const [ent, camp, proj] = await Promise.all([
|
||||||
|
api.get('/budget'),
|
||||||
|
api.get('/campaigns'),
|
||||||
|
api.get('/projects'),
|
||||||
|
])
|
||||||
|
setEntries(ent.data || ent || [])
|
||||||
|
setCampaigns(camp.data || camp || [])
|
||||||
|
setProjects(proj.data || proj || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load budgets:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
label: form.label,
|
||||||
|
amount: Number(form.amount),
|
||||||
|
source: form.source || null,
|
||||||
|
destination: form.destination || null,
|
||||||
|
campaign_id: form.campaign_id ? Number(form.campaign_id) : null,
|
||||||
|
project_id: form.project_id ? Number(form.project_id) : null,
|
||||||
|
category: form.category,
|
||||||
|
date_received: form.date_received,
|
||||||
|
notes: form.notes,
|
||||||
|
type: form.type || 'income',
|
||||||
|
}
|
||||||
|
if (editing) {
|
||||||
|
await api.patch(`/budget/${editing._id || editing.id}`, data)
|
||||||
|
} else {
|
||||||
|
await api.post('/budget', data)
|
||||||
|
}
|
||||||
|
setShowModal(false)
|
||||||
|
setEditing(null)
|
||||||
|
setForm(EMPTY_ENTRY)
|
||||||
|
loadAll()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Save failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (entry) => {
|
||||||
|
setEditing(entry)
|
||||||
|
setForm({
|
||||||
|
label: entry.label || '',
|
||||||
|
amount: entry.amount || '',
|
||||||
|
source: entry.source || '',
|
||||||
|
destination: entry.destination || '',
|
||||||
|
campaign_id: entry.campaignId || entry.campaign_id || '',
|
||||||
|
project_id: entry.project_id || '',
|
||||||
|
category: entry.category || 'marketing',
|
||||||
|
date_received: entry.dateReceived || entry.date_received || '',
|
||||||
|
notes: entry.notes || '',
|
||||||
|
type: entry.type || 'income',
|
||||||
|
})
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
setEntryToDelete(id)
|
||||||
|
setShowDeleteConfirm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!entryToDelete) return
|
||||||
|
await api.delete(`/budget/${entryToDelete}`)
|
||||||
|
setEntryToDelete(null)
|
||||||
|
loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredEntries = entries.filter(e => {
|
||||||
|
if (searchQuery) {
|
||||||
|
const q = searchQuery.toLowerCase()
|
||||||
|
if (!(e.label || '').toLowerCase().includes(q) &&
|
||||||
|
!(e.source || '').toLowerCase().includes(q) &&
|
||||||
|
!(e.campaign_name || '').toLowerCase().includes(q) &&
|
||||||
|
!(e.project_name || '').toLowerCase().includes(q) &&
|
||||||
|
!(e.notes || '').toLowerCase().includes(q)) return false
|
||||||
|
}
|
||||||
|
if (filterCategory && e.category !== filterCategory) return false
|
||||||
|
if (filterDestination && e.destination !== filterDestination) return false
|
||||||
|
if (filterType && (e.type || 'income') !== filterType) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalIncome = filteredEntries.filter(e => (e.type || 'income') === 'income').reduce((sum, e) => sum + (e.amount || 0), 0)
|
||||||
|
const totalExpenseAmt = filteredEntries.filter(e => e.type === 'expense').reduce((sum, e) => sum + (e.amount || 0), 0)
|
||||||
|
const totalFiltered = totalIncome - totalExpenseAmt
|
||||||
|
|
||||||
|
const destConfig = (val) => DESTINATIONS.find(d => d.value === val)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <SkeletonTable rows={6} cols={6} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary">{t('budgets.title')}</h1>
|
||||||
|
<p className="text-sm text-text-tertiary mt-0.5">{t('budgets.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
{canManageFinance && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditing(null); setForm(EMPTY_ENTRY); setShowModal(true) }}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> {t('budgets.addEntry')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
||||||
|
<Search className="absolute left-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('budgets.searchEntries')}
|
||||||
|
className="w-full pl-9 pr-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={filterCategory}
|
||||||
|
onChange={e => setFilterCategory(e.target.value)}
|
||||||
|
className="px-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">{t('budgets.allCategories')}</option>
|
||||||
|
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={filterDestination}
|
||||||
|
onChange={e => setFilterDestination(e.target.value)}
|
||||||
|
className="px-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">{t('budgets.allDestinations')}</option>
|
||||||
|
{DESTINATIONS.map(d => <option key={d.value} value={d.value}>{t(d.labelKey)}</option>)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Type filter */}
|
||||||
|
<div className="flex rounded-lg border border-border overflow-hidden">
|
||||||
|
{[{ value: '', label: t('budgets.allTypes') }, { value: 'income', label: t('budgets.income') }, { value: 'expense', label: t('budgets.expense') }].map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setFilterType(opt.value)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
|
filterType === opt.value
|
||||||
|
? opt.value === 'expense' ? 'bg-red-500 text-white' : opt.value === 'income' ? 'bg-emerald-500 text-white' : 'bg-brand-primary text-white'
|
||||||
|
: 'bg-white text-text-secondary hover:bg-surface-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredEntries.length > 0 && (
|
||||||
|
<div className="ml-auto flex items-center gap-3 text-sm text-text-tertiary">
|
||||||
|
<span>{filteredEntries.length} {filteredEntries.length === 1 ? 'entry' : 'entries'}</span>
|
||||||
|
<span className="text-emerald-600 font-semibold">+{totalIncome.toLocaleString()}</span>
|
||||||
|
{totalExpenseAmt > 0 && <span className="text-red-500 font-semibold">-{totalExpenseAmt.toLocaleString()}</span>}
|
||||||
|
<span className="font-bold text-text-primary">= {totalFiltered.toLocaleString()} {currencySymbol}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Entries table */}
|
||||||
|
<div className="section-card">
|
||||||
|
{filteredEntries.length === 0 ? (
|
||||||
|
<div className="py-16 text-center text-sm text-text-tertiary">
|
||||||
|
{entries.length === 0 ? t('budgets.noEntries') : t('budgets.noMatch')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-surface-secondary">
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.label')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.source')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.destination')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.linkedTo')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">{t('budgets.date')}</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">{t('budgets.amount')}</th>
|
||||||
|
{canManageFinance && <th className="px-4 py-3 w-20" />}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-light">
|
||||||
|
{filteredEntries.map(entry => {
|
||||||
|
const dest = destConfig(entry.destination)
|
||||||
|
const DestIcon = dest?.icon || DollarSign
|
||||||
|
return (
|
||||||
|
<tr key={entry.id || entry._id} className="hover:bg-surface-secondary">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium text-text-primary">{entry.label}</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary capitalize">{entry.category}</span>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
||||||
|
(entry.type || 'income') === 'expense'
|
||||||
|
? 'bg-red-50 text-red-600 border border-red-100'
|
||||||
|
: 'bg-emerald-50 text-emerald-600 border border-emerald-100'
|
||||||
|
}`}>
|
||||||
|
{(entry.type || 'income') === 'expense' ? t('budgets.expense') : t('budgets.income')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{entry.notes && <p className="text-xs text-text-tertiary mt-0.5 truncate max-w-[200px]">{entry.notes}</p>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">{entry.source || <span className="text-text-tertiary">--</span>}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{entry.destination ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||||
|
<DestIcon className="w-3 h-3 text-text-tertiary" />
|
||||||
|
<span className="text-text-secondary">{t(dest?.labelKey || 'budgets.otherDest')}</span>
|
||||||
|
</span>
|
||||||
|
) : <span className="text-text-tertiary">--</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{entry.campaign_name && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium border border-blue-100">
|
||||||
|
{entry.campaign_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entry.project_name && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-50 text-purple-600 font-medium border border-purple-100">
|
||||||
|
{entry.project_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!entry.campaign_name && !entry.project_name && <span className="text-text-tertiary text-xs">{t('budgets.general')}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary whitespace-nowrap">
|
||||||
|
{entry.date_received ? format(new Date(entry.date_received), 'MMM d, yyyy') : '--'}
|
||||||
|
</td>
|
||||||
|
<td className={`px-4 py-3 text-right font-semibold whitespace-nowrap ${
|
||||||
|
(entry.type || 'income') === 'expense' ? 'text-red-500' : 'text-emerald-600'
|
||||||
|
}`}>
|
||||||
|
{(entry.type || 'income') === 'expense' ? '-' : '+'}{Number(entry.amount).toLocaleString()} {currencySymbol}
|
||||||
|
</td>
|
||||||
|
{canManageFinance && (
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => openEdit(entry)} className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary">
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(entry.id || entry._id)} className="p-1.5 hover:bg-red-50 rounded-lg text-text-tertiary hover:text-red-500">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add/Edit Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => { setShowModal(false); setEditing(null) }}
|
||||||
|
title={editing ? t('budgets.editEntry') : t('budgets.addEntry')}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Income / Expense toggle */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1.5">{t('budgets.type')}</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm(f => ({ ...f, type: 'income' }))}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${
|
||||||
|
form.type === 'income'
|
||||||
|
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
|
||||||
|
: 'border-border bg-white text-text-secondary hover:bg-surface-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
{t('budgets.income')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm(f => ({ ...f, type: 'expense' }))}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border-2 transition-all ${
|
||||||
|
form.type === 'expense'
|
||||||
|
? 'border-red-500 bg-red-50 text-red-700'
|
||||||
|
: 'border-border bg-white text-text-secondary hover:bg-surface-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TrendingDown className="w-4 h-4" />
|
||||||
|
{t('budgets.expense')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.label')} *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.label}
|
||||||
|
onChange={e => setForm(f => ({ ...f, label: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
placeholder={t('budgets.labelPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.amount')} ({currencySymbol}) *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.amount}
|
||||||
|
onChange={e => setForm(f => ({ ...f, amount: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
placeholder="50000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">{form.type === 'expense' ? t('budgets.dateExpensed') : t('budgets.dateReceived')} *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.date_received}
|
||||||
|
onChange={e => setForm(f => ({ ...f, date_received: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.source')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.source}
|
||||||
|
onChange={e => setForm(f => ({ ...f, source: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||||
|
placeholder={t('budgets.sourcePlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.destination')}</label>
|
||||||
|
<select
|
||||||
|
value={form.destination}
|
||||||
|
onChange={e => setForm(f => ({ ...f, destination: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">{t('budgets.selectDestination')}</option>
|
||||||
|
{DESTINATIONS.map(d => <option key={d.value} value={d.value}>{t(d.labelKey)}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.category')}</label>
|
||||||
|
<select
|
||||||
|
value={form.category}
|
||||||
|
onChange={e => setForm(f => ({ ...f, category: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||||
|
>
|
||||||
|
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.linkedTo')}</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={form.campaign_id}
|
||||||
|
onChange={e => setForm(f => ({ ...f, campaign_id: e.target.value, project_id: '' }))}
|
||||||
|
disabled={!!form.project_id}
|
||||||
|
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none disabled:opacity-50 disabled:bg-surface-secondary"
|
||||||
|
>
|
||||||
|
<option value="">{t('budgets.noCampaign')}</option>
|
||||||
|
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={form.project_id}
|
||||||
|
onChange={e => setForm(f => ({ ...f, project_id: e.target.value, campaign_id: '' }))}
|
||||||
|
disabled={!!form.campaign_id}
|
||||||
|
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none disabled:opacity-50 disabled:bg-surface-secondary"
|
||||||
|
>
|
||||||
|
<option value="">{t('budgets.noProject')}</option>
|
||||||
|
{projects.map(p => <option key={p._id || p.id} value={p._id || p.id}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.notes')}</label>
|
||||||
|
<textarea
|
||||||
|
value={form.notes}
|
||||||
|
onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none resize-none"
|
||||||
|
placeholder={t('budgets.notesPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||||||
|
<button onClick={() => setShowModal(false)} className="px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary rounded-lg">{t('common.cancel')}</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!form.label || !form.amount || !form.date_received}
|
||||||
|
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||||
|
>
|
||||||
|
{editing ? t('common.save') : t('budgets.addEntry')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showDeleteConfirm}
|
||||||
|
onClose={() => { setShowDeleteConfirm(false); setEntryToDelete(null) }}
|
||||||
|
title={t('budgets.deleteEntry')}
|
||||||
|
isConfirm
|
||||||
|
danger
|
||||||
|
confirmText={t('common.delete')}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
>
|
||||||
|
{t('budgets.deleteConfirm')}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ import BrandBadge from '../components/BrandBadge'
|
|||||||
import Modal from '../components/Modal'
|
import Modal from '../components/Modal'
|
||||||
import BudgetBar from '../components/BudgetBar'
|
import BudgetBar from '../components/BudgetBar'
|
||||||
import CommentsSection from '../components/CommentsSection'
|
import CommentsSection from '../components/CommentsSection'
|
||||||
|
import CampaignDetailPanel from '../components/CampaignDetailPanel'
|
||||||
|
import TrackDetailPanel from '../components/TrackDetailPanel'
|
||||||
|
import PostDetailPanel from '../components/PostDetailPanel'
|
||||||
|
|
||||||
const TRACK_TYPES = {
|
const TRACK_TYPES = {
|
||||||
organic_social: { label: 'Organic Social', icon: Megaphone, color: 'text-green-600 bg-green-50', hasBudget: false },
|
organic_social: { label: 'Organic Social', icon: Megaphone, color: 'text-green-600 bg-green-50', hasBudget: false },
|
||||||
@@ -23,14 +26,6 @@ const TRACK_TYPES = {
|
|||||||
|
|
||||||
const TRACK_STATUSES = ['planned', 'active', 'paused', 'completed']
|
const TRACK_STATUSES = ['planned', 'active', 'paused', 'completed']
|
||||||
|
|
||||||
const EMPTY_TRACK = {
|
|
||||||
name: '', type: 'organic_social', platform: '', budget_allocated: '', status: 'planned', notes: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_METRICS = {
|
|
||||||
budget_spent: '', revenue: '', impressions: '', clicks: '', conversions: '', notes: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) {
|
function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -44,8 +39,8 @@ function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) {
|
|||||||
export default function CampaignDetail() {
|
export default function CampaignDetail() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { brands, getBrandName } = useContext(AppContext)
|
const { brands, getBrandName, teamMembers } = useContext(AppContext)
|
||||||
const { lang } = useLanguage()
|
const { lang, currencySymbol } = useLanguage()
|
||||||
const { permissions, user } = useAuth()
|
const { permissions, user } = useAuth()
|
||||||
const isSuperadmin = user?.role === 'superadmin'
|
const isSuperadmin = user?.role === 'superadmin'
|
||||||
const [campaign, setCampaign] = useState(null)
|
const [campaign, setCampaign] = useState(null)
|
||||||
@@ -59,25 +54,25 @@ export default function CampaignDetail() {
|
|||||||
const canSetBudget = permissions?.canSetBudget
|
const canSetBudget = permissions?.canSetBudget
|
||||||
const [editingBudget, setEditingBudget] = useState(false)
|
const [editingBudget, setEditingBudget] = useState(false)
|
||||||
const [budgetValue, setBudgetValue] = useState('')
|
const [budgetValue, setBudgetValue] = useState('')
|
||||||
const [showTrackModal, setShowTrackModal] = useState(false)
|
|
||||||
const [editingTrack, setEditingTrack] = useState(null)
|
|
||||||
const [trackForm, setTrackForm] = useState(EMPTY_TRACK)
|
|
||||||
const [showMetricsModal, setShowMetricsModal] = useState(false)
|
|
||||||
const [metricsTrack, setMetricsTrack] = useState(null)
|
|
||||||
const [metricsForm, setMetricsForm] = useState(EMPTY_METRICS)
|
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const [trackToDelete, setTrackToDelete] = useState(null)
|
const [trackToDelete, setTrackToDelete] = useState(null)
|
||||||
const [selectedPost, setSelectedPost] = useState(null)
|
const [selectedPost, setSelectedPost] = useState(null)
|
||||||
const [showDiscussion, setShowDiscussion] = useState(false)
|
const [showDiscussion, setShowDiscussion] = useState(false)
|
||||||
const [showEditModal, setShowEditModal] = useState(false)
|
const [allCampaigns, setAllCampaigns] = useState([])
|
||||||
const [editForm, setEditForm] = useState({})
|
|
||||||
const [showDeleteCampaignConfirm, setShowDeleteCampaignConfirm] = useState(false)
|
// Panel state
|
||||||
|
const [panelCampaign, setPanelCampaign] = useState(null)
|
||||||
|
const [panelTrack, setPanelTrack] = useState(null)
|
||||||
|
const [trackScrollToMetrics, setTrackScrollToMetrics] = useState(false)
|
||||||
|
|
||||||
const isCreator = campaign?.createdByUserId === user?.id || campaign?.created_by_user_id === user?.id
|
const isCreator = campaign?.createdByUserId === user?.id || campaign?.created_by_user_id === user?.id
|
||||||
const canManage = isSuperadmin || (permissions?.canEditCampaigns && isCreator)
|
const canManage = isSuperadmin || (permissions?.canEditCampaigns && isCreator)
|
||||||
const canAssign = isSuperadmin || (permissions?.canAssignCampaigns && isCreator)
|
const canAssign = isSuperadmin || (permissions?.canAssignCampaigns && isCreator)
|
||||||
|
|
||||||
useEffect(() => { loadAll() }, [id])
|
useEffect(() => { loadAll() }, [id])
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/campaigns').then(r => setAllCampaigns(Array.isArray(r) ? r : (r.data || []))).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const loadAll = async () => {
|
const loadAll = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -141,28 +136,46 @@ export default function CampaignDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveTrack = async () => {
|
// Panel handlers
|
||||||
try {
|
const handleCampaignPanelSave = async (campaignId, data) => {
|
||||||
const data = {
|
await api.patch(`/campaigns/${campaignId}`, data)
|
||||||
name: trackForm.name,
|
loadAll()
|
||||||
type: trackForm.type,
|
|
||||||
platform: trackForm.platform || null,
|
|
||||||
budget_allocated: trackForm.budget_allocated ? Number(trackForm.budget_allocated) : 0,
|
|
||||||
status: trackForm.status,
|
|
||||||
notes: trackForm.notes,
|
|
||||||
}
|
}
|
||||||
if (editingTrack) {
|
|
||||||
await api.patch(`/tracks/${editingTrack.id}`, data)
|
const handleCampaignPanelDelete = async (campaignId) => {
|
||||||
|
await api.delete(`/campaigns/${campaignId}`)
|
||||||
|
navigate('/campaigns')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTrackPanelSave = async (trackId, data) => {
|
||||||
|
if (trackId) {
|
||||||
|
await api.patch(`/tracks/${trackId}`, data)
|
||||||
} else {
|
} else {
|
||||||
await api.post(`/campaigns/${id}/tracks`, data)
|
await api.post(`/campaigns/${id}/tracks`, data)
|
||||||
}
|
}
|
||||||
setShowTrackModal(false)
|
setPanelTrack(null)
|
||||||
setEditingTrack(null)
|
|
||||||
setTrackForm(EMPTY_TRACK)
|
|
||||||
loadAll()
|
loadAll()
|
||||||
} catch (err) {
|
|
||||||
console.error('Save track failed:', err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTrackPanelDelete = async (trackId) => {
|
||||||
|
await api.delete(`/tracks/${trackId}`)
|
||||||
|
setPanelTrack(null)
|
||||||
|
loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePostPanelSave = async (postId, data) => {
|
||||||
|
if (postId) {
|
||||||
|
await api.patch(`/posts/${postId}`, data)
|
||||||
|
} else {
|
||||||
|
await api.post('/posts', data)
|
||||||
|
}
|
||||||
|
loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePostPanelDelete = async (postId) => {
|
||||||
|
await api.delete(`/posts/${postId}`)
|
||||||
|
setSelectedPost(null)
|
||||||
|
loadAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteTrack = async (trackId) => {
|
const deleteTrack = async (trackId) => {
|
||||||
@@ -177,87 +190,6 @@ export default function CampaignDetail() {
|
|||||||
loadAll()
|
loadAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveMetrics = async () => {
|
|
||||||
try {
|
|
||||||
await api.patch(`/tracks/${metricsTrack.id}`, {
|
|
||||||
budget_spent: metricsForm.budget_spent ? Number(metricsForm.budget_spent) : 0,
|
|
||||||
revenue: metricsForm.revenue ? Number(metricsForm.revenue) : 0,
|
|
||||||
impressions: metricsForm.impressions ? Number(metricsForm.impressions) : 0,
|
|
||||||
clicks: metricsForm.clicks ? Number(metricsForm.clicks) : 0,
|
|
||||||
conversions: metricsForm.conversions ? Number(metricsForm.conversions) : 0,
|
|
||||||
notes: metricsForm.notes || '',
|
|
||||||
})
|
|
||||||
setShowMetricsModal(false)
|
|
||||||
setMetricsTrack(null)
|
|
||||||
loadAll()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Save metrics failed:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEditTrack = (track) => {
|
|
||||||
setEditingTrack(track)
|
|
||||||
setTrackForm({
|
|
||||||
name: track.name || '',
|
|
||||||
type: track.type || 'organic_social',
|
|
||||||
platform: track.platform || '',
|
|
||||||
budget_allocated: track.budget_allocated || '',
|
|
||||||
status: track.status || 'planned',
|
|
||||||
notes: track.notes || '',
|
|
||||||
})
|
|
||||||
setShowTrackModal(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEditCampaign = () => {
|
|
||||||
setEditForm({
|
|
||||||
name: campaign.name || '',
|
|
||||||
description: campaign.description || '',
|
|
||||||
status: campaign.status || 'planning',
|
|
||||||
start_date: campaign.start_date ? new Date(campaign.start_date).toISOString().slice(0, 10) : '',
|
|
||||||
end_date: campaign.end_date ? new Date(campaign.end_date).toISOString().slice(0, 10) : '',
|
|
||||||
goals: campaign.goals || '',
|
|
||||||
platforms: campaign.platforms || [],
|
|
||||||
notes: campaign.notes || '',
|
|
||||||
brand_id: campaign.brand_id || '',
|
|
||||||
budget: campaign.budget || '',
|
|
||||||
})
|
|
||||||
setShowEditModal(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveCampaignEdit = async () => {
|
|
||||||
try {
|
|
||||||
await api.patch(`/campaigns/${id}`, {
|
|
||||||
name: editForm.name,
|
|
||||||
description: editForm.description,
|
|
||||||
status: editForm.status,
|
|
||||||
start_date: editForm.start_date,
|
|
||||||
end_date: editForm.end_date,
|
|
||||||
goals: editForm.goals,
|
|
||||||
platforms: editForm.platforms,
|
|
||||||
notes: editForm.notes,
|
|
||||||
brand_id: editForm.brand_id || null,
|
|
||||||
budget: editForm.budget ? Number(editForm.budget) : null,
|
|
||||||
})
|
|
||||||
setShowEditModal(false)
|
|
||||||
loadAll()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to update campaign:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openMetrics = (track) => {
|
|
||||||
setMetricsTrack(track)
|
|
||||||
setMetricsForm({
|
|
||||||
budget_spent: track.budget_spent || '',
|
|
||||||
revenue: track.revenue || '',
|
|
||||||
impressions: track.impressions || '',
|
|
||||||
clicks: track.clicks || '',
|
|
||||||
conversions: track.conversions || '',
|
|
||||||
notes: track.notes || '',
|
|
||||||
})
|
|
||||||
setShowMetricsModal(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="animate-pulse"><div className="h-64 bg-surface-tertiary rounded-xl"></div></div>
|
return <div className="animate-pulse"><div className="h-64 bg-surface-tertiary rounded-xl"></div></div>
|
||||||
}
|
}
|
||||||
@@ -299,7 +231,7 @@ export default function CampaignDetail() {
|
|||||||
<span>{format(new Date(campaign.start_date), 'MMM d')} – {format(new Date(campaign.end_date), 'MMM d, yyyy')}</span>
|
<span>{format(new Date(campaign.start_date), 'MMM d')} – {format(new Date(campaign.end_date), 'MMM d, yyyy')}</span>
|
||||||
)}
|
)}
|
||||||
<span>
|
<span>
|
||||||
Budget: {campaign.budget > 0 ? `${campaign.budget.toLocaleString()} SAR` : 'Not set'}
|
Budget: {campaign.budget > 0 ? `${campaign.budget.toLocaleString()} ${currencySymbol}` : 'Not set'}
|
||||||
</span>
|
</span>
|
||||||
{campaign.platforms && campaign.platforms.length > 0 && (
|
{campaign.platforms && campaign.platforms.length > 0 && (
|
||||||
<PlatformIcons platforms={campaign.platforms} size={16} />
|
<PlatformIcons platforms={campaign.platforms} size={16} />
|
||||||
@@ -330,7 +262,7 @@ export default function CampaignDetail() {
|
|||||||
)}
|
)}
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<button
|
<button
|
||||||
onClick={openEditCampaign}
|
onClick={() => setPanelCampaign(campaign)}
|
||||||
className="flex items-center gap-1.5 px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-colors"
|
className="flex items-center gap-1.5 px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-colors"
|
||||||
>
|
>
|
||||||
<Settings className="w-4 h-4" />
|
<Settings className="w-4 h-4" />
|
||||||
@@ -409,7 +341,7 @@ export default function CampaignDetail() {
|
|||||||
<h3 className="font-semibold text-text-primary">Tracks</h3>
|
<h3 className="font-semibold text-text-primary">Tracks</h3>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditingTrack(null); setTrackForm(EMPTY_TRACK); setShowTrackModal(true) }}
|
onClick={() => { setPanelTrack({}); setTrackScrollToMetrics(false) }}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" /> Add Track
|
<Plus className="w-3.5 h-3.5" /> Add Track
|
||||||
@@ -461,7 +393,7 @@ export default function CampaignDetail() {
|
|||||||
{track.clicks > 0 && <span>🖱 {track.clicks.toLocaleString()}</span>}
|
{track.clicks > 0 && <span>🖱 {track.clicks.toLocaleString()}</span>}
|
||||||
{track.conversions > 0 && <span>🎯 {track.conversions.toLocaleString()}</span>}
|
{track.conversions > 0 && <span>🎯 {track.conversions.toLocaleString()}</span>}
|
||||||
{track.clicks > 0 && track.budget_spent > 0 && (
|
{track.clicks > 0 && track.budget_spent > 0 && (
|
||||||
<span>CPC: {(track.budget_spent / track.clicks).toFixed(2)} SAR</span>
|
<span>CPC: {(track.budget_spent / track.clicks).toFixed(2)} {currencySymbol}</span>
|
||||||
)}
|
)}
|
||||||
{track.impressions > 0 && track.clicks > 0 && (
|
{track.impressions > 0 && track.clicks > 0 && (
|
||||||
<span>CTR: {(track.clicks / track.impressions * 100).toFixed(2)}%</span>
|
<span>CTR: {(track.clicks / track.impressions * 100).toFixed(2)}%</span>
|
||||||
@@ -485,14 +417,14 @@ export default function CampaignDetail() {
|
|||||||
{canManage && (
|
{canManage && (
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => openMetrics(track)}
|
onClick={() => { setPanelTrack(track); setTrackScrollToMetrics(true) }}
|
||||||
title="Update metrics"
|
title="Update metrics"
|
||||||
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-brand-primary"
|
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-brand-primary"
|
||||||
>
|
>
|
||||||
<TrendingUp className="w-4 h-4" />
|
<TrendingUp className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditTrack(track)}
|
onClick={() => { setPanelTrack(track); setTrackScrollToMetrics(false) }}
|
||||||
title="Edit track"
|
title="Edit track"
|
||||||
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary"
|
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary"
|
||||||
>
|
>
|
||||||
@@ -571,176 +503,6 @@ export default function CampaignDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add/Edit Track Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={showTrackModal}
|
|
||||||
onClose={() => { setShowTrackModal(false); setEditingTrack(null) }}
|
|
||||||
title={editingTrack ? 'Edit Track' : 'Add Track'}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Track Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={trackForm.name}
|
|
||||||
onChange={e => setTrackForm(f => ({ ...f, name: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder="e.g., Instagram Paid Ads, Organic Wave, Google Search..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Type</label>
|
|
||||||
<select
|
|
||||||
value={trackForm.type}
|
|
||||||
onChange={e => setTrackForm(f => ({ ...f, type: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
|
||||||
>
|
|
||||||
{Object.entries(TRACK_TYPES).map(([k, v]) => (
|
|
||||||
<option key={k} value={k}>{v.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Platform</label>
|
|
||||||
<select
|
|
||||||
value={trackForm.platform}
|
|
||||||
onChange={e => setTrackForm(f => ({ ...f, platform: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">All / Multiple</option>
|
|
||||||
{Object.entries(PLATFORMS).map(([k, v]) => (
|
|
||||||
<option key={k} value={k}>{v.label}</option>
|
|
||||||
))}
|
|
||||||
<option value="google_ads">Google Ads</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Budget Allocated (SAR)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={trackForm.budget_allocated}
|
|
||||||
onChange={e => setTrackForm(f => ({ ...f, budget_allocated: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
|
||||||
placeholder="0 for free/organic"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
|
|
||||||
<select
|
|
||||||
value={trackForm.status}
|
|
||||||
onChange={e => setTrackForm(f => ({ ...f, status: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
|
||||||
>
|
|
||||||
{TRACK_STATUSES.map(s => (
|
|
||||||
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
|
|
||||||
<textarea
|
|
||||||
value={trackForm.notes}
|
|
||||||
onChange={e => setTrackForm(f => ({ ...f, notes: e.target.value }))}
|
|
||||||
rows={2}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none resize-none"
|
|
||||||
placeholder="Keywords, targeting details, content plan..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
|
||||||
<button onClick={() => setShowTrackModal(false)} className="px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary rounded-lg">Cancel</button>
|
|
||||||
<button onClick={saveTrack} className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm">
|
|
||||||
{editingTrack ? 'Save' : 'Add Track'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Update Metrics Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={showMetricsModal}
|
|
||||||
onClose={() => { setShowMetricsModal(false); setMetricsTrack(null) }}
|
|
||||||
title={`Update Metrics — ${metricsTrack?.name || ''}`}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Budget Spent (SAR)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={metricsForm.budget_spent}
|
|
||||||
onChange={e => setMetricsForm(f => ({ ...f, budget_spent: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Revenue (SAR)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={metricsForm.revenue}
|
|
||||||
onChange={e => setMetricsForm(f => ({ ...f, revenue: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Impressions</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={metricsForm.impressions}
|
|
||||||
onChange={e => setMetricsForm(f => ({ ...f, impressions: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Clicks</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={metricsForm.clicks}
|
|
||||||
onChange={e => setMetricsForm(f => ({ ...f, clicks: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Conversions</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={metricsForm.conversions}
|
|
||||||
onChange={e => setMetricsForm(f => ({ ...f, conversions: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
|
|
||||||
<textarea
|
|
||||||
value={metricsForm.notes}
|
|
||||||
onChange={e => setMetricsForm(f => ({ ...f, notes: e.target.value }))}
|
|
||||||
rows={2}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none resize-none"
|
|
||||||
placeholder="What's working, what to adjust..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
|
||||||
<button onClick={() => setShowMetricsModal(false)} className="px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary rounded-lg">Cancel</button>
|
|
||||||
<button onClick={saveMetrics} className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm">
|
|
||||||
Save Metrics
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Delete Track Confirmation */}
|
{/* Delete Track Confirmation */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showDeleteConfirm}
|
isOpen={showDeleteConfirm}
|
||||||
@@ -806,7 +568,7 @@ export default function CampaignDetail() {
|
|||||||
<Modal isOpen={editingBudget} onClose={() => setEditingBudget(false)} title="Set Campaign Budget" size="sm">
|
<Modal isOpen={editingBudget} onClose={() => setEditingBudget(false)} title="Set Campaign Budget" size="sm">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Budget (SAR)</label>
|
<label className="block text-sm font-medium text-text-primary mb-1">Budget ({currencySymbol})</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={budgetValue}
|
value={budgetValue}
|
||||||
@@ -842,307 +604,42 @@ export default function CampaignDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Edit Campaign Modal */}
|
{/* Post Detail Panel */}
|
||||||
<Modal
|
|
||||||
isOpen={showEditModal}
|
|
||||||
onClose={() => setShowEditModal(false)}
|
|
||||||
title="Edit Campaign"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editForm.name || ''}
|
|
||||||
onChange={e => setEditForm(f => ({ ...f, name: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Brand</label>
|
|
||||||
<select
|
|
||||||
value={editForm.brand_id || ''}
|
|
||||||
onChange={e => setEditForm(f => ({ ...f, brand_id: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
>
|
|
||||||
<option value="">No brand</option>
|
|
||||||
{brands.map(b => (
|
|
||||||
<option key={b.id} value={b.id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
|
|
||||||
<textarea
|
|
||||||
value={editForm.description || ''}
|
|
||||||
onChange={e => setEditForm(f => ({ ...f, description: e.target.value }))}
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
|
|
||||||
<select
|
|
||||||
value={editForm.status || 'planning'}
|
|
||||||
onChange={e => setEditForm(f => ({ ...f, status: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
>
|
|
||||||
<option value="planning">Planning</option>
|
|
||||||
<option value="active">Active</option>
|
|
||||||
<option value="paused">Paused</option>
|
|
||||||
<option value="completed">Completed</option>
|
|
||||||
<option value="cancelled">Cancelled</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Goals</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editForm.goals || ''}
|
|
||||||
onChange={e => setEditForm(f => ({ ...f, goals: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Budget (SAR)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editForm.budget || ''}
|
|
||||||
onChange={e => setEditForm(f => ({ ...f, budget: e.target.value }))}
|
|
||||||
min="0"
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Start Date</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={editForm.start_date || ''}
|
|
||||||
onChange={e => setEditForm(f => ({ ...f, start_date: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">End Date</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={editForm.end_date || ''}
|
|
||||||
onChange={e => setEditForm(f => ({ ...f, end_date: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Platforms</label>
|
|
||||||
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[42px]">
|
|
||||||
{Object.entries(PLATFORMS).map(([k, v]) => {
|
|
||||||
const checked = (editForm.platforms || []).includes(k)
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={k}
|
|
||||||
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-full cursor-pointer border transition-colors ${
|
|
||||||
checked
|
|
||||||
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary font-medium'
|
|
||||||
: 'bg-surface-tertiary border-transparent text-text-secondary hover:bg-surface-tertiary/80'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => {
|
|
||||||
setEditForm(f => ({
|
|
||||||
...f,
|
|
||||||
platforms: checked
|
|
||||||
? f.platforms.filter(p => p !== k)
|
|
||||||
: [...(f.platforms || []), k]
|
|
||||||
}))
|
|
||||||
}}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
{v.label}
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
|
|
||||||
<textarea
|
|
||||||
value={editForm.notes || ''}
|
|
||||||
onChange={e => setEditForm(f => ({ ...f, notes: e.target.value }))}
|
|
||||||
rows={2}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
|
||||||
{permissions?.canDeleteCampaigns && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowDeleteCampaignConfirm(true)}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
|
|
||||||
>
|
|
||||||
Delete Campaign
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowEditModal(false)}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={saveCampaignEdit}
|
|
||||||
disabled={!editForm.name}
|
|
||||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
|
||||||
>
|
|
||||||
Save Changes
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Delete Campaign Confirmation */}
|
|
||||||
<Modal
|
|
||||||
isOpen={showDeleteCampaignConfirm}
|
|
||||||
onClose={() => setShowDeleteCampaignConfirm(false)}
|
|
||||||
title="Delete Campaign?"
|
|
||||||
isConfirm
|
|
||||||
danger
|
|
||||||
confirmText="Delete Campaign"
|
|
||||||
onConfirm={async () => {
|
|
||||||
try {
|
|
||||||
await api.delete(`/campaigns/${id}`)
|
|
||||||
setShowDeleteCampaignConfirm(false)
|
|
||||||
setShowEditModal(false)
|
|
||||||
navigate('/campaigns')
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete campaign:', err)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Are you sure you want to delete this campaign? All tracks and linked data will be permanently removed. This action cannot be undone.
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Post Detail Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={!!selectedPost}
|
|
||||||
onClose={() => setSelectedPost(null)}
|
|
||||||
title={selectedPost?.title || 'Post Details'}
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{selectedPost && (
|
{selectedPost && (
|
||||||
<div className="space-y-4">
|
<PostDetailPanel
|
||||||
{/* Thumbnail / Media */}
|
post={selectedPost}
|
||||||
{selectedPost.thumbnail_url && (
|
onClose={() => setSelectedPost(null)}
|
||||||
<div className="rounded-lg overflow-hidden border border-border">
|
onSave={handlePostPanelSave}
|
||||||
<img
|
onDelete={handlePostPanelDelete}
|
||||||
src={selectedPost.thumbnail_url}
|
brands={brands}
|
||||||
alt={selectedPost.title}
|
teamMembers={teamMembers}
|
||||||
className="w-full max-h-64 object-contain bg-surface-secondary"
|
campaigns={allCampaigns}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status & Platforms */}
|
{/* Campaign Edit Panel */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{panelCampaign && (
|
||||||
<StatusBadge status={selectedPost.status} />
|
<CampaignDetailPanel
|
||||||
{selectedPost.brand_name && <BrandBadge brand={selectedPost.brand_name} />}
|
campaign={panelCampaign}
|
||||||
{selectedPost.platforms && selectedPost.platforms.length > 0 && (
|
onClose={() => setPanelCampaign(null)}
|
||||||
<PlatformIcons platforms={selectedPost.platforms} size={18} />
|
onSave={handleCampaignPanelSave}
|
||||||
)}
|
onDelete={permissions?.canDeleteCampaigns ? handleCampaignPanelDelete : null}
|
||||||
</div>
|
brands={brands}
|
||||||
|
permissions={permissions}
|
||||||
{/* Description */}
|
/>
|
||||||
{selectedPost.description && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-1">Description</h4>
|
|
||||||
<p className="text-sm text-text-secondary whitespace-pre-wrap">{selectedPost.description}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Meta info grid */}
|
{/* Track Detail Panel */}
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
{panelTrack && (
|
||||||
{selectedPost.track_name && (
|
<TrackDetailPanel
|
||||||
<div>
|
track={panelTrack}
|
||||||
<span className="text-text-tertiary text-xs">Track</span>
|
campaignId={id}
|
||||||
<p className="font-medium text-text-primary">{selectedPost.track_name}</p>
|
onClose={() => setPanelTrack(null)}
|
||||||
</div>
|
onSave={handleTrackPanelSave}
|
||||||
|
onDelete={handleTrackPanelDelete}
|
||||||
|
scrollToMetrics={trackScrollToMetrics}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{selectedPost.assigned_name && (
|
|
||||||
<div>
|
|
||||||
<span className="text-text-tertiary text-xs">Assigned to</span>
|
|
||||||
<p className="font-medium text-text-primary">{selectedPost.assigned_name}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedPost.creator_user_name && (
|
|
||||||
<div>
|
|
||||||
<span className="text-text-tertiary text-xs">Created by</span>
|
|
||||||
<p className="font-medium text-text-primary">{selectedPost.creator_user_name}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedPost.scheduled_date && (
|
|
||||||
<div>
|
|
||||||
<span className="text-text-tertiary text-xs">Scheduled</span>
|
|
||||||
<p className="font-medium text-text-primary">{format(new Date(selectedPost.scheduled_date), 'MMM d, yyyy')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedPost.published_date && (
|
|
||||||
<div>
|
|
||||||
<span className="text-text-tertiary text-xs">Published</span>
|
|
||||||
<p className="font-medium text-text-primary">{format(new Date(selectedPost.published_date), 'MMM d, yyyy')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedPost.created_at && (
|
|
||||||
<div>
|
|
||||||
<span className="text-text-tertiary text-xs">Created</span>
|
|
||||||
<p className="font-medium text-text-primary">{format(new Date(selectedPost.created_at), 'MMM d, yyyy')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Publication Links */}
|
|
||||||
{selectedPost.publication_links && selectedPost.publication_links.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-2">Publication Links</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{selectedPost.publication_links.map((link, i) => {
|
|
||||||
const url = typeof link === 'string' ? link : link.url
|
|
||||||
const platform = typeof link === 'string' ? null : link.platform
|
|
||||||
const platformInfo = platform ? PLATFORMS[platform] : null
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={i}
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-2 p-2 rounded-lg border border-border hover:bg-surface-secondary transition-colors group"
|
|
||||||
>
|
|
||||||
{platformInfo && <PlatformIcon platform={platform} size={18} />}
|
|
||||||
<span className="text-sm font-medium text-brand-primary group-hover:underline truncate flex-1">
|
|
||||||
{platformInfo ? platformInfo.label : url}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-text-tertiary truncate max-w-[200px]">{url}</span>
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Notes */}
|
|
||||||
{selectedPost.notes && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-1">Notes</h4>
|
|
||||||
<p className="text-sm text-text-secondary whitespace-pre-wrap">{selectedPost.notes}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,15 +9,9 @@ import { api, PLATFORMS } from '../utils/api'
|
|||||||
import { PlatformIcons } from '../components/PlatformIcon'
|
import { PlatformIcons } from '../components/PlatformIcon'
|
||||||
import StatusBadge from '../components/StatusBadge'
|
import StatusBadge from '../components/StatusBadge'
|
||||||
import BrandBadge from '../components/BrandBadge'
|
import BrandBadge from '../components/BrandBadge'
|
||||||
import Modal from '../components/Modal'
|
|
||||||
import BudgetBar from '../components/BudgetBar'
|
import BudgetBar from '../components/BudgetBar'
|
||||||
import InteractiveTimeline from '../components/InteractiveTimeline'
|
import InteractiveTimeline from '../components/InteractiveTimeline'
|
||||||
|
import CampaignDetailPanel from '../components/CampaignDetailPanel'
|
||||||
const EMPTY_CAMPAIGN = {
|
|
||||||
name: '', description: '', brand_id: '', status: 'planning',
|
|
||||||
start_date: '', end_date: '', budget: '', goals: '', platforms: [],
|
|
||||||
budget_spent: '', revenue: '', impressions: '', clicks: '', conversions: '', cost_per_click: '', notes: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
function ROIBadge({ revenue, spent }) {
|
function ROIBadge({ revenue, spent }) {
|
||||||
if (!spent || spent <= 0) return null
|
if (!spent || spent <= 0) return null
|
||||||
@@ -42,17 +36,13 @@ function MetricCard({ icon: Icon, label, value, color = 'text-text-primary' }) {
|
|||||||
|
|
||||||
export default function Campaigns() {
|
export default function Campaigns() {
|
||||||
const { brands, getBrandName } = useContext(AppContext)
|
const { brands, getBrandName } = useContext(AppContext)
|
||||||
const { lang } = useLanguage()
|
const { lang, currencySymbol } = useLanguage()
|
||||||
const { permissions } = useAuth()
|
const { permissions } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [campaigns, setCampaigns] = useState([])
|
const [campaigns, setCampaigns] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [panelCampaign, setPanelCampaign] = useState(null)
|
||||||
const [editingCampaign, setEditingCampaign] = useState(null)
|
|
||||||
const [formData, setFormData] = useState(EMPTY_CAMPAIGN)
|
|
||||||
const [filters, setFilters] = useState({ brand: '', status: '' })
|
const [filters, setFilters] = useState({ brand: '', status: '' })
|
||||||
const [activeTab, setActiveTab] = useState('details') // details | performance
|
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => { loadCampaigns() }, [])
|
useEffect(() => { loadCampaigns() }, [])
|
||||||
|
|
||||||
@@ -67,69 +57,22 @@ export default function Campaigns() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handlePanelSave = async (campaignId, data) => {
|
||||||
try {
|
if (campaignId) {
|
||||||
const data = {
|
await api.patch(`/campaigns/${campaignId}`, data)
|
||||||
name: formData.name,
|
|
||||||
description: formData.description,
|
|
||||||
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
|
|
||||||
status: formData.status,
|
|
||||||
start_date: formData.start_date,
|
|
||||||
end_date: formData.end_date,
|
|
||||||
budget: formData.budget ? Number(formData.budget) : null,
|
|
||||||
goals: formData.goals,
|
|
||||||
platforms: formData.platforms || [],
|
|
||||||
budget_spent: formData.budget_spent ? Number(formData.budget_spent) : 0,
|
|
||||||
revenue: formData.revenue ? Number(formData.revenue) : 0,
|
|
||||||
impressions: formData.impressions ? Number(formData.impressions) : 0,
|
|
||||||
clicks: formData.clicks ? Number(formData.clicks) : 0,
|
|
||||||
conversions: formData.conversions ? Number(formData.conversions) : 0,
|
|
||||||
cost_per_click: formData.cost_per_click ? Number(formData.cost_per_click) : 0,
|
|
||||||
notes: formData.notes || '',
|
|
||||||
}
|
|
||||||
if (editingCampaign) {
|
|
||||||
await api.patch(`/campaigns/${editingCampaign.id || editingCampaign._id}`, data)
|
|
||||||
} else {
|
} else {
|
||||||
await api.post('/campaigns', data)
|
await api.post('/campaigns', data)
|
||||||
}
|
}
|
||||||
setShowModal(false)
|
|
||||||
setEditingCampaign(null)
|
|
||||||
setFormData(EMPTY_CAMPAIGN)
|
|
||||||
loadCampaigns()
|
loadCampaigns()
|
||||||
} catch (err) {
|
|
||||||
console.error('Save failed:', err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEdit = (campaign) => {
|
const handlePanelDelete = async (campaignId) => {
|
||||||
setEditingCampaign(campaign)
|
await api.delete(`/campaigns/${campaignId}`)
|
||||||
setFormData({
|
loadCampaigns()
|
||||||
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) : '',
|
|
||||||
end_date: campaign.endDate ? new Date(campaign.endDate).toISOString().slice(0, 10) : '',
|
|
||||||
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 || '',
|
|
||||||
cost_per_click: campaign.costPerClick || campaign.cost_per_click || '',
|
|
||||||
notes: campaign.notes || '',
|
|
||||||
})
|
|
||||||
setActiveTab('details')
|
|
||||||
setShowModal(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openNew = () => {
|
const openNew = () => {
|
||||||
setEditingCampaign(null)
|
setPanelCampaign({ status: 'planning', platforms: [] })
|
||||||
setFormData(EMPTY_CAMPAIGN)
|
|
||||||
setActiveTab('details')
|
|
||||||
setShowModal(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = campaigns.filter(c => {
|
const filtered = campaigns.filter(c => {
|
||||||
@@ -201,7 +144,7 @@ export default function Campaigns() {
|
|||||||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Budget</span>
|
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Budget</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-text-primary">{totalBudget.toLocaleString()}</div>
|
<div className="text-lg font-bold text-text-primary">{totalBudget.toLocaleString()}</div>
|
||||||
<div className="text-[10px] text-text-tertiary">SAR total</div>
|
<div className="text-[10px] text-text-tertiary">{currencySymbol} total</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl border border-border p-4">
|
<div className="bg-white rounded-xl border border-border p-4">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
@@ -209,7 +152,7 @@ export default function Campaigns() {
|
|||||||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Spent</span>
|
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Spent</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-text-primary">{totalSpent.toLocaleString()}</div>
|
<div className="text-lg font-bold text-text-primary">{totalSpent.toLocaleString()}</div>
|
||||||
<div className="text-[10px] text-text-tertiary">SAR spent</div>
|
<div className="text-[10px] text-text-tertiary">{currencySymbol} spent</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl border border-border p-4">
|
<div className="bg-white rounded-xl border border-border p-4">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
@@ -238,7 +181,7 @@ export default function Campaigns() {
|
|||||||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Revenue</span>
|
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Revenue</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-text-primary">{totalRevenue.toLocaleString()}</div>
|
<div className="text-lg font-bold text-text-primary">{totalRevenue.toLocaleString()}</div>
|
||||||
<div className="text-[10px] text-text-tertiary">SAR</div>
|
<div className="text-[10px] text-text-tertiary">{currencySymbol}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -338,317 +281,17 @@ export default function Campaigns() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Campaign Panel */}
|
||||||
<Modal
|
{panelCampaign && (
|
||||||
isOpen={showModal}
|
<CampaignDetailPanel
|
||||||
onClose={() => { setShowModal(false); setEditingCampaign(null) }}
|
campaign={panelCampaign}
|
||||||
title={editingCampaign ? 'Edit Campaign' : 'Create Campaign'}
|
onClose={() => setPanelCampaign(null)}
|
||||||
size="lg"
|
onSave={handlePanelSave}
|
||||||
>
|
onDelete={permissions?.canDeleteCampaigns ? handlePanelDelete : null}
|
||||||
<div className="space-y-4">
|
brands={brands}
|
||||||
{/* Tabs */}
|
permissions={permissions}
|
||||||
{editingCampaign && (
|
/>
|
||||||
<div className="flex gap-1 p-1 bg-surface-tertiary rounded-lg">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('details')}
|
|
||||||
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
|
||||||
activeTab === 'details' ? 'bg-white text-text-primary shadow-sm' : 'text-text-tertiary hover:text-text-secondary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Details
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('performance')}
|
|
||||||
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
|
||||||
activeTab === 'performance' ? 'bg-white text-text-primary shadow-sm' : 'text-text-tertiary hover:text-text-secondary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Performance & ROI
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'details' ? (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, name: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder="Campaign name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.description}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
|
||||||
placeholder="Campaign description..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Brand</label>
|
|
||||||
<select
|
|
||||||
value={formData.brand_id}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, brand_id: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
>
|
|
||||||
<option value="">Select brand</option>
|
|
||||||
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
|
|
||||||
<select
|
|
||||||
value={formData.status}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, status: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
>
|
|
||||||
<option value="planning">Planning</option>
|
|
||||||
<option value="active">Active</option>
|
|
||||||
<option value="paused">Paused</option>
|
|
||||||
<option value="completed">Completed</option>
|
|
||||||
<option value="cancelled">Cancelled</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Platforms multi-select */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Platforms</label>
|
|
||||||
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[42px]">
|
|
||||||
{Object.entries(PLATFORMS).map(([k, v]) => {
|
|
||||||
const checked = (formData.platforms || []).includes(k)
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={k}
|
|
||||||
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-full cursor-pointer border transition-colors ${
|
|
||||||
checked
|
|
||||||
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary font-medium'
|
|
||||||
: 'bg-surface-tertiary border-transparent text-text-secondary hover:bg-surface-tertiary/80'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => {
|
|
||||||
setFormData(f => ({
|
|
||||||
...f,
|
|
||||||
platforms: checked
|
|
||||||
? f.platforms.filter(p => p !== k)
|
|
||||||
: [...(f.platforms || []), k]
|
|
||||||
}))
|
|
||||||
}}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
{v.label}
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Start Date *</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={formData.start_date}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, start_date: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">End Date *</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={formData.end_date}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, end_date: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
|
||||||
Budget (SAR)
|
|
||||||
{!permissions?.canSetBudget && <span className="text-[10px] text-text-tertiary ml-1">(Superadmin only)</span>}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={formData.budget}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, budget: e.target.value }))}
|
|
||||||
disabled={!permissions?.canSetBudget}
|
|
||||||
className={`w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary ${!permissions?.canSetBudget ? 'bg-surface-tertiary text-text-tertiary cursor-not-allowed' : ''}`}
|
|
||||||
placeholder="e.g., 50000"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Goals</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.goals}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, goals: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder="Campaign goals"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
/* Performance & ROI Tab */
|
|
||||||
<>
|
|
||||||
{/* Live metrics summary */}
|
|
||||||
{(formData.budget_spent || formData.impressions || formData.clicks) && (
|
|
||||||
<div className="grid grid-cols-4 gap-2 mb-2">
|
|
||||||
<MetricCard icon={DollarSign} label="Spent" value={formData.budget_spent ? `${Number(formData.budget_spent).toLocaleString()} SAR` : null} color="text-amber-600" />
|
|
||||||
<MetricCard icon={Eye} label="Impressions" value={formData.impressions ? Number(formData.impressions).toLocaleString() : null} color="text-purple-600" />
|
|
||||||
<MetricCard icon={MousePointer} label="Clicks" value={formData.clicks ? Number(formData.clicks).toLocaleString() : null} color="text-blue-600" />
|
|
||||||
<MetricCard icon={Target} label="Conversions" value={formData.conversions ? Number(formData.conversions).toLocaleString() : null} color="text-emerald-600" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{formData.budget && formData.budget_spent && (
|
|
||||||
<div className="p-3 bg-surface-secondary rounded-lg">
|
|
||||||
<BudgetBar budget={Number(formData.budget)} spent={Number(formData.budget_spent)} />
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<ROIBadge revenue={Number(formData.revenue) || 0} spent={Number(formData.budget_spent) || 0} />
|
|
||||||
{formData.clicks > 0 && formData.budget_spent > 0 && (
|
|
||||||
<span className="text-[10px] text-text-tertiary">
|
|
||||||
CPC: {(Number(formData.budget_spent) / Number(formData.clicks)).toFixed(2)} SAR
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{formData.impressions > 0 && formData.clicks > 0 && (
|
|
||||||
<span className="text-[10px] text-text-tertiary">
|
|
||||||
CTR: {(Number(formData.clicks) / Number(formData.impressions) * 100).toFixed(2)}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Budget Spent (SAR)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={formData.budget_spent}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, budget_spent: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder="Amount spent so far"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Revenue (SAR)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={formData.revenue}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, revenue: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder="Revenue generated"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Impressions</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={formData.impressions}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, impressions: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder="Total impressions"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Clicks</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={formData.clicks}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, clicks: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder="Total clicks"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Conversions</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={formData.conversions}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, conversions: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder="Conversions (visits, tickets...)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.notes}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, notes: e.target.value }))}
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
|
||||||
placeholder="Performance notes, observations, what's working..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
|
||||||
{editingCampaign && permissions?.canDeleteCampaigns && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowModal(false); setEditingCampaign(null) }}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!formData.name || !formData.start_date || !formData.end_date}
|
|
||||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
|
||||||
>
|
|
||||||
{editingCampaign ? 'Save Changes' : 'Create Campaign'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
|
||||||
<Modal
|
|
||||||
isOpen={showDeleteConfirm}
|
|
||||||
onClose={() => setShowDeleteConfirm(false)}
|
|
||||||
title="Delete Campaign?"
|
|
||||||
isConfirm
|
|
||||||
danger
|
|
||||||
confirmText="Delete Campaign"
|
|
||||||
onConfirm={async () => {
|
|
||||||
if (editingCampaign) {
|
|
||||||
await api.delete(`/campaigns/${editingCampaign.id || editingCampaign._id}`)
|
|
||||||
setShowModal(false)
|
|
||||||
setEditingCampaign(null)
|
|
||||||
loadCampaigns()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Are you sure you want to delete this campaign? All associated posts and tracks will also be deleted. This action cannot be undone.
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useContext, useEffect, useState } from 'react'
|
import { useContext, useEffect, useState, useMemo } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { format, isAfter, isBefore, addDays } from 'date-fns'
|
import { format, isAfter, isBefore, addDays } from 'date-fns'
|
||||||
import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Wallet, TrendingUp, DollarSign, PiggyBank } from 'lucide-react'
|
import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Wallet, TrendingUp, TrendingDown, DollarSign, Landmark, CheckSquare, FolderKanban } from 'lucide-react'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { api, PRIORITY_CONFIG } from '../utils/api'
|
import { api, PRIORITY_CONFIG } from '../utils/api'
|
||||||
import StatCard from '../components/StatCard'
|
import StatCard from '../components/StatCard'
|
||||||
import StatusBadge from '../components/StatusBadge'
|
import StatusBadge from '../components/StatusBadge'
|
||||||
import BrandBadge from '../components/BrandBadge'
|
import BrandBadge from '../components/BrandBadge'
|
||||||
|
import DatePresetPicker from '../components/DatePresetPicker'
|
||||||
import { SkeletonDashboard } from '../components/SkeletonLoader'
|
import { SkeletonDashboard } from '../components/SkeletonLoader'
|
||||||
|
|
||||||
function getBudgetBarColor(percentage) {
|
function getBudgetBarColor(percentage) {
|
||||||
@@ -17,14 +18,20 @@ function getBudgetBarColor(percentage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FinanceMini({ finance }) {
|
function FinanceMini({ finance }) {
|
||||||
const { t } = useLanguage()
|
const { t, currencySymbol } = useLanguage()
|
||||||
if (!finance) return null
|
if (!finance) return null
|
||||||
const totalReceived = finance.totalReceived || 0
|
const totalReceived = finance.totalReceived || 0
|
||||||
const spent = finance.spent || 0
|
const spent = finance.spent || 0
|
||||||
const remaining = finance.remaining || 0
|
const remaining = finance.remaining || 0
|
||||||
const roi = finance.roi || 0
|
const roi = finance.roi || 0
|
||||||
|
const totalExpenses = finance.totalExpenses || 0
|
||||||
|
const campaignBudget = finance.totalCampaignBudget || 0
|
||||||
|
const projectBudget = finance.totalProjectBudget || 0
|
||||||
|
const unallocated = finance.unallocated ?? (totalReceived - campaignBudget - projectBudget)
|
||||||
const pct = totalReceived > 0 ? (spent / totalReceived) * 100 : 0
|
const pct = totalReceived > 0 ? (spent / totalReceived) * 100 : 0
|
||||||
const barColor = getBudgetBarColor(pct)
|
const barColor = getBudgetBarColor(pct)
|
||||||
|
const campPct = totalReceived > 0 ? (campaignBudget / totalReceived) * 100 : 0
|
||||||
|
const projPct = totalReceived > 0 ? (projectBudget / totalReceived) * 100 : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-border p-5">
|
<div className="bg-white rounded-xl border border-border p-5">
|
||||||
@@ -37,35 +44,55 @@ function FinanceMini({ finance }) {
|
|||||||
|
|
||||||
{totalReceived === 0 ? (
|
{totalReceived === 0 ? (
|
||||||
<div className="text-center py-6 text-sm text-text-tertiary">
|
<div className="text-center py-6 text-sm text-text-tertiary">
|
||||||
{t('dashboard.noBudgetRecorded')}. <Link to="/finance" className="text-brand-primary underline">{t('dashboard.addBudget')}</Link>
|
{t('dashboard.noBudgetRecorded')}. <Link to="/budgets" className="text-brand-primary underline">{t('dashboard.addBudget')}</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Budget bar */}
|
{/* Spending bar */}
|
||||||
<div className="mb-4">
|
<div className="mb-3">
|
||||||
<div className="flex justify-between text-xs text-text-tertiary mb-1">
|
<div className="flex justify-between text-xs text-text-tertiary mb-1">
|
||||||
<span>{spent.toLocaleString()} {t('dashboard.sar')} {t('dashboard.spent')}</span>
|
<span>{spent.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
|
||||||
<span>{totalReceived.toLocaleString()} {t('dashboard.sar')} {t('dashboard.received')}</span>
|
<span>{totalReceived.toLocaleString()} {currencySymbol} {t('dashboard.received')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-3 bg-surface-tertiary rounded-full overflow-hidden">
|
<div className="h-2.5 bg-surface-tertiary rounded-full overflow-hidden">
|
||||||
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${Math.min(pct, 100)}%` }} />
|
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${Math.min(pct, 100)}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Allocation bar */}
|
||||||
|
{(campaignBudget > 0 || projectBudget > 0) && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-[10px] text-text-tertiary mb-1 font-medium uppercase tracking-wide">Allocation</div>
|
||||||
|
<div className="h-2 bg-surface-tertiary rounded-full overflow-hidden flex">
|
||||||
|
{campPct > 0 && <div className="h-full bg-blue-500" style={{ width: `${campPct}%` }} />}
|
||||||
|
{projPct > 0 && <div className="h-full bg-purple-500" style={{ width: `${projPct}%` }} />}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 mt-1 text-[10px] text-text-tertiary">
|
||||||
|
{campaignBudget > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-500 mr-1" />{campaignBudget.toLocaleString()}</span>}
|
||||||
|
{projectBudget > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-purple-500 mr-1" />{projectBudget.toLocaleString()}</span>}
|
||||||
|
{unallocated > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-gray-300 mr-1" />{unallocated.toLocaleString()} free</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Key numbers */}
|
{/* Key numbers */}
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className={`grid ${totalExpenses > 0 ? 'grid-cols-3' : 'grid-cols-2'} gap-3`}>
|
||||||
<div className="text-center p-2 bg-surface-secondary rounded-lg">
|
<div className="text-center p-2 bg-surface-secondary rounded-lg">
|
||||||
<PiggyBank className="w-4 h-4 mx-auto mb-1 text-emerald-500" />
|
<Landmark className="w-4 h-4 mx-auto mb-1 text-emerald-500" />
|
||||||
<div className={`text-sm font-bold ${remaining >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
<div className={`text-sm font-bold ${remaining >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||||
{remaining.toLocaleString()}
|
{remaining.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-text-tertiary">{t('dashboard.remaining')}</div>
|
<div className="text-[10px] text-text-tertiary">{t('dashboard.remaining')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{totalExpenses > 0 && (
|
||||||
<div className="text-center p-2 bg-surface-secondary rounded-lg">
|
<div className="text-center p-2 bg-surface-secondary rounded-lg">
|
||||||
<DollarSign className="w-4 h-4 mx-auto mb-1 text-purple-500" />
|
<TrendingDown className="w-4 h-4 mx-auto mb-1 text-red-500" />
|
||||||
<div className="text-sm font-bold text-purple-600">{(finance.revenue || 0).toLocaleString()}</div>
|
<div className="text-sm font-bold text-red-600">
|
||||||
<div className="text-[10px] text-text-tertiary">{t('dashboard.revenue')}</div>
|
{totalExpenses.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-[10px] text-text-tertiary">{t('dashboard.expenses')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="text-center p-2 bg-surface-secondary rounded-lg">
|
<div className="text-center p-2 bg-surface-secondary rounded-lg">
|
||||||
<TrendingUp className="w-4 h-4 mx-auto mb-1 text-blue-500" />
|
<TrendingUp className="w-4 h-4 mx-auto mb-1 text-blue-500" />
|
||||||
<div className={`text-sm font-bold ${roi >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
<div className={`text-sm font-bold ${roi >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||||
@@ -81,22 +108,22 @@ function FinanceMini({ finance }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ActiveCampaignsList({ campaigns, finance }) {
|
function ActiveCampaignsList({ campaigns, finance }) {
|
||||||
const { t } = useLanguage()
|
const { t, currencySymbol } = useLanguage()
|
||||||
const active = campaigns.filter(c => c.status === 'active')
|
const active = campaigns.filter(c => c.status === 'active')
|
||||||
const campaignData = (finance?.campaigns || []).filter(c => c.status === 'active')
|
const campaignData = (finance?.campaigns || []).filter(c => c.status === 'active')
|
||||||
|
|
||||||
if (active.length === 0) return null
|
if (active.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-border">
|
<div className="section-card">
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
<div className="section-card-header flex items-center justify-between">
|
||||||
<h3 className="font-semibold text-text-primary">{t('dashboard.activeCampaigns')}</h3>
|
<h3 className="font-semibold text-text-primary">{t('dashboard.activeCampaigns')}</h3>
|
||||||
<Link to="/campaigns" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
<Link to="/campaigns" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
||||||
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-border-light">
|
<div className="divide-y divide-border-light">
|
||||||
{active.map(c => {
|
{active.slice(0, 5).map(c => {
|
||||||
const cd = campaignData.find(d => d.id === (c._id || c.id)) || {}
|
const cd = campaignData.find(d => d.id === (c._id || c.id)) || {}
|
||||||
const spent = cd.tracks_spent || 0
|
const spent = cd.tracks_spent || 0
|
||||||
const allocated = cd.tracks_allocated || 0
|
const allocated = cd.tracks_allocated || 0
|
||||||
@@ -110,7 +137,7 @@ function ActiveCampaignsList({ campaigns, finance }) {
|
|||||||
<div className="mt-1.5 w-32">
|
<div className="mt-1.5 w-32">
|
||||||
<div className="flex justify-between text-[9px] text-text-tertiary mb-0.5">
|
<div className="flex justify-between text-[9px] text-text-tertiary mb-0.5">
|
||||||
<span>{spent.toLocaleString()}</span>
|
<span>{spent.toLocaleString()}</span>
|
||||||
<span>{allocated.toLocaleString()} SAR</span>
|
<span>{allocated.toLocaleString()} {currencySymbol}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
|
<div className="h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
|
||||||
<div className={`h-full ${barColor} rounded-full`} style={{ width: `${Math.min(pct, 100)}%` }} />
|
<div className={`h-full ${barColor} rounded-full`} style={{ width: `${Math.min(pct, 100)}%` }} />
|
||||||
@@ -121,7 +148,7 @@ function ActiveCampaignsList({ campaigns, finance }) {
|
|||||||
<div className="text-right shrink-0">
|
<div className="text-right shrink-0">
|
||||||
{cd.tracks_impressions > 0 && (
|
{cd.tracks_impressions > 0 && (
|
||||||
<div className="text-[10px] text-text-tertiary">
|
<div className="text-[10px] text-text-tertiary">
|
||||||
👁 {cd.tracks_impressions.toLocaleString()} · 🖱 {cd.tracks_clicks.toLocaleString()}
|
{cd.tracks_impressions.toLocaleString()} imp. / {cd.tracks_clicks.toLocaleString()} clicks
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -133,31 +160,140 @@ function ActiveCampaignsList({ campaigns, finance }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MyTasksList({ tasks, currentUserId, navigate, t }) {
|
||||||
|
const myTasks = tasks
|
||||||
|
.filter(task => {
|
||||||
|
const assignedId = task.assigned_to_id || task.assignedTo
|
||||||
|
return assignedId === currentUserId && task.status !== 'done'
|
||||||
|
})
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-card">
|
||||||
|
<div className="section-card-header flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||||
|
<CheckSquare className="w-4 h-4 text-brand-primary" />
|
||||||
|
{t('dashboard.myTasks')}
|
||||||
|
</h3>
|
||||||
|
<Link to="/tasks" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
||||||
|
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border-light">
|
||||||
|
{myTasks.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-text-tertiary">
|
||||||
|
{t('dashboard.allOnTrack')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
myTasks.map(task => (
|
||||||
|
<div
|
||||||
|
key={task._id || task.id}
|
||||||
|
onClick={() => navigate('/tasks')}
|
||||||
|
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className={`w-2 h-2 rounded-full shrink-0 ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
|
||||||
|
<StatusBadge status={task.status} size="xs" />
|
||||||
|
</div>
|
||||||
|
{task.dueDate && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-text-tertiary shrink-0">
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
{format(new Date(task.dueDate), 'MMM d')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectProgress({ projects, tasks, t }) {
|
||||||
|
if (!projects || projects.length === 0) return null
|
||||||
|
|
||||||
|
const activeProjects = projects
|
||||||
|
.filter(p => p.status === 'active' || p.status === 'in_progress')
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
if (activeProjects.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-card">
|
||||||
|
<div className="section-card-header flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||||
|
<FolderKanban className="w-4 h-4 text-purple-500" />
|
||||||
|
{t('dashboard.projectProgress')}
|
||||||
|
</h3>
|
||||||
|
<Link to="/projects" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
||||||
|
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border-light">
|
||||||
|
{activeProjects.map(project => {
|
||||||
|
const projectId = project._id || project.id
|
||||||
|
const projectTasks = tasks.filter(t => (t.project_id || t.projectId) === projectId)
|
||||||
|
const doneTasks = projectTasks.filter(t => t.status === 'done').length
|
||||||
|
const totalTasks = projectTasks.length
|
||||||
|
const pct = totalTasks > 0 ? (doneTasks / totalTasks) * 100 : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link key={projectId} to={`/projects/${projectId}`} className="flex items-center gap-4 px-5 py-3 hover:bg-surface-secondary transition-colors">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-text-primary truncate">{project.name}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
|
<div className="flex-1 h-1.5 bg-surface-tertiary rounded-full overflow-hidden max-w-[120px]">
|
||||||
|
<div className="h-full bg-purple-500 rounded-full transition-all" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-text-tertiary shrink-0">
|
||||||
|
{doneTasks}/{totalTasks} {t('tasks.tasks')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={project.status} size="xs" />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { t } = useLanguage()
|
const { t, currencySymbol } = useLanguage()
|
||||||
|
const navigate = useNavigate()
|
||||||
const { currentUser, teamMembers } = useContext(AppContext)
|
const { currentUser, teamMembers } = useContext(AppContext)
|
||||||
const [posts, setPosts] = useState([])
|
const [posts, setPosts] = useState([])
|
||||||
const [campaigns, setCampaigns] = useState([])
|
const [campaigns, setCampaigns] = useState([])
|
||||||
const [tasks, setTasks] = useState([])
|
const [tasks, setTasks] = useState([])
|
||||||
|
const [projects, setProjects] = useState([])
|
||||||
const [finance, setFinance] = useState(null)
|
const [finance, setFinance] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// Date filtering
|
||||||
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
|
const [dateTo, setDateTo] = useState('')
|
||||||
|
const [activePreset, setActivePreset] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [postsRes, campaignsRes, tasksRes, financeRes] = await Promise.allSettled([
|
const [postsRes, campaignsRes, tasksRes, financeRes, projectsRes] = await Promise.allSettled([
|
||||||
api.get('/posts?limit=10&sort=-createdAt'),
|
api.get('/posts?limit=50&sort=-createdAt'),
|
||||||
api.get('/campaigns'),
|
api.get('/campaigns'),
|
||||||
api.get('/tasks'),
|
api.get('/tasks'),
|
||||||
api.get('/finance/summary'),
|
api.get('/finance/summary'),
|
||||||
|
api.get('/projects'),
|
||||||
])
|
])
|
||||||
setPosts(postsRes.status === 'fulfilled' ? (postsRes.value.data || postsRes.value || []) : [])
|
setPosts(postsRes.status === 'fulfilled' ? (postsRes.value.data || postsRes.value || []) : [])
|
||||||
setCampaigns(campaignsRes.status === 'fulfilled' ? (campaignsRes.value.data || campaignsRes.value || []) : [])
|
setCampaigns(campaignsRes.status === 'fulfilled' ? (campaignsRes.value.data || campaignsRes.value || []) : [])
|
||||||
setTasks(tasksRes.status === 'fulfilled' ? (tasksRes.value.data || tasksRes.value || []) : [])
|
setTasks(tasksRes.status === 'fulfilled' ? (tasksRes.value.data || tasksRes.value || []) : [])
|
||||||
setFinance(financeRes.status === 'fulfilled' ? (financeRes.value.data || financeRes.value || null) : null)
|
setFinance(financeRes.status === 'fulfilled' ? (financeRes.value.data || financeRes.value || null) : null)
|
||||||
|
setProjects(projectsRes.status === 'fulfilled' ? (projectsRes.value.data || projectsRes.value || []) : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Dashboard load error:', err)
|
console.error('Dashboard load error:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -165,12 +301,35 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filtered data based on date range
|
||||||
|
const filteredPosts = useMemo(() => {
|
||||||
|
if (!dateFrom && !dateTo) return posts
|
||||||
|
return posts.filter(p => {
|
||||||
|
const d = p.scheduled_date || p.scheduledDate
|
||||||
|
if (!d) return true
|
||||||
|
if (dateFrom && d < dateFrom) return false
|
||||||
|
if (dateTo && d > dateTo) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [posts, dateFrom, dateTo])
|
||||||
|
|
||||||
|
const filteredTasks = useMemo(() => {
|
||||||
|
if (!dateFrom && !dateTo) return tasks
|
||||||
|
return tasks.filter(t => {
|
||||||
|
const d = t.due_date || t.dueDate
|
||||||
|
if (!d) return true
|
||||||
|
if (dateFrom && d < dateFrom) return false
|
||||||
|
if (dateTo && d > dateTo) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [tasks, dateFrom, dateTo])
|
||||||
|
|
||||||
const activeCampaigns = campaigns.filter(c => c.status === 'active').length
|
const activeCampaigns = campaigns.filter(c => c.status === 'active').length
|
||||||
const overdueTasks = tasks.filter(t =>
|
const overdueTasks = filteredTasks.filter(t =>
|
||||||
t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done'
|
t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done'
|
||||||
).length
|
).length
|
||||||
|
|
||||||
const upcomingDeadlines = tasks
|
const upcomingDeadlines = filteredTasks
|
||||||
.filter(t => {
|
.filter(t => {
|
||||||
if (!t.dueDate || t.status === 'done') return false
|
if (!t.dueDate || t.status === 'done') return false
|
||||||
const due = new Date(t.dueDate)
|
const due = new Date(t.dueDate)
|
||||||
@@ -186,23 +345,30 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* Welcome */}
|
{/* Welcome + Date presets */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-text-primary">
|
<h1 className="text-2xl font-bold text-gradient">
|
||||||
{t('dashboard.welcomeBack')}, {currentUser?.name || 'there'}
|
{t('dashboard.welcomeBack')}, {currentUser?.name || 'there'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-text-secondary mt-1">
|
<p className="text-text-secondary mt-1">
|
||||||
{t('dashboard.happeningToday')}
|
{t('dashboard.happeningToday')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<DatePresetPicker
|
||||||
|
activePreset={activePreset}
|
||||||
|
onSelect={(from, to, key) => { setDateFrom(from); setDateTo(to); setActivePreset(key) }}
|
||||||
|
onClear={() => { setDateFrom(''); setDateTo(''); setActivePreset('') }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children">
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
label={t('dashboard.totalPosts')}
|
label={t('dashboard.totalPosts')}
|
||||||
value={posts.length || 0}
|
value={filteredPosts.length || 0}
|
||||||
subtitle={`${posts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`}
|
subtitle={`${filteredPosts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`}
|
||||||
color="brand-primary"
|
color="brand-primary"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
@@ -213,10 +379,10 @@ export default function Dashboard() {
|
|||||||
color="brand-secondary"
|
color="brand-secondary"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={Wallet}
|
icon={Landmark}
|
||||||
label={t('dashboard.budgetSpent')}
|
label={t('dashboard.budgetRemaining')}
|
||||||
value={`${(finance?.spent || 0).toLocaleString()}`}
|
value={`${(finance?.remaining ?? 0).toLocaleString()}`}
|
||||||
subtitle={finance?.totalReceived ? `${t('dashboard.of')} ${finance.totalReceived.toLocaleString()} ${t('dashboard.sar')}` : t('dashboard.noBudget')}
|
subtitle={finance?.totalReceived ? `${(finance.spent || 0).toLocaleString()} ${t('dashboard.spent')} ${t('dashboard.of')} ${finance.totalReceived.toLocaleString()} ${currencySymbol}` : t('dashboard.noBudget')}
|
||||||
color="brand-tertiary"
|
color="brand-tertiary"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
@@ -228,35 +394,42 @@ export default function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Three columns on large, stack on small */}
|
{/* My Tasks + Project Progress */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Budget Overview */}
|
<MyTasksList tasks={filteredTasks} currentUserId={currentUser?.id || currentUser?._id} navigate={navigate} t={t} />
|
||||||
<FinanceMini finance={finance} />
|
<ProjectProgress projects={projects} tasks={tasks} t={t} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Active Campaigns with budget bars */}
|
{/* Budget + Active Campaigns */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<FinanceMini finance={finance} />
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<ActiveCampaignsList campaigns={campaigns} finance={finance} />
|
<ActiveCampaignsList campaigns={campaigns} finance={finance} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Two columns */}
|
{/* Recent Posts + Upcoming Deadlines */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Recent Posts */}
|
{/* Recent Posts */}
|
||||||
<div className="bg-white rounded-xl border border-border">
|
<div className="section-card">
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
<div className="section-card-header flex items-center justify-between">
|
||||||
<h3 className="font-semibold text-text-primary">{t('dashboard.recentPosts')}</h3>
|
<h3 className="font-semibold text-text-primary">{t('dashboard.recentPosts')}</h3>
|
||||||
<Link to="/posts" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
<Link to="/posts" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
||||||
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-border-light">
|
<div className="divide-y divide-border-light">
|
||||||
{posts.length === 0 ? (
|
{filteredPosts.length === 0 ? (
|
||||||
<div className="py-12 text-center text-sm text-text-tertiary">
|
<div className="py-12 text-center text-sm text-text-tertiary">
|
||||||
{t('dashboard.noPostsYet')}
|
{t('dashboard.noPostsYet')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
posts.slice(0, 8).map((post) => (
|
filteredPosts.slice(0, 8).map((post) => (
|
||||||
<div key={post._id} className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors">
|
<div
|
||||||
|
key={post._id}
|
||||||
|
onClick={() => navigate('/posts')}
|
||||||
|
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
|
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
@@ -271,8 +444,8 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upcoming Deadlines */}
|
{/* Upcoming Deadlines */}
|
||||||
<div className="bg-white rounded-xl border border-border">
|
<div className="section-card">
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
<div className="section-card-header flex items-center justify-between">
|
||||||
<h3 className="font-semibold text-text-primary">{t('dashboard.upcomingDeadlines')}</h3>
|
<h3 className="font-semibold text-text-primary">{t('dashboard.upcomingDeadlines')}</h3>
|
||||||
<Link to="/tasks" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
<Link to="/tasks" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
||||||
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
||||||
@@ -285,7 +458,11 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
upcomingDeadlines.map((task) => (
|
upcomingDeadlines.map((task) => (
|
||||||
<div key={task._id} className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors">
|
<div
|
||||||
|
key={task._id}
|
||||||
|
onClick={() => navigate('/tasks')}
|
||||||
|
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
<div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
|
<div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
|
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
|
||||||
|
|||||||
@@ -1,23 +1,12 @@
|
|||||||
import { useState, useEffect, useContext } from 'react'
|
import { useState, useEffect, useContext } from 'react'
|
||||||
import { Plus, DollarSign, TrendingUp, TrendingDown, Wallet, PiggyBank, Eye, MousePointer, Target, Edit2, Trash2 } from 'lucide-react'
|
import { DollarSign, TrendingUp, TrendingDown, Wallet, Landmark, Eye, MousePointer, Target, Briefcase, ArrowRight, Receipt } from 'lucide-react'
|
||||||
import { format } from 'date-fns'
|
import { Link } from 'react-router-dom'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import Modal from '../components/Modal'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import StatusBadge from '../components/StatusBadge'
|
import StatusBadge from '../components/StatusBadge'
|
||||||
|
import { SkeletonStatCard, SkeletonTable } from '../components/SkeletonLoader'
|
||||||
const CATEGORIES = [
|
|
||||||
{ value: 'marketing', label: 'Marketing' },
|
|
||||||
{ value: 'production', label: 'Production' },
|
|
||||||
{ value: 'equipment', label: 'Equipment' },
|
|
||||||
{ value: 'travel', label: 'Travel' },
|
|
||||||
{ value: 'other', label: 'Other' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const EMPTY_ENTRY = {
|
|
||||||
label: '', amount: '', source: '', campaign_id: '', category: 'marketing', date_received: new Date().toISOString().slice(0, 10), notes: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
function FinanceStatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-white' }) {
|
function FinanceStatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-white' }) {
|
||||||
return (
|
return (
|
||||||
@@ -54,29 +43,16 @@ function ProgressRing({ pct, size = 80, stroke = 8, color = '#10b981' }) {
|
|||||||
export default function Finance() {
|
export default function Finance() {
|
||||||
const { brands } = useContext(AppContext)
|
const { brands } = useContext(AppContext)
|
||||||
const { permissions } = useAuth()
|
const { permissions } = useAuth()
|
||||||
const canManageFinance = permissions?.canManageFinance
|
const { currencySymbol } = useLanguage()
|
||||||
const [entries, setEntries] = useState([])
|
|
||||||
const [summary, setSummary] = useState(null)
|
const [summary, setSummary] = useState(null)
|
||||||
const [campaigns, setCampaigns] = useState([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showModal, setShowModal] = useState(false)
|
|
||||||
const [editing, setEditing] = useState(null)
|
|
||||||
const [form, setForm] = useState(EMPTY_ENTRY)
|
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
||||||
const [entryToDelete, setEntryToDelete] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => { loadAll() }, [])
|
useEffect(() => { loadAll() }, [])
|
||||||
|
|
||||||
const loadAll = async () => {
|
const loadAll = async () => {
|
||||||
try {
|
try {
|
||||||
const [ent, sum, camp] = await Promise.all([
|
const sum = await api.get('/finance/summary')
|
||||||
api.get('/budget'),
|
|
||||||
api.get('/finance/summary'),
|
|
||||||
api.get('/campaigns'),
|
|
||||||
])
|
|
||||||
setEntries(ent.data || ent || [])
|
|
||||||
setSummary(sum.data || sum || {})
|
setSummary(sum.data || sum || {})
|
||||||
setCampaigns(camp.data || camp || [])
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load finance:', err)
|
console.error('Failed to load finance:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -84,63 +60,13 @@ export default function Finance() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
const data = {
|
|
||||||
label: form.label,
|
|
||||||
amount: Number(form.amount),
|
|
||||||
source: form.source || null,
|
|
||||||
campaign_id: form.campaign_id ? Number(form.campaign_id) : null,
|
|
||||||
category: form.category,
|
|
||||||
date_received: form.date_received,
|
|
||||||
notes: form.notes,
|
|
||||||
}
|
|
||||||
if (editing) {
|
|
||||||
await api.patch(`/budget/${editing._id || editing.id}`, data)
|
|
||||||
} else {
|
|
||||||
await api.post('/budget', data)
|
|
||||||
}
|
|
||||||
setShowModal(false)
|
|
||||||
setEditing(null)
|
|
||||||
setForm(EMPTY_ENTRY)
|
|
||||||
loadAll()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Save failed:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEdit = (entry) => {
|
|
||||||
setEditing(entry)
|
|
||||||
setForm({
|
|
||||||
label: entry.label || '',
|
|
||||||
amount: entry.amount || '',
|
|
||||||
source: entry.source || '',
|
|
||||||
campaign_id: entry.campaignId || entry.campaign_id || '',
|
|
||||||
category: entry.category || 'marketing',
|
|
||||||
date_received: entry.dateReceived || entry.date_received || '',
|
|
||||||
notes: entry.notes || '',
|
|
||||||
})
|
|
||||||
setShowModal(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
|
||||||
setEntryToDelete(id)
|
|
||||||
setShowDeleteConfirm(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
|
||||||
if (!entryToDelete) return
|
|
||||||
await api.delete(`/budget/${entryToDelete}`)
|
|
||||||
setEntryToDelete(null)
|
|
||||||
loadAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 animate-pulse">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
{[1, 2, 3, 4].map(i => <div key={i} className="h-28 bg-surface-tertiary rounded-xl" />)}
|
{[1, 2, 3, 4, 5].map(i => <SkeletonStatCard key={i} />)}
|
||||||
</div>
|
</div>
|
||||||
|
<SkeletonTable rows={5} cols={7} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -151,25 +77,72 @@ export default function Finance() {
|
|||||||
const remaining = s.remaining || 0
|
const remaining = s.remaining || 0
|
||||||
const totalRevenue = s.revenue || 0
|
const totalRevenue = s.revenue || 0
|
||||||
const roi = s.roi || 0
|
const roi = s.roi || 0
|
||||||
|
const totalExpenses = s.totalExpenses || 0
|
||||||
const spendPct = totalReceived > 0 ? (totalSpent / totalReceived) * 100 : 0
|
const spendPct = totalReceived > 0 ? (totalSpent / totalReceived) * 100 : 0
|
||||||
|
const totalCampaignBudget = s.totalCampaignBudget || 0
|
||||||
|
const totalProjectBudget = s.totalProjectBudget || 0
|
||||||
|
const unallocated = s.unallocated ?? (totalReceived - totalCampaignBudget - totalProjectBudget)
|
||||||
|
const campaignPct = totalReceived > 0 ? (totalCampaignBudget / totalReceived) * 100 : 0
|
||||||
|
const projectPct = totalReceived > 0 ? (totalProjectBudget / totalReceived) * 100 : 0
|
||||||
|
const unallocatedPct = totalReceived > 0 ? (Math.max(0, unallocated) / totalReceived) * 100 : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* Top metrics */}
|
{/* Top metrics */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
<div className={`grid grid-cols-2 ${totalExpenses > 0 ? 'lg:grid-cols-6' : 'lg:grid-cols-5'} gap-4`}>
|
||||||
<FinanceStatCard icon={Wallet} label="Total Received" value={`${totalReceived.toLocaleString()} SAR`} color="text-blue-600" />
|
<FinanceStatCard icon={Wallet} label="Total Received" value={`${totalReceived.toLocaleString()} ${currencySymbol}`} color="text-blue-600" />
|
||||||
<FinanceStatCard icon={TrendingUp} label="Total Spent" value={`${totalSpent.toLocaleString()} SAR`} sub={`${spendPct.toFixed(1)}% of budget`} color="text-amber-600" />
|
<FinanceStatCard icon={TrendingUp} label="Total Spent" value={`${totalSpent.toLocaleString()} ${currencySymbol}`} sub={`${spendPct.toFixed(1)}% of budget`} color="text-amber-600" />
|
||||||
<FinanceStatCard icon={PiggyBank} label="Remaining" value={`${remaining.toLocaleString()} SAR`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
|
{totalExpenses > 0 && (
|
||||||
<FinanceStatCard icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()} SAR`} color="text-purple-600" />
|
<FinanceStatCard icon={Receipt} label="Expenses" value={`${totalExpenses.toLocaleString()} ${currencySymbol}`} color="text-red-600" />
|
||||||
|
)}
|
||||||
|
<FinanceStatCard icon={Landmark} label="Remaining" value={`${remaining.toLocaleString()} ${currencySymbol}`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
|
||||||
|
<FinanceStatCard icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()} ${currencySymbol}`} color="text-purple-600" />
|
||||||
<FinanceStatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label="Global ROI"
|
<FinanceStatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label="Global ROI"
|
||||||
value={`${roi.toFixed(1)}%`}
|
value={`${roi.toFixed(1)}%`}
|
||||||
color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} />
|
color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Budget allocation bar */}
|
||||||
|
{totalReceived > 0 && (
|
||||||
|
<div className="section-card p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">Budget Allocation</h3>
|
||||||
|
<Link to="/budgets" className="text-xs text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
||||||
|
Manage Budgets <ArrowRight className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="h-4 bg-surface-tertiary rounded-full overflow-hidden flex">
|
||||||
|
{campaignPct > 0 && (
|
||||||
|
<div className="h-full bg-blue-500 transition-all" style={{ width: `${campaignPct}%` }} title={`Campaigns: ${totalCampaignBudget.toLocaleString()} ${currencySymbol}`} />
|
||||||
|
)}
|
||||||
|
{projectPct > 0 && (
|
||||||
|
<div className="h-full bg-purple-500 transition-all" style={{ width: `${projectPct}%` }} title={`Projects: ${totalProjectBudget.toLocaleString()} ${currencySymbol}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 mt-2.5 text-xs">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full bg-blue-500" />
|
||||||
|
<span className="text-text-secondary">Campaigns: <span className="font-semibold text-text-primary">{totalCampaignBudget.toLocaleString()} {currencySymbol}</span></span>
|
||||||
|
<span className="text-text-tertiary">({campaignPct.toFixed(0)}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full bg-purple-500" />
|
||||||
|
<span className="text-text-secondary">Projects: <span className="font-semibold text-text-primary">{totalProjectBudget.toLocaleString()} {currencySymbol}</span></span>
|
||||||
|
<span className="text-text-tertiary">({projectPct.toFixed(0)}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full bg-gray-300" />
|
||||||
|
<span className="text-text-secondary">Unallocated: <span className="font-semibold text-text-primary">{Math.max(0, unallocated).toLocaleString()} {currencySymbol}</span></span>
|
||||||
|
<span className="text-text-tertiary">({unallocatedPct.toFixed(0)}%)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Budget utilization + Global metrics */}
|
{/* Budget utilization + Global metrics */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
{/* Utilization ring */}
|
{/* Utilization ring */}
|
||||||
<div className="bg-white rounded-xl border border-border p-5 flex flex-col items-center justify-center">
|
<div className="section-card p-5 flex flex-col items-center justify-center">
|
||||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Budget Utilization</h3>
|
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Budget Utilization</h3>
|
||||||
<ProgressRing
|
<ProgressRing
|
||||||
pct={spendPct}
|
pct={spendPct}
|
||||||
@@ -178,12 +151,12 @@ export default function Finance() {
|
|||||||
color={spendPct > 90 ? '#ef4444' : spendPct > 70 ? '#f59e0b' : '#10b981'}
|
color={spendPct > 90 ? '#ef4444' : spendPct > 70 ? '#f59e0b' : '#10b981'}
|
||||||
/>
|
/>
|
||||||
<div className="text-xs text-text-tertiary mt-3">
|
<div className="text-xs text-text-tertiary mt-3">
|
||||||
{totalSpent.toLocaleString()} of {totalReceived.toLocaleString()} SAR
|
{totalSpent.toLocaleString()} of {totalReceived.toLocaleString()} {currencySymbol}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Global performance */}
|
{/* Global performance */}
|
||||||
<div className="bg-white rounded-xl border border-border p-5 lg:col-span-2">
|
<div className="section-card p-5 lg:col-span-2">
|
||||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Global Performance</h3>
|
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Global Performance</h3>
|
||||||
<div className="grid grid-cols-3 gap-6">
|
<div className="grid grid-cols-3 gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -196,7 +169,7 @@ export default function Finance() {
|
|||||||
<div className="text-xl font-bold text-text-primary">{(s.clicks || 0).toLocaleString()}</div>
|
<div className="text-xl font-bold text-text-primary">{(s.clicks || 0).toLocaleString()}</div>
|
||||||
<div className="text-xs text-text-tertiary">Clicks</div>
|
<div className="text-xs text-text-tertiary">Clicks</div>
|
||||||
{s.clicks > 0 && s.spent > 0 && (
|
{s.clicks > 0 && s.spent > 0 && (
|
||||||
<div className="text-[10px] text-text-tertiary mt-0.5">CPC: {(s.spent / s.clicks).toFixed(2)} SAR</div>
|
<div className="text-[10px] text-text-tertiary mt-0.5">CPC: {(s.spent / s.clicks).toFixed(2)} {currencySymbol}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -204,7 +177,7 @@ export default function Finance() {
|
|||||||
<div className="text-xl font-bold text-text-primary">{(s.conversions || 0).toLocaleString()}</div>
|
<div className="text-xl font-bold text-text-primary">{(s.conversions || 0).toLocaleString()}</div>
|
||||||
<div className="text-xs text-text-tertiary">Conversions</div>
|
<div className="text-xs text-text-tertiary">Conversions</div>
|
||||||
{s.conversions > 0 && s.spent > 0 && (
|
{s.conversions > 0 && s.spent > 0 && (
|
||||||
<div className="text-[10px] text-text-tertiary mt-0.5">CPA: {(s.spent / s.conversions).toFixed(2)} SAR</div>
|
<div className="text-[10px] text-text-tertiary mt-0.5">CPA: {(s.spent / s.conversions).toFixed(2)} {currencySymbol}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,42 +194,57 @@ export default function Finance() {
|
|||||||
|
|
||||||
{/* Per-campaign breakdown */}
|
{/* Per-campaign breakdown */}
|
||||||
{s.campaigns && s.campaigns.length > 0 && (
|
{s.campaigns && s.campaigns.length > 0 && (
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
<div className="section-card">
|
||||||
<div className="px-5 py-4 border-b border-border">
|
<div className="section-card-header flex items-center gap-3">
|
||||||
<h3 className="font-semibold text-text-primary">Campaign Breakdown</h3>
|
<div className="p-2 rounded-lg bg-blue-50">
|
||||||
|
<Target className="w-4 h-4 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-text-primary text-base">Campaign Breakdown</h3>
|
||||||
|
<p className="text-xs text-text-tertiary mt-0.5">{s.campaigns.length} campaigns · Track-level budget allocation</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface-secondary">
|
<tr className="border-b border-border bg-surface-secondary">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">Campaign</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">Campaign</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Allocated</th>
|
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Budget Assigned</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Track Allocated</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Spent</th>
|
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Spent</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Expenses</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Revenue</th>
|
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Revenue</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">ROI</th>
|
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">ROI</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Impressions</th>
|
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Clicks</th>
|
|
||||||
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
|
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border-light">
|
<tbody className="divide-y divide-border-light">
|
||||||
{s.campaigns.map(c => {
|
{s.campaigns.map(c => {
|
||||||
const cRoi = c.tracks_spent > 0 ? ((c.tracks_revenue - c.tracks_spent) / c.tracks_spent * 100) : 0
|
const totalCampaignConsumed = c.tracks_spent + (c.expenses || 0)
|
||||||
|
const cRoi = totalCampaignConsumed > 0 ? ((c.tracks_revenue - totalCampaignConsumed) / totalCampaignConsumed * 100) : 0
|
||||||
return (
|
return (
|
||||||
<tr key={c.id} className="hover:bg-surface-secondary">
|
<tr key={c.id} className="hover:bg-surface-secondary">
|
||||||
<td className="px-4 py-3 font-medium text-text-primary">{c.name}</td>
|
<td className="px-4 py-3 font-medium text-text-primary">{c.name}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{c.budget_from_entries > 0 ? (
|
||||||
|
<span className="font-semibold text-blue-600">{c.budget_from_entries.toLocaleString()}</span>
|
||||||
|
) : <span className="text-text-tertiary">{'\u2014'}</span>}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_allocated.toLocaleString()}</td>
|
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_allocated.toLocaleString()}</td>
|
||||||
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_spent.toLocaleString()}</td>
|
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_spent.toLocaleString()}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{c.expenses > 0 ? (
|
||||||
|
<span className="font-semibold text-red-500">-{c.expenses.toLocaleString()}</span>
|
||||||
|
) : <span className="text-text-tertiary">{'\u2014'}</span>}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_revenue.toLocaleString()}</td>
|
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_revenue.toLocaleString()}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
{c.tracks_spent > 0 ? (
|
{totalCampaignConsumed > 0 ? (
|
||||||
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${cRoi >= 0 ? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'}`}>
|
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${cRoi >= 0 ? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'}`}>
|
||||||
{cRoi.toFixed(0)}%
|
{cRoi.toFixed(0)}%
|
||||||
</span>
|
</span>
|
||||||
) : '—'}
|
) : '\u2014'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_impressions.toLocaleString()}</td>
|
|
||||||
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_clicks.toLocaleString()}</td>
|
|
||||||
<td className="px-4 py-3 text-center"><StatusBadge status={c.status} size="xs" /></td>
|
<td className="px-4 py-3 text-center"><StatusBadge status={c.status} size="xs" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
@@ -267,175 +255,46 @@ export default function Finance() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Budget entries */}
|
{/* Allocated Funds breakdown */}
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
{s.projects && s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).length > 0 && (
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
<div className="section-card">
|
||||||
<h3 className="font-semibold text-text-primary">Budget Received</h3>
|
<div className="section-card-header flex items-center gap-3">
|
||||||
{canManageFinance && (
|
<div className="p-2 rounded-lg bg-purple-50">
|
||||||
<button
|
<Briefcase className="w-4 h-4 text-purple-600" />
|
||||||
onClick={() => { setEditing(null); setForm(EMPTY_ENTRY); setShowModal(true) }}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5" /> Add Entry
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
{entries.length === 0 ? (
|
<h3 className="font-semibold text-text-primary text-base">Allocated Funds</h3>
|
||||||
<div className="py-12 text-center text-sm text-text-tertiary">
|
<p className="text-xs text-text-tertiary mt-0.5">{s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).length} work orders with assigned budget</p>
|
||||||
No budget entries yet. Add your first received budget.
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-border-light">
|
|
||||||
{entries.map(entry => (
|
|
||||||
<div key={entry.id || entry._id} className="flex items-center gap-4 px-5 py-4 hover:bg-surface-secondary">
|
|
||||||
<div className="p-2 rounded-lg bg-emerald-50">
|
|
||||||
<DollarSign className="w-4 h-4 text-emerald-600" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
|
||||||
<h4 className="text-sm font-semibold text-text-primary">{entry.label}</h4>
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary capitalize">
|
|
||||||
{entry.category}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-text-tertiary">
|
|
||||||
{entry.source && <span>{entry.source} · </span>}
|
|
||||||
{entry.campaign_name && <span>{entry.campaign_name} · </span>}
|
|
||||||
{entry.date_received && format(new Date(entry.date_received), 'MMM d, yyyy')}
|
|
||||||
</div>
|
|
||||||
{entry.notes && <p className="text-xs text-text-secondary mt-0.5">{entry.notes}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="text-right shrink-0">
|
|
||||||
<div className="text-base font-bold text-emerald-600">{Number(entry.amount).toLocaleString()} SAR</div>
|
|
||||||
</div>
|
|
||||||
{canManageFinance && (
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
|
||||||
<button onClick={() => openEdit(entry)} className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary">
|
|
||||||
<Edit2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => handleDelete(entry.id || entry._id)} className="p-1.5 hover:bg-red-50 rounded-lg text-text-tertiary hover:text-red-500">
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-surface-secondary">
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">Work Order</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Budget Allocated</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Expenses</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-light">
|
||||||
|
{s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).map(p => (
|
||||||
|
<tr key={p.id} className="hover:bg-surface-secondary">
|
||||||
|
<td className="px-4 py-3 font-medium text-text-primary">{p.name}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-text-secondary">{p.budget_allocated.toLocaleString()} {currencySymbol}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{p.expenses > 0 ? (
|
||||||
|
<span className="font-semibold text-red-500">-{p.expenses.toLocaleString()}</span>
|
||||||
|
) : <span className="text-text-tertiary">{'\u2014'}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center"><StatusBadge status={p.status} size="xs" /></td>
|
||||||
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add/Edit Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={showModal}
|
|
||||||
onClose={() => { setShowModal(false); setEditing(null) }}
|
|
||||||
title={editing ? 'Edit Budget Entry' : 'Add Budget Entry'}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Label *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.label}
|
|
||||||
onChange={e => setForm(f => ({ ...f, label: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder="e.g., Seerah Campaign Budget, Additional Q1 Funds..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Amount (SAR) *</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={form.amount}
|
|
||||||
onChange={e => setForm(f => ({ ...f, amount: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder="50000"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Date Received *</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={form.date_received}
|
|
||||||
onChange={e => setForm(f => ({ ...f, date_received: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Source</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.source}
|
|
||||||
onChange={e => setForm(f => ({ ...f, source: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
|
||||||
placeholder="e.g., CEO Approval, Annual Budget..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Category</label>
|
|
||||||
<select
|
|
||||||
value={form.category}
|
|
||||||
onChange={e => setForm(f => ({ ...f, category: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
|
||||||
>
|
|
||||||
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Campaign (optional)</label>
|
|
||||||
<select
|
|
||||||
value={form.campaign_id}
|
|
||||||
onChange={e => setForm(f => ({ ...f, campaign_id: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">General / Not linked</option>
|
|
||||||
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
|
|
||||||
<textarea
|
|
||||||
value={form.notes}
|
|
||||||
onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
|
|
||||||
rows={2}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none resize-none"
|
|
||||||
placeholder="Any details about this budget entry..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
|
||||||
<button onClick={() => setShowModal(false)} className="px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary rounded-lg">Cancel</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!form.label || !form.amount || !form.date_received}
|
|
||||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
|
||||||
>
|
|
||||||
{editing ? 'Save Changes' : 'Add Entry'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Delete Budget Entry Confirmation */}
|
|
||||||
<Modal
|
|
||||||
isOpen={showDeleteConfirm}
|
|
||||||
onClose={() => { setShowDeleteConfirm(false); setEntryToDelete(null) }}
|
|
||||||
title="Delete Budget Entry?"
|
|
||||||
isConfirm
|
|
||||||
danger
|
|
||||||
confirmText="Delete Entry"
|
|
||||||
onConfirm={confirmDelete}
|
|
||||||
>
|
|
||||||
Are you sure you want to delete this budget entry? This action cannot be undone.
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Settings as SettingsIcon, Play, CheckCircle, Languages } from 'lucide-react'
|
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import { CURRENCIES } from '../i18n/LanguageContext'
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { t, lang, setLang } = useLanguage()
|
const { t, lang, setLang, currency, setCurrency } = useLanguage()
|
||||||
const [restarting, setRestarting] = useState(false)
|
const [restarting, setRestarting] = useState(false)
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
@@ -57,6 +58,26 @@ export default function Settings() {
|
|||||||
<option value="ar">{t('settings.arabic')}</option>
|
<option value="ar">{t('settings.arabic')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Currency Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
|
||||||
|
<Coins className="w-4 h-4" />
|
||||||
|
{t('settings.currency')}
|
||||||
|
</label>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{CURRENCIES.map(c => (
|
||||||
|
<option key={c.code} value={c.code}>
|
||||||
|
{c.symbol} — {lang === 'ar' ? c.labelAr : c.labelEn}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-text-tertiary mt-1.5">{t('settings.currencyHint')}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
477
server/server.js
477
server/server.js
@@ -152,8 +152,9 @@ const FK_COLUMNS = {
|
|||||||
Posts: ['brand_id', 'assigned_to_id', 'campaign_id', 'track_id', 'created_by_user_id'],
|
Posts: ['brand_id', 'assigned_to_id', 'campaign_id', 'track_id', 'created_by_user_id'],
|
||||||
Assets: ['brand_id', 'campaign_id', 'uploader_id'],
|
Assets: ['brand_id', 'campaign_id', 'uploader_id'],
|
||||||
PostAttachments: ['post_id'],
|
PostAttachments: ['post_id'],
|
||||||
|
TaskAttachments: ['task_id'],
|
||||||
Comments: ['user_id'],
|
Comments: ['user_id'],
|
||||||
BudgetEntries: ['campaign_id'],
|
BudgetEntries: ['campaign_id', 'project_id'],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Maps link column names to FK field names for migration
|
// Maps link column names to FK field names for migration
|
||||||
@@ -166,10 +167,109 @@ const LINK_TO_FK = {
|
|||||||
Posts: { Brand: 'brand_id', AssignedTo: 'assigned_to_id', Campaign: 'campaign_id', Track: 'track_id', CreatedByUser: 'created_by_user_id' },
|
Posts: { Brand: 'brand_id', AssignedTo: 'assigned_to_id', Campaign: 'campaign_id', Track: 'track_id', CreatedByUser: 'created_by_user_id' },
|
||||||
Assets: { Brand: 'brand_id', Campaign: 'campaign_id', Uploader: 'uploader_id' },
|
Assets: { Brand: 'brand_id', Campaign: 'campaign_id', Uploader: 'uploader_id' },
|
||||||
PostAttachments: { Post: 'post_id' },
|
PostAttachments: { Post: 'post_id' },
|
||||||
|
TaskAttachments: { Task: 'task_id' },
|
||||||
Comments: { User: 'user_id' },
|
Comments: { User: 'user_id' },
|
||||||
BudgetEntries: { Campaign: 'campaign_id' },
|
BudgetEntries: { Campaign: 'campaign_id', Project: 'project_id' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── TABLE CREATION: Ensure required tables exist ────────────────
|
||||||
|
const REQUIRED_TABLES = {
|
||||||
|
TaskAttachments: [
|
||||||
|
{ title: 'filename', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'original_name', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'mime_type', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'size', uidt: 'Number' },
|
||||||
|
{ title: 'url', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'task_id', uidt: 'Number' },
|
||||||
|
],
|
||||||
|
Teams: [
|
||||||
|
{ title: 'name', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'description', uidt: 'LongText' },
|
||||||
|
],
|
||||||
|
TeamMembers: [
|
||||||
|
{ title: 'team_id', uidt: 'Number' },
|
||||||
|
{ title: 'user_id', uidt: 'Number' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function ensureRequiredTables() {
|
||||||
|
// Fetch existing tables
|
||||||
|
const res = await fetch(`${nocodb.url}/api/v2/meta/bases/${nocodb.baseId}/tables`, {
|
||||||
|
headers: { 'xc-token': nocodb.token },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('Failed to fetch tables for ensureRequiredTables');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const existingTables = new Set((data.list || []).map(t => t.title));
|
||||||
|
|
||||||
|
for (const [tableName, columns] of Object.entries(REQUIRED_TABLES)) {
|
||||||
|
if (existingTables.has(tableName)) continue;
|
||||||
|
console.log(` Creating table ${tableName}...`);
|
||||||
|
try {
|
||||||
|
const createRes = await fetch(`${nocodb.url}/api/v2/meta/bases/${nocodb.baseId}/tables`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
table_name: tableName,
|
||||||
|
title: tableName,
|
||||||
|
columns: [
|
||||||
|
{ title: 'Id', uidt: 'ID' },
|
||||||
|
...columns,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (createRes.ok) {
|
||||||
|
console.log(` Created table ${tableName}`);
|
||||||
|
nocodb.clearCache(); // clear table ID cache so it picks up the new table
|
||||||
|
} else {
|
||||||
|
const err = await createRes.text();
|
||||||
|
console.error(` Failed to create table ${tableName}:`, err);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` Failed to create table ${tableName}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text/string columns that must exist on tables (not FKs — those are Number type)
|
||||||
|
const TEXT_COLUMNS = {
|
||||||
|
Projects: [{ name: 'thumbnail', uidt: 'SingleLineText' }],
|
||||||
|
Tasks: [{ name: 'thumbnail', uidt: 'SingleLineText' }],
|
||||||
|
Users: [{ name: 'modules', uidt: 'LongText' }],
|
||||||
|
BudgetEntries: [{ name: 'project_id', uidt: 'Number' }, { name: 'destination', uidt: 'SingleLineText' }, { name: 'type', uidt: 'SingleLineText' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_MODULES = ['marketing', 'projects', 'finance'];
|
||||||
|
|
||||||
|
async function ensureTextColumns() {
|
||||||
|
for (const [table, columns] of Object.entries(TEXT_COLUMNS)) {
|
||||||
|
try {
|
||||||
|
const tableId = await nocodb.resolveTableId(table);
|
||||||
|
const res = await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}`, {
|
||||||
|
headers: { 'xc-token': nocodb.token },
|
||||||
|
});
|
||||||
|
if (!res.ok) continue;
|
||||||
|
const meta = await res.json();
|
||||||
|
const existingCols = new Set((meta.columns || []).map(c => c.title));
|
||||||
|
|
||||||
|
for (const col of columns) {
|
||||||
|
if (!existingCols.has(col.name)) {
|
||||||
|
console.log(` Adding text column ${table}.${col.name}...`);
|
||||||
|
await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}/columns`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title: col.name, uidt: col.uidt }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` Failed to ensure text columns for ${table}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureFKColumns() {
|
async function ensureFKColumns() {
|
||||||
for (const [table, columns] of Object.entries(FK_COLUMNS)) {
|
for (const [table, columns] of Object.entries(FK_COLUMNS)) {
|
||||||
try {
|
try {
|
||||||
@@ -271,6 +371,10 @@ app.post('/api/auth/login', async (req, res) => {
|
|||||||
req.session.userRole = user.role;
|
req.session.userRole = user.role;
|
||||||
req.session.userName = user.name;
|
req.session.userName = user.name;
|
||||||
|
|
||||||
|
let modules = ALL_MODULES;
|
||||||
|
if (user.role !== 'superadmin' && user.modules) {
|
||||||
|
try { modules = JSON.parse(user.modules); } catch { modules = ALL_MODULES; }
|
||||||
|
}
|
||||||
res.json({
|
res.json({
|
||||||
user: {
|
user: {
|
||||||
id: user.Id,
|
id: user.Id,
|
||||||
@@ -281,6 +385,7 @@ app.post('/api/auth/login', async (req, res) => {
|
|||||||
team_role: user.team_role,
|
team_role: user.team_role,
|
||||||
tutorial_completed: user.tutorial_completed,
|
tutorial_completed: user.tutorial_completed,
|
||||||
profileComplete: !!user.team_role,
|
profileComplete: !!user.team_role,
|
||||||
|
modules,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -301,6 +406,10 @@ app.get('/api/auth/me', requireAuth, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const user = await nocodb.get('Users', req.session.userId);
|
const user = await nocodb.get('Users', req.session.userId);
|
||||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
let modules = ALL_MODULES;
|
||||||
|
if (user.role !== 'superadmin' && user.modules) {
|
||||||
|
try { modules = JSON.parse(user.modules); } catch { modules = ALL_MODULES; }
|
||||||
|
}
|
||||||
res.json({
|
res.json({
|
||||||
Id: user.Id, id: user.Id, name: user.name, email: user.email,
|
Id: user.Id, id: user.Id, name: user.name, email: user.email,
|
||||||
role: user.role, avatar: user.avatar, team_role: user.team_role,
|
role: user.role, avatar: user.avatar, team_role: user.team_role,
|
||||||
@@ -308,6 +417,7 @@ app.get('/api/auth/me', requireAuth, async (req, res) => {
|
|||||||
tutorial_completed: user.tutorial_completed,
|
tutorial_completed: user.tutorial_completed,
|
||||||
CreatedAt: user.CreatedAt, created_at: user.CreatedAt,
|
CreatedAt: user.CreatedAt, created_at: user.CreatedAt,
|
||||||
profileComplete: !!user.team_role,
|
profileComplete: !!user.team_role,
|
||||||
|
modules,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Auth/me error:', err);
|
console.error('Auth/me error:', err);
|
||||||
@@ -492,7 +602,21 @@ app.get('/api/users/team', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(filtered.map(u => ({ ...u, id: u.Id, _id: u.Id })));
|
// Attach teams to each user
|
||||||
|
let allTeamMembers = [];
|
||||||
|
let allTeams = [];
|
||||||
|
try {
|
||||||
|
allTeamMembers = await nocodb.list('TeamMembers', { limit: 10000 });
|
||||||
|
allTeams = await nocodb.list('Teams', { limit: 500 });
|
||||||
|
} catch {}
|
||||||
|
const teamMap = {};
|
||||||
|
for (const t of allTeams) teamMap[t.Id] = t.name;
|
||||||
|
|
||||||
|
res.json(filtered.map(u => {
|
||||||
|
const userTeamEntries = allTeamMembers.filter(tm => tm.user_id === u.Id);
|
||||||
|
const teams = userTeamEntries.map(tm => ({ id: tm.team_id, name: teamMap[tm.team_id] || 'Unknown' }));
|
||||||
|
return { ...u, id: u.Id, _id: u.Id, teams };
|
||||||
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Team list error:', err);
|
console.error('Team list error:', err);
|
||||||
res.status(500).json({ error: 'Failed to load team' });
|
res.status(500).json({ error: 'Failed to load team' });
|
||||||
@@ -516,6 +640,7 @@ app.post('/api/users/team', requireAuth, requireRole('superadmin', 'manager'), a
|
|||||||
const created = await nocodb.create('Users', {
|
const created = await nocodb.create('Users', {
|
||||||
name, email, role: userRole, team_role: team_role || null,
|
name, email, role: userRole, team_role: team_role || null,
|
||||||
brands: JSON.stringify(brands || []), phone: phone || null,
|
brands: JSON.stringify(brands || []), phone: phone || null,
|
||||||
|
modules: JSON.stringify(req.body.modules || ALL_MODULES),
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultPassword = password || 'changeme123';
|
const defaultPassword = password || 'changeme123';
|
||||||
@@ -540,6 +665,7 @@ app.patch('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manager
|
|||||||
if (req.body[f] !== undefined) data[f] = req.body[f];
|
if (req.body[f] !== undefined) data[f] = req.body[f];
|
||||||
}
|
}
|
||||||
if (req.body.brands !== undefined) data.brands = JSON.stringify(req.body.brands);
|
if (req.body.brands !== undefined) data.brands = JSON.stringify(req.body.brands);
|
||||||
|
if (req.body.modules !== undefined) data.modules = JSON.stringify(req.body.modules);
|
||||||
|
|
||||||
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
|
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||||
|
|
||||||
@@ -1329,13 +1455,20 @@ app.get('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async
|
|||||||
}
|
}
|
||||||
|
|
||||||
const campaignIds = new Set();
|
const campaignIds = new Set();
|
||||||
for (const e of entries) if (e.campaign_id) campaignIds.add(e.campaign_id);
|
const projectIds = new Set();
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.campaign_id) campaignIds.add(e.campaign_id);
|
||||||
|
if (e.project_id) projectIds.add(e.project_id);
|
||||||
|
}
|
||||||
const cNames = {};
|
const cNames = {};
|
||||||
for (const id of campaignIds) cNames[id] = await getRecordName('Campaigns', id);
|
for (const id of campaignIds) cNames[id] = await getRecordName('Campaigns', id);
|
||||||
|
const pNames = {};
|
||||||
|
for (const id of projectIds) pNames[id] = await getRecordName('Projects', id);
|
||||||
|
|
||||||
res.json(entries.map(e => ({
|
res.json(entries.map(e => ({
|
||||||
...e,
|
...e,
|
||||||
campaign_name: cNames[e.campaign_id] || null,
|
campaign_name: cNames[e.campaign_id] || null,
|
||||||
|
project_name: pNames[e.project_id] || null,
|
||||||
})));
|
})));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: 'Failed to load budget entries' });
|
res.status(500).json({ error: 'Failed to load budget entries' });
|
||||||
@@ -1343,17 +1476,23 @@ app.get('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
app.post('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||||
const { label, amount, source, campaign_id, category, date_received, notes } = req.body;
|
const { label, amount, source, destination, campaign_id, project_id, category, date_received, notes, type } = req.body;
|
||||||
if (!label || !amount || !date_received) return res.status(400).json({ error: 'Label, amount, and date are required' });
|
if (!label || !amount || !date_received) return res.status(400).json({ error: 'Label, amount, and date are required' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await nocodb.create('BudgetEntries', {
|
const created = await nocodb.create('BudgetEntries', {
|
||||||
label, amount, source: source || null,
|
label, amount, source: source || null, destination: destination || null,
|
||||||
category: category || 'marketing', date_received, notes: notes || '',
|
category: category || 'marketing', date_received, notes: notes || '',
|
||||||
campaign_id: campaign_id ? Number(campaign_id) : null,
|
campaign_id: campaign_id ? Number(campaign_id) : null,
|
||||||
|
project_id: project_id ? Number(project_id) : null,
|
||||||
|
type: type || 'income',
|
||||||
});
|
});
|
||||||
const entry = await nocodb.get('BudgetEntries', created.Id);
|
const entry = await nocodb.get('BudgetEntries', created.Id);
|
||||||
res.status(201).json({ ...entry, campaign_name: await getRecordName('Campaigns', entry.campaign_id) });
|
res.status(201).json({
|
||||||
|
...entry,
|
||||||
|
campaign_name: await getRecordName('Campaigns', entry.campaign_id),
|
||||||
|
project_name: await getRecordName('Projects', entry.project_id),
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: 'Failed to create budget entry' });
|
res.status(500).json({ error: 'Failed to create budget entry' });
|
||||||
}
|
}
|
||||||
@@ -1365,17 +1504,22 @@ app.patch('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'),
|
|||||||
if (!existing) return res.status(404).json({ error: 'Budget entry not found' });
|
if (!existing) return res.status(404).json({ error: 'Budget entry not found' });
|
||||||
|
|
||||||
const data = {};
|
const data = {};
|
||||||
for (const f of ['label', 'amount', 'source', 'category', 'date_received', 'notes']) {
|
for (const f of ['label', 'amount', 'source', 'destination', 'category', 'date_received', 'notes', 'type']) {
|
||||||
if (req.body[f] !== undefined) data[f] = req.body[f];
|
if (req.body[f] !== undefined) data[f] = req.body[f];
|
||||||
}
|
}
|
||||||
if (req.body.campaign_id !== undefined) data.campaign_id = req.body.campaign_id ? Number(req.body.campaign_id) : null;
|
if (req.body.campaign_id !== undefined) data.campaign_id = req.body.campaign_id ? Number(req.body.campaign_id) : null;
|
||||||
|
if (req.body.project_id !== undefined) data.project_id = req.body.project_id ? Number(req.body.project_id) : null;
|
||||||
|
|
||||||
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
|
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||||
|
|
||||||
await nocodb.update('BudgetEntries', req.params.id, data);
|
await nocodb.update('BudgetEntries', req.params.id, data);
|
||||||
|
|
||||||
const entry = await nocodb.get('BudgetEntries', req.params.id);
|
const entry = await nocodb.get('BudgetEntries', req.params.id);
|
||||||
res.json({ ...entry, campaign_name: await getRecordName('Campaigns', entry.campaign_id) });
|
res.json({
|
||||||
|
...entry,
|
||||||
|
campaign_name: await getRecordName('Campaigns', entry.campaign_id),
|
||||||
|
project_name: await getRecordName('Projects', entry.project_id),
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: 'Failed to update budget entry' });
|
res.status(500).json({ error: 'Failed to update budget entry' });
|
||||||
}
|
}
|
||||||
@@ -1407,15 +1551,23 @@ app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'
|
|||||||
budgetEntries = budgetEntries.filter(e => !e.campaign_id || myCampaignIds.has(e.campaign_id));
|
budgetEntries = budgetEntries.filter(e => !e.campaign_id || myCampaignIds.has(e.campaign_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalReceived = isSuperadmin
|
const incomeEntries = budgetEntries.filter(e => (e.type || 'income') === 'income');
|
||||||
? budgetEntries.reduce((sum, e) => sum + (e.amount || 0), 0)
|
const expenseEntries = budgetEntries.filter(e => e.type === 'expense');
|
||||||
|
const totalIncome = isSuperadmin
|
||||||
|
? incomeEntries.reduce((sum, e) => sum + (e.amount || 0), 0)
|
||||||
: campaigns.reduce((sum, c) => sum + (c.budget || 0), 0);
|
: campaigns.reduce((sum, c) => sum + (c.budget || 0), 0);
|
||||||
|
const totalExpenses = expenseEntries.reduce((sum, e) => sum + (e.amount || 0), 0);
|
||||||
|
const totalReceived = totalIncome;
|
||||||
|
|
||||||
const allTracks = await nocodb.list('CampaignTracks', { limit: 10000 });
|
const allTracks = await nocodb.list('CampaignTracks', { limit: 10000 });
|
||||||
const campaignStats = campaigns.map(c => {
|
const campaignStats = campaigns.map(c => {
|
||||||
const cTracks = allTracks.filter(t => t.campaign_id === c.Id);
|
const cTracks = allTracks.filter(t => t.campaign_id === c.Id);
|
||||||
|
const cEntries = incomeEntries.filter(e => e.campaign_id && Number(e.campaign_id) === c.Id);
|
||||||
|
const cExpenses = expenseEntries.filter(e => e.campaign_id && Number(e.campaign_id) === c.Id);
|
||||||
return {
|
return {
|
||||||
id: c.Id, name: c.name, budget: c.budget, status: c.status,
|
id: c.Id, name: c.name, budget: c.budget, status: c.status,
|
||||||
|
budget_from_entries: cEntries.reduce((s, e) => s + (e.amount || 0), 0),
|
||||||
|
expenses: cExpenses.reduce((s, e) => s + (e.amount || 0), 0),
|
||||||
tracks_allocated: cTracks.reduce((s, t) => s + (t.budget_allocated || 0), 0),
|
tracks_allocated: cTracks.reduce((s, t) => s + (t.budget_allocated || 0), 0),
|
||||||
tracks_spent: cTracks.reduce((s, t) => s + (t.budget_spent || 0), 0),
|
tracks_spent: cTracks.reduce((s, t) => s + (t.budget_spent || 0), 0),
|
||||||
tracks_revenue: cTracks.reduce((s, t) => s + (t.revenue || 0), 0),
|
tracks_revenue: cTracks.reduce((s, t) => s + (t.revenue || 0), 0),
|
||||||
@@ -1434,11 +1586,35 @@ app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'
|
|||||||
conversions: acc.conversions + c.tracks_conversions,
|
conversions: acc.conversions + c.tracks_conversions,
|
||||||
}), { allocated: 0, spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0 });
|
}), { allocated: 0, spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0 });
|
||||||
|
|
||||||
|
const totalCampaignBudget = campaignStats.reduce((s, c) => s + (c.budget || 0), 0);
|
||||||
|
|
||||||
|
// Project budget breakdown
|
||||||
|
let projects = await nocodb.list('Projects', { limit: 10000 });
|
||||||
|
if (!isSuperadmin) {
|
||||||
|
projects = projects.filter(p => p.owner_id === userId || p.created_by_user_id === userId);
|
||||||
|
}
|
||||||
|
const projectStats = projects.map(p => {
|
||||||
|
const pEntries = incomeEntries.filter(e => e.project_id && Number(e.project_id) === p.Id);
|
||||||
|
const pExpenses = expenseEntries.filter(e => e.project_id && Number(e.project_id) === p.Id);
|
||||||
|
return {
|
||||||
|
id: p.Id, name: p.name, status: p.status,
|
||||||
|
budget_allocated: pEntries.reduce((s, e) => s + (e.amount || 0), 0),
|
||||||
|
expenses: pExpenses.reduce((s, e) => s + (e.amount || 0), 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const totalProjectBudget = projectStats.reduce((s, p) => s + p.budget_allocated, 0);
|
||||||
|
|
||||||
|
const unallocated = totalReceived - totalCampaignBudget - totalProjectBudget;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
totalReceived, ...totals,
|
totalReceived, ...totals, totalExpenses,
|
||||||
remaining: totalReceived - totals.spent,
|
remaining: totalReceived - totalCampaignBudget - totalProjectBudget - totals.spent - totalExpenses,
|
||||||
roi: totals.spent > 0 ? ((totals.revenue - totals.spent) / totals.spent * 100) : 0,
|
roi: totals.spent > 0 ? ((totals.revenue - totals.spent) / totals.spent * 100) : 0,
|
||||||
campaigns: campaignStats,
|
campaigns: campaignStats,
|
||||||
|
projects: projectStats,
|
||||||
|
totalCampaignBudget,
|
||||||
|
totalProjectBudget,
|
||||||
|
unallocated,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Finance summary error:', err);
|
console.error('Finance summary error:', err);
|
||||||
@@ -1584,6 +1760,7 @@ app.get('/api/projects', requireAuth, async (req, res) => {
|
|||||||
brand_name: names[`brand:${p.brand_id}`] || null,
|
brand_name: names[`brand:${p.brand_id}`] || null,
|
||||||
owner_name: names[`user:${p.owner_id}`] || null,
|
owner_name: names[`user:${p.owner_id}`] || null,
|
||||||
creator_user_name: names[`user:${p.created_by_user_id}`] || null,
|
creator_user_name: names[`user:${p.created_by_user_id}`] || null,
|
||||||
|
thumbnail_url: p.thumbnail ? `/api/uploads/${p.thumbnail}` : null,
|
||||||
})));
|
})));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: 'Failed to load projects' });
|
res.status(500).json({ error: 'Failed to load projects' });
|
||||||
@@ -1599,6 +1776,7 @@ app.get('/api/projects/:id', requireAuth, async (req, res) => {
|
|||||||
brand_name: await getRecordName('Brands', project.brand_id),
|
brand_name: await getRecordName('Brands', project.brand_id),
|
||||||
owner_name: await getRecordName('Users', project.owner_id),
|
owner_name: await getRecordName('Users', project.owner_id),
|
||||||
creator_user_name: await getRecordName('Users', project.created_by_user_id),
|
creator_user_name: await getRecordName('Users', project.created_by_user_id),
|
||||||
|
thumbnail_url: project.thumbnail ? `/api/uploads/${project.thumbnail}` : null,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: 'Failed to load project' });
|
res.status(500).json({ error: 'Failed to load project' });
|
||||||
@@ -1673,6 +1851,30 @@ app.delete('/api/projects/:id', requireAuth, requireRole('superadmin', 'manager'
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/thumbnail', requireAuth, requireRole('superadmin', 'manager'), upload.single('file'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await nocodb.get('Projects', req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Project not found' });
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
await nocodb.update('Projects', req.params.id, { thumbnail: req.file.filename });
|
||||||
|
const project = await nocodb.get('Projects', req.params.id);
|
||||||
|
res.json(project);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to upload thumbnail' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/projects/:id/thumbnail', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await nocodb.get('Projects', req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Project not found' });
|
||||||
|
await nocodb.update('Projects', req.params.id, { thumbnail: null });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to remove thumbnail' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── TASKS ──────────────────────────────────────────────────────
|
// ─── TASKS ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/tasks', requireAuth, async (req, res) => {
|
app.get('/api/tasks', requireAuth, async (req, res) => {
|
||||||
@@ -1685,6 +1887,8 @@ app.get('/api/tasks', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
if (req.query.project_id) whereParts.push(`(project_id,eq,${req.query.project_id})`);
|
if (req.query.project_id) whereParts.push(`(project_id,eq,${req.query.project_id})`);
|
||||||
if (req.query.assigned_to) whereParts.push(`(assigned_to_id,eq,${req.query.assigned_to})`);
|
if (req.query.assigned_to) whereParts.push(`(assigned_to_id,eq,${req.query.assigned_to})`);
|
||||||
|
if (req.query.priority) whereParts.push(`(priority,eq,${req.query.priority})`);
|
||||||
|
if (req.query.created_by) whereParts.push(`(created_by_user_id,eq,${req.query.created_by})`);
|
||||||
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
|
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
|
||||||
|
|
||||||
let tasks = await nocodb.list('Tasks', { where, sort: '-CreatedAt', limit: 10000 });
|
let tasks = await nocodb.list('Tasks', { where, sort: '-CreatedAt', limit: 10000 });
|
||||||
@@ -1696,6 +1900,17 @@ app.get('/api/tasks', requireAuth, async (req, res) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Post-fetch date range filters
|
||||||
|
if (req.query.due_date_from) {
|
||||||
|
const from = new Date(req.query.due_date_from);
|
||||||
|
tasks = tasks.filter(t => t.due_date && new Date(t.due_date) >= from);
|
||||||
|
}
|
||||||
|
if (req.query.due_date_to) {
|
||||||
|
const to = new Date(req.query.due_date_to);
|
||||||
|
to.setHours(23, 59, 59, 999);
|
||||||
|
tasks = tasks.filter(t => t.due_date && new Date(t.due_date) <= to);
|
||||||
|
}
|
||||||
|
|
||||||
const projectIds = new Set(), userIds = new Set();
|
const projectIds = new Set(), userIds = new Set();
|
||||||
for (const t of tasks) {
|
for (const t of tasks) {
|
||||||
if (t.project_id) projectIds.add(t.project_id);
|
if (t.project_id) projectIds.add(t.project_id);
|
||||||
@@ -1706,12 +1921,48 @@ app.get('/api/tasks', requireAuth, async (req, res) => {
|
|||||||
for (const id of projectIds) names[`project:${id}`] = await getRecordName('Projects', id);
|
for (const id of projectIds) names[`project:${id}`] = await getRecordName('Projects', id);
|
||||||
for (const id of userIds) names[`user:${id}`] = await getRecordName('Users', id);
|
for (const id of userIds) names[`user:${id}`] = await getRecordName('Users', id);
|
||||||
|
|
||||||
|
// Resolve brand info from projects
|
||||||
|
const projectData = {};
|
||||||
|
for (const pid of projectIds) {
|
||||||
|
try {
|
||||||
|
const proj = await nocodb.get('Projects', pid);
|
||||||
|
if (proj) {
|
||||||
|
projectData[pid] = {
|
||||||
|
brand_id: proj.brand_id,
|
||||||
|
brand_name: proj.brand_id ? await getRecordName('Brands', proj.brand_id) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-fetch brand filter (brand lives on the project)
|
||||||
|
if (req.query.brand_id) {
|
||||||
|
const brandId = Number(req.query.brand_id);
|
||||||
|
tasks = tasks.filter(t => t.project_id && projectData[t.project_id]?.brand_id === brandId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch comment counts for all tasks
|
||||||
|
const commentCounts = {};
|
||||||
|
try {
|
||||||
|
const allComments = await nocodb.list('Comments', {
|
||||||
|
where: '(entity_type,eq,task)',
|
||||||
|
fields: ['entity_id'],
|
||||||
|
limit: 50000,
|
||||||
|
});
|
||||||
|
for (const c of allComments) {
|
||||||
|
commentCounts[c.entity_id] = (commentCounts[c.entity_id] || 0) + 1;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
res.json(tasks.map(t => ({
|
res.json(tasks.map(t => ({
|
||||||
...t,
|
...t,
|
||||||
assigned_to: t.assigned_to_id,
|
assigned_to: t.assigned_to_id,
|
||||||
project_name: names[`project:${t.project_id}`] || null,
|
project_name: names[`project:${t.project_id}`] || null,
|
||||||
assigned_name: names[`user:${t.assigned_to_id}`] || null,
|
assigned_name: names[`user:${t.assigned_to_id}`] || null,
|
||||||
creator_user_name: names[`user:${t.created_by_user_id}`] || null,
|
creator_user_name: names[`user:${t.created_by_user_id}`] || null,
|
||||||
|
brand_id: t.project_id && projectData[t.project_id] ? projectData[t.project_id].brand_id : null,
|
||||||
|
brand_name: t.project_id && projectData[t.project_id] ? projectData[t.project_id].brand_name : null,
|
||||||
|
comment_count: commentCounts[t.Id || t.id] || 0,
|
||||||
})));
|
})));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('GET /tasks error:', err);
|
console.error('GET /tasks error:', err);
|
||||||
@@ -1823,6 +2074,95 @@ app.delete('/api/tasks/:id', requireAuth, requireOwnerOrRole('tasks', 'superadmi
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── TASK ATTACHMENTS ───────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/tasks/:id/attachments', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const attachments = await nocodb.list('TaskAttachments', {
|
||||||
|
where: `(task_id,eq,${req.params.id})`,
|
||||||
|
sort: '-CreatedAt', limit: 1000,
|
||||||
|
});
|
||||||
|
res.json(attachments);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to load attachments' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/tasks/:id/attachments', requireAuth, upload.single('file'), async (req, res) => {
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const task = await nocodb.get('Tasks', req.params.id);
|
||||||
|
if (!task) return res.status(404).json({ error: 'Task not found' });
|
||||||
|
|
||||||
|
const url = `/api/uploads/${req.file.filename}`;
|
||||||
|
const created = await nocodb.create('TaskAttachments', {
|
||||||
|
filename: req.file.filename,
|
||||||
|
original_name: req.file.originalname,
|
||||||
|
mime_type: req.file.mimetype,
|
||||||
|
size: req.file.size,
|
||||||
|
url,
|
||||||
|
task_id: Number(req.params.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachment = await nocodb.get('TaskAttachments', created.Id);
|
||||||
|
res.status(201).json(attachment);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Upload task attachment error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to upload attachment' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/task-attachments/:id', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const attachment = await nocodb.get('TaskAttachments', req.params.id);
|
||||||
|
if (!attachment) return res.status(404).json({ error: 'Attachment not found' });
|
||||||
|
|
||||||
|
// Check if file is referenced elsewhere before deleting from disk
|
||||||
|
const otherTaskAtts = await nocodb.list('TaskAttachments', {
|
||||||
|
where: `(filename,eq,${attachment.filename})`, limit: 10,
|
||||||
|
});
|
||||||
|
const otherPostAtts = await nocodb.list('PostAttachments', {
|
||||||
|
where: `(filename,eq,${attachment.filename})`, limit: 10,
|
||||||
|
});
|
||||||
|
const assets = await nocodb.list('Assets', {
|
||||||
|
where: `(filename,eq,${attachment.filename})`, limit: 10,
|
||||||
|
});
|
||||||
|
if (otherTaskAtts.length <= 1 && otherPostAtts.length === 0 && assets.length === 0) {
|
||||||
|
const filePath = path.join(uploadsDir, attachment.filename);
|
||||||
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await nocodb.delete('TaskAttachments', req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete task attachment error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete attachment' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set a task's thumbnail from one of its image attachments
|
||||||
|
app.patch('/api/tasks/:id/thumbnail', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { attachment_id } = req.body;
|
||||||
|
const task = await nocodb.get('Tasks', req.params.id);
|
||||||
|
if (!task) return res.status(404).json({ error: 'Task not found' });
|
||||||
|
|
||||||
|
if (attachment_id) {
|
||||||
|
const att = await nocodb.get('TaskAttachments', attachment_id);
|
||||||
|
if (!att) return res.status(404).json({ error: 'Attachment not found' });
|
||||||
|
await nocodb.update('Tasks', req.params.id, { thumbnail: att.url || `/api/uploads/${att.filename}` });
|
||||||
|
} else {
|
||||||
|
await nocodb.update('Tasks', req.params.id, { thumbnail: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await nocodb.get('Tasks', req.params.id);
|
||||||
|
res.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to set thumbnail' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── DASHBOARD ──────────────────────────────────────────────────
|
// ─── DASHBOARD ──────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/dashboard', requireAuth, async (req, res) => {
|
app.get('/api/dashboard', requireAuth, async (req, res) => {
|
||||||
@@ -2055,6 +2395,114 @@ async function getUserCampaignIds(userId) {
|
|||||||
|
|
||||||
// ─── ERROR HANDLING ─────────────────────────────────────────────
|
// ─── ERROR HANDLING ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ─── TEAMS ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/teams', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const teams = await nocodb.list('Teams', { sort: 'name', limit: 500 });
|
||||||
|
const members = await nocodb.list('TeamMembers', { limit: 10000 });
|
||||||
|
const result = teams.map(t => {
|
||||||
|
const teamMembers = members.filter(m => m.team_id === t.Id);
|
||||||
|
return {
|
||||||
|
...t, id: t.Id, _id: t.Id,
|
||||||
|
member_ids: teamMembers.map(m => m.user_id),
|
||||||
|
member_count: teamMembers.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Teams list error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load teams' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/teams', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||||
|
const { name, description, member_ids } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||||
|
try {
|
||||||
|
const created = await nocodb.create('Teams', { name, description: description || null });
|
||||||
|
if (member_ids && member_ids.length > 0) {
|
||||||
|
await nocodb.bulkCreate('TeamMembers', member_ids.map(uid => ({ team_id: created.Id, user_id: uid })));
|
||||||
|
}
|
||||||
|
const team = await nocodb.get('Teams', created.Id);
|
||||||
|
res.status(201).json({ ...team, id: team.Id, _id: team.Id, member_ids: member_ids || [] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Create team error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to create team' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/teams/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await nocodb.get('Teams', req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Team not found' });
|
||||||
|
const data = {};
|
||||||
|
if (req.body.name !== undefined) data.name = req.body.name;
|
||||||
|
if (req.body.description !== undefined) data.description = req.body.description;
|
||||||
|
if (Object.keys(data).length > 0) await nocodb.update('Teams', req.params.id, data);
|
||||||
|
|
||||||
|
// Sync members if provided
|
||||||
|
if (req.body.member_ids !== undefined) {
|
||||||
|
const oldMembers = await nocodb.list('TeamMembers', { where: `(team_id,eq,${req.params.id})`, limit: 10000 });
|
||||||
|
if (oldMembers.length > 0) {
|
||||||
|
await nocodb.bulkDelete('TeamMembers', oldMembers.map(m => ({ Id: m.Id })));
|
||||||
|
}
|
||||||
|
if (req.body.member_ids.length > 0) {
|
||||||
|
await nocodb.bulkCreate('TeamMembers', req.body.member_ids.map(uid => ({ team_id: Number(req.params.id), user_id: uid })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = await nocodb.get('Teams', req.params.id);
|
||||||
|
const members = await nocodb.list('TeamMembers', { where: `(team_id,eq,${req.params.id})`, limit: 10000 });
|
||||||
|
res.json({ ...team, id: team.Id, _id: team.Id, member_ids: members.map(m => m.user_id) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update team error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to update team' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/teams/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await nocodb.get('Teams', req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Team not found' });
|
||||||
|
const members = await nocodb.list('TeamMembers', { where: `(team_id,eq,${req.params.id})`, limit: 10000 });
|
||||||
|
if (members.length > 0) {
|
||||||
|
await nocodb.bulkDelete('TeamMembers', members.map(m => ({ Id: m.Id })));
|
||||||
|
}
|
||||||
|
await nocodb.delete('Teams', req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete team error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete team' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/teams/:id/members', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||||
|
const { user_id } = req.body;
|
||||||
|
if (!user_id) return res.status(400).json({ error: 'user_id is required' });
|
||||||
|
try {
|
||||||
|
await nocodb.create('TeamMembers', { team_id: Number(req.params.id), user_id: Number(user_id) });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to add member' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/teams/:id/members/:userId', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const entries = await nocodb.list('TeamMembers', {
|
||||||
|
where: `(team_id,eq,${req.params.id})~and(user_id,eq,${req.params.userId})`,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
if (entries.length > 0) {
|
||||||
|
await nocodb.bulkDelete('TeamMembers', entries.map(e => ({ Id: e.Id })));
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to remove member' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
console.error(`[ERROR] ${req.method} ${req.path}:`, err.message);
|
console.error(`[ERROR] ${req.method} ${req.path}:`, err.message);
|
||||||
res.status(500).json({ error: 'Internal server error', details: err.message });
|
res.status(500).json({ error: 'Internal server error', details: err.message });
|
||||||
@@ -2070,8 +2518,11 @@ process.on('unhandledRejection', (err) => {
|
|||||||
// ─── START SERVER ───────────────────────────────────────────────
|
// ─── START SERVER ───────────────────────────────────────────────
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
|
console.log('Ensuring required tables...');
|
||||||
|
await ensureRequiredTables();
|
||||||
console.log('Running FK column migration...');
|
console.log('Running FK column migration...');
|
||||||
await ensureFKColumns();
|
await ensureFKColumns();
|
||||||
|
await ensureTextColumns();
|
||||||
await backfillFKs();
|
await backfillFKs();
|
||||||
console.log('Migration complete.');
|
console.log('Migration complete.');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user