feat: convert all slide panels to tabbed modals with shared TabbedModal component
Deploy / deploy (push) Successful in 11s
Deploy / deploy (push) Successful in 11s
Extract reusable TabbedModal component (portal, backdrop, tab bar with icons/badges/underline, scrollable body, footer) and convert all 9 detail panels from SlidePanel+CollapsibleSection to tabbed modal layout: - PostDetailPanel (5 tabs), TaskDetailPanel (3), ProjectEditPanel (2) - TrackDetailPanel (2), CampaignDetailPanel (3), TeamMemberPanel (3) - TeamPanel (2), IssueDetailPanel (4), ArtefactDetailPanel (4) Also adds post versioning system (server routes + frontend). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { X, Trash2, DollarSign, Eye, MousePointer, Target } from 'lucide-react'
|
||||
import { Trash2, DollarSign, Eye, MousePointer, Target, FileEdit, BarChart3, MessageSquare } 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 TabbedModal from './TabbedModal'
|
||||
import BudgetBar from './BudgetBar'
|
||||
import { AppContext } from '../App'
|
||||
|
||||
@@ -16,6 +15,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
|
||||
const campaignId = campaign?._id || campaign?.id
|
||||
const isCreateMode = !campaignId
|
||||
@@ -102,50 +102,79 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
||||
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>
|
||||
)
|
||||
const tabs = isCreateMode
|
||||
? [{ key: 'details', label: t('campaigns.details'), icon: FileEdit }]
|
||||
: [
|
||||
{ key: 'details', label: t('campaigns.details'), icon: FileEdit },
|
||||
{ key: 'performance', label: t('campaigns.performance'), icon: BarChart3 },
|
||||
{ key: 'discussion', label: t('campaigns.discussion'), icon: MessageSquare },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
|
||||
{/* Details Section */}
|
||||
<CollapsibleSection title={t('campaigns.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
header={
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{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 className="flex items-center gap-2.5">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || !form.start_date || !form.end_date || saving}
|
||||
className={`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>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Details Tab */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.description')}</label>
|
||||
<textarea
|
||||
@@ -274,159 +303,134 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
||||
/>
|
||||
</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>
|
||||
{/* Performance Tab */}
|
||||
{activeTab === 'performance' && !isCreateMode && (
|
||||
<div className="p-6 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>
|
||||
)}
|
||||
|
||||
{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 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="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 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>
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
<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"
|
||||
/>
|
||||
{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.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..."
|
||||
<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>
|
||||
</CollapsibleSection>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{/* Discussion Tab */}
|
||||
{activeTab === 'discussion' && !isCreateMode && (
|
||||
<div className="p-6 space-y-3">
|
||||
<CommentsSection entityType="campaign" entityId={campaignId} />
|
||||
</div>
|
||||
)}
|
||||
</SlidePanel>
|
||||
</TabbedModal>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
|
||||
Reference in New Issue
Block a user