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,418 +340,467 @@ 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">
<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" />
</div>
<div className="flex-1 min-w-0">
<input
type="text"
value={editTitle}
onChange={e => setEditTitle(e.target.value)}
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 border-b border-transparent hover:border-border focus:border-brand-primary focus:outline-none focus:ring-0 px-0 py-0.5 transition-colors"
/>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[artefact.status] || 'bg-surface-tertiary text-text-secondary'}`}>
{artefact.status?.replace('_', ' ')}
</span>
<span className="text-xs text-text-tertiary capitalize">{artefact.type}</span>
{artefact.creator_name && (
<span className="text-xs text-text-secondary font-medium">
{t('review.createdBy')} <strong className="text-text-primary">{artefact.creator_name}</strong>
</span>
<>
<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" />
</div>
<div className="flex-1 min-w-0">
<input
type="text"
value={editTitle}
onChange={e => setEditTitle(e.target.value)}
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 border-b border-transparent hover:border-border focus:border-brand-primary focus:outline-none focus:ring-0 px-0 py-0.5 transition-colors"
/>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[artefact.status] || 'bg-surface-tertiary text-text-secondary'}`}>
{artefact.status?.replace('_', ' ')}
</span>
<span className="text-xs text-text-tertiary capitalize">{artefact.type}</span>
{artefact.creator_name && (
<span className="text-xs text-text-secondary font-medium">
{t('review.createdBy')} <strong className="text-text-primary">{artefact.creator_name}</strong>
</span>
)}
</div>
</div>
</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>
</div>
<div className="flex items-center gap-1 shrink-0">
<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">
{/* Description */}
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('artefacts.descriptionLabel')}</h4>
<textarea
value={editDescription}
onChange={e => setEditDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 text-sm text-text-secondary bg-surface-secondary border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder={t('artefacts.descriptionFieldPlaceholder')}
/>
</div>
</>
}
>
{/* 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>
<textarea
value={editDescription}
onChange={e => setEditDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 text-sm text-text-secondary bg-surface-secondary border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder={t('artefacts.descriptionFieldPlaceholder')}
/>
</div>
{/* Project & Campaign dropdowns */}
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.project')}</h4>
<select
value={editProjectId}
onChange={e => {
setEditProjectId(e.target.value)
handleUpdateField('project_id', e.target.value)
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value=""></option>
{projects.map(p => <option key={p.Id || p._id || p.id} value={p.Id || p._id || p.id}>{p.name || p.title}</option>)}
</select>
</div>
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.campaign')}</h4>
<select
value={editCampaignId}
onChange={e => {
setEditCampaignId(e.target.value)
handleUpdateField('campaign_id', e.target.value)
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value=""></option>
{campaigns.map(c => <option key={c.Id || c._id || c.id} value={c.Id || c._id || c.id}>{c.name || c.title}</option>)}
</select>
</div>
</div>
{/* Approvers */}
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.approversLabel')}</h4>
<ApproverMultiSelect
users={assignableUsers}
selected={editApproverIds}
onChange={ids => {
setEditApproverIds(ids)
handleUpdateField('approver_ids', ids.length > 0 ? ids.join(',') : '')
}}
/>
</div>
{/* Version Timeline */}
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.versions')}</h4>
<button
onClick={() => setShowNewVersionModal(true)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Plus className="w-3 h-3" />
{t('artefacts.newVersion')}
</button>
</div>
<ArtefactVersionTimeline
versions={versions}
activeVersionId={selectedVersion?.Id}
onSelectVersion={handleSelectVersion}
artefactType={artefact.type}
/>
</div>
{/* Type-specific content */}
{versionData && selectedVersion && (
<div className="border-t border-border pt-6">
{/* COPY TYPE: Language entries */}
{artefact.type === 'copy' && (
{/* Project & Campaign dropdowns */}
<div className="grid grid-cols-2 gap-4">
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.languages')}</h4>
<button
onClick={() => setShowLanguageModal(true)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Plus className="w-3 h-3" />
{t('artefacts.addLanguage')}
</button>
</div>
{versionData.texts && versionData.texts.length > 0 ? (
<div className="space-y-3">
{versionData.texts.map(text => (
<div key={text.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-surface border border-border rounded text-xs font-mono font-medium">
{text.language_code}
</span>
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
</div>
<button
onClick={() => setConfirmDeleteLangId(text.Id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans">
{text.content}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<Globe className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('artefacts.noLanguages')}</p>
</div>
)}
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.project')}</h4>
<select
value={editProjectId}
onChange={e => {
setEditProjectId(e.target.value)
handleUpdateField('project_id', e.target.value)
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value=""></option>
{projects.map(p => <option key={p.Id || p._id || p.id} value={p.Id || p._id || p.id}>{p.name || p.title}</option>)}
</select>
</div>
)}
{/* DESIGN TYPE: Image gallery */}
{artefact.type === 'design' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.imagesLabel')}</h4>
<label className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors cursor-pointer">
<Upload className="w-3 h-3" />
{uploading ? t('artefacts.uploading') : t('artefacts.uploadImage')}
<input
type="file"
className="hidden"
accept="image/*"
onChange={handleFileUpload}
disabled={uploading}
/>
</label>
</div>
{versionData.attachments && versionData.attachments.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{versionData.attachments.map(att => (
<div key={att.Id} className="relative group">
<img
src={att.url}
alt={att.original_name}
className="w-full h-48 object-cover rounded-lg border border-border"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors rounded-lg flex items-center justify-center">
<button
onClick={() => setConfirmDeleteAttId(att.Id)}
className="opacity-0 group-hover:opacity-100 transition-opacity px-3 py-2 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="mt-1 px-2 py-1 bg-surface-secondary rounded text-xs text-text-secondary truncate">
{att.original_name}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<ImageIcon className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('artefacts.noImages')}</p>
</div>
)}
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.campaign')}</h4>
<select
value={editCampaignId}
onChange={e => {
setEditCampaignId(e.target.value)
handleUpdateField('campaign_id', e.target.value)
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value=""></option>
{campaigns.map(c => <option key={c.Id || c._id || c.id} value={c.Id || c._id || c.id}>{c.name || c.title}</option>)}
</select>
</div>
)}
</div>
{/* VIDEO TYPE: Files and Drive links */}
{artefact.type === 'video' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.videosLabel')}</h4>
<button
onClick={() => setShowVideoModal(true)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Plus className="w-3 h-3" />
{t('artefacts.addVideoBtn')}
</button>
</div>
{versionData.attachments && versionData.attachments.length > 0 ? (
<div className="space-y-3">
{versionData.attachments.map(att => (
<div key={att.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
{att.drive_url ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary">{t('artefacts.googleDriveVideo')}</span>
<button
onClick={() => setConfirmDeleteAttId(att.Id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<iframe
src={getDriveEmbedUrl(att.drive_url)}
className="w-full h-64 rounded border border-border"
allow="autoplay"
/>
</div>
) : (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary truncate">{att.original_name}</span>
<button
onClick={() => setConfirmDeleteAttId(att.Id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<video
src={att.url}
controls
className="w-full rounded border border-border"
/>
</div>
)}
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<Film className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('artefacts.noVideos')}</p>
</div>
)}
</div>
)}
{/* Approvers */}
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.approversLabel')}</h4>
<ApproverMultiSelect
users={assignableUsers}
selected={editApproverIds}
onChange={ids => {
setEditApproverIds(ids)
handleUpdateField('approver_ids', ids.length > 0 ? ids.join(',') : '')
}}
/>
</div>
</div>
)}
{/* Comments */}
{selectedVersion && (
<div className="border-t border-border pt-6">
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">
{t('artefacts.comments')} ({comments.length})
</h4>
{/* Versions Tab */}
{activeTab === 'versions' && (
<div className="p-6 space-y-5">
{/* Version Timeline */}
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.versions')}</h4>
<button
onClick={() => setShowNewVersionModal(true)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Plus className="w-3 h-3" />
{t('artefacts.newVersion')}
</button>
</div>
<ArtefactVersionTimeline
versions={versions}
activeVersionId={selectedVersion?.Id}
onSelectVersion={handleSelectVersion}
artefactType={artefact.type}
/>
</div>
<div className="space-y-3 mb-4">
{comments.map(comment => (
<div key={comment.Id} className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-brand-primary/10 flex items-center justify-center shrink-0">
{comment.user_avatar ? (
<img src={comment.user_avatar} alt="" className="w-full h-full rounded-full object-cover" />
{/* Type-specific content */}
{versionData && selectedVersion && (
<div className="border-t border-border pt-5">
{/* COPY TYPE: Language entries */}
{artefact.type === 'copy' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.languages')}</h4>
<button
onClick={() => setShowLanguageModal(true)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Plus className="w-3 h-3" />
{t('artefacts.addLanguage')}
</button>
</div>
{versionData.texts && versionData.texts.length > 0 ? (
<div className="space-y-3">
{versionData.texts.map(text => (
<div key={text.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-surface border border-border rounded text-xs font-mono font-medium">
{text.language_code}
</span>
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
</div>
<button
onClick={() => setConfirmDeleteLangId(text.Id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans">
{text.content}
</div>
</div>
))}
</div>
) : (
<MessageSquare className="w-4 h-4 text-brand-primary" />
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<Globe className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('artefacts.noLanguages')}</p>
</div>
)}
</div>
<div className="flex-1 bg-surface-secondary rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-text-primary">{comment.user_name || 'Anonymous'}</span>
<span className="text-xs text-text-tertiary">
{new Date(comment.CreatedAt).toLocaleString()}
</span>
)}
{/* DESIGN TYPE: Image gallery */}
{artefact.type === 'design' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.imagesLabel')}</h4>
<label className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors cursor-pointer">
<Upload className="w-3 h-3" />
{uploading ? t('artefacts.uploading') : t('artefacts.uploadImage')}
<input
type="file"
className="hidden"
accept="image/*"
onChange={handleFileUpload}
disabled={uploading}
/>
</label>
</div>
<p className="text-sm text-text-secondary whitespace-pre-wrap">{comment.content}</p>
{versionData.attachments && versionData.attachments.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{versionData.attachments.map(att => (
<div key={att.Id} className="relative group">
<img
src={att.url}
alt={att.original_name}
className="w-full h-48 object-cover rounded-lg border border-border"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors rounded-lg flex items-center justify-center">
<button
onClick={() => setConfirmDeleteAttId(att.Id)}
className="opacity-0 group-hover:opacity-100 transition-opacity px-3 py-2 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="mt-1 px-2 py-1 bg-surface-secondary rounded text-xs text-text-secondary truncate">
{att.original_name}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<ImageIcon className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('artefacts.noImages')}</p>
</div>
)}
</div>
</div>
))}
</div>
)}
<div className="flex gap-2">
<input
type="text"
value={newComment}
onChange={e => setNewComment(e.target.value)}
onKeyPress={e => e.key === 'Enter' && handleAddComment()}
placeholder={t('artefacts.addCommentPlaceholder')}
className="flex-1 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"
/>
<button
onClick={handleAddComment}
disabled={addingComment || !newComment.trim()}
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
>
{t('artefacts.sendComment')}
</button>
</div>
</div>
)}
{/* VIDEO TYPE: Files and Drive links */}
{artefact.type === 'video' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.videosLabel')}</h4>
<button
onClick={() => setShowVideoModal(true)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Plus className="w-3 h-3" />
{t('artefacts.addVideoBtn')}
</button>
</div>
{/* Submit for Review */}
{['draft', 'revision_requested', 'rejected'].includes(artefact.status) && (
<div className="border-t border-border pt-6">
<button
onClick={handleSubmitReview}
disabled={submitting}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors font-medium disabled:opacity-50"
>
<ExternalLink className="w-4 h-4" />
{submitting ? t('artefacts.submitting') : t('artefacts.submitForReview')}
</button>
</div>
)}
{/* Review Link */}
{reviewUrl && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="text-sm font-semibold text-blue-900 mb-2">{t('artefacts.reviewLinkTitle')}</div>
<div className="flex items-center gap-2">
<input
type="text"
value={reviewUrl}
readOnly
className="flex-1 px-3 py-2 text-sm bg-surface border border-border rounded"
/>
<button
onClick={copyReviewLink}
className="p-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
)}
{/* Feedback */}
{artefact.feedback && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<h4 className="text-sm font-semibold text-amber-900 mb-2">{t('artefacts.feedbackTitle')}</h4>
<p className="text-sm text-amber-800 whitespace-pre-wrap">{artefact.feedback}</p>
</div>
)}
{/* Approval Info */}
{artefact.status === 'approved' && artefact.approved_by_name && (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
<div className="font-medium text-emerald-900">{t('artefacts.approvedByLabel')} {artefact.approved_by_name}</div>
{artefact.approved_at && (
<div className="text-sm text-emerald-700 mt-1">
{new Date(artefact.approved_at).toLocaleString()}
{versionData.attachments && versionData.attachments.length > 0 ? (
<div className="space-y-3">
{versionData.attachments.map(att => (
<div key={att.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
{att.drive_url ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary">{t('artefacts.googleDriveVideo')}</span>
<button
onClick={() => setConfirmDeleteAttId(att.Id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<iframe
src={getDriveEmbedUrl(att.drive_url)}
className="w-full h-64 rounded border border-border"
allow="autoplay"
/>
</div>
) : (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary truncate">{att.original_name}</span>
<button
onClick={() => setConfirmDeleteAttId(att.Id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<video
src={att.url}
controls
className="w-full rounded border border-border"
/>
</div>
)}
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<Film className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('artefacts.noVideos')}</p>
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
{/* 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>
<div className="space-y-3 mb-4">
{comments.map(comment => (
<div key={comment.Id} className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-brand-primary/10 flex items-center justify-center shrink-0">
{comment.user_avatar ? (
<img src={comment.user_avatar} alt="" className="w-full h-full rounded-full object-cover" />
) : (
<MessageSquare className="w-4 h-4 text-brand-primary" />
)}
</div>
<div className="flex-1 bg-surface-secondary rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-text-primary">{comment.user_name || 'Anonymous'}</span>
<span className="text-xs text-text-tertiary">
{new Date(comment.CreatedAt).toLocaleString()}
</span>
</div>
<p className="text-sm text-text-secondary whitespace-pre-wrap">{comment.content}</p>
</div>
</div>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={newComment}
onChange={e => setNewComment(e.target.value)}
onKeyPress={e => e.key === 'Enter' && handleAddComment()}
placeholder={t('artefacts.addCommentPlaceholder')}
className="flex-1 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"
/>
<button
onClick={handleAddComment}
disabled={addingComment || !newComment.trim()}
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
>
{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) && (
<button
onClick={handleSubmitReview}
disabled={submitting}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors font-medium disabled:opacity-50"
>
<ExternalLink className="w-4 h-4" />
{submitting ? t('artefacts.submitting') : t('artefacts.submitForReview')}
</button>
)}
{/* Review Link */}
{reviewUrl && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="text-sm font-semibold text-blue-900 mb-2">{t('artefacts.reviewLinkTitle')}</div>
<div className="flex items-center gap-2">
<input
type="text"
value={reviewUrl}
readOnly
className="flex-1 px-3 py-2 text-sm bg-surface border border-border rounded"
/>
<button
onClick={copyReviewLink}
className="p-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
)}
{/* Feedback */}
{artefact.feedback && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<h4 className="text-sm font-semibold text-amber-900 mb-2">{t('artefacts.feedbackTitle')}</h4>
<p className="text-sm text-amber-800 whitespace-pre-wrap">{artefact.feedback}</p>
</div>
)}
{/* Approval Info */}
{artefact.status === 'approved' && artefact.approved_by_name && (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
<div className="font-medium text-emerald-900">{t('artefacts.approvedByLabel')} {artefact.approved_by_name}</div>
{artefact.approved_at && (
<div className="text-sm text-emerald-700 mt-1">
{new Date(artefact.approved_at).toLocaleString()}
</div>
)}
</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,50 +102,79 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
return campaign.brand_name || campaign.brandName || null
})()
const header = (
<div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<input
type="text"
value={form.name}
onChange={e => update('name', e.target.value)}
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
placeholder={t('campaigns.name')}
/>
<div className="flex items-center gap-2 mt-2">
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${
form.status === 'active' ? 'bg-emerald-100 text-emerald-700' :
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600'
}`}>
{statusOptions.find(s => s.value === form.status)?.label}
</span>
{brandName && (
<span className={`text-[10px] px-1.5 py-0.5 rounded ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
{brandName}
</span>
)}
</div>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
)
const tabs = isCreateMode
? [{ key: 'details', label: t('campaigns.details'), icon: FileEdit }]
: [
{ key: 'details', label: t('campaigns.details'), icon: FileEdit },
{ key: 'performance', label: t('campaigns.performance'), icon: BarChart3 },
{ key: 'discussion', label: t('campaigns.discussion'), icon: MessageSquare },
]
return (
<>
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
{/* Details Section */}
<CollapsibleSection title={t('campaigns.details')}>
<div className="px-5 pb-4 space-y-3">
<TabbedModal
onClose={onClose}
size="lg"
header={
<>
<input
type="text"
value={form.name}
onChange={e => update('name', e.target.value)}
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
placeholder={t('campaigns.name')}
/>
<div className="flex items-center gap-2 mt-2">
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${
form.status === 'active' ? 'bg-emerald-100 text-emerald-700' :
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600'
}`}>
{statusOptions.find(s => s.value === form.status)?.label}
</span>
{brandName && (
<span className={`text-[10px] px-1.5 py-0.5 rounded ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
{brandName}
</span>
)}
</div>
</>
}
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={
<>
<div className="flex items-center gap-2">
{onDelete && !isCreateMode && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('common.delete')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<div className="flex items-center gap-2.5">
{dirty && (
<button
onClick={handleSave}
disabled={!form.name || !form.start_date || !form.end_date || saving}
className={`px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
>
{isCreateMode ? t('campaigns.createCampaign') : t('tasks.saveChanges')}
</button>
)}
</div>
</>
}
>
{/* Details Tab */}
{activeTab === 'details' && (
<div className="p-6 space-y-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.description')}</label>
<textarea
@@ -274,159 +303,134 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
/>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
{dirty && (
<button
onClick={handleSave}
disabled={!form.name || !form.start_date || !form.end_date || saving}
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
>
{isCreateMode ? t('campaigns.createCampaign') : t('tasks.saveChanges')}
</button>
)}
{onDelete && !isCreateMode && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('common.delete')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
</CollapsibleSection>
)}
{/* Performance Section (hidden in create mode) */}
{!isCreateMode && (
<CollapsibleSection title={t('campaigns.performance')}>
<div className="px-5 pb-4 space-y-3">
{(form.budget_spent || form.impressions || form.clicks) && (
<div className="grid grid-cols-4 gap-2">
<div className="bg-surface-secondary rounded-lg p-2 text-center">
<DollarSign className="w-3.5 h-3.5 mx-auto mb-0.5 text-amber-600" />
<div className="text-xs font-bold text-amber-600">{form.budget_spent ? Number(form.budget_spent).toLocaleString() : '—'}</div>
<div className="text-[10px] text-text-tertiary">{t('campaigns.budgetSpent')}</div>
</div>
<div className="bg-surface-secondary rounded-lg p-2 text-center">
<Eye className="w-3.5 h-3.5 mx-auto mb-0.5 text-purple-600" />
<div className="text-xs font-bold text-purple-600">{form.impressions ? Number(form.impressions).toLocaleString() : '—'}</div>
<div className="text-[10px] text-text-tertiary">{t('campaigns.impressions')}</div>
</div>
<div className="bg-surface-secondary rounded-lg p-2 text-center">
<MousePointer className="w-3.5 h-3.5 mx-auto mb-0.5 text-blue-600" />
<div className="text-xs font-bold text-blue-600">{form.clicks ? Number(form.clicks).toLocaleString() : '—'}</div>
<div className="text-[10px] text-text-tertiary">{t('campaigns.clicks')}</div>
</div>
<div className="bg-surface-secondary rounded-lg p-2 text-center">
<Target className="w-3.5 h-3.5 mx-auto mb-0.5 text-emerald-600" />
<div className="text-xs font-bold text-emerald-600">{form.conversions ? Number(form.conversions).toLocaleString() : '—'}</div>
<div className="text-[10px] text-text-tertiary">{t('campaigns.conversions')}</div>
</div>
{/* Performance Tab */}
{activeTab === 'performance' && !isCreateMode && (
<div className="p-6 space-y-3">
{(form.budget_spent || form.impressions || form.clicks) && (
<div className="grid grid-cols-4 gap-2">
<div className="bg-surface-secondary rounded-lg p-2 text-center">
<DollarSign className="w-3.5 h-3.5 mx-auto mb-0.5 text-amber-600" />
<div className="text-xs font-bold text-amber-600">{form.budget_spent ? Number(form.budget_spent).toLocaleString() : '—'}</div>
<div className="text-[10px] text-text-tertiary">{t('campaigns.budgetSpent')}</div>
</div>
)}
{form.budget && form.budget_spent && (
<div className="p-3 bg-surface-secondary rounded-lg">
<BudgetBar budget={Number(form.budget)} spent={Number(form.budget_spent)} />
<div className="flex items-center gap-2 mt-2">
{Number(form.budget_spent) > 0 && (
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${
((Number(form.revenue) - Number(form.budget_spent)) / Number(form.budget_spent) * 100) >= 0
? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'
}`}>
ROI {((Number(form.revenue || 0) - Number(form.budget_spent)) / Number(form.budget_spent) * 100).toFixed(0)}%
</span>
)}
{Number(form.clicks) > 0 && Number(form.budget_spent) > 0 && (
<span className="text-[10px] text-text-tertiary">
CPC: {(Number(form.budget_spent) / Number(form.clicks)).toFixed(2)} {currencySymbol}
</span>
)}
{Number(form.impressions) > 0 && Number(form.clicks) > 0 && (
<span className="text-[10px] text-text-tertiary">
CTR: {(Number(form.clicks) / Number(form.impressions) * 100).toFixed(2)}%
</span>
)}
</div>
<div className="bg-surface-secondary rounded-lg p-2 text-center">
<Eye className="w-3.5 h-3.5 mx-auto mb-0.5 text-purple-600" />
<div className="text-xs font-bold text-purple-600">{form.impressions ? Number(form.impressions).toLocaleString() : '—'}</div>
<div className="text-[10px] text-text-tertiary">{t('campaigns.impressions')}</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.budgetSpent')} ({currencySymbol})</label>
<input
type="number"
value={form.budget_spent}
onChange={e => update('budget_spent', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
<div className="bg-surface-secondary rounded-lg p-2 text-center">
<MousePointer className="w-3.5 h-3.5 mx-auto mb-0.5 text-blue-600" />
<div className="text-xs font-bold text-blue-600">{form.clicks ? Number(form.clicks).toLocaleString() : '—'}</div>
<div className="text-[10px] text-text-tertiary">{t('campaigns.clicks')}</div>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.revenue')} ({currencySymbol})</label>
<input
type="number"
value={form.revenue}
onChange={e => update('revenue', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
<div className="bg-surface-secondary rounded-lg p-2 text-center">
<Target className="w-3.5 h-3.5 mx-auto mb-0.5 text-emerald-600" />
<div className="text-xs font-bold text-emerald-600">{form.conversions ? Number(form.conversions).toLocaleString() : '—'}</div>
<div className="text-[10px] text-text-tertiary">{t('campaigns.conversions')}</div>
</div>
</div>
)}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.impressions')}</label>
<input
type="number"
value={form.impressions}
onChange={e => update('impressions', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.clicks')}</label>
<input
type="number"
value={form.clicks}
onChange={e => update('clicks', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.conversions')}</label>
<input
type="number"
value={form.conversions}
onChange={e => update('conversions', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
{form.budget && form.budget_spent && (
<div className="p-3 bg-surface-secondary rounded-lg">
<BudgetBar budget={Number(form.budget)} spent={Number(form.budget_spent)} />
<div className="flex items-center gap-2 mt-2">
{Number(form.budget_spent) > 0 && (
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${
((Number(form.revenue) - Number(form.budget_spent)) / Number(form.budget_spent) * 100) >= 0
? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'
}`}>
ROI {((Number(form.revenue || 0) - Number(form.budget_spent)) / Number(form.budget_spent) * 100).toFixed(0)}%
</span>
)}
{Number(form.clicks) > 0 && Number(form.budget_spent) > 0 && (
<span className="text-[10px] text-text-tertiary">
CPC: {(Number(form.budget_spent) / Number(form.clicks)).toFixed(2)} {currencySymbol}
</span>
)}
{Number(form.impressions) > 0 && Number(form.clicks) > 0 && (
<span className="text-[10px] text-text-tertiary">
CTR: {(Number(form.clicks) / Number(form.impressions) * 100).toFixed(2)}%
</span>
)}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.notes')}</label>
<textarea
value={form.notes}
onChange={e => update('notes', e.target.value)}
rows={2}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder="Performance notes..."
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.budgetSpent')} ({currencySymbol})</label>
<input
type="number"
value={form.budget_spent}
onChange={e => update('budget_spent', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.revenue')} ({currencySymbol})</label>
<input
type="number"
value={form.revenue}
onChange={e => update('revenue', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
</div>
</CollapsibleSection>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.impressions')}</label>
<input
type="number"
value={form.impressions}
onChange={e => update('impressions', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.clicks')}</label>
<input
type="number"
value={form.clicks}
onChange={e => update('clicks', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.conversions')}</label>
<input
type="number"
value={form.conversions}
onChange={e => update('conversions', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.notes')}</label>
<textarea
value={form.notes}
onChange={e => update('notes', e.target.value)}
rows={2}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder="Performance notes..."
/>
</div>
</div>
)}
{/* Discussion Section (hidden in create mode) */}
{!isCreateMode && (
<CollapsibleSection title={t('campaigns.discussion')} noBorder>
<div className="px-5 pb-5">
<CommentsSection entityType="campaign" entityId={campaignId} />
</div>
</CollapsibleSection>
{/* Discussion Tab */}
{activeTab === 'discussion' && !isCreateMode && (
<div className="p-6 space-y-3">
<CommentsSection entityType="campaign" entityId={campaignId} />
</div>
)}
</SlidePanel>
</TabbedModal>
<Modal
isOpen={showDeleteConfirm}

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,195 +236,207 @@ 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">
{/* 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>
<div className="space-y-1 text-sm">
<div><span className="text-text-tertiary">{t('issues.nameLabel')}</span> <span className="text-text-primary font-medium">{issueData.submitter_name}</span></div>
<div><span className="text-text-tertiary">{t('issues.emailLabel')}</span> <span className="text-text-primary">{issueData.submitter_email}</span></div>
{issueData.submitter_phone && (
<div><span className="text-text-tertiary">{t('issues.phoneLabel')}</span> <span className="text-text-primary">{issueData.submitter_phone}</span></div>
)}
<div><span className="text-text-tertiary">{t('issues.submittedLabel')}</span> <span className="text-text-primary">{formatDate(issueData.created_at)}</span></div>
{/* 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>
<div className="space-y-1 text-sm">
<div><span className="text-text-tertiary">{t('issues.nameLabel')}</span> <span className="text-text-primary font-medium">{issueData.submitter_name}</span></div>
<div><span className="text-text-tertiary">{t('issues.emailLabel')}</span> <span className="text-text-primary">{issueData.submitter_email}</span></div>
{issueData.submitter_phone && (
<div><span className="text-text-tertiary">{t('issues.phoneLabel')}</span> <span className="text-text-primary">{issueData.submitter_phone}</span></div>
)}
<div><span className="text-text-tertiary">{t('issues.submittedLabel')}</span> <span className="text-text-primary">{formatDate(issueData.created_at)}</span></div>
</div>
</div>
</div>
{/* Description */}
<div>
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.description')}</h3>
<p className="text-sm text-text-secondary whitespace-pre-wrap">{issueData.description || t('issues.noDescription')}</p>
</div>
{/* Assigned To */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.assignedTo')}</label>
<select
value={assignedTo}
onChange={(e) => handleAssignmentChange(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('issues.unassigned')}</option>
{teamMembers.map((member) => (
<option key={member.id || member._id} value={member.id || member._id}>
{member.name}
</option>
))}
</select>
</div>
{/* Team */}
{teams.length > 0 && (
{/* Description */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.team')}</label>
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.description')}</h3>
<p className="text-sm text-text-secondary whitespace-pre-wrap">{issueData.description || t('issues.noDescription')}</p>
</div>
{/* Assigned To */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.assignedTo')}</label>
<select
value={teamId}
onChange={async (e) => {
const val = e.target.value || null
setTeamId(val || '')
try {
await api.patch(`/issues/${issueId}`, { team_id: val })
await onUpdate()
await loadIssueDetails()
} catch (err) {
console.error('Failed to update team:', err)
}
}}
value={assignedTo}
onChange={(e) => handleAssignmentChange(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('issues.allTeams')}</option>
{teams.map((team) => (
<option key={team.id || team._id} value={team.id || team._id}>{team.name}</option>
<option value="">{t('issues.unassigned')}</option>
{teamMembers.map((member) => (
<option key={member.id || member._id} value={member.id || member._id}>
{member.name}
</option>
))}
</select>
</div>
)}
{/* Brand */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.brandLabel')}</label>
<select
value={issueData.brand_id || ''}
onChange={async (e) => {
const val = e.target.value || null;
try {
await api.patch(`/issues/${issueId}`, { brand_id: val });
loadIssueDetails();
onUpdate();
} catch {}
}}
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"
>
<option value="">{t('issues.noBrand')}</option>
{(brands || []).map((b) => (
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
))}
</select>
</div>
{/* Internal Notes */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2 flex items-center gap-2">
<Lock className="w-4 h-4" />
{t('issues.internalNotes')}
</label>
<textarea
value={internalNotes}
onChange={(e) => setInternalNotes(e.target.value)}
onBlur={handleNotesChange}
rows={4}
placeholder={t('issues.internalNotesPlaceholder')}
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"
/>
</div>
{/* Resolution Summary (if resolved/declined) */}
{(issueData.status === 'resolved' || issueData.status === 'declined') && issueData.resolution_summary && (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
<h3 className="text-sm font-semibold text-emerald-900 mb-2 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" />
{t('issues.resolutionSummary')}
</h3>
<p className="text-sm text-emerald-800 whitespace-pre-wrap">{issueData.resolution_summary}</p>
{issueData.resolved_at && (
<p className="text-xs text-emerald-600 mt-2">{t('issues.resolvedOn')} {formatDate(issueData.resolved_at)}</p>
)}
</div>
)}
{/* Status Actions */}
{issueData.status !== 'resolved' && issueData.status !== 'declined' && (
<div className="flex gap-2 flex-wrap">
{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"
{/* Team */}
{teams.length > 0 && (
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.team')}</label>
<select
value={teamId}
onChange={async (e) => {
const val = e.target.value || null
setTeamId(val || '')
try {
await api.patch(`/issues/${issueId}`, { team_id: val })
await onUpdate()
await loadIssueDetails()
} catch (err) {
console.error('Failed to update team:', err)
}
}}
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"
>
<Check className="w-4 h-4 inline mr-1" />
{t('issues.acknowledge')}
</button>
)}
{(issueData.status === 'new' || issueData.status === 'acknowledged') && (
<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"
>
<Clock className="w-4 h-4 inline mr-1" />
{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"
>
<CheckCircle2 className="w-4 h-4 inline mr-1" />
{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"
>
<XCircle className="w-4 h-4 inline mr-1" />
{t('issues.decline')}
</button>
</div>
)}
<option value="">{t('issues.allTeams')}</option>
{teams.map((team) => (
<option key={team.id || team._id} value={team.id || team._id}>{team.name}</option>
))}
</select>
</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"
{/* Brand */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.brandLabel')}</label>
<select
value={issueData.brand_id || ''}
onChange={async (e) => {
const val = e.target.value || null;
try {
await api.patch(`/issues/${issueId}`, { brand_id: val });
loadIssueDetails();
onUpdate();
} catch {}
}}
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"
>
<option value="">{t('issues.noBrand')}</option>
{(brands || []).map((b) => (
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
))}
</select>
</div>
{/* Internal Notes */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2 flex items-center gap-2">
<Lock className="w-4 h-4" />
{t('issues.internalNotes')}
</label>
<textarea
value={internalNotes}
onChange={(e) => setInternalNotes(e.target.value)}
onBlur={handleNotesChange}
rows={4}
placeholder={t('issues.internalNotesPlaceholder')}
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"
/>
<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>
{/* Resolution Summary (if resolved/declined) */}
{(issueData.status === 'resolved' || issueData.status === 'declined') && issueData.resolution_summary && (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
<h3 className="text-sm font-semibold text-emerald-900 mb-2 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" />
{t('issues.resolutionSummary')}
</h3>
<p className="text-sm text-emerald-800 whitespace-pre-wrap">{issueData.resolution_summary}</p>
{issueData.resolved_at && (
<p className="text-xs text-emerald-600 mt-2">{t('issues.resolvedOn')} {formatDate(issueData.resolved_at)}</p>
)}
</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>
{/* 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="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" />
{t('issues.acknowledge')}
</button>
)}
{(issueData.status === 'new' || issueData.status === 'acknowledged') && (
<button
onClick={() => handleUpdateStatus('in_progress')}
disabled={saving}
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" />
{t('issues.startWork')}
</button>
)}
<button
onClick={() => setShowResolveModal(true)}
disabled={saving}
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" />
{t('issues.resolve')}
</button>
<button
onClick={() => setShowDeclineModal(true)}
disabled={saving}
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" />
{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>
)}
{/* 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>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
)
return (
<>
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
{/* Details Section */}
<CollapsibleSection title={t('projects.details')}>
<div className="px-5 pb-4 space-y-3">
</>}
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={<>
<div className="flex items-center gap-2">
{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 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>
</>}
>
{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,188 +210,186 @@ 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}
onChange={e => update('title', e.target.value)}
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
placeholder={t('tasks.taskTitle')}
/>
<div className="flex items-center gap-2 mt-2">
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full font-medium ${priority.color === 'bg-gray-400' ? 'bg-gray-100 text-gray-600' : priority.color === 'bg-amber-400' ? 'bg-amber-100 text-amber-700' : priority.color === 'bg-orange-500' ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'}`}>
<div className={`w-1.5 h-1.5 rounded-full ${priority.color}`} />
{priorityOptions.find(p => p.value === form.priority)?.label}
</span>
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${form.status === 'done' ? 'bg-emerald-100 text-emerald-700' : form.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
{statusOptions.find(s => s.value === form.status)?.label}
</span>
{isOverdue && !isCreateMode && (
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-red-100 text-red-600 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t('tasks.overdue')}
</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>
<input
type="text"
value={form.title}
onChange={e => update('title', e.target.value)}
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
placeholder={t('tasks.taskTitle')}
/>
<div className="flex items-center gap-2 mt-2">
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full font-medium ${priority.color === 'bg-gray-400' ? 'bg-gray-100 text-gray-600' : priority.color === 'bg-amber-400' ? 'bg-amber-100 text-amber-700' : priority.color === 'bg-orange-500' ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'}`}>
<div className={`w-1.5 h-1.5 rounded-full ${priority.color}`} />
{priorityOptions.find(p => p.value === form.priority)?.label}
</span>
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${form.status === 'done' ? 'bg-emerald-100 text-emerald-700' : form.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
{statusOptions.find(s => s.value === form.status)?.label}
</span>
{isOverdue && !isCreateMode && (
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-red-100 text-red-600 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t('tasks.overdue')}
</span>
)}
</div>
</div>
</>
)
const footerContent = (
<>
<div className="flex items-center gap-2">
{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 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">
{/* Description */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.description')}</label>
<textarea
value={form.description}
onChange={e => update('description', e.target.value)}
rows={3}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder={t('posts.optionalDetails')}
/>
</div>
{/* Project */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.project')}</label>
<div className="flex items-center gap-2">
<select
value={form.project_id}
onChange={e => update('project_id', e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('tasks.noProject')}</option>
{(projects || []).map(p => (
<option key={p._id || p.id} value={p._id || p.id}>{p.name || p.title}</option>
))}
</select>
{brandName && (
<span className={`text-[10px] px-1.5 py-0.5 rounded shrink-0 ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
{brandName}
</span>
)}
</div>
</div>
{/* Assignee */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.assignee')}</label>
<select
value={form.assigned_to}
onChange={e => update('assigned_to', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('common.unassigned')}</option>
{(users || []).map(m => (
<option key={m._id || m.team_member_id} value={m._id || m.team_member_id}>{m.name}</option>
))}
</select>
</div>
{/* Priority & Status */}
<div className="grid grid-cols-2 gap-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.priority')}</label>
<select
value={form.priority}
onChange={e => update('priority', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
{priorityOptions.map(p => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.status')}</label>
<select
value={form.status}
onChange={e => update('status', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
{statusOptions.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
</div>
{/* Start Date & Due Date */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.startDate')}</label>
<input
type="date"
value={form.start_date}
onChange={e => update('start_date', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.description')}</label>
<textarea
value={form.description}
onChange={e => update('description', e.target.value)}
rows={3}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder={t('posts.optionalDetails')}
/>
</div>
{/* Project */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.dueDate')}</label>
<input
type="date"
value={form.due_date}
onChange={e => update('due_date', e.target.value)}
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.project')}</label>
<div className="flex items-center gap-2">
<select
value={form.project_id}
onChange={e => update('project_id', e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">{t('tasks.noProject')}</option>
{(projects || []).map(p => (
<option key={p._id || p.id} value={p._id || p.id}>{p.name || p.title}</option>
))}
</select>
{brandName && (
<span className={`text-[10px] px-1.5 py-0.5 rounded shrink-0 ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
{brandName}
</span>
)}
</div>
</div>
{/* Assignee */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.assignee')}</label>
<select
value={form.assigned_to}
onChange={e => update('assigned_to', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
</div>
{/* Created by (read-only) */}
{creatorName && !isCreateMode && (
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.createdBy')}</label>
<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>
<option value="">{t('common.unassigned')}</option>
{(users || []).map(m => (
<option key={m._id || m.team_member_id} value={m._id || m.team_member_id}>{m.name}</option>
))}
</select>
</div>
{/* Priority & Status */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.priority')}</label>
<select
value={form.priority}
onChange={e => update('priority', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
{priorityOptions.map(p => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.status')}</label>
<select
value={form.status}
onChange={e => update('status', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
{statusOptions.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
</div>
{/* Start Date & Due Date */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.startDate')}</label>
<input
type="date"
value={form.start_date}
onChange={e => update('start_date', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.dueDate')}</label>
<input
type="date"
value={form.due_date}
onChange={e => update('due_date', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
</div>
{/* Created by (read-only) */}
{creatorName && !isCreateMode && (
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.createdBy')}</label>
<p className="text-sm text-text-secondary">{creatorName}</p>
</div>
)}
</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">
<CommentsSection entityType="task" entityId={taskId} />
</div>
</CollapsibleSection>
)}
</SlidePanel>
{/* Discussion Tab */}
{activeTab === 'discussion' && !isCreateMode && (
<div className="p-6">
<CommentsSection entityType="task" entityId={taskId} />
</div>
)}
</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,42 +151,69 @@ 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">
<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}
</div>
<div className="flex-1 min-w-0">
<input
type="text"
value={form.name}
onChange={e => update('name', e.target.value)}
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
placeholder={t('team.fullName')}
/>
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-brand-primary/10 text-brand-primary capitalize">
{roleName}
</span>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
)
const 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 (
<>
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
{/* Details Section */}
<CollapsibleSection title={t('team.details')}>
<div className="px-5 pb-4 space-y-3">
<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}
</div>
<div className="flex-1 min-w-0">
<input
type="text"
value={form.name}
onChange={e => update('name', e.target.value)}
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
placeholder={t('team.fullName')}
/>
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-brand-primary/10 text-brand-primary capitalize">
{roleName}
</span>
</div>
</div>
}
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={<>
<div className="flex items-center gap-2">
{canManageTeam && onDelete && !isEditingSelf && (
<button
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"
>
<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>
</>}
>
{/* 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,146 +403,119 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
</div>
</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>
{/* 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">
<p className="text-lg font-bold text-text-primary">{memberTasks.length}</p>
<p className="text-[10px] text-text-tertiary">{t('team.totalTasks')}</p>
</div>
<div className="bg-surface-secondary rounded-lg p-2 text-center">
<p className="text-lg font-bold text-amber-500">{todoCount}</p>
<p className="text-[10px] text-text-tertiary">{t('team.toDo')}</p>
</div>
<div className="bg-surface-secondary rounded-lg p-2 text-center">
<p className="text-lg font-bold text-blue-500">{inProgressCount}</p>
<p className="text-[10px] text-text-tertiary">{t('team.inProgress')}</p>
</div>
<div className="bg-surface-secondary rounded-lg p-2 text-center">
<p className="text-lg font-bold text-emerald-500">{doneCount}</p>
<p className="text-[10px] text-text-tertiary">{t('tasks.done')}</p>
</div>
</div>
{/* Recent tasks */}
{memberTasks.length > 0 && (
<div>
<h4 className="text-xs font-medium text-text-tertiary mb-2">{t('team.recentTasks')}</h4>
<div className="space-y-1 max-h-40 overflow-y-auto">
{memberTasks.slice(0, 8).map(task => (
<div key={task._id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-surface-secondary">
<span className={`text-xs flex-1 min-w-0 truncate ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
{task.title}
</span>
<StatusBadge status={task.status} size="xs" />
</div>
))}
</div>
</div>
)}
{/* Recent posts */}
{memberPosts.length > 0 && (
<div>
<h4 className="text-xs font-medium text-text-tertiary mb-2">{t('team.recentPosts')}</h4>
<div className="space-y-1 max-h-40 overflow-y-auto">
{memberPosts.slice(0, 8).map(post => (
<div key={post._id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-surface-secondary">
<span className="text-xs text-text-primary flex-1 min-w-0 truncate">{post.title}</span>
<StatusBadge status={post.status} size="xs" />
</div>
))}
</div>
</div>
)}
{loadingWorkload && (
<p className="text-xs text-text-tertiary text-center py-2">{t('common.loading')}</p>
)}
</div>
</CollapsibleSection>
)}
{/* Workload Section */}
<CollapsibleSection title={t('team.workload')} noBorder>
<div className="px-5 pb-4 space-y-3">
{/* Stats */}
<div className="grid grid-cols-4 gap-2">
<div className="bg-surface-secondary rounded-lg p-2 text-center">
<p className="text-lg font-bold text-text-primary">{memberTasks.length}</p>
<p className="text-[10px] text-text-tertiary">{t('team.totalTasks')}</p>
</div>
<div className="bg-surface-secondary rounded-lg p-2 text-center">
<p className="text-lg font-bold text-amber-500">{todoCount}</p>
<p className="text-[10px] text-text-tertiary">{t('team.toDo')}</p>
</div>
<div className="bg-surface-secondary rounded-lg p-2 text-center">
<p className="text-lg font-bold text-blue-500">{inProgressCount}</p>
<p className="text-[10px] text-text-tertiary">{t('team.inProgress')}</p>
</div>
<div className="bg-surface-secondary rounded-lg p-2 text-center">
<p className="text-lg font-bold text-emerald-500">{doneCount}</p>
<p className="text-[10px] text-text-tertiary">{t('tasks.done')}</p>
</div>
</div>
{/* Recent tasks */}
{memberTasks.length > 0 && (
<div>
<h4 className="text-xs font-medium text-text-tertiary mb-2">{t('team.recentTasks')}</h4>
<div className="space-y-1 max-h-40 overflow-y-auto">
{memberTasks.slice(0, 8).map(task => (
<div key={task._id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-surface-secondary">
<span className={`text-xs flex-1 min-w-0 truncate ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
{task.title}
</span>
<StatusBadge status={task.status} size="xs" />
</div>
))}
</div>
</div>
)}
{/* Recent posts */}
{memberPosts.length > 0 && (
<div>
<h4 className="text-xs font-medium text-text-tertiary mb-2">{t('team.recentPosts')}</h4>
<div className="space-y-1 max-h-40 overflow-y-auto">
{memberPosts.slice(0, 8).map(post => (
<div key={post._id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-surface-secondary">
<span className="text-xs text-text-primary flex-1 min-w-0 truncate">{post.title}</span>
<StatusBadge status={post.status} size="xs" />
</div>
))}
</div>
</div>
)}
{loadingWorkload && (
<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">
{/* Change password */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.password')}</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={form.password}
onChange={e => update('password', e.target.value)}
className="w-full px-3 py-2 pe-9 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('team.newPassword')}
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowPassword(v => !v)}
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-text-tertiary hover:text-text-primary"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.confirmPassword')}</label>
{/* 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>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
className={`w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary ${passwordMismatch ? 'border-red-400' : 'border-border'}`}
placeholder={t('team.confirmPassword')}
value={form.password}
onChange={e => update('password', e.target.value)}
className="w-full px-3 py-2 pe-9 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('team.newPassword')}
autoComplete="new-password"
/>
{passwordMismatch && (
<p className="text-[11px] text-red-500 mt-1">{t('team.passwordsDoNotMatch')}</p>
)}
</div>
<button
onClick={handlePasswordChange}
disabled={!form.password || form.password.length < 6 || form.password !== confirmPassword || passwordSaving}
className={`w-full px-4 py-2 bg-amber-500 text-white rounded-lg text-sm font-medium hover:bg-amber-600 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${passwordSaving ? 'btn-loading' : ''}`}
>
{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"
type="button"
onClick={() => setShowPassword(v => !v)}
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-text-tertiary hover:text-text-primary"
tabIndex={-1}
>
<Trash2 className="w-4 h-4" />
{t('team.removeMember')}
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.confirmPassword')}</label>
<input
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
className={`w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary ${passwordMismatch ? 'border-red-400' : 'border-border'}`}
placeholder={t('team.confirmPassword')}
autoComplete="new-password"
/>
{passwordMismatch && (
<p className="text-[11px] text-red-500 mt-1">{t('team.passwordsDoNotMatch')}</p>
)}
</div>
</CollapsibleSection>
<button
onClick={handlePasswordChange}
disabled={!form.password || form.password.length < 6 || form.password !== confirmPassword || passwordSaving}
className={`w-full px-4 py-2 bg-amber-500 text-white rounded-lg text-sm font-medium hover:bg-amber-600 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${passwordSaving ? 'btn-loading' : ''}`}
>
{t('team.changePassword')}
</button>
</div>
)}
</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,36 +68,62 @@ 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">
<input
type="text"
value={form.name}
onChange={e => update('name', e.target.value)}
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
placeholder={t('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')}
</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>
)
const memberCount = (form.member_ids || []).length
return (
<>
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
<CollapsibleSection title={t('teams.details')}>
<div className="px-5 pb-4 space-y-3">
<TabbedModal
onClose={onClose}
size="md"
header={
<>
<input
type="text"
value={form.name}
onChange={e => update('name', e.target.value)}
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
placeholder={t('teams.name')}
/>
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-blue-100 text-blue-700">
{memberCount} {t('teams.members')}
</span>
</>
}
tabs={[
{ key: 'details', label: t('teams.details'), icon: FileEdit },
{ key: 'members', label: t('teams.members'), icon: Users, badge: memberCount },
]}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={
<>
<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,47 +85,74 @@ 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">
<input
type="text"
value={form.name}
onChange={e => update('name', e.target.value)}
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
placeholder={t('tracks.trackName')}
/>
<div className="flex items-center gap-2 mt-2">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
{typeInfo.label}
</span>
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${
form.status === 'active' ? 'bg-emerald-100 text-emerald-700' :
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-600'
}`}>
{form.status?.charAt(0).toUpperCase() + form.status?.slice(1)}
</span>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
)
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 (
<>
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
{/* Details Section */}
<CollapsibleSection title={t('tracks.details')}>
<div className="px-5 pb-4 space-y-3">
<TabbedModal
onClose={onClose}
size="md"
header={
<>
<input
type="text"
value={form.name}
onChange={e => update('name', e.target.value)}
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
placeholder={t('tracks.trackName')}
/>
<div className="flex items-center gap-2 mt-2">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
{typeInfo.label}
</span>
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${
form.status === 'active' ? 'bg-emerald-100 text-emerald-700' :
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-600'
}`}>
{form.status?.charAt(0).toUpperCase() + form.status?.slice(1)}
</span>
</div>
</>
}
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={
<>
<div className="flex items-center gap-2">
{onDelete && !isCreateMode && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('common.delete')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<div className="flex items-center gap-2.5">
{dirty && (
<button
onClick={handleSave}
disabled={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,106 +217,82 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
placeholder="Keywords, targeting details..."
/>
</div>
</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>
)}
{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" />
<div className="flex items-center gap-2 mt-2">
{Number(form.clicks) > 0 && Number(form.budget_spent) > 0 && (
<span className="text-[10px] text-text-tertiary">
CPC: {(Number(form.budget_spent) / Number(form.clicks)).toFixed(2)} {currencySymbol}
</span>
)}
{Number(form.impressions) > 0 && Number(form.clicks) > 0 && (
<span className="text-[10px] text-text-tertiary">
CTR: {(Number(form.clicks) / Number(form.impressions) * 100).toFixed(2)}%
</span>
)}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.budgetSpent')} ({currencySymbol})</label>
<input
type="number"
value={form.budget_spent}
onChange={e => update('budget_spent', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.revenue')} ({currencySymbol})</label>
<input
type="number"
value={form.revenue}
onChange={e => update('revenue', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.impressions')}</label>
<input
type="number"
value={form.impressions}
onChange={e => update('impressions', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.clicks')}</label>
<input
type="number"
value={form.clicks}
onChange={e => update('clicks', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.conversions')}</label>
<input
type="number"
value={form.conversions}
onChange={e => update('conversions', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
</div>
</div>
</CollapsibleSection>
{/* Metrics Section (hidden in create mode) */}
{!isCreateMode && (
<CollapsibleSection title={t('tracks.metrics')} defaultOpen={!!scrollToMetrics} noBorder>
<div className="px-5 pb-4 space-y-3">
{Number(form.budget_allocated) > 0 && (
<div className="p-3 bg-surface-secondary rounded-lg">
<BudgetBar budget={Number(form.budget_allocated)} spent={Number(form.budget_spent) || 0} height="h-2" />
<div className="flex items-center gap-2 mt-2">
{Number(form.clicks) > 0 && Number(form.budget_spent) > 0 && (
<span className="text-[10px] text-text-tertiary">
CPC: {(Number(form.budget_spent) / Number(form.clicks)).toFixed(2)} {currencySymbol}
</span>
)}
{Number(form.impressions) > 0 && Number(form.clicks) > 0 && (
<span className="text-[10px] text-text-tertiary">
CTR: {(Number(form.clicks) / Number(form.impressions) * 100).toFixed(2)}%
</span>
)}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.budgetSpent')} ({currencySymbol})</label>
<input
type="number"
value={form.budget_spent}
onChange={e => update('budget_spent', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.revenue')} ({currencySymbol})</label>
<input
type="number"
value={form.revenue}
onChange={e => update('revenue', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.impressions')}</label>
<input
type="number"
value={form.impressions}
onChange={e => update('impressions', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.clicks')}</label>
<input
type="number"
value={form.clicks}
onChange={e => update('clicks', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.conversions')}</label>
<input
type="number"
value={form.conversions}
onChange={e => update('conversions', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
</div>
</div>
</CollapsibleSection>
)}
</SlidePanel>
</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) => {