feat: convert all slide panels to tabbed modals with shared TabbedModal component
All checks were successful
Deploy / deploy (push) Successful in 11s
All checks were successful
Deploy / deploy (push) Successful in 11s
Extract reusable TabbedModal component (portal, backdrop, tab bar with icons/badges/underline, scrollable body, footer) and convert all 9 detail panels from SlidePanel+CollapsibleSection to tabbed modal layout: - PostDetailPanel (5 tabs), TaskDetailPanel (3), ProjectEditPanel (2) - TrackDetailPanel (2), CampaignDetailPanel (3), TeamMemberPanel (3) - TeamPanel (2), IssueDetailPanel (4), ArtefactDetailPanel (4) Also adds post versioning system (server routes + frontend). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { Check, ChevronDown, X } from 'lucide-react'
|
||||
|
||||
export default function ApproverMultiSelect({ users = [], selected = [], onChange }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [dropUp, setDropUp] = useState(false)
|
||||
const wrapperRef = useRef(null)
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
@@ -17,6 +18,14 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [open])
|
||||
|
||||
// Detect if dropdown should open upward
|
||||
useEffect(() => {
|
||||
if (!open || !wrapperRef.current) return
|
||||
const rect = wrapperRef.current.getBoundingClientRect()
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
setDropUp(spaceBelow < 220)
|
||||
}, [open])
|
||||
|
||||
const toggle = (userId) => {
|
||||
const id = String(userId)
|
||||
const next = selected.includes(id) ? selected.filter(s => s !== id) : [...selected, id]
|
||||
@@ -58,7 +67,7 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
|
||||
<ChevronDown className={`w-4 h-4 text-text-tertiary ml-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||
<div className={`absolute z-50 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'}`}>
|
||||
{users.map(u => {
|
||||
const uid = String(u._id || u.id || u.Id)
|
||||
const isSelected = selected.includes(uid)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, Copy, Check, ExternalLink, Upload, Globe, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save } from 'lucide-react'
|
||||
import { Plus, Copy, Check, ExternalLink, Upload, Globe, Trash2, FileText, Image as ImageIcon, Film, Sparkles, MessageSquare, Save, FileEdit, Layers, ShieldCheck } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api } from '../utils/api'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import { useToast } from './ToastContainer'
|
||||
import ArtefactVersionTimeline from './ArtefactVersionTimeline'
|
||||
import ApproverMultiSelect from './ApproverMultiSelect'
|
||||
@@ -42,6 +42,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [reviewUrl, setReviewUrl] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
|
||||
// Editable fields
|
||||
const [editTitle, setEditTitle] = useState(artefact.title || '')
|
||||
@@ -339,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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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('')
|
||||
@@ -26,7 +26,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
const [resolutionSummary, setResolutionSummary] = useState('')
|
||||
const [newUpdate, setNewUpdate] = useState('')
|
||||
const [updateIsPublic, setUpdateIsPublic] = useState(false)
|
||||
|
||||
|
||||
// Modals
|
||||
const [showResolveModal, setShowResolveModal] = useState(false)
|
||||
const [showDeclineModal, setShowDeclineModal] = useState(false)
|
||||
@@ -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)} • {att.uploaded_by}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -533,8 +544,8 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SlidePanel>
|
||||
)}
|
||||
</TabbedModal>
|
||||
|
||||
{/* Resolve Modal */}
|
||||
{showResolveModal && (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,10 @@
|
||||
import { useState, useEffect, useRef, useContext } from 'react'
|
||||
import { X, Trash2, Upload } from 'lucide-react'
|
||||
import { Trash2, Upload, FileEdit, MessageSquare } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api, getBrandColor } from '../utils/api'
|
||||
import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CollapsibleSection from './CollapsibleSection'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import { AppContext } from '../App'
|
||||
|
||||
export default function ProjectEditPanel({ project, onClose, onSave, onDelete, brands, teamMembers }) {
|
||||
@@ -17,6 +16,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [thumbnailUploading, setThumbnailUploading] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
|
||||
const projectId = project?._id || project?.id
|
||||
if (!project) return null
|
||||
@@ -107,10 +107,17 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
return project.brand_name || project.brandName || null
|
||||
})()
|
||||
|
||||
const header = (
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
const tabs = [
|
||||
{ key: 'details', label: t('projects.details'), icon: FileEdit },
|
||||
{ key: 'discussion', label: t('projects.discussion'), icon: MessageSquare },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
header={<>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
@@ -134,23 +141,37 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<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}
|
||||
|
||||
97
client/src/components/TabbedModal.jsx
Normal file
97
client/src/components/TabbedModal.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
const SIZE_CLASSES = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
}
|
||||
|
||||
export default function TabbedModal({
|
||||
onClose,
|
||||
size = 'md',
|
||||
header,
|
||||
tabs = [],
|
||||
activeTab,
|
||||
onTabChange,
|
||||
footer,
|
||||
children,
|
||||
}) {
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [])
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[5vh] px-4">
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" onClick={onClose} />
|
||||
|
||||
<div className={`relative bg-white rounded-2xl shadow-2xl w-full ${SIZE_CLASSES[size] || SIZE_CLASSES.md} max-h-[90vh] flex flex-col animate-scale-in`}>
|
||||
{/* Header */}
|
||||
<div className="shrink-0">
|
||||
<div className="px-6 pt-5 pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
{header}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0 -mt-1 -me-1"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
{tabs.length > 0 && (
|
||||
<div className="flex gap-0 px-6 border-b border-border overflow-x-auto">
|
||||
{tabs.map(tab => {
|
||||
const TabIcon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => onTabChange(tab.key)}
|
||||
className={`relative flex items-center gap-2 px-4 py-3 text-[13px] font-medium whitespace-nowrap transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'text-brand-primary'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{TabIcon && <TabIcon className="w-4 h-4" />}
|
||||
{tab.label}
|
||||
{tab.badge > 0 && (
|
||||
<span className={`text-[10px] px-1.5 py-px rounded-full font-medium leading-tight ${
|
||||
activeTab === tab.key ? 'bg-brand-primary/10 text-brand-primary' : 'bg-surface-tertiary text-text-tertiary'
|
||||
}`}>
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
{activeTab === tab.key && (
|
||||
<span className="absolute bottom-0 inset-x-1 h-0.5 bg-brand-primary rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="border-t border-border px-6 py-3.5 flex items-center justify-between shrink-0 rounded-b-2xl bg-white">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { X, Trash2, AlertCircle, Upload, FileText, Star } from 'lucide-react'
|
||||
import { X, Trash2, AlertCircle, Upload, FileText, Star, FileEdit, Paperclip, MessageSquare } from 'lucide-react'
|
||||
import { PRIORITY_CONFIG, getBrandColor, api } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CollapsibleSection from './CollapsibleSection'
|
||||
import TabbedModal from './TabbedModal'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
export default function TaskDetailPanel({ task, onClose, onSave, onDelete, projects, users, brands }) {
|
||||
const { t } = useLanguage()
|
||||
const fileInputRef = useRef(null)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
const [form, setForm] = useState({
|
||||
title: '', description: '', project_id: '', assigned_to: '',
|
||||
priority: 'medium', status: 'todo', start_date: '', due_date: '',
|
||||
@@ -186,11 +186,19 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
const selectedProject = projects?.find(p => String(p._id || p.id) === String(form.project_id))
|
||||
const brandName = selectedProject ? (selectedProject.brand_name || selectedProject.brandName) : (task.brand_name || task.brandName)
|
||||
|
||||
const header = (
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
const attachmentCount = attachments.length + pendingFiles.length
|
||||
|
||||
const tabs = [
|
||||
{ key: 'details', label: t('tasks.details'), icon: FileEdit },
|
||||
{ key: 'attachments', label: t('tasks.attachments'), icon: Paperclip, badge: attachmentCount },
|
||||
...(!isCreateMode ? [{ key: 'discussion', label: t('tasks.discussion'), icon: MessageSquare }] : []),
|
||||
]
|
||||
|
||||
const headerContent = (
|
||||
<>
|
||||
{/* Thumbnail banner */}
|
||||
{currentThumbnail && (
|
||||
<div className="relative -mx-5 -mt-4 mb-3 h-32 overflow-hidden">
|
||||
<div className="relative -mx-6 -mt-5 mb-3 h-32 overflow-hidden rounded-t-2xl">
|
||||
<img src={currentThumbnail} alt="" className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white/80 to-transparent" />
|
||||
<button
|
||||
@@ -202,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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -915,5 +915,22 @@
|
||||
"review.confirmApprovePostDesc": "هل أنت متأكد من الموافقة على هذا المنشور؟",
|
||||
"review.confirmRejectPostDesc": "هل أنت متأكد من رفض هذا المنشور؟ يرجى تقديم ملاحظات توضح السبب.",
|
||||
"review.feedbackRequired": "الملاحظات (مطلوبة)",
|
||||
"review.feedbackRequiredError": "يرجى تقديم ملاحظات عند الرفض"
|
||||
"review.feedbackRequiredError": "يرجى تقديم ملاحظات عند الرفض",
|
||||
"posts.versions": "الإصدارات",
|
||||
"posts.newVersion": "إصدار جديد",
|
||||
"posts.createNewVersion": "إنشاء إصدار جديد",
|
||||
"posts.createVersion": "إنشاء إصدار",
|
||||
"posts.creatingVersion": "جارٍ الإنشاء...",
|
||||
"posts.whatChanged": "ما الذي تغير في هذا الإصدار؟",
|
||||
"posts.copyLanguages": "نسخ اللغات من الإصدار السابق",
|
||||
"posts.languages": "اللغات",
|
||||
"posts.addLanguage": "إضافة لغة",
|
||||
"posts.selectLanguage": "اختر لغة...",
|
||||
"posts.enterContent": "أدخل المحتوى بهذه اللغة...",
|
||||
"posts.noLanguages": "لم تتم إضافة لغات بعد",
|
||||
"posts.noVersions": "لا توجد إصدارات بعد. أنشئ إصدارًا لبدء إدارة المحتوى متعدد اللغات والوسائط.",
|
||||
"posts.deleteLanguage": "حذف هذه اللغة؟",
|
||||
"posts.deleteLanguageConfirm": "سيتم حذف محتوى اللغة من هذا الإصدار.",
|
||||
"posts.media": "الوسائط",
|
||||
"posts.noMedia": "لم يتم رفع ملفات وسائط"
|
||||
}
|
||||
@@ -915,5 +915,22 @@
|
||||
"review.confirmApprovePostDesc": "Are you sure you want to approve this post?",
|
||||
"review.confirmRejectPostDesc": "Are you sure you want to reject this post? Please provide feedback explaining why.",
|
||||
"review.feedbackRequired": "Feedback (required)",
|
||||
"review.feedbackRequiredError": "Please provide feedback when rejecting"
|
||||
"review.feedbackRequiredError": "Please provide feedback when rejecting",
|
||||
"posts.versions": "Versions",
|
||||
"posts.newVersion": "New Version",
|
||||
"posts.createNewVersion": "Create New Version",
|
||||
"posts.createVersion": "Create Version",
|
||||
"posts.creatingVersion": "Creating...",
|
||||
"posts.whatChanged": "What changed in this version?",
|
||||
"posts.copyLanguages": "Copy languages from previous version",
|
||||
"posts.languages": "Languages",
|
||||
"posts.addLanguage": "Add Language",
|
||||
"posts.selectLanguage": "Select a language...",
|
||||
"posts.enterContent": "Enter the content in this language...",
|
||||
"posts.noLanguages": "No languages added yet",
|
||||
"posts.noVersions": "No versions yet. Create one to start managing multilingual content and media.",
|
||||
"posts.deleteLanguage": "Delete this language?",
|
||||
"posts.deleteLanguageConfirm": "This will remove the language content from this version.",
|
||||
"posts.media": "Media",
|
||||
"posts.noMedia": "No media files uploaded"
|
||||
}
|
||||
Reference in New Issue
Block a user