feat: convert all slide panels to tabbed modals with shared TabbedModal component
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:
fahed
2026-03-09 17:12:32 +03:00
parent 539c204bde
commit 44e706f777
14 changed files with 2839 additions and 1921 deletions

View File

@@ -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)

View File

@@ -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>
</>
)
}

View File

@@ -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}

View File

@@ -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)} &bull; {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

View File

@@ -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}

View 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
)
}

View File

@@ -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

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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": "لم يتم رفع ملفات وسائط"
}

View File

@@ -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"
}

View File

@@ -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) => {