feat: convert all slide panels to tabbed modals with shared TabbedModal component
All checks were successful
Deploy / deploy (push) Successful in 11s
All checks were successful
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:
@@ -3,6 +3,7 @@ import { Check, ChevronDown, X } from 'lucide-react'
|
||||
|
||||
export default function ApproverMultiSelect({ users = [], selected = [], onChange }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [dropUp, setDropUp] = useState(false)
|
||||
const wrapperRef = useRef(null)
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
@@ -17,6 +18,14 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [open])
|
||||
|
||||
// Detect if dropdown should open upward
|
||||
useEffect(() => {
|
||||
if (!open || !wrapperRef.current) return
|
||||
const rect = wrapperRef.current.getBoundingClientRect()
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
setDropUp(spaceBelow < 220)
|
||||
}, [open])
|
||||
|
||||
const toggle = (userId) => {
|
||||
const id = String(userId)
|
||||
const next = selected.includes(id) ? selected.filter(s => s !== id) : [...selected, id]
|
||||
@@ -58,7 +67,7 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
|
||||
<ChevronDown className={`w-4 h-4 text-text-tertiary ml-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||
<div className={`absolute z-50 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'}`}>
|
||||
{users.map(u => {
|
||||
const uid = String(u._id || u.id || u.Id)
|
||||
const isSelected = selected.includes(uid)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, Copy, Check, ExternalLink, Upload, Globe, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save } from 'lucide-react'
|
||||
import { Plus, Copy, Check, ExternalLink, Upload, Globe, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save, FileEdit, Layers, ShieldCheck } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api } from '../utils/api'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import { useToast } from './ToastContainer'
|
||||
import ArtefactVersionTimeline from './ArtefactVersionTimeline'
|
||||
import ApproverMultiSelect from './ApproverMultiSelect'
|
||||
@@ -42,6 +42,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [reviewUrl, setReviewUrl] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
|
||||
// Editable fields
|
||||
const [editTitle, setEditTitle] = useState(artefact.title || '')
|
||||
@@ -339,21 +340,32 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
return fileId ? `https://drive.google.com/file/d/${fileId}/preview` : url
|
||||
}
|
||||
|
||||
const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles
|
||||
|
||||
const tabs = [
|
||||
{ key: 'details', label: t('artefacts.details') || 'Details', icon: FileEdit },
|
||||
{ key: 'versions', label: t('artefacts.versions') || 'Versions', icon: Layers, badge: versions.length },
|
||||
{ key: 'discussion', label: t('artefacts.comments') || 'Discussion', icon: MessageSquare, badge: comments.length },
|
||||
{ key: 'review', label: t('artefacts.review') || 'Review', icon: ShieldCheck },
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SlidePanel onClose={onClose} maxWidth="700px">
|
||||
<TabbedModal onClose={onClose} size="xl">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-8 h-8 border-4 border-brand-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</SlidePanel>
|
||||
</TabbedModal>
|
||||
)
|
||||
}
|
||||
|
||||
const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles
|
||||
|
||||
return (
|
||||
<SlidePanel onClose={onClose} maxWidth="700px" header={
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<>
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
header={
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0">
|
||||
<TypeIcon className="w-5 h-5 text-brand-primary" />
|
||||
@@ -377,31 +389,42 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={
|
||||
<>
|
||||
<div>
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => setShowDeleteArtefactConfirm(true)}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('artefacts.deleteArtefactTooltip')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveDraft}
|
||||
disabled={savingDraft}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
|
||||
title={t('artefacts.saveDraftTooltip')}
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{savingDraft ? t('artefacts.savingDraft') : t('artefacts.saveDraft')}
|
||||
</button>
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => setShowDeleteArtefactConfirm(true)}
|
||||
disabled={deleting}
|
||||
className="p-1.5 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('artefacts.deleteArtefactTooltip')}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Details Tab */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('artefacts.descriptionLabel')}</h4>
|
||||
@@ -458,7 +481,12 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Versions Tab */}
|
||||
{activeTab === 'versions' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Version Timeline */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -481,7 +509,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
|
||||
{/* Type-specific content */}
|
||||
{versionData && selectedVersion && (
|
||||
<div className="border-t border-border pt-6">
|
||||
<div className="border-t border-border pt-5">
|
||||
{/* COPY TYPE: Language entries */}
|
||||
{artefact.type === 'copy' && (
|
||||
<div>
|
||||
@@ -645,10 +673,14 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments */}
|
||||
{selectedVersion && (
|
||||
<div className="border-t border-border pt-6">
|
||||
{/* Discussion Tab */}
|
||||
{activeTab === 'discussion' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{selectedVersion ? (
|
||||
<>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">
|
||||
{t('artefacts.comments')} ({comments.length})
|
||||
</h4>
|
||||
@@ -693,12 +725,20 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
{t('artefacts.sendComment')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-sm text-text-tertiary">
|
||||
{t('artefacts.selectVersionFirst') || 'Select a version first to view comments.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Tab */}
|
||||
{activeTab === 'review' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Submit for Review */}
|
||||
{['draft', 'revision_requested', 'rejected'].includes(artefact.status) && (
|
||||
<div className="border-t border-border pt-6">
|
||||
<button
|
||||
onClick={handleSubmitReview}
|
||||
disabled={submitting}
|
||||
@@ -707,7 +747,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{submitting ? t('artefacts.submitting') : t('artefacts.submitForReview')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Link */}
|
||||
@@ -750,7 +789,18 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state when no review actions available */}
|
||||
{!['draft', 'revision_requested', 'rejected'].includes(artefact.status) && !reviewUrl && !artefact.feedback && !(artefact.status === 'approved' && artefact.approved_by_name) && (
|
||||
<div className="text-center py-8 text-sm text-text-tertiary">
|
||||
{artefact.status === 'pending_review'
|
||||
? t('artefacts.pendingReviewInfo') || 'This artefact is currently pending review.'
|
||||
: t('artefacts.noReviewInfo') || 'No review information available.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabbedModal>
|
||||
|
||||
{/* Language Modal */}
|
||||
<Modal isOpen={showLanguageModal} onClose={() => setShowLanguageModal(false)} title={t('artefacts.addLanguage')} size="md">
|
||||
@@ -961,6 +1011,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
>
|
||||
{t('artefacts.deleteArtefactDesc')}
|
||||
</Modal>
|
||||
</SlidePanel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,10 +102,21 @@ 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">
|
||||
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 (
|
||||
<>
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
header={
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
@@ -129,23 +140,41 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
||||
</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 (
|
||||
</>
|
||||
}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
|
||||
{/* Details Section */}
|
||||
<CollapsibleSection title={t('campaigns.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
<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,34 +303,12 @@ 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">
|
||||
{/* 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">
|
||||
@@ -415,18 +422,15 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Discussion Section (hidden in create mode) */}
|
||||
{!isCreateMode && (
|
||||
<CollapsibleSection title={t('campaigns.discussion')} noBorder>
|
||||
<div className="px-5 pb-5">
|
||||
{/* Discussion Tab */}
|
||||
{activeTab === 'discussion' && !isCreateMode && (
|
||||
<div className="p-6 space-y-3">
|
||||
<CommentsSection entityType="campaign" entityId={campaignId} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</SlidePanel>
|
||||
</TabbedModal>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { X, Copy, Eye, Lock, Send, Upload, FileText, Trash2, Check, Clock, CheckCircle2, XCircle } from 'lucide-react'
|
||||
import { Copy, Eye, Lock, Send, Upload, FileText, Trash2, Check, Clock, CheckCircle2, XCircle, FileEdit, Wrench, MessageSquare, Paperclip } from 'lucide-react'
|
||||
import { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import FormInput from './FormInput'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import Modal from './Modal'
|
||||
import { useToast } from './ToastContainer'
|
||||
import { AppContext } from '../App'
|
||||
@@ -18,6 +17,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
const [initialLoading, setInitialLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
|
||||
// Form state
|
||||
const [assignedTo, setAssignedTo] = useState('')
|
||||
@@ -190,31 +190,33 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
|
||||
if (initialLoading || !issueData) {
|
||||
return (
|
||||
<SlidePanel onClose={onClose} maxWidth="600px">
|
||||
<TabbedModal onClose={onClose} size="lg">
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-primary"></div>
|
||||
</div>
|
||||
</SlidePanel>
|
||||
</TabbedModal>
|
||||
)
|
||||
}
|
||||
|
||||
const statusConfig = STATUS_CONFIG[issueData.status] || STATUS_CONFIG.new
|
||||
const priorityConfig = PRIORITY_CONFIG[issueData.priority] || PRIORITY_CONFIG.medium
|
||||
|
||||
const tabs = [
|
||||
{ key: 'details', label: t('issues.details') || 'Details', icon: FileEdit },
|
||||
{ key: 'actions', label: t('issues.actions') || 'Actions', icon: Wrench },
|
||||
{ key: 'updates', label: t('issues.updates') || 'Updates', icon: MessageSquare, badge: updates.length },
|
||||
{ key: 'attachments', label: t('issues.attachments') || 'Attachments', icon: Paperclip, badge: attachments.length },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
maxWidth="600px"
|
||||
size="lg"
|
||||
header={
|
||||
<div className="p-4 border-b border-border bg-surface-secondary">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<h2 className="text-lg font-bold text-text-primary flex-1">{issueData.title}</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-surface-tertiary rounded">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<>
|
||||
<h2 className="text-lg font-bold text-text-primary">{issueData.title}</h2>
|
||||
<div className="flex items-center gap-2 flex-wrap mt-2">
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1.5 ${statusConfig.bg} ${statusConfig.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${statusConfig.dot}`}></span>
|
||||
{statusConfig.label}
|
||||
@@ -234,10 +236,32 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={copyTrackingLink}
|
||||
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
{t('issues.publicTrackingLink')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary transition-colors"
|
||||
>
|
||||
{t('common.close') || 'Close'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Details Tab */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Submitter Info */}
|
||||
<div className="bg-surface-secondary rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.submitterInfo')}</h3>
|
||||
@@ -352,17 +376,21 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Actions */}
|
||||
{issueData.status !== 'resolved' && issueData.status !== 'declined' && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{/* Actions Tab */}
|
||||
{activeTab === 'actions' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{issueData.status !== 'resolved' && issueData.status !== 'declined' ? (
|
||||
<div className="space-y-3">
|
||||
{issueData.status === 'new' && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus('acknowledged')}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
|
||||
className="w-full px-4 py-3 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Check className="w-4 h-4 inline mr-1" />
|
||||
<Check className="w-4 h-4" />
|
||||
{t('issues.acknowledge')}
|
||||
</button>
|
||||
)}
|
||||
@@ -370,59 +398,45 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
<button
|
||||
onClick={() => handleUpdateStatus('in_progress')}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-amber-600 text-white rounded-lg text-sm font-medium hover:bg-amber-700 disabled:opacity-50"
|
||||
className="w-full px-4 py-3 bg-amber-600 text-white rounded-lg text-sm font-medium hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Clock className="w-4 h-4 inline mr-1" />
|
||||
<Clock className="w-4 h-4" />
|
||||
{t('issues.startWork')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowResolveModal(true)}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50"
|
||||
className="w-full px-4 py-3 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 inline mr-1" />
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
{t('issues.resolve')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeclineModal(true)}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50"
|
||||
className="w-full px-4 py-3 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<XCircle className="w-4 h-4 inline mr-1" />
|
||||
<XCircle className="w-4 h-4" />
|
||||
{t('issues.decline')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<CheckCircle2 className="w-10 h-10 mx-auto mb-3 text-text-tertiary" />
|
||||
<p className="text-sm text-text-tertiary">
|
||||
{issueData.status === 'resolved' ? t('issues.issueResolved') || 'This issue has been resolved.' : t('issues.issueDeclined') || 'This issue has been declined.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tracking Link */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.publicTrackingLink')}</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`${window.location.origin}/track/${issueData.tracking_token}`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-surface-secondary"
|
||||
/>
|
||||
<button
|
||||
onClick={copyTrackingLink}
|
||||
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Updates Timeline */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
|
||||
{t('issues.updatesTimeline')}
|
||||
<span className="text-xs text-text-tertiary font-normal">({updates.length})</span>
|
||||
</h3>
|
||||
|
||||
{/* Updates Tab */}
|
||||
{activeTab === 'updates' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Add Update */}
|
||||
<div className="bg-surface-secondary rounded-lg p-3 mb-4">
|
||||
<div className="bg-surface-secondary rounded-lg p-3">
|
||||
<textarea
|
||||
value={newUpdate}
|
||||
onChange={(e) => setNewUpdate(e.target.value)}
|
||||
@@ -481,16 +495,13 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachments */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
|
||||
{t('issues.attachments')}
|
||||
<span className="text-xs text-text-tertiary font-normal">({attachments.length})</span>
|
||||
</h3>
|
||||
|
||||
{/* Attachments Tab */}
|
||||
{activeTab === 'attachments' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Upload */}
|
||||
<label className="block mb-3">
|
||||
<label className="block">
|
||||
<input type="file" onChange={handleFileUpload} disabled={uploadingFile} className="hidden" />
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center cursor-pointer hover:bg-surface-secondary transition-colors">
|
||||
<Upload className="w-6 h-6 mx-auto mb-2 text-text-tertiary" />
|
||||
@@ -509,7 +520,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{att.original_name}</p>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{formatFileSize(att.size)} • {att.uploaded_by}
|
||||
{formatFileSize(att.size)} • {att.uploaded_by}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -533,8 +544,8 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SlidePanel>
|
||||
)}
|
||||
</TabbedModal>
|
||||
|
||||
{/* Resolve Modal */}
|
||||
{showResolveModal && (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,10 @@
|
||||
import { useState, useEffect, useRef, useContext } from 'react'
|
||||
import { X, Trash2, Upload } from 'lucide-react'
|
||||
import { Trash2, Upload, FileEdit, MessageSquare } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api, 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 { AppContext } from '../App'
|
||||
|
||||
export default function ProjectEditPanel({ project, onClose, onSave, onDelete, brands, teamMembers }) {
|
||||
@@ -17,6 +16,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [thumbnailUploading, setThumbnailUploading] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
|
||||
const projectId = project?._id || project?.id
|
||||
if (!project) return null
|
||||
@@ -107,10 +107,17 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
return project.brand_name || project.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">
|
||||
const tabs = [
|
||||
{ key: 'details', label: t('projects.details'), icon: FileEdit },
|
||||
{ key: 'discussion', label: t('projects.discussion'), icon: MessageSquare },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
header={<>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
@@ -134,23 +141,37 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={<>
|
||||
<div className="flex items-center gap-2">
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||
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')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || 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' : ''}`}
|
||||
>
|
||||
{t('tasks.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||
{/* Details Section */}
|
||||
<CollapsibleSection title={t('projects.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
</>}
|
||||
>
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.description')}</label>
|
||||
<textarea
|
||||
@@ -272,37 +293,15 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
onChange={e => { handleThumbnailUpload(e.target.files[0]); e.target.value = '' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || 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' : ''}`}
|
||||
>
|
||||
{t('tasks.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Discussion Section */}
|
||||
<CollapsibleSection title={t('projects.discussion')} noBorder>
|
||||
<div className="px-5 pb-5">
|
||||
{activeTab === 'discussion' && (
|
||||
<div className="p-6 space-y-3">
|
||||
<CommentsSection entityType="project" entityId={projectId} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</SlidePanel>
|
||||
)}
|
||||
</TabbedModal>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
|
||||
97
client/src/components/TabbedModal.jsx
Normal file
97
client/src/components/TabbedModal.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
const SIZE_CLASSES = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
}
|
||||
|
||||
export default function TabbedModal({
|
||||
onClose,
|
||||
size = 'md',
|
||||
header,
|
||||
tabs = [],
|
||||
activeTab,
|
||||
onTabChange,
|
||||
footer,
|
||||
children,
|
||||
}) {
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [])
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[5vh] px-4">
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" onClick={onClose} />
|
||||
|
||||
<div className={`relative bg-white rounded-2xl shadow-2xl w-full ${SIZE_CLASSES[size] || SIZE_CLASSES.md} max-h-[90vh] flex flex-col animate-scale-in`}>
|
||||
{/* Header */}
|
||||
<div className="shrink-0">
|
||||
<div className="px-6 pt-5 pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
{header}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0 -mt-1 -me-1"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
{tabs.length > 0 && (
|
||||
<div className="flex gap-0 px-6 border-b border-border overflow-x-auto">
|
||||
{tabs.map(tab => {
|
||||
const TabIcon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => onTabChange(tab.key)}
|
||||
className={`relative flex items-center gap-2 px-4 py-3 text-[13px] font-medium whitespace-nowrap transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'text-brand-primary'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{TabIcon && <TabIcon className="w-4 h-4" />}
|
||||
{tab.label}
|
||||
{tab.badge > 0 && (
|
||||
<span className={`text-[10px] px-1.5 py-px rounded-full font-medium leading-tight ${
|
||||
activeTab === tab.key ? 'bg-brand-primary/10 text-brand-primary' : 'bg-surface-tertiary text-text-tertiary'
|
||||
}`}>
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
{activeTab === tab.key && (
|
||||
<span className="absolute bottom-0 inset-x-1 h-0.5 bg-brand-primary rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="border-t border-border px-6 py-3.5 flex items-center justify-between shrink-0 rounded-b-2xl bg-white">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { X, Trash2, AlertCircle, Upload, FileText, Star } from 'lucide-react'
|
||||
import { X, Trash2, AlertCircle, Upload, FileText, Star, FileEdit, Paperclip, MessageSquare } from 'lucide-react'
|
||||
import { PRIORITY_CONFIG, getBrandColor, api } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CollapsibleSection from './CollapsibleSection'
|
||||
import TabbedModal from './TabbedModal'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
export default function TaskDetailPanel({ task, onClose, onSave, onDelete, projects, users, brands }) {
|
||||
const { t } = useLanguage()
|
||||
const fileInputRef = useRef(null)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
const [form, setForm] = useState({
|
||||
title: '', description: '', project_id: '', assigned_to: '',
|
||||
priority: 'medium', status: 'todo', start_date: '', due_date: '',
|
||||
@@ -186,11 +186,19 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
const selectedProject = projects?.find(p => String(p._id || p.id) === String(form.project_id))
|
||||
const brandName = selectedProject ? (selectedProject.brand_name || selectedProject.brandName) : (task.brand_name || task.brandName)
|
||||
|
||||
const header = (
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
const attachmentCount = attachments.length + pendingFiles.length
|
||||
|
||||
const tabs = [
|
||||
{ key: 'details', label: t('tasks.details'), icon: FileEdit },
|
||||
{ key: 'attachments', label: t('tasks.attachments'), icon: Paperclip, badge: attachmentCount },
|
||||
...(!isCreateMode ? [{ key: 'discussion', label: t('tasks.discussion'), icon: MessageSquare }] : []),
|
||||
]
|
||||
|
||||
const headerContent = (
|
||||
<>
|
||||
{/* Thumbnail banner */}
|
||||
{currentThumbnail && (
|
||||
<div className="relative -mx-5 -mt-4 mb-3 h-32 overflow-hidden">
|
||||
<div className="relative -mx-6 -mt-5 mb-3 h-32 overflow-hidden rounded-t-2xl">
|
||||
<img src={currentThumbnail} alt="" className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white/80 to-transparent" />
|
||||
<button
|
||||
@@ -202,8 +210,6 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
@@ -226,23 +232,51 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
const footerContent = (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{onDelete && !isCreateMode && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||
onClick={handleDelete}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.title || 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('tasks.createTask') : t('tasks.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||
{/* Details Section */}
|
||||
<CollapsibleSection title={t('tasks.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
header={headerContent}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={footerContent}
|
||||
>
|
||||
{/* Details Tab */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6">
|
||||
<div className="space-y-3">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.description')}</label>
|
||||
@@ -349,41 +383,13 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
<p className="text-sm text-text-secondary">{creatorName}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.title || 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('tasks.createTask') : t('tasks.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
{onDelete && !isCreateMode && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Attachments Section */}
|
||||
<CollapsibleSection
|
||||
title={t('tasks.attachments')}
|
||||
badge={(attachments.length + pendingFiles.length) > 0 ? (
|
||||
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-1.5 py-0.5 rounded-full">
|
||||
{attachments.length + pendingFiles.length}
|
||||
</span>
|
||||
) : null}
|
||||
>
|
||||
<div className="px-5 pb-4">
|
||||
{/* Attachments Tab */}
|
||||
{activeTab === 'attachments' && (
|
||||
<div className="p-6">
|
||||
{/* Existing attachment grid (edit mode) */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
@@ -524,17 +530,15 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Discussion Section (hidden in create mode) */}
|
||||
{!isCreateMode && (
|
||||
<CollapsibleSection title={t('tasks.discussion')} noBorder>
|
||||
<div className="px-5 pb-5">
|
||||
{/* Discussion Tab */}
|
||||
{activeTab === 'discussion' && !isCreateMode && (
|
||||
<div className="p-6">
|
||||
<CommentsSection entityType="task" entityId={taskId} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</SlidePanel>
|
||||
</TabbedModal>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Modal
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useState, useEffect, useRef, useContext } from 'react'
|
||||
import { X, Trash2, ChevronDown, Check, ShieldAlert, Eye, EyeOff } from 'lucide-react'
|
||||
import { Trash2, ChevronDown, Check, ShieldAlert, Eye, EyeOff, FileEdit, BarChart3, X } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api } from '../utils/api'
|
||||
import { useToast } from './ToastContainer'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CollapsibleSection from './CollapsibleSection'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import { AppContext, PERMISSION_LEVELS } from '../App'
|
||||
|
||||
@@ -29,6 +28,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [passwordSaving, setPasswordSaving] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
const brandsDropdownRef = useRef(null)
|
||||
|
||||
// Workload state (loaded internally)
|
||||
@@ -54,6 +54,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
setDirty(false)
|
||||
setConfirmPassword('')
|
||||
setShowPassword(false)
|
||||
setActiveTab('details')
|
||||
if (memberId) loadWorkload()
|
||||
}
|
||||
}, [member])
|
||||
@@ -150,9 +151,20 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length
|
||||
const doneCount = memberTasks.filter(t => t.status === 'done').length
|
||||
|
||||
const header = (
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
const showAdminTab = !isEditingSelf && userRole === 'superadmin'
|
||||
|
||||
const tabs = [
|
||||
{ key: 'details', label: t('team.details'), icon: FileEdit },
|
||||
{ key: 'workload', label: t('team.workload'), icon: BarChart3 },
|
||||
...(showAdminTab ? [{ key: 'admin', label: t('team.adminActions'), icon: ShieldAlert }] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
header={
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-sm font-bold shrink-0">
|
||||
{initials}
|
||||
@@ -170,22 +182,38 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={<>
|
||||
<div className="flex items-center gap-2">
|
||||
{canManageTeam && onDelete && !isEditingSelf && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-red-600 border border-red-200 rounded-lg hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('team.removeMember')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || 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' : ''}`}
|
||||
>
|
||||
{isEditingSelf ? t('team.saveProfile') : t('team.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||
{/* Details Section */}
|
||||
<CollapsibleSection title={t('team.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
</>}
|
||||
>
|
||||
{/* Details Tab */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6 space-y-3">
|
||||
{!isEditingSelf && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.email')}</label>
|
||||
@@ -375,22 +403,12 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || saving}
|
||||
className={`w-full 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' : ''}`}
|
||||
>
|
||||
{isEditingSelf ? t('team.saveProfile') : t('team.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Workload Section */}
|
||||
<CollapsibleSection title={t('team.workload')} noBorder>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
{/* Workload Tab */}
|
||||
{activeTab === 'workload' && (
|
||||
<div className="p-6 space-y-3">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
||||
@@ -447,16 +465,11 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
<p className="text-xs text-text-tertiary text-center py-2">{t('common.loading')}</p>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Admin Actions Section (superadmin only, not self) */}
|
||||
{!isEditingSelf && userRole === 'superadmin' && (
|
||||
<CollapsibleSection
|
||||
title={<span className="flex items-center gap-1.5"><ShieldAlert className="w-3.5 h-3.5 text-red-500" />{t('team.adminActions')}</span>}
|
||||
defaultOpen={false}
|
||||
noBorder
|
||||
>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
{/* Admin Actions Tab */}
|
||||
{activeTab === 'admin' && showAdminTab && (
|
||||
<div className="p-6 space-y-3">
|
||||
{/* Change password */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.password')}</label>
|
||||
@@ -500,21 +513,9 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
>
|
||||
{t('team.changePassword')}
|
||||
</button>
|
||||
|
||||
{/* Delete member */}
|
||||
{canManageTeam && onDelete && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-red-600 border border-red-200 rounded-lg hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('team.removeMember')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</SlidePanel>
|
||||
</TabbedModal>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Trash2, Search } from 'lucide-react'
|
||||
import { Trash2, Search, FileEdit, Users } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { getInitials } from '../utils/api'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CollapsibleSection from './CollapsibleSection'
|
||||
import TabbedModal from './TabbedModal'
|
||||
|
||||
export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers }) {
|
||||
const { t } = useLanguage()
|
||||
@@ -13,6 +12,7 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [memberSearch, setMemberSearch] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
|
||||
const teamId = team?.id || team?._id
|
||||
const isCreateMode = !teamId
|
||||
@@ -68,10 +68,15 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
|
||||
!memberSearch || m.name?.toLowerCase().includes(memberSearch.toLowerCase())
|
||||
)
|
||||
|
||||
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">
|
||||
const memberCount = (form.member_ids || []).length
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
header={
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
@@ -80,24 +85,45 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
|
||||
placeholder={t('teams.name')}
|
||||
/>
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-blue-100 text-blue-700">
|
||||
{(form.member_ids || []).length} {t('teams.members')}
|
||||
{memberCount} {t('teams.members')}
|
||||
</span>
|
||||
</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 (
|
||||
</>
|
||||
}
|
||||
tabs={[
|
||||
{ key: 'details', label: t('teams.details'), icon: FileEdit },
|
||||
{ key: 'members', label: t('teams.members'), icon: Users, badge: memberCount },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||
<CollapsibleSection title={t('teams.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{!isCreateMode && onDelete && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('teams.deleteTeam')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || 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('teams.createTeam') : t('common.save')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('teams.name')}</label>
|
||||
<input
|
||||
@@ -117,32 +143,11 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
|
||||
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 gap-2 pt-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || 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('teams.createTeam') : t('common.save')}
|
||||
</button>
|
||||
)}
|
||||
{!isCreateMode && onDelete && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('teams.deleteTeam')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
<CollapsibleSection title={t('teams.members')} noBorder>
|
||||
<div className="px-5 pb-4">
|
||||
{activeTab === 'members' && (
|
||||
<div className="p-6">
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
|
||||
<input
|
||||
@@ -180,8 +185,8 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</SlidePanel>
|
||||
)}
|
||||
</TabbedModal>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Trash2 } from 'lucide-react'
|
||||
import { Trash2, FileEdit, BarChart3 } 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 TabbedModal from './TabbedModal'
|
||||
import BudgetBar from './BudgetBar'
|
||||
|
||||
const TRACK_TYPES = {
|
||||
@@ -23,6 +22,7 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState(scrollToMetrics ? 'metrics' : 'details')
|
||||
|
||||
const trackId = track?._id || track?.id
|
||||
const isCreateMode = !trackId
|
||||
@@ -85,10 +85,20 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
|
||||
|
||||
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">
|
||||
const tabs = isCreateMode
|
||||
? [{ key: 'details', label: t('tracks.details'), icon: FileEdit }]
|
||||
: [
|
||||
{ key: 'details', label: t('tracks.details'), icon: FileEdit },
|
||||
{ key: 'metrics', label: t('tracks.metrics'), icon: BarChart3 },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
header={
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
@@ -109,23 +119,40 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
|
||||
{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 (
|
||||
</>
|
||||
}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={
|
||||
<>
|
||||
<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="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={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('tracks.addTrack') : t('tasks.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6 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>
|
||||
@@ -190,34 +217,11 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
|
||||
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">
|
||||
{activeTab === 'metrics' && !isCreateMode && (
|
||||
<div className="p-6 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" />
|
||||
@@ -287,9 +291,8 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</SlidePanel>
|
||||
</TabbedModal>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
|
||||
@@ -915,5 +915,22 @@
|
||||
"review.confirmApprovePostDesc": "هل أنت متأكد من الموافقة على هذا المنشور؟",
|
||||
"review.confirmRejectPostDesc": "هل أنت متأكد من رفض هذا المنشور؟ يرجى تقديم ملاحظات توضح السبب.",
|
||||
"review.feedbackRequired": "الملاحظات (مطلوبة)",
|
||||
"review.feedbackRequiredError": "يرجى تقديم ملاحظات عند الرفض"
|
||||
"review.feedbackRequiredError": "يرجى تقديم ملاحظات عند الرفض",
|
||||
"posts.versions": "الإصدارات",
|
||||
"posts.newVersion": "إصدار جديد",
|
||||
"posts.createNewVersion": "إنشاء إصدار جديد",
|
||||
"posts.createVersion": "إنشاء إصدار",
|
||||
"posts.creatingVersion": "جارٍ الإنشاء...",
|
||||
"posts.whatChanged": "ما الذي تغير في هذا الإصدار؟",
|
||||
"posts.copyLanguages": "نسخ اللغات من الإصدار السابق",
|
||||
"posts.languages": "اللغات",
|
||||
"posts.addLanguage": "إضافة لغة",
|
||||
"posts.selectLanguage": "اختر لغة...",
|
||||
"posts.enterContent": "أدخل المحتوى بهذه اللغة...",
|
||||
"posts.noLanguages": "لم تتم إضافة لغات بعد",
|
||||
"posts.noVersions": "لا توجد إصدارات بعد. أنشئ إصدارًا لبدء إدارة المحتوى متعدد اللغات والوسائط.",
|
||||
"posts.deleteLanguage": "حذف هذه اللغة؟",
|
||||
"posts.deleteLanguageConfirm": "سيتم حذف محتوى اللغة من هذا الإصدار.",
|
||||
"posts.media": "الوسائط",
|
||||
"posts.noMedia": "لم يتم رفع ملفات وسائط"
|
||||
}
|
||||
@@ -915,5 +915,22 @@
|
||||
"review.confirmApprovePostDesc": "Are you sure you want to approve this post?",
|
||||
"review.confirmRejectPostDesc": "Are you sure you want to reject this post? Please provide feedback explaining why.",
|
||||
"review.feedbackRequired": "Feedback (required)",
|
||||
"review.feedbackRequiredError": "Please provide feedback when rejecting"
|
||||
"review.feedbackRequiredError": "Please provide feedback when rejecting",
|
||||
"posts.versions": "Versions",
|
||||
"posts.newVersion": "New Version",
|
||||
"posts.createNewVersion": "Create New Version",
|
||||
"posts.createVersion": "Create Version",
|
||||
"posts.creatingVersion": "Creating...",
|
||||
"posts.whatChanged": "What changed in this version?",
|
||||
"posts.copyLanguages": "Copy languages from previous version",
|
||||
"posts.languages": "Languages",
|
||||
"posts.addLanguage": "Add Language",
|
||||
"posts.selectLanguage": "Select a language...",
|
||||
"posts.enterContent": "Enter the content in this language...",
|
||||
"posts.noLanguages": "No languages added yet",
|
||||
"posts.noVersions": "No versions yet. Create one to start managing multilingual content and media.",
|
||||
"posts.deleteLanguage": "Delete this language?",
|
||||
"posts.deleteLanguageConfirm": "This will remove the language content from this version.",
|
||||
"posts.media": "Media",
|
||||
"posts.noMedia": "No media files uploaded"
|
||||
}
|
||||
231
server/server.js
231
server/server.js
@@ -154,6 +154,8 @@ const FK_COLUMNS = {
|
||||
Comments: ['user_id'],
|
||||
BudgetEntries: ['campaign_id', 'project_id'],
|
||||
Artefacts: ['project_id', 'campaign_id'],
|
||||
PostVersions: ['post_id', 'created_by_user_id'],
|
||||
PostVersionTexts: ['version_id'],
|
||||
Issues: ['brand_id', 'assigned_to_id', 'team_id'],
|
||||
Users: ['role_id'],
|
||||
};
|
||||
@@ -367,6 +369,19 @@ const REQUIRED_TABLES = {
|
||||
{ title: 'size', uidt: 'Number' },
|
||||
{ title: 'drive_url', uidt: 'SingleLineText' },
|
||||
],
|
||||
PostVersions: [
|
||||
{ title: 'post_id', uidt: 'Number' },
|
||||
{ title: 'version_number', uidt: 'Number' },
|
||||
{ title: 'created_by_user_id', uidt: 'Number' },
|
||||
{ title: 'created_at', uidt: 'DateTime' },
|
||||
{ title: 'notes', uidt: 'LongText' },
|
||||
],
|
||||
PostVersionTexts: [
|
||||
{ title: 'version_id', uidt: 'Number' },
|
||||
{ title: 'language_code', uidt: 'SingleLineText' },
|
||||
{ title: 'language_label', uidt: 'SingleLineText' },
|
||||
{ title: 'content', uidt: 'LongText' },
|
||||
],
|
||||
Issues: [
|
||||
{ title: 'title', uidt: 'SingleLineText' },
|
||||
{ title: 'description', uidt: 'LongText' },
|
||||
@@ -471,7 +486,10 @@ const TEXT_COLUMNS = {
|
||||
{ name: 'approved_by_name', uidt: 'SingleLineText' },
|
||||
{ name: 'approved_at', uidt: 'SingleLineText' },
|
||||
{ name: 'feedback', uidt: 'LongText' },
|
||||
{ name: 'current_version', uidt: 'Number' },
|
||||
{ name: 'review_version', uidt: 'Number' },
|
||||
],
|
||||
PostAttachments: [{ name: 'version_id', uidt: 'Number' }],
|
||||
};
|
||||
|
||||
async function ensureTextColumns() {
|
||||
@@ -1508,6 +1526,10 @@ app.post('/api/posts/:id/submit-review', requireAuth, requireOwnerOrRole('posts'
|
||||
updateData.approved_at = null;
|
||||
updateData.feedback = null;
|
||||
}
|
||||
// Track which version is under review
|
||||
if (existing.current_version) {
|
||||
updateData.review_version = existing.current_version;
|
||||
}
|
||||
await nocodb.update('Posts', req.params.id, updateData);
|
||||
|
||||
const reviewUrl = `${req.protocol}://${req.get('host')}/review-post/${token}`;
|
||||
@@ -1640,6 +1662,215 @@ app.post('/api/public/review-post/:token/reject', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── POST VERSIONS ──────────────────────────────────────────────
|
||||
|
||||
// List all versions for a post
|
||||
app.get('/api/posts/:id/versions', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const versions = await nocodb.list('PostVersions', {
|
||||
where: `(post_id,eq,${sanitizeWhereValue(req.params.id)})`,
|
||||
sort: 'version_number',
|
||||
limit: QUERY_LIMITS.large,
|
||||
});
|
||||
const enriched = [];
|
||||
for (const v of versions) {
|
||||
const creatorName = await getRecordName('Users', v.created_by_user_id);
|
||||
enriched.push({ ...v, creator_name: creatorName });
|
||||
}
|
||||
res.json(enriched);
|
||||
} catch (err) {
|
||||
console.error('List post versions error:', err);
|
||||
res.status(500).json({ error: 'Failed to load versions' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create new version
|
||||
app.post('/api/posts/:id/versions', requireAuth, async (req, res) => {
|
||||
const { notes, copy_from_previous } = req.body;
|
||||
try {
|
||||
const post = await nocodb.get('Posts', req.params.id);
|
||||
if (!post) return res.status(404).json({ error: 'Post not found' });
|
||||
|
||||
if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
|
||||
return res.status(403).json({ error: 'You can only create versions for your own posts' });
|
||||
}
|
||||
|
||||
const versions = await nocodb.list('PostVersions', {
|
||||
where: `(post_id,eq,${sanitizeWhereValue(req.params.id)})`,
|
||||
sort: '-version_number',
|
||||
limit: 1,
|
||||
});
|
||||
const newVersionNumber = versions.length > 0 ? versions[0].version_number + 1 : 1;
|
||||
|
||||
const created = await nocodb.create('PostVersions', {
|
||||
post_id: Number(req.params.id),
|
||||
version_number: newVersionNumber,
|
||||
created_by_user_id: req.session.userId,
|
||||
created_at: new Date().toISOString(),
|
||||
notes: notes || `Version ${newVersionNumber}`,
|
||||
});
|
||||
|
||||
await nocodb.update('Posts', req.params.id, { current_version: newVersionNumber });
|
||||
|
||||
// Copy texts from previous version if requested
|
||||
if (copy_from_previous && versions.length > 0) {
|
||||
const prevVersionId = versions[0].Id;
|
||||
const prevTexts = await nocodb.list('PostVersionTexts', {
|
||||
where: `(version_id,eq,${prevVersionId})`,
|
||||
limit: QUERY_LIMITS.large,
|
||||
});
|
||||
for (const text of prevTexts) {
|
||||
await nocodb.create('PostVersionTexts', {
|
||||
version_id: created.Id,
|
||||
language_code: text.language_code,
|
||||
language_label: text.language_label,
|
||||
content: text.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const version = await nocodb.get('PostVersions', created.Id);
|
||||
const creatorName = await getRecordName('Users', version.created_by_user_id);
|
||||
res.status(201).json({ ...version, creator_name: creatorName });
|
||||
} catch (err) {
|
||||
console.error('Create post version error:', err);
|
||||
res.status(500).json({ error: 'Failed to create version' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get specific version with texts and attachments
|
||||
app.get('/api/posts/:id/versions/:versionId', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const version = await nocodb.get('PostVersions', req.params.versionId);
|
||||
if (!version) return res.status(404).json({ error: 'Version not found' });
|
||||
if (version.post_id !== Number(req.params.id)) {
|
||||
return res.status(400).json({ error: 'Version does not belong to this post' });
|
||||
}
|
||||
|
||||
const [texts, attachments] = await Promise.all([
|
||||
nocodb.list('PostVersionTexts', {
|
||||
where: `(version_id,eq,${sanitizeWhereValue(req.params.versionId)})`,
|
||||
limit: QUERY_LIMITS.large,
|
||||
}),
|
||||
nocodb.list('PostAttachments', {
|
||||
where: `(version_id,eq,${sanitizeWhereValue(req.params.versionId)})`,
|
||||
limit: QUERY_LIMITS.large,
|
||||
}),
|
||||
]);
|
||||
|
||||
const creatorName = await getRecordName('Users', version.created_by_user_id);
|
||||
|
||||
res.json({
|
||||
...version,
|
||||
creator_name: creatorName,
|
||||
texts,
|
||||
attachments: attachments.map(a => ({
|
||||
...a,
|
||||
url: a.url || `/api/uploads/${a.filename}`,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Get post version error:', err);
|
||||
res.status(500).json({ error: 'Failed to load version' });
|
||||
}
|
||||
});
|
||||
|
||||
// Add/update language text for a version
|
||||
app.post('/api/posts/:id/versions/:versionId/texts', requireAuth, async (req, res) => {
|
||||
const { language_code, language_label, content } = req.body;
|
||||
if (!language_code || !language_label || !content) {
|
||||
return res.status(400).json({ error: 'language_code, language_label, and content are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const post = await nocodb.get('Posts', req.params.id);
|
||||
if (!post) return res.status(404).json({ error: 'Post not found' });
|
||||
|
||||
if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
|
||||
return res.status(403).json({ error: 'You can only manage texts for your own posts' });
|
||||
}
|
||||
|
||||
const existing = await nocodb.list('PostVersionTexts', {
|
||||
where: `(version_id,eq,${sanitizeWhereValue(req.params.versionId)})~and(language_code,eq,${sanitizeWhereValue(language_code)})`,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
let text;
|
||||
if (existing.length > 0) {
|
||||
await nocodb.update('PostVersionTexts', existing[0].Id, { language_label, content });
|
||||
text = await nocodb.get('PostVersionTexts', existing[0].Id);
|
||||
} else {
|
||||
const created = await nocodb.create('PostVersionTexts', {
|
||||
version_id: Number(req.params.versionId),
|
||||
language_code,
|
||||
language_label,
|
||||
content,
|
||||
});
|
||||
text = await nocodb.get('PostVersionTexts', created.Id);
|
||||
}
|
||||
|
||||
res.json(text);
|
||||
} catch (err) {
|
||||
console.error('Add/update post text error:', err);
|
||||
res.status(500).json({ error: 'Failed to add/update text' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete language text
|
||||
app.delete('/api/post-version-texts/:id', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const text = await nocodb.get('PostVersionTexts', req.params.id);
|
||||
if (!text) return res.status(404).json({ error: 'Text not found' });
|
||||
|
||||
const version = await nocodb.get('PostVersions', text.version_id);
|
||||
const post = await nocodb.get('Posts', version.post_id);
|
||||
|
||||
if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
|
||||
return res.status(403).json({ error: 'You can only manage texts for your own posts' });
|
||||
}
|
||||
|
||||
await nocodb.delete('PostVersionTexts', req.params.id);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Delete post text error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete text' });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload attachment to specific version
|
||||
app.post('/api/posts/:id/versions/:versionId/attachments', requireAuth, dynamicUpload('file'), async (req, res) => {
|
||||
try {
|
||||
const post = await nocodb.get('Posts', req.params.id);
|
||||
if (!post) return res.status(404).json({ error: 'Post not found' });
|
||||
|
||||
if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
|
||||
if (req.file) fs.unlinkSync(path.join(uploadsDir, req.file.filename));
|
||||
return res.status(403).json({ error: 'You can only manage attachments on your own posts' });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'File upload is required' });
|
||||
}
|
||||
|
||||
const url = `/api/uploads/${req.file.filename}`;
|
||||
const created = await nocodb.create('PostAttachments', {
|
||||
filename: req.file.filename,
|
||||
original_name: req.file.originalname,
|
||||
mime_type: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
url,
|
||||
post_id: Number(req.params.id),
|
||||
version_id: Number(req.params.versionId),
|
||||
});
|
||||
|
||||
const attachment = await nocodb.get('PostAttachments', created.Id);
|
||||
res.status(201).json(attachment);
|
||||
} catch (err) {
|
||||
console.error('Upload post version attachment error:', err);
|
||||
res.status(500).json({ error: 'Failed to upload attachment' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── ASSETS ─────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/assets', requireAuth, async (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user