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' }) {
|
||||
const { currencySymbol } = useLanguage()
|
||||
if (!budget || budget <= 0) return null
|
||||
const pct = Math.min((spent / budget) * 100, 100)
|
||||
|
||||
@@ -9,8 +12,8 @@ export default function BudgetBar({ budget, spent, height = 'h-1.5' }) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between text-[10px] text-text-tertiary mb-0.5">
|
||||
<span>{(spent || 0).toLocaleString()} SAR spent</span>
|
||||
<span>{budget.toLocaleString()} SAR</span>
|
||||
<span>{(spent || 0).toLocaleString()} {currencySymbol} spent</span>
|
||||
<span>{budget.toLocaleString()} {currencySymbol}</span>
|
||||
</div>
|
||||
<div className={`${height} bg-surface-tertiary rounded-full overflow-hidden`}>
|
||||
<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)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary">
|
||||
<div className="min-h-screen bg-mesh">
|
||||
<Sidebar collapsed={collapsed} setCollapsed={setCollapsed} />
|
||||
<div
|
||||
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 {
|
||||
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'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
const navItems = [
|
||||
// Standalone items (no category)
|
||||
const standaloneTop = [
|
||||
{ to: '/', icon: LayoutDashboard, labelKey: 'nav.dashboard', end: true, tutorial: 'dashboard' },
|
||||
{ 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: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' },
|
||||
{ to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
|
||||
{ to: '/tasks', icon: CheckSquare, labelKey: 'nav.tasks', tutorial: 'tasks' },
|
||||
]
|
||||
|
||||
// Grouped items by module
|
||||
const moduleGroups = [
|
||||
{
|
||||
module: 'marketing',
|
||||
labelKey: 'modules.marketing',
|
||||
icon: Calendar,
|
||||
items: [
|
||||
{ to: '/campaigns', icon: Calendar, labelKey: 'nav.campaigns', tutorial: 'campaigns' },
|
||||
{ to: '/posts', icon: FileEdit, labelKey: 'nav.posts', tutorial: 'posts' },
|
||||
{ to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' },
|
||||
{ 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' },
|
||||
{ to: '/brands', icon: Tag, labelKey: 'nav.brands' },
|
||||
]
|
||||
|
||||
const ROLE_LEVEL = { contributor: 0, manager: 1, superadmin: 2 }
|
||||
|
||||
export default function Sidebar({ collapsed, setCollapsed }) {
|
||||
const { user: currentUser, logout } = useAuth()
|
||||
const { user: currentUser, logout, hasModule } = useAuth()
|
||||
const { t, lang, setLang } = useLanguage()
|
||||
const userLevel = ROLE_LEVEL[currentUser?.role] ?? 0
|
||||
|
||||
const visibleItems = navItems.filter(item => {
|
||||
if (!item.minRole) return true
|
||||
return userLevel >= (ROLE_LEVEL[item.minRole] ?? 0)
|
||||
// Track expanded state for each module group
|
||||
const [expandedGroups, setExpandedGroups] = useState(() => {
|
||||
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 (
|
||||
@@ -39,7 +104,7 @@ export default function Sidebar({ collapsed, setCollapsed }) {
|
||||
>
|
||||
{/* Logo */}
|
||||
<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" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
@@ -51,32 +116,53 @@ export default function Sidebar({ collapsed, setCollapsed }) {
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-4 px-3 space-y-1 overflow-y-auto">
|
||||
{visibleItems.map(({ to, icon: Icon, labelKey, end, tutorial }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
data-tutorial={tutorial}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group ${
|
||||
isActive
|
||||
? 'bg-white/15 text-white shadow-sm'
|
||||
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-5 h-5 shrink-0" />
|
||||
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t(labelKey)}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
<nav className="flex-1 py-3 px-3 space-y-0.5 overflow-y-auto">
|
||||
{/* Dashboard (always visible, standalone) */}
|
||||
{standaloneTop.map(item => navLink(item))}
|
||||
|
||||
{/* Module groups */}
|
||||
{visibleGroups.map(group => {
|
||||
const GroupIcon = group.icon
|
||||
const isExpanded = expandedGroups[group.module]
|
||||
|
||||
if (collapsed) {
|
||||
// When collapsed, just show the sub-item icons
|
||||
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"
|
||||
>
|
||||
<GroupIcon className="w-4 h-4 shrink-0 opacity-70" />
|
||||
<span className="flex-1 text-start">{t(group.labelKey)}</span>
|
||||
<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 */}
|
||||
{currentUser?.role === 'superadmin' && (
|
||||
<NavLink
|
||||
to="/users"
|
||||
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
|
||||
? 'bg-white/15 text-white shadow-sm'
|
||||
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
|
||||
@@ -92,7 +178,7 @@ export default function Sidebar({ collapsed, setCollapsed }) {
|
||||
<NavLink
|
||||
to="/settings"
|
||||
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
|
||||
? 'bg-white/15 text-white shadow-sm'
|
||||
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
|
||||
@@ -123,7 +209,7 @@ export default function Sidebar({ collapsed, setCollapsed }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{!collapsed && (
|
||||
<>
|
||||
{/* Language Toggle */}
|
||||
@@ -149,7 +235,7 @@ export default function Sidebar({ collapsed, setCollapsed }) {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<div className="p-3 border-t border-white/10">
|
||||
<button
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
export default function StatCard({ icon: Icon, label, value, subtitle, color = 'brand-primary', trend }) {
|
||||
const colorMap = {
|
||||
'brand-primary': 'from-indigo-500 to-indigo-600',
|
||||
'brand-secondary': 'from-pink-500 to-pink-600',
|
||||
'brand-tertiary': 'from-amber-500 to-amber-600',
|
||||
'brand-quaternary': 'from-emerald-500 to-emerald-600',
|
||||
const accentMap = {
|
||||
'brand-primary': 'accent-primary',
|
||||
'brand-secondary': 'accent-secondary',
|
||||
'brand-tertiary': 'accent-tertiary',
|
||||
'brand-quaternary': 'accent-quaternary',
|
||||
}
|
||||
|
||||
const iconBgMap = {
|
||||
'brand-primary': 'bg-indigo-50 text-indigo-600',
|
||||
'brand-secondary': 'bg-pink-50 text-pink-600',
|
||||
'brand-tertiary': 'bg-amber-50 text-amber-600',
|
||||
'brand-quaternary': 'bg-emerald-50 text-emerald-600',
|
||||
'brand-primary': 'bg-indigo-50 text-indigo-600 shadow-lg shadow-indigo-500/20',
|
||||
'brand-secondary': 'bg-pink-50 text-pink-600 shadow-lg shadow-pink-500/20',
|
||||
'brand-tertiary': 'bg-amber-50 text-amber-600 shadow-lg shadow-amber-500/20',
|
||||
'brand-quaternary': 'bg-emerald-50 text-emerald-600 shadow-lg shadow-emerald-500/20',
|
||||
}
|
||||
|
||||
const accentClass = accentMap[color] || 'accent-primary'
|
||||
|
||||
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>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user