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 }) {
|
export default function ApproverMultiSelect({ users = [], selected = [], onChange }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [dropUp, setDropUp] = useState(false)
|
||||||
const wrapperRef = useRef(null)
|
const wrapperRef = useRef(null)
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
@@ -17,6 +18,14 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
|
|||||||
return () => document.removeEventListener('mousedown', handleClick)
|
return () => document.removeEventListener('mousedown', handleClick)
|
||||||
}, [open])
|
}, [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 toggle = (userId) => {
|
||||||
const id = String(userId)
|
const id = String(userId)
|
||||||
const next = selected.includes(id) ? selected.filter(s => s !== id) : [...selected, id]
|
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' : ''}`} />
|
<ChevronDown className={`w-4 h-4 text-text-tertiary ml-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||||
</div>
|
</div>
|
||||||
{open && (
|
{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 => {
|
{users.map(u => {
|
||||||
const uid = String(u._id || u.id || u.Id)
|
const uid = String(u._id || u.id || u.Id)
|
||||||
const isSelected = selected.includes(uid)
|
const isSelected = selected.includes(uid)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState, useEffect, useContext } from 'react'
|
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 { AppContext } from '../App'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
import SlidePanel from './SlidePanel'
|
import TabbedModal from './TabbedModal'
|
||||||
import { useToast } from './ToastContainer'
|
import { useToast } from './ToastContainer'
|
||||||
import ArtefactVersionTimeline from './ArtefactVersionTimeline'
|
import ArtefactVersionTimeline from './ArtefactVersionTimeline'
|
||||||
import ApproverMultiSelect from './ApproverMultiSelect'
|
import ApproverMultiSelect from './ApproverMultiSelect'
|
||||||
@@ -42,6 +42,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [reviewUrl, setReviewUrl] = useState('')
|
const [reviewUrl, setReviewUrl] = useState('')
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState('details')
|
||||||
|
|
||||||
// Editable fields
|
// Editable fields
|
||||||
const [editTitle, setEditTitle] = useState(artefact.title || '')
|
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
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<SlidePanel onClose={onClose} maxWidth="700px">
|
<TabbedModal onClose={onClose} size="xl">
|
||||||
<div className="flex items-center justify-center h-64">
|
<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 className="w-8 h-8 border-4 border-brand-primary border-t-transparent rounded-full animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
</SlidePanel>
|
</TabbedModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SlidePanel onClose={onClose} maxWidth="700px" header={
|
<>
|
||||||
<div className="px-6 py-4 border-b border-border">
|
<TabbedModal
|
||||||
<div className="flex items-start gap-3">
|
onClose={onClose}
|
||||||
<div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0">
|
size="xl"
|
||||||
<TypeIcon className="w-5 h-5 text-brand-primary" />
|
header={
|
||||||
</div>
|
<>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex items-start gap-3">
|
||||||
<input
|
<div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0">
|
||||||
type="text"
|
<TypeIcon className="w-5 h-5 text-brand-primary" />
|
||||||
value={editTitle}
|
</div>
|
||||||
onChange={e => setEditTitle(e.target.value)}
|
<div className="flex-1 min-w-0">
|
||||||
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"
|
<input
|
||||||
/>
|
type="text"
|
||||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
value={editTitle}
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[artefact.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
onChange={e => setEditTitle(e.target.value)}
|
||||||
{artefact.status?.replace('_', ' ')}
|
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"
|
||||||
</span>
|
/>
|
||||||
<span className="text-xs text-text-tertiary capitalize">{artefact.type}</span>
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
{artefact.creator_name && (
|
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[artefact.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
||||||
<span className="text-xs text-text-secondary font-medium">
|
{artefact.status?.replace('_', ' ')}
|
||||||
{t('review.createdBy')} <strong className="text-text-primary">{artefact.creator_name}</strong>
|
</span>
|
||||||
</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>
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveDraft}
|
onClick={handleSaveDraft}
|
||||||
disabled={savingDraft}
|
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')}
|
title={t('artefacts.saveDraftTooltip')}
|
||||||
>
|
>
|
||||||
<Save className="w-3.5 h-3.5" />
|
<Save className="w-3.5 h-3.5" />
|
||||||
{savingDraft ? t('artefacts.savingDraft') : t('artefacts.saveDraft')}
|
{savingDraft ? t('artefacts.savingDraft') : t('artefacts.saveDraft')}
|
||||||
</button>
|
</button>
|
||||||
{onDelete && (
|
</>
|
||||||
<button
|
}
|
||||||
onClick={() => setShowDeleteArtefactConfirm(true)}
|
>
|
||||||
disabled={deleting}
|
{/* Details Tab */}
|
||||||
className="p-1.5 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
{activeTab === 'details' && (
|
||||||
title={t('artefacts.deleteArtefactTooltip')}
|
<div className="p-6 space-y-5">
|
||||||
>
|
{/* Description */}
|
||||||
<Trash2 className="w-4 h-4" />
|
<div>
|
||||||
</button>
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('artefacts.descriptionLabel')}</h4>
|
||||||
)}
|
<textarea
|
||||||
</div>
|
value={editDescription}
|
||||||
</div>
|
onChange={e => setEditDescription(e.target.value)}
|
||||||
</div>
|
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"
|
||||||
<div className="p-6 space-y-6">
|
placeholder={t('artefacts.descriptionFieldPlaceholder')}
|
||||||
{/* Description */}
|
/>
|
||||||
<div>
|
</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 */}
|
{/* Project & Campaign dropdowns */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<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' && (
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.project')}</h4>
|
||||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.languages')}</h4>
|
<select
|
||||||
<button
|
value={editProjectId}
|
||||||
onClick={() => setShowLanguageModal(true)}
|
onChange={e => {
|
||||||
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"
|
setEditProjectId(e.target.value)
|
||||||
>
|
handleUpdateField('project_id', e.target.value)
|
||||||
<Plus className="w-3 h-3" />
|
}}
|
||||||
{t('artefacts.addLanguage')}
|
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"
|
||||||
</button>
|
>
|
||||||
</div>
|
<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>)}
|
||||||
{versionData.texts && versionData.texts.length > 0 ? (
|
</select>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* DESIGN TYPE: Image gallery */}
|
|
||||||
{artefact.type === 'design' && (
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.campaign')}</h4>
|
||||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.imagesLabel')}</h4>
|
<select
|
||||||
<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">
|
value={editCampaignId}
|
||||||
<Upload className="w-3 h-3" />
|
onChange={e => {
|
||||||
{uploading ? t('artefacts.uploading') : t('artefacts.uploadImage')}
|
setEditCampaignId(e.target.value)
|
||||||
<input
|
handleUpdateField('campaign_id', e.target.value)
|
||||||
type="file"
|
}}
|
||||||
className="hidden"
|
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"
|
||||||
accept="image/*"
|
>
|
||||||
onChange={handleFileUpload}
|
<option value="">—</option>
|
||||||
disabled={uploading}
|
{campaigns.map(c => <option key={c.Id || c._id || c.id} value={c.Id || c._id || c.id}>{c.name || c.title}</option>)}
|
||||||
/>
|
</select>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* VIDEO TYPE: Files and Drive links */}
|
{/* Approvers */}
|
||||||
{artefact.type === 'video' && (
|
<div>
|
||||||
<div>
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.approversLabel')}</h4>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<ApproverMultiSelect
|
||||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.videosLabel')}</h4>
|
users={assignableUsers}
|
||||||
<button
|
selected={editApproverIds}
|
||||||
onClick={() => setShowVideoModal(true)}
|
onChange={ids => {
|
||||||
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"
|
setEditApproverIds(ids)
|
||||||
>
|
handleUpdateField('approver_ids', ids.length > 0 ? ids.join(',') : '')
|
||||||
<Plus className="w-3 h-3" />
|
}}
|
||||||
{t('artefacts.addVideoBtn')}
|
/>
|
||||||
</button>
|
</div>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Comments */}
|
{/* Versions Tab */}
|
||||||
{selectedVersion && (
|
{activeTab === 'versions' && (
|
||||||
<div className="border-t border-border pt-6">
|
<div className="p-6 space-y-5">
|
||||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">
|
{/* Version Timeline */}
|
||||||
{t('artefacts.comments')} ({comments.length})
|
<div>
|
||||||
</h4>
|
<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">
|
{/* Type-specific content */}
|
||||||
{comments.map(comment => (
|
{versionData && selectedVersion && (
|
||||||
<div key={comment.Id} className="flex gap-3">
|
<div className="border-t border-border pt-5">
|
||||||
<div className="w-8 h-8 rounded-full bg-brand-primary/10 flex items-center justify-center shrink-0">
|
{/* COPY TYPE: Language entries */}
|
||||||
{comment.user_avatar ? (
|
{artefact.type === 'copy' && (
|
||||||
<img src={comment.user_avatar} alt="" className="w-full h-full rounded-full object-cover" />
|
<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>
|
||||||
<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>
|
{/* DESIGN TYPE: Image gallery */}
|
||||||
<span className="text-xs text-text-tertiary">
|
{artefact.type === 'design' && (
|
||||||
{new Date(comment.CreatedAt).toLocaleString()}
|
<div>
|
||||||
</span>
|
<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>
|
</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>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
{/* VIDEO TYPE: Files and Drive links */}
|
||||||
<input
|
{artefact.type === 'video' && (
|
||||||
type="text"
|
<div>
|
||||||
value={newComment}
|
<div className="flex items-center justify-between mb-3">
|
||||||
onChange={e => setNewComment(e.target.value)}
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.videosLabel')}</h4>
|
||||||
onKeyPress={e => e.key === 'Enter' && handleAddComment()}
|
<button
|
||||||
placeholder={t('artefacts.addCommentPlaceholder')}
|
onClick={() => setShowVideoModal(true)}
|
||||||
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"
|
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"
|
||||||
/>
|
>
|
||||||
<button
|
<Plus className="w-3 h-3" />
|
||||||
onClick={handleAddComment}
|
{t('artefacts.addVideoBtn')}
|
||||||
disabled={addingComment || !newComment.trim()}
|
</button>
|
||||||
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"
|
</div>
|
||||||
>
|
|
||||||
{t('artefacts.sendComment')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Submit for Review */}
|
{versionData.attachments && versionData.attachments.length > 0 ? (
|
||||||
{['draft', 'revision_requested', 'rejected'].includes(artefact.status) && (
|
<div className="space-y-3">
|
||||||
<div className="border-t border-border pt-6">
|
{versionData.attachments.map(att => (
|
||||||
<button
|
<div key={att.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
|
||||||
onClick={handleSubmitReview}
|
{att.drive_url ? (
|
||||||
disabled={submitting}
|
<div>
|
||||||
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"
|
<div className="flex items-center justify-between mb-2">
|
||||||
>
|
<span className="text-sm font-medium text-text-primary">{t('artefacts.googleDriveVideo')}</span>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<button
|
||||||
{submitting ? t('artefacts.submitting') : t('artefacts.submitForReview')}
|
onClick={() => setConfirmDeleteAttId(att.Id)}
|
||||||
</button>
|
className="text-red-600 hover:text-red-700"
|
||||||
</div>
|
>
|
||||||
)}
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
{/* Review Link */}
|
</div>
|
||||||
{reviewUrl && (
|
<iframe
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
src={getDriveEmbedUrl(att.drive_url)}
|
||||||
<div className="text-sm font-semibold text-blue-900 mb-2">{t('artefacts.reviewLinkTitle')}</div>
|
className="w-full h-64 rounded border border-border"
|
||||||
<div className="flex items-center gap-2">
|
allow="autoplay"
|
||||||
<input
|
/>
|
||||||
type="text"
|
</div>
|
||||||
value={reviewUrl}
|
) : (
|
||||||
readOnly
|
<div>
|
||||||
className="flex-1 px-3 py-2 text-sm bg-surface border border-border rounded"
|
<div className="flex items-center justify-between mb-2">
|
||||||
/>
|
<span className="text-sm font-medium text-text-primary truncate">{att.original_name}</span>
|
||||||
<button
|
<button
|
||||||
onClick={copyReviewLink}
|
onClick={() => setConfirmDeleteAttId(att.Id)}
|
||||||
className="p-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
className="text-red-600 hover:text-red-700"
|
||||||
>
|
>
|
||||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<video
|
||||||
)}
|
src={att.url}
|
||||||
|
controls
|
||||||
{/* Feedback */}
|
className="w-full rounded border border-border"
|
||||||
{artefact.feedback && (
|
/>
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
</div>
|
||||||
<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>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
|
) : (
|
||||||
{/* Approval Info */}
|
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
||||||
{artefact.status === 'approved' && artefact.approved_by_name && (
|
<Film className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
||||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
<p className="text-sm text-text-secondary">{t('artefacts.noVideos')}</p>
|
||||||
<div className="font-medium text-emerald-900">{t('artefacts.approvedByLabel')} {artefact.approved_by_name}</div>
|
</div>
|
||||||
{artefact.approved_at && (
|
)}
|
||||||
<div className="text-sm text-emerald-700 mt-1">
|
</div>
|
||||||
{new Date(artefact.approved_at).toLocaleString()}
|
)}
|
||||||
</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 */}
|
{/* Language Modal */}
|
||||||
<Modal isOpen={showLanguageModal} onClose={() => setShowLanguageModal(false)} title={t('artefacts.addLanguage')} size="md">
|
<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')}
|
{t('artefacts.deleteArtefactDesc')}
|
||||||
</Modal>
|
</Modal>
|
||||||
</SlidePanel>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useState, useEffect, useContext } from 'react'
|
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 { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { PLATFORMS, getBrandColor } from '../utils/api'
|
import { PLATFORMS, getBrandColor } from '../utils/api'
|
||||||
import CommentsSection from './CommentsSection'
|
import CommentsSection from './CommentsSection'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
import SlidePanel from './SlidePanel'
|
import TabbedModal from './TabbedModal'
|
||||||
import CollapsibleSection from './CollapsibleSection'
|
|
||||||
import BudgetBar from './BudgetBar'
|
import BudgetBar from './BudgetBar'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
|
|
||||||
@@ -16,6 +15,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
|||||||
const [dirty, setDirty] = useState(false)
|
const [dirty, setDirty] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState('details')
|
||||||
|
|
||||||
const campaignId = campaign?._id || campaign?.id
|
const campaignId = campaign?._id || campaign?.id
|
||||||
const isCreateMode = !campaignId
|
const isCreateMode = !campaignId
|
||||||
@@ -102,50 +102,79 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
|||||||
return campaign.brand_name || campaign.brandName || null
|
return campaign.brand_name || campaign.brandName || null
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const header = (
|
const tabs = isCreateMode
|
||||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
? [{ key: 'details', label: t('campaigns.details'), icon: FileEdit }]
|
||||||
<div className="flex items-start justify-between gap-3">
|
: [
|
||||||
<div className="flex-1 min-w-0">
|
{ key: 'details', label: t('campaigns.details'), icon: FileEdit },
|
||||||
<input
|
{ key: 'performance', label: t('campaigns.performance'), icon: BarChart3 },
|
||||||
type="text"
|
{ key: 'discussion', label: t('campaigns.discussion'), icon: MessageSquare },
|
||||||
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>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
|
<TabbedModal
|
||||||
{/* Details Section */}
|
onClose={onClose}
|
||||||
<CollapsibleSection title={t('campaigns.details')}>
|
size="lg"
|
||||||
<div className="px-5 pb-4 space-y-3">
|
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>
|
<div>
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.description')}</label>
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.description')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -274,159 +303,134 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</CollapsibleSection>
|
)}
|
||||||
|
|
||||||
{/* Performance Section (hidden in create mode) */}
|
{/* Performance Tab */}
|
||||||
{!isCreateMode && (
|
{activeTab === 'performance' && !isCreateMode && (
|
||||||
<CollapsibleSection title={t('campaigns.performance')}>
|
<div className="p-6 space-y-3">
|
||||||
<div className="px-5 pb-4 space-y-3">
|
{(form.budget_spent || form.impressions || form.clicks) && (
|
||||||
{(form.budget_spent || form.impressions || form.clicks) && (
|
<div className="grid grid-cols-4 gap-2">
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
||||||
<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" />
|
||||||
<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-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 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>
|
|
||||||
</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" />
|
||||||
{form.budget && form.budget_spent && (
|
<div className="text-xs font-bold text-purple-600">{form.impressions ? Number(form.impressions).toLocaleString() : '—'}</div>
|
||||||
<div className="p-3 bg-surface-secondary rounded-lg">
|
<div className="text-[10px] text-text-tertiary">{t('campaigns.impressions')}</div>
|
||||||
<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>
|
||||||
)}
|
<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="grid grid-cols-2 gap-3">
|
<div className="text-xs font-bold text-blue-600">{form.clicks ? Number(form.clicks).toLocaleString() : '—'}</div>
|
||||||
<div>
|
<div className="text-[10px] text-text-tertiary">{t('campaigns.clicks')}</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>
|
</div>
|
||||||
<div>
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.revenue')} ({currencySymbol})</label>
|
<Target className="w-3.5 h-3.5 mx-auto mb-0.5 text-emerald-600" />
|
||||||
<input
|
<div className="text-xs font-bold text-emerald-600">{form.conversions ? Number(form.conversions).toLocaleString() : '—'}</div>
|
||||||
type="number"
|
<div className="text-[10px] text-text-tertiary">{t('campaigns.conversions')}</div>
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
{form.budget && form.budget_spent && (
|
||||||
<div>
|
<div className="p-3 bg-surface-secondary rounded-lg">
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.impressions')}</label>
|
<BudgetBar budget={Number(form.budget)} spent={Number(form.budget_spent)} />
|
||||||
<input
|
<div className="flex items-center gap-2 mt-2">
|
||||||
type="number"
|
{Number(form.budget_spent) > 0 && (
|
||||||
value={form.impressions}
|
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${
|
||||||
onChange={e => update('impressions', e.target.value)}
|
((Number(form.revenue) - Number(form.budget_spent)) / Number(form.budget_spent) * 100) >= 0
|
||||||
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"
|
? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'
|
||||||
/>
|
}`}>
|
||||||
</div>
|
ROI {((Number(form.revenue || 0) - Number(form.budget_spent)) / Number(form.budget_spent) * 100).toFixed(0)}%
|
||||||
<div>
|
</span>
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.clicks')}</label>
|
)}
|
||||||
<input
|
{Number(form.clicks) > 0 && Number(form.budget_spent) > 0 && (
|
||||||
type="number"
|
<span className="text-[10px] text-text-tertiary">
|
||||||
value={form.clicks}
|
CPC: {(Number(form.budget_spent) / Number(form.clicks)).toFixed(2)} {currencySymbol}
|
||||||
onChange={e => update('clicks', e.target.value)}
|
</span>
|
||||||
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"
|
)}
|
||||||
/>
|
{Number(form.impressions) > 0 && Number(form.clicks) > 0 && (
|
||||||
</div>
|
<span className="text-[10px] text-text-tertiary">
|
||||||
<div>
|
CTR: {(Number(form.clicks) / Number(form.impressions) * 100).toFixed(2)}%
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.conversions')}</label>
|
</span>
|
||||||
<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>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.notes')}</label>
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.budgetSpent')} ({currencySymbol})</label>
|
||||||
<textarea
|
<input
|
||||||
value={form.notes}
|
type="number"
|
||||||
onChange={e => update('notes', e.target.value)}
|
value={form.budget_spent}
|
||||||
rows={2}
|
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 resize-none"
|
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"
|
||||||
placeholder="Performance notes..."
|
/>
|
||||||
|
</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>
|
||||||
</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) */}
|
{/* Discussion Tab */}
|
||||||
{!isCreateMode && (
|
{activeTab === 'discussion' && !isCreateMode && (
|
||||||
<CollapsibleSection title={t('campaigns.discussion')} noBorder>
|
<div className="p-6 space-y-3">
|
||||||
<div className="px-5 pb-5">
|
<CommentsSection entityType="campaign" entityId={campaignId} />
|
||||||
<CommentsSection entityType="campaign" entityId={campaignId} />
|
</div>
|
||||||
</div>
|
|
||||||
</CollapsibleSection>
|
|
||||||
)}
|
)}
|
||||||
</SlidePanel>
|
</TabbedModal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showDeleteConfirm}
|
isOpen={showDeleteConfirm}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useState, useEffect, useContext } from 'react'
|
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 { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
|
||||||
import SlidePanel from './SlidePanel'
|
import TabbedModal from './TabbedModal'
|
||||||
import FormInput from './FormInput'
|
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
import { useToast } from './ToastContainer'
|
import { useToast } from './ToastContainer'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
@@ -18,6 +17,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
|||||||
const [initialLoading, setInitialLoading] = useState(true)
|
const [initialLoading, setInitialLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [uploadingFile, setUploadingFile] = useState(false)
|
const [uploadingFile, setUploadingFile] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState('details')
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [assignedTo, setAssignedTo] = useState('')
|
const [assignedTo, setAssignedTo] = useState('')
|
||||||
@@ -26,7 +26,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
|||||||
const [resolutionSummary, setResolutionSummary] = useState('')
|
const [resolutionSummary, setResolutionSummary] = useState('')
|
||||||
const [newUpdate, setNewUpdate] = useState('')
|
const [newUpdate, setNewUpdate] = useState('')
|
||||||
const [updateIsPublic, setUpdateIsPublic] = useState(false)
|
const [updateIsPublic, setUpdateIsPublic] = useState(false)
|
||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
const [showResolveModal, setShowResolveModal] = useState(false)
|
const [showResolveModal, setShowResolveModal] = useState(false)
|
||||||
const [showDeclineModal, setShowDeclineModal] = useState(false)
|
const [showDeclineModal, setShowDeclineModal] = useState(false)
|
||||||
@@ -190,31 +190,33 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
|||||||
|
|
||||||
if (initialLoading || !issueData) {
|
if (initialLoading || !issueData) {
|
||||||
return (
|
return (
|
||||||
<SlidePanel onClose={onClose} maxWidth="600px">
|
<TabbedModal onClose={onClose} size="lg">
|
||||||
<div className="flex items-center justify-center h-96">
|
<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 className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-primary"></div>
|
||||||
</div>
|
</div>
|
||||||
</SlidePanel>
|
</TabbedModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusConfig = STATUS_CONFIG[issueData.status] || STATUS_CONFIG.new
|
const statusConfig = STATUS_CONFIG[issueData.status] || STATUS_CONFIG.new
|
||||||
const priorityConfig = PRIORITY_CONFIG[issueData.priority] || PRIORITY_CONFIG.medium
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SlidePanel
|
<TabbedModal
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
maxWidth="600px"
|
size="lg"
|
||||||
header={
|
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">{issueData.title}</h2>
|
||||||
<h2 className="text-lg font-bold text-text-primary flex-1">{issueData.title}</h2>
|
<div className="flex items-center gap-2 flex-wrap mt-2">
|
||||||
<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">
|
|
||||||
<span className={`text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1.5 ${statusConfig.bg} ${statusConfig.text}`}>
|
<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>
|
<span className={`w-1.5 h-1.5 rounded-full ${statusConfig.dot}`}></span>
|
||||||
{statusConfig.label}
|
{statusConfig.label}
|
||||||
@@ -234,195 +236,207 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
|
}
|
||||||
|
tabs={tabs}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={setActiveTab}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={copyTrackingLink}
|
||||||
|
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
{t('issues.publicTrackingLink')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary transition-colors"
|
||||||
|
>
|
||||||
|
{t('common.close') || 'Close'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="p-4 space-y-6">
|
{/* Details Tab */}
|
||||||
{/* Submitter Info */}
|
{activeTab === 'details' && (
|
||||||
<div className="bg-surface-secondary rounded-lg p-4">
|
<div className="p-6 space-y-5">
|
||||||
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.submitterInfo')}</h3>
|
{/* Submitter Info */}
|
||||||
<div className="space-y-1 text-sm">
|
<div className="bg-surface-secondary rounded-lg p-4">
|
||||||
<div><span className="text-text-tertiary">{t('issues.nameLabel')}</span> <span className="text-text-primary font-medium">{issueData.submitter_name}</span></div>
|
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.submitterInfo')}</h3>
|
||||||
<div><span className="text-text-tertiary">{t('issues.emailLabel')}</span> <span className="text-text-primary">{issueData.submitter_email}</span></div>
|
<div className="space-y-1 text-sm">
|
||||||
{issueData.submitter_phone && (
|
<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.phoneLabel')}</span> <span className="text-text-primary">{issueData.submitter_phone}</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.submittedLabel')}</span> <span className="text-text-primary">{formatDate(issueData.created_at)}</span></div>
|
<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>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
{/* 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 && (
|
|
||||||
<div>
|
<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
|
<select
|
||||||
value={teamId}
|
value={assignedTo}
|
||||||
onChange={async (e) => {
|
onChange={(e) => handleAssignmentChange(e.target.value)}
|
||||||
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"
|
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>
|
<option value="">{t('issues.unassigned')}</option>
|
||||||
{teams.map((team) => (
|
{teamMembers.map((member) => (
|
||||||
<option key={team.id || team._id} value={team.id || team._id}>{team.name}</option>
|
<option key={member.id || member._id} value={member.id || member._id}>
|
||||||
|
{member.name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Brand */}
|
{/* Team */}
|
||||||
<div>
|
{teams.length > 0 && (
|
||||||
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.brandLabel')}</label>
|
<div>
|
||||||
<select
|
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.team')}</label>
|
||||||
value={issueData.brand_id || ''}
|
<select
|
||||||
onChange={async (e) => {
|
value={teamId}
|
||||||
const val = e.target.value || null;
|
onChange={async (e) => {
|
||||||
try {
|
const val = e.target.value || null
|
||||||
await api.patch(`/issues/${issueId}`, { brand_id: val });
|
setTeamId(val || '')
|
||||||
loadIssueDetails();
|
try {
|
||||||
onUpdate();
|
await api.patch(`/issues/${issueId}`, { team_id: val })
|
||||||
} catch {}
|
await onUpdate()
|
||||||
}}
|
await loadIssueDetails()
|
||||||
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"
|
} catch (err) {
|
||||||
>
|
console.error('Failed to update team:', err)
|
||||||
<option value="">{t('issues.noBrand')}</option>
|
}
|
||||||
{(brands || []).map((b) => (
|
}}
|
||||||
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
|
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"
|
||||||
))}
|
|
||||||
</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"
|
|
||||||
>
|
>
|
||||||
<Check className="w-4 h-4 inline mr-1" />
|
<option value="">{t('issues.allTeams')}</option>
|
||||||
{t('issues.acknowledge')}
|
{teams.map((team) => (
|
||||||
</button>
|
<option key={team.id || team._id} value={team.id || team._id}>{team.name}</option>
|
||||||
)}
|
))}
|
||||||
{(issueData.status === 'new' || issueData.status === 'acknowledged') && (
|
</select>
|
||||||
<button
|
</div>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tracking Link */}
|
{/* Brand */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.publicTrackingLink')}</label>
|
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.brandLabel')}</label>
|
||||||
<div className="flex gap-2">
|
<select
|
||||||
<input
|
value={issueData.brand_id || ''}
|
||||||
type="text"
|
onChange={async (e) => {
|
||||||
value={`${window.location.origin}/track/${issueData.tracking_token}`}
|
const val = e.target.value || null;
|
||||||
readOnly
|
try {
|
||||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-surface-secondary"
|
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>
|
</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>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Updates Timeline */}
|
{/* Actions Tab */}
|
||||||
<div>
|
{activeTab === 'actions' && (
|
||||||
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
|
<div className="p-6 space-y-5">
|
||||||
{t('issues.updatesTimeline')}
|
{issueData.status !== 'resolved' && issueData.status !== 'declined' ? (
|
||||||
<span className="text-xs text-text-tertiary font-normal">({updates.length})</span>
|
<div className="space-y-3">
|
||||||
</h3>
|
{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 */}
|
{/* Add Update */}
|
||||||
<div className="bg-surface-secondary rounded-lg p-3 mb-4">
|
<div className="bg-surface-secondary rounded-lg p-3">
|
||||||
<textarea
|
<textarea
|
||||||
value={newUpdate}
|
value={newUpdate}
|
||||||
onChange={(e) => setNewUpdate(e.target.value)}
|
onChange={(e) => setNewUpdate(e.target.value)}
|
||||||
@@ -481,16 +495,13 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Attachments */}
|
{/* Attachments Tab */}
|
||||||
<div>
|
{activeTab === 'attachments' && (
|
||||||
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
|
<div className="p-6 space-y-5">
|
||||||
{t('issues.attachments')}
|
|
||||||
<span className="text-xs text-text-tertiary font-normal">({attachments.length})</span>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Upload */}
|
{/* Upload */}
|
||||||
<label className="block mb-3">
|
<label className="block">
|
||||||
<input type="file" onChange={handleFileUpload} disabled={uploadingFile} className="hidden" />
|
<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">
|
<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" />
|
<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">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-text-primary truncate">{att.original_name}</p>
|
<p className="text-sm font-medium text-text-primary truncate">{att.original_name}</p>
|
||||||
<p className="text-xs text-text-tertiary">
|
<p className="text-xs text-text-tertiary">
|
||||||
{formatFileSize(att.size)} • {att.uploaded_by}
|
{formatFileSize(att.size)} • {att.uploaded_by}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -533,8 +544,8 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</SlidePanel>
|
</TabbedModal>
|
||||||
|
|
||||||
{/* Resolve Modal */}
|
{/* Resolve Modal */}
|
||||||
{showResolveModal && (
|
{showResolveModal && (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,10 @@
|
|||||||
import { useState, useEffect, useRef, useContext } from 'react'
|
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 { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { api, getBrandColor } from '../utils/api'
|
import { api, getBrandColor } from '../utils/api'
|
||||||
import CommentsSection from './CommentsSection'
|
import CommentsSection from './CommentsSection'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
import SlidePanel from './SlidePanel'
|
import TabbedModal from './TabbedModal'
|
||||||
import CollapsibleSection from './CollapsibleSection'
|
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
|
|
||||||
export default function ProjectEditPanel({ project, onClose, onSave, onDelete, brands, teamMembers }) {
|
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 [saving, setSaving] = useState(false)
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const [thumbnailUploading, setThumbnailUploading] = useState(false)
|
const [thumbnailUploading, setThumbnailUploading] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState('details')
|
||||||
|
|
||||||
const projectId = project?._id || project?.id
|
const projectId = project?._id || project?.id
|
||||||
if (!project) return null
|
if (!project) return null
|
||||||
@@ -107,10 +107,17 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
|||||||
return project.brand_name || project.brandName || null
|
return project.brand_name || project.brandName || null
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const header = (
|
const tabs = [
|
||||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
{ key: 'details', label: t('projects.details'), icon: FileEdit },
|
||||||
<div className="flex items-start justify-between gap-3">
|
{ key: 'discussion', label: t('projects.discussion'), icon: MessageSquare },
|
||||||
<div className="flex-1 min-w-0">
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TabbedModal
|
||||||
|
onClose={onClose}
|
||||||
|
size="md"
|
||||||
|
header={<>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
@@ -134,23 +141,37 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>}
|
||||||
<button
|
tabs={tabs}
|
||||||
onClick={onClose}
|
activeTab={activeTab}
|
||||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
onTabChange={setActiveTab}
|
||||||
>
|
footer={<>
|
||||||
<X className="w-5 h-5" />
|
<div className="flex items-center gap-2">
|
||||||
</button>
|
{onDelete && (
|
||||||
</div>
|
<button
|
||||||
</div>
|
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')}
|
||||||
return (
|
>
|
||||||
<>
|
<Trash2 className="w-4 h-4" />
|
||||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
</button>
|
||||||
{/* Details Section */}
|
)}
|
||||||
<CollapsibleSection title={t('projects.details')}>
|
</div>
|
||||||
<div className="px-5 pb-4 space-y-3">
|
<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>
|
<div>
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.description')}</label>
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.description')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -272,37 +293,15 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
|||||||
onChange={e => { handleThumbnailUpload(e.target.files[0]); e.target.value = '' }}
|
onChange={e => { handleThumbnailUpload(e.target.files[0]); e.target.value = '' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</CollapsibleSection>
|
)}
|
||||||
|
|
||||||
{/* Discussion Section */}
|
{activeTab === 'discussion' && (
|
||||||
<CollapsibleSection title={t('projects.discussion')} noBorder>
|
<div className="p-6 space-y-3">
|
||||||
<div className="px-5 pb-5">
|
|
||||||
<CommentsSection entityType="project" entityId={projectId} />
|
<CommentsSection entityType="project" entityId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
)}
|
||||||
</SlidePanel>
|
</TabbedModal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showDeleteConfirm}
|
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 { 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 { PRIORITY_CONFIG, getBrandColor, api } from '../utils/api'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import CommentsSection from './CommentsSection'
|
import CommentsSection from './CommentsSection'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
import SlidePanel from './SlidePanel'
|
import TabbedModal from './TabbedModal'
|
||||||
import CollapsibleSection from './CollapsibleSection'
|
|
||||||
|
|
||||||
const API_BASE = '/api'
|
const API_BASE = '/api'
|
||||||
|
|
||||||
export default function TaskDetailPanel({ task, onClose, onSave, onDelete, projects, users, brands }) {
|
export default function TaskDetailPanel({ task, onClose, onSave, onDelete, projects, users, brands }) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
const [activeTab, setActiveTab] = useState('details')
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
title: '', description: '', project_id: '', assigned_to: '',
|
title: '', description: '', project_id: '', assigned_to: '',
|
||||||
priority: 'medium', status: 'todo', start_date: '', due_date: '',
|
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 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 brandName = selectedProject ? (selectedProject.brand_name || selectedProject.brandName) : (task.brand_name || task.brandName)
|
||||||
|
|
||||||
const header = (
|
const attachmentCount = attachments.length + pendingFiles.length
|
||||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
|
||||||
|
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 */}
|
{/* Thumbnail banner */}
|
||||||
{currentThumbnail && (
|
{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" />
|
<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" />
|
<div className="absolute inset-0 bg-gradient-to-t from-white/80 to-transparent" />
|
||||||
<button
|
<button
|
||||||
@@ -202,188 +210,186 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-start justify-between gap-3">
|
<input
|
||||||
<div className="flex-1 min-w-0">
|
type="text"
|
||||||
<input
|
value={form.title}
|
||||||
type="text"
|
onChange={e => update('title', e.target.value)}
|
||||||
value={form.title}
|
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
|
||||||
onChange={e => update('title', e.target.value)}
|
placeholder={t('tasks.taskTitle')}
|
||||||
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="flex items-center gap-2 mt-2">
|
<div className={`w-1.5 h-1.5 rounded-full ${priority.color}`} />
|
||||||
<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'}`}>
|
{priorityOptions.find(p => p.value === form.priority)?.label}
|
||||||
<div className={`w-1.5 h-1.5 rounded-full ${priority.color}`} />
|
</span>
|
||||||
{priorityOptions.find(p => p.value === form.priority)?.label}
|
<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'}`}>
|
||||||
</span>
|
{statusOptions.find(s => s.value === form.status)?.label}
|
||||||
<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'}`}>
|
</span>
|
||||||
{statusOptions.find(s => s.value === form.status)?.label}
|
{isOverdue && !isCreateMode && (
|
||||||
</span>
|
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-red-100 text-red-600 flex items-center gap-1">
|
||||||
{isOverdue && !isCreateMode && (
|
<AlertCircle className="w-3 h-3" />
|
||||||
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-red-100 text-red-600 flex items-center gap-1">
|
{t('tasks.overdue')}
|
||||||
<AlertCircle className="w-3 h-3" />
|
</span>
|
||||||
{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>
|
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
<TabbedModal
|
||||||
{/* Details Section */}
|
onClose={onClose}
|
||||||
<CollapsibleSection title={t('tasks.details')}>
|
size="md"
|
||||||
<div className="px-5 pb-4 space-y-3">
|
header={headerContent}
|
||||||
{/* Description */}
|
tabs={tabs}
|
||||||
<div>
|
activeTab={activeTab}
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.description')}</label>
|
onTabChange={setActiveTab}
|
||||||
<textarea
|
footer={footerContent}
|
||||||
value={form.description}
|
>
|
||||||
onChange={e => update('description', e.target.value)}
|
{/* Details Tab */}
|
||||||
rows={3}
|
{activeTab === 'details' && (
|
||||||
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 className="p-6">
|
||||||
placeholder={t('posts.optionalDetails')}
|
<div className="space-y-3">
|
||||||
/>
|
{/* Description */}
|
||||||
</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">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.priority')}</label>
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.description')}</label>
|
||||||
<select
|
<textarea
|
||||||
value={form.priority}
|
value={form.description}
|
||||||
onChange={e => update('priority', e.target.value)}
|
onChange={e => update('description', 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"
|
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"
|
||||||
{priorityOptions.map(p => (
|
placeholder={t('posts.optionalDetails')}
|
||||||
<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>
|
||||||
|
|
||||||
|
{/* Project */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.dueDate')}</label>
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.project')}</label>
|
||||||
<input
|
<div className="flex items-center gap-2">
|
||||||
type="date"
|
<select
|
||||||
value={form.due_date}
|
value={form.project_id}
|
||||||
onChange={e => update('due_date', e.target.value)}
|
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"
|
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')}
|
<option value="">{t('common.unassigned')}</option>
|
||||||
</button>
|
{(users || []).map(m => (
|
||||||
)}
|
<option key={m._id || m.team_member_id} value={m._id || m.team_member_id}>{m.name}</option>
|
||||||
{onDelete && !isCreateMode && (
|
))}
|
||||||
<button
|
</select>
|
||||||
onClick={handleDelete}
|
</div>
|
||||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
|
||||||
title={t('common.delete')}
|
{/* Priority & Status */}
|
||||||
>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Trash2 className="w-4 h-4" />
|
<div>
|
||||||
</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
)}
|
||||||
|
|
||||||
{/* Attachments Section */}
|
{/* Attachments Tab */}
|
||||||
<CollapsibleSection
|
{activeTab === 'attachments' && (
|
||||||
title={t('tasks.attachments')}
|
<div className="p-6">
|
||||||
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">
|
|
||||||
{/* Existing attachment grid (edit mode) */}
|
{/* Existing attachment grid (edit mode) */}
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
<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>
|
||||||
)}
|
)}
|
||||||
</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 */}
|
{/* Delete Confirmation */}
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useState, useEffect, useRef, useContext } from 'react'
|
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 { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { useToast } from './ToastContainer'
|
import { useToast } from './ToastContainer'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
import SlidePanel from './SlidePanel'
|
import TabbedModal from './TabbedModal'
|
||||||
import CollapsibleSection from './CollapsibleSection'
|
|
||||||
import StatusBadge from './StatusBadge'
|
import StatusBadge from './StatusBadge'
|
||||||
import { AppContext, PERMISSION_LEVELS } from '../App'
|
import { AppContext, PERMISSION_LEVELS } from '../App'
|
||||||
|
|
||||||
@@ -29,6 +28,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
const [confirmPassword, setConfirmPassword] = useState('')
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [passwordSaving, setPasswordSaving] = useState(false)
|
const [passwordSaving, setPasswordSaving] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState('details')
|
||||||
const brandsDropdownRef = useRef(null)
|
const brandsDropdownRef = useRef(null)
|
||||||
|
|
||||||
// Workload state (loaded internally)
|
// Workload state (loaded internally)
|
||||||
@@ -54,6 +54,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
setDirty(false)
|
setDirty(false)
|
||||||
setConfirmPassword('')
|
setConfirmPassword('')
|
||||||
setShowPassword(false)
|
setShowPassword(false)
|
||||||
|
setActiveTab('details')
|
||||||
if (memberId) loadWorkload()
|
if (memberId) loadWorkload()
|
||||||
}
|
}
|
||||||
}, [member])
|
}, [member])
|
||||||
@@ -150,42 +151,69 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length
|
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length
|
||||||
const doneCount = memberTasks.filter(t => t.status === 'done').length
|
const doneCount = memberTasks.filter(t => t.status === 'done').length
|
||||||
|
|
||||||
const header = (
|
const showAdminTab = !isEditingSelf && userRole === 'superadmin'
|
||||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
const tabs = [
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
{ key: 'details', label: t('team.details'), icon: FileEdit },
|
||||||
<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">
|
{ key: 'workload', label: t('team.workload'), icon: BarChart3 },
|
||||||
{initials}
|
...(showAdminTab ? [{ key: 'admin', label: t('team.adminActions'), icon: ShieldAlert }] : []),
|
||||||
</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>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
<TabbedModal
|
||||||
{/* Details Section */}
|
onClose={onClose}
|
||||||
<CollapsibleSection title={t('team.details')}>
|
size="md"
|
||||||
<div className="px-5 pb-4 space-y-3">
|
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 && (
|
{!isEditingSelf && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.email')}</label>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{dirty && (
|
{/* Workload Tab */}
|
||||||
<button
|
{activeTab === 'workload' && (
|
||||||
onClick={handleSave}
|
<div className="p-6 space-y-3">
|
||||||
disabled={!form.name || saving}
|
{/* Stats */}
|
||||||
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' : ''}`}
|
<div className="grid grid-cols-4 gap-2">
|
||||||
>
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
||||||
{isEditingSelf ? t('team.saveProfile') : t('team.saveChanges')}
|
<p className="text-lg font-bold text-text-primary">{memberTasks.length}</p>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</CollapsibleSection>
|
)}
|
||||||
|
|
||||||
{/* Workload Section */}
|
{/* Admin Actions Tab */}
|
||||||
<CollapsibleSection title={t('team.workload')} noBorder>
|
{activeTab === 'admin' && showAdminTab && (
|
||||||
<div className="px-5 pb-4 space-y-3">
|
<div className="p-6 space-y-3">
|
||||||
{/* Stats */}
|
{/* Change password */}
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div>
|
||||||
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.password')}</label>
|
||||||
<p className="text-lg font-bold text-text-primary">{memberTasks.length}</p>
|
<div className="relative">
|
||||||
<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>
|
|
||||||
<input
|
<input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={confirmPassword}
|
value={form.password}
|
||||||
onChange={e => setConfirmPassword(e.target.value)}
|
onChange={e => update('password', 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'}`}
|
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.confirmPassword')}
|
placeholder={t('team.newPassword')}
|
||||||
autoComplete="new-password"
|
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
|
<button
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
type="button"
|
||||||
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"
|
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" />
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
{t('team.removeMember')}
|
|
||||||
</button>
|
</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>
|
</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
|
<Modal
|
||||||
isOpen={showDeleteConfirm}
|
isOpen={showDeleteConfirm}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { getInitials } from '../utils/api'
|
import { getInitials } from '../utils/api'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
import SlidePanel from './SlidePanel'
|
import TabbedModal from './TabbedModal'
|
||||||
import CollapsibleSection from './CollapsibleSection'
|
|
||||||
|
|
||||||
export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers }) {
|
export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers }) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
@@ -13,6 +12,7 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const [memberSearch, setMemberSearch] = useState('')
|
const [memberSearch, setMemberSearch] = useState('')
|
||||||
|
const [activeTab, setActiveTab] = useState('details')
|
||||||
|
|
||||||
const teamId = team?.id || team?._id
|
const teamId = team?.id || team?._id
|
||||||
const isCreateMode = !teamId
|
const isCreateMode = !teamId
|
||||||
@@ -68,36 +68,62 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
|
|||||||
!memberSearch || m.name?.toLowerCase().includes(memberSearch.toLowerCase())
|
!memberSearch || m.name?.toLowerCase().includes(memberSearch.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
const header = (
|
const memberCount = (form.member_ids || []).length
|
||||||
<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>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
<TabbedModal
|
||||||
<CollapsibleSection title={t('teams.details')}>
|
onClose={onClose}
|
||||||
<div className="px-5 pb-4 space-y-3">
|
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>
|
<div>
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('teams.name')}</label>
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('teams.name')}</label>
|
||||||
<input
|
<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"
|
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>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</CollapsibleSection>
|
)}
|
||||||
|
|
||||||
<CollapsibleSection title={t('teams.members')} noBorder>
|
{activeTab === 'members' && (
|
||||||
<div className="px-5 pb-4">
|
<div className="p-6">
|
||||||
<div className="relative mb-3">
|
<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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
|
||||||
<input
|
<input
|
||||||
@@ -180,8 +185,8 @@ export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
)}
|
||||||
</SlidePanel>
|
</TabbedModal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showDeleteConfirm}
|
isOpen={showDeleteConfirm}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { X, Trash2 } from 'lucide-react'
|
import { Trash2, FileEdit, BarChart3 } from 'lucide-react'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { PLATFORMS } from '../utils/api'
|
import { PLATFORMS } from '../utils/api'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
import SlidePanel from './SlidePanel'
|
import TabbedModal from './TabbedModal'
|
||||||
import CollapsibleSection from './CollapsibleSection'
|
|
||||||
import BudgetBar from './BudgetBar'
|
import BudgetBar from './BudgetBar'
|
||||||
|
|
||||||
const TRACK_TYPES = {
|
const TRACK_TYPES = {
|
||||||
@@ -23,6 +22,7 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
|
|||||||
const [dirty, setDirty] = useState(false)
|
const [dirty, setDirty] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState(scrollToMetrics ? 'metrics' : 'details')
|
||||||
|
|
||||||
const trackId = track?._id || track?.id
|
const trackId = track?._id || track?.id
|
||||||
const isCreateMode = !trackId
|
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 typeInfo = TRACK_TYPES[form.type] || TRACK_TYPES.organic_social
|
||||||
|
|
||||||
const header = (
|
const tabs = isCreateMode
|
||||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
? [{ key: 'details', label: t('tracks.details'), icon: FileEdit }]
|
||||||
<div className="flex items-start justify-between gap-3">
|
: [
|
||||||
<div className="flex-1 min-w-0">
|
{ key: 'details', label: t('tracks.details'), icon: FileEdit },
|
||||||
<input
|
{ key: 'metrics', label: t('tracks.metrics'), icon: BarChart3 },
|
||||||
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>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
<TabbedModal
|
||||||
{/* Details Section */}
|
onClose={onClose}
|
||||||
<CollapsibleSection title={t('tracks.details')}>
|
size="md"
|
||||||
<div className="px-5 pb-4 space-y-3">
|
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 className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.type')}</label>
|
<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..."
|
placeholder="Keywords, targeting details..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pt-2">
|
{activeTab === 'metrics' && !isCreateMode && (
|
||||||
{dirty && (
|
<div className="p-6 space-y-3">
|
||||||
<button
|
{Number(form.budget_allocated) > 0 && (
|
||||||
onClick={handleSave}
|
<div className="p-3 bg-surface-secondary rounded-lg">
|
||||||
disabled={saving}
|
<BudgetBar budget={Number(form.budget_allocated)} spent={Number(form.budget_spent) || 0} height="h-2" />
|
||||||
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' : ''}`}
|
<div className="flex items-center gap-2 mt-2">
|
||||||
>
|
{Number(form.clicks) > 0 && Number(form.budget_spent) > 0 && (
|
||||||
{isCreateMode ? t('tracks.addTrack') : t('tasks.saveChanges')}
|
<span className="text-[10px] text-text-tertiary">
|
||||||
</button>
|
CPC: {(Number(form.budget_spent) / Number(form.clicks)).toFixed(2)} {currencySymbol}
|
||||||
)}
|
</span>
|
||||||
{onDelete && !isCreateMode && (
|
)}
|
||||||
<button
|
{Number(form.impressions) > 0 && Number(form.clicks) > 0 && (
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
<span className="text-[10px] text-text-tertiary">
|
||||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
CTR: {(Number(form.clicks) / Number(form.impressions) * 100).toFixed(2)}%
|
||||||
title={t('common.delete')}
|
</span>
|
||||||
>
|
)}
|
||||||
<Trash2 className="w-4 h-4" />
|
</div>
|
||||||
</button>
|
</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>
|
||||||
</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
|
<Modal
|
||||||
isOpen={showDeleteConfirm}
|
isOpen={showDeleteConfirm}
|
||||||
|
|||||||
@@ -915,5 +915,22 @@
|
|||||||
"review.confirmApprovePostDesc": "هل أنت متأكد من الموافقة على هذا المنشور؟",
|
"review.confirmApprovePostDesc": "هل أنت متأكد من الموافقة على هذا المنشور؟",
|
||||||
"review.confirmRejectPostDesc": "هل أنت متأكد من رفض هذا المنشور؟ يرجى تقديم ملاحظات توضح السبب.",
|
"review.confirmRejectPostDesc": "هل أنت متأكد من رفض هذا المنشور؟ يرجى تقديم ملاحظات توضح السبب.",
|
||||||
"review.feedbackRequired": "الملاحظات (مطلوبة)",
|
"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.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.confirmRejectPostDesc": "Are you sure you want to reject this post? Please provide feedback explaining why.",
|
||||||
"review.feedbackRequired": "Feedback (required)",
|
"review.feedbackRequired": "Feedback (required)",
|
||||||
"review.feedbackRequiredError": "Please provide feedback when rejecting"
|
"review.feedbackRequiredError": "Please provide feedback when rejecting",
|
||||||
|
"posts.versions": "Versions",
|
||||||
|
"posts.newVersion": "New Version",
|
||||||
|
"posts.createNewVersion": "Create New Version",
|
||||||
|
"posts.createVersion": "Create Version",
|
||||||
|
"posts.creatingVersion": "Creating...",
|
||||||
|
"posts.whatChanged": "What changed in this version?",
|
||||||
|
"posts.copyLanguages": "Copy languages from previous version",
|
||||||
|
"posts.languages": "Languages",
|
||||||
|
"posts.addLanguage": "Add Language",
|
||||||
|
"posts.selectLanguage": "Select a language...",
|
||||||
|
"posts.enterContent": "Enter the content in this language...",
|
||||||
|
"posts.noLanguages": "No languages added yet",
|
||||||
|
"posts.noVersions": "No versions yet. Create one to start managing multilingual content and media.",
|
||||||
|
"posts.deleteLanguage": "Delete this language?",
|
||||||
|
"posts.deleteLanguageConfirm": "This will remove the language content from this version.",
|
||||||
|
"posts.media": "Media",
|
||||||
|
"posts.noMedia": "No media files uploaded"
|
||||||
}
|
}
|
||||||
231
server/server.js
231
server/server.js
@@ -154,6 +154,8 @@ const FK_COLUMNS = {
|
|||||||
Comments: ['user_id'],
|
Comments: ['user_id'],
|
||||||
BudgetEntries: ['campaign_id', 'project_id'],
|
BudgetEntries: ['campaign_id', 'project_id'],
|
||||||
Artefacts: ['project_id', 'campaign_id'],
|
Artefacts: ['project_id', 'campaign_id'],
|
||||||
|
PostVersions: ['post_id', 'created_by_user_id'],
|
||||||
|
PostVersionTexts: ['version_id'],
|
||||||
Issues: ['brand_id', 'assigned_to_id', 'team_id'],
|
Issues: ['brand_id', 'assigned_to_id', 'team_id'],
|
||||||
Users: ['role_id'],
|
Users: ['role_id'],
|
||||||
};
|
};
|
||||||
@@ -367,6 +369,19 @@ const REQUIRED_TABLES = {
|
|||||||
{ title: 'size', uidt: 'Number' },
|
{ title: 'size', uidt: 'Number' },
|
||||||
{ title: 'drive_url', uidt: 'SingleLineText' },
|
{ title: 'drive_url', uidt: 'SingleLineText' },
|
||||||
],
|
],
|
||||||
|
PostVersions: [
|
||||||
|
{ title: 'post_id', uidt: 'Number' },
|
||||||
|
{ title: 'version_number', uidt: 'Number' },
|
||||||
|
{ title: 'created_by_user_id', uidt: 'Number' },
|
||||||
|
{ title: 'created_at', uidt: 'DateTime' },
|
||||||
|
{ title: 'notes', uidt: 'LongText' },
|
||||||
|
],
|
||||||
|
PostVersionTexts: [
|
||||||
|
{ title: 'version_id', uidt: 'Number' },
|
||||||
|
{ title: 'language_code', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'language_label', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'content', uidt: 'LongText' },
|
||||||
|
],
|
||||||
Issues: [
|
Issues: [
|
||||||
{ title: 'title', uidt: 'SingleLineText' },
|
{ title: 'title', uidt: 'SingleLineText' },
|
||||||
{ title: 'description', uidt: 'LongText' },
|
{ title: 'description', uidt: 'LongText' },
|
||||||
@@ -471,7 +486,10 @@ const TEXT_COLUMNS = {
|
|||||||
{ name: 'approved_by_name', uidt: 'SingleLineText' },
|
{ name: 'approved_by_name', uidt: 'SingleLineText' },
|
||||||
{ name: 'approved_at', uidt: 'SingleLineText' },
|
{ name: 'approved_at', uidt: 'SingleLineText' },
|
||||||
{ name: 'feedback', uidt: 'LongText' },
|
{ name: 'feedback', uidt: 'LongText' },
|
||||||
|
{ name: 'current_version', uidt: 'Number' },
|
||||||
|
{ name: 'review_version', uidt: 'Number' },
|
||||||
],
|
],
|
||||||
|
PostAttachments: [{ name: 'version_id', uidt: 'Number' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
async function ensureTextColumns() {
|
async function ensureTextColumns() {
|
||||||
@@ -1508,6 +1526,10 @@ app.post('/api/posts/:id/submit-review', requireAuth, requireOwnerOrRole('posts'
|
|||||||
updateData.approved_at = null;
|
updateData.approved_at = null;
|
||||||
updateData.feedback = null;
|
updateData.feedback = null;
|
||||||
}
|
}
|
||||||
|
// Track which version is under review
|
||||||
|
if (existing.current_version) {
|
||||||
|
updateData.review_version = existing.current_version;
|
||||||
|
}
|
||||||
await nocodb.update('Posts', req.params.id, updateData);
|
await nocodb.update('Posts', req.params.id, updateData);
|
||||||
|
|
||||||
const reviewUrl = `${req.protocol}://${req.get('host')}/review-post/${token}`;
|
const reviewUrl = `${req.protocol}://${req.get('host')}/review-post/${token}`;
|
||||||
@@ -1640,6 +1662,215 @@ app.post('/api/public/review-post/:token/reject', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── POST VERSIONS ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// List all versions for a post
|
||||||
|
app.get('/api/posts/:id/versions', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const versions = await nocodb.list('PostVersions', {
|
||||||
|
where: `(post_id,eq,${sanitizeWhereValue(req.params.id)})`,
|
||||||
|
sort: 'version_number',
|
||||||
|
limit: QUERY_LIMITS.large,
|
||||||
|
});
|
||||||
|
const enriched = [];
|
||||||
|
for (const v of versions) {
|
||||||
|
const creatorName = await getRecordName('Users', v.created_by_user_id);
|
||||||
|
enriched.push({ ...v, creator_name: creatorName });
|
||||||
|
}
|
||||||
|
res.json(enriched);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('List post versions error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load versions' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new version
|
||||||
|
app.post('/api/posts/:id/versions', requireAuth, async (req, res) => {
|
||||||
|
const { notes, copy_from_previous } = req.body;
|
||||||
|
try {
|
||||||
|
const post = await nocodb.get('Posts', req.params.id);
|
||||||
|
if (!post) return res.status(404).json({ error: 'Post not found' });
|
||||||
|
|
||||||
|
if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
|
||||||
|
return res.status(403).json({ error: 'You can only create versions for your own posts' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = await nocodb.list('PostVersions', {
|
||||||
|
where: `(post_id,eq,${sanitizeWhereValue(req.params.id)})`,
|
||||||
|
sort: '-version_number',
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
const newVersionNumber = versions.length > 0 ? versions[0].version_number + 1 : 1;
|
||||||
|
|
||||||
|
const created = await nocodb.create('PostVersions', {
|
||||||
|
post_id: Number(req.params.id),
|
||||||
|
version_number: newVersionNumber,
|
||||||
|
created_by_user_id: req.session.userId,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
notes: notes || `Version ${newVersionNumber}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await nocodb.update('Posts', req.params.id, { current_version: newVersionNumber });
|
||||||
|
|
||||||
|
// Copy texts from previous version if requested
|
||||||
|
if (copy_from_previous && versions.length > 0) {
|
||||||
|
const prevVersionId = versions[0].Id;
|
||||||
|
const prevTexts = await nocodb.list('PostVersionTexts', {
|
||||||
|
where: `(version_id,eq,${prevVersionId})`,
|
||||||
|
limit: QUERY_LIMITS.large,
|
||||||
|
});
|
||||||
|
for (const text of prevTexts) {
|
||||||
|
await nocodb.create('PostVersionTexts', {
|
||||||
|
version_id: created.Id,
|
||||||
|
language_code: text.language_code,
|
||||||
|
language_label: text.language_label,
|
||||||
|
content: text.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = await nocodb.get('PostVersions', created.Id);
|
||||||
|
const creatorName = await getRecordName('Users', version.created_by_user_id);
|
||||||
|
res.status(201).json({ ...version, creator_name: creatorName });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Create post version error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to create version' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get specific version with texts and attachments
|
||||||
|
app.get('/api/posts/:id/versions/:versionId', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const version = await nocodb.get('PostVersions', req.params.versionId);
|
||||||
|
if (!version) return res.status(404).json({ error: 'Version not found' });
|
||||||
|
if (version.post_id !== Number(req.params.id)) {
|
||||||
|
return res.status(400).json({ error: 'Version does not belong to this post' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [texts, attachments] = await Promise.all([
|
||||||
|
nocodb.list('PostVersionTexts', {
|
||||||
|
where: `(version_id,eq,${sanitizeWhereValue(req.params.versionId)})`,
|
||||||
|
limit: QUERY_LIMITS.large,
|
||||||
|
}),
|
||||||
|
nocodb.list('PostAttachments', {
|
||||||
|
where: `(version_id,eq,${sanitizeWhereValue(req.params.versionId)})`,
|
||||||
|
limit: QUERY_LIMITS.large,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const creatorName = await getRecordName('Users', version.created_by_user_id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
...version,
|
||||||
|
creator_name: creatorName,
|
||||||
|
texts,
|
||||||
|
attachments: attachments.map(a => ({
|
||||||
|
...a,
|
||||||
|
url: a.url || `/api/uploads/${a.filename}`,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Get post version error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load version' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add/update language text for a version
|
||||||
|
app.post('/api/posts/:id/versions/:versionId/texts', requireAuth, async (req, res) => {
|
||||||
|
const { language_code, language_label, content } = req.body;
|
||||||
|
if (!language_code || !language_label || !content) {
|
||||||
|
return res.status(400).json({ error: 'language_code, language_label, and content are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const post = await nocodb.get('Posts', req.params.id);
|
||||||
|
if (!post) return res.status(404).json({ error: 'Post not found' });
|
||||||
|
|
||||||
|
if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
|
||||||
|
return res.status(403).json({ error: 'You can only manage texts for your own posts' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await nocodb.list('PostVersionTexts', {
|
||||||
|
where: `(version_id,eq,${sanitizeWhereValue(req.params.versionId)})~and(language_code,eq,${sanitizeWhereValue(language_code)})`,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
let text;
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await nocodb.update('PostVersionTexts', existing[0].Id, { language_label, content });
|
||||||
|
text = await nocodb.get('PostVersionTexts', existing[0].Id);
|
||||||
|
} else {
|
||||||
|
const created = await nocodb.create('PostVersionTexts', {
|
||||||
|
version_id: Number(req.params.versionId),
|
||||||
|
language_code,
|
||||||
|
language_label,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
text = await nocodb.get('PostVersionTexts', created.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(text);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Add/update post text error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to add/update text' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete language text
|
||||||
|
app.delete('/api/post-version-texts/:id', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const text = await nocodb.get('PostVersionTexts', req.params.id);
|
||||||
|
if (!text) return res.status(404).json({ error: 'Text not found' });
|
||||||
|
|
||||||
|
const version = await nocodb.get('PostVersions', text.version_id);
|
||||||
|
const post = await nocodb.get('Posts', version.post_id);
|
||||||
|
|
||||||
|
if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
|
||||||
|
return res.status(403).json({ error: 'You can only manage texts for your own posts' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await nocodb.delete('PostVersionTexts', req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete post text error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete text' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload attachment to specific version
|
||||||
|
app.post('/api/posts/:id/versions/:versionId/attachments', requireAuth, dynamicUpload('file'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const post = await nocodb.get('Posts', req.params.id);
|
||||||
|
if (!post) return res.status(404).json({ error: 'Post not found' });
|
||||||
|
|
||||||
|
if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
|
||||||
|
if (req.file) fs.unlinkSync(path.join(uploadsDir, req.file.filename));
|
||||||
|
return res.status(403).json({ error: 'You can only manage attachments on your own posts' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'File upload is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `/api/uploads/${req.file.filename}`;
|
||||||
|
const created = await nocodb.create('PostAttachments', {
|
||||||
|
filename: req.file.filename,
|
||||||
|
original_name: req.file.originalname,
|
||||||
|
mime_type: req.file.mimetype,
|
||||||
|
size: req.file.size,
|
||||||
|
url,
|
||||||
|
post_id: Number(req.params.id),
|
||||||
|
version_id: Number(req.params.versionId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachment = await nocodb.get('PostAttachments', created.Id);
|
||||||
|
res.status(201).json(attachment);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Upload post version attachment error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to upload attachment' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── ASSETS ─────────────────────────────────────────────────────
|
// ─── ASSETS ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/assets', requireAuth, async (req, res) => {
|
app.get('/api/assets', requireAuth, async (req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user