feat: convert all slide panels to tabbed modals with shared TabbedModal component
Deploy / deploy (push) Successful in 11s

Extract reusable TabbedModal component (portal, backdrop, tab bar with icons/badges/underline, scrollable body, footer) and convert all 9 detail panels from SlidePanel+CollapsibleSection to tabbed modal layout:
- PostDetailPanel (5 tabs), TaskDetailPanel (3), ProjectEditPanel (2)
- TrackDetailPanel (2), CampaignDetailPanel (3), TeamMemberPanel (3)
- TeamPanel (2), IssueDetailPanel (4), ArtefactDetailPanel (4)
Also adds post versioning system (server routes + frontend).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-09 17:12:32 +03:00
parent 539c204bde
commit 44e706f777
14 changed files with 2839 additions and 1921 deletions
+207 -196
View File
@@ -1,8 +1,7 @@
import { useState, useEffect, useContext } from 'react'
import { X, Copy, Eye, Lock, Send, Upload, FileText, Trash2, Check, Clock, CheckCircle2, XCircle } from 'lucide-react'
import { Copy, Eye, Lock, Send, Upload, FileText, Trash2, Check, Clock, CheckCircle2, XCircle, FileEdit, Wrench, MessageSquare, Paperclip } from 'lucide-react'
import { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
import SlidePanel from './SlidePanel'
import FormInput from './FormInput'
import TabbedModal from './TabbedModal'
import Modal from './Modal'
import { useToast } from './ToastContainer'
import { AppContext } from '../App'
@@ -18,6 +17,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
const [initialLoading, setInitialLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false)
const [activeTab, setActiveTab] = useState('details')
// Form state
const [assignedTo, setAssignedTo] = useState('')
@@ -26,7 +26,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
const [resolutionSummary, setResolutionSummary] = useState('')
const [newUpdate, setNewUpdate] = useState('')
const [updateIsPublic, setUpdateIsPublic] = useState(false)
// Modals
const [showResolveModal, setShowResolveModal] = useState(false)
const [showDeclineModal, setShowDeclineModal] = useState(false)
@@ -190,31 +190,33 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
if (initialLoading || !issueData) {
return (
<SlidePanel onClose={onClose} maxWidth="600px">
<TabbedModal onClose={onClose} size="lg">
<div className="flex items-center justify-center h-96">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-primary"></div>
</div>
</SlidePanel>
</TabbedModal>
)
}
const statusConfig = STATUS_CONFIG[issueData.status] || STATUS_CONFIG.new
const priorityConfig = PRIORITY_CONFIG[issueData.priority] || PRIORITY_CONFIG.medium
const tabs = [
{ key: 'details', label: t('issues.details') || 'Details', icon: FileEdit },
{ key: 'actions', label: t('issues.actions') || 'Actions', icon: Wrench },
{ key: 'updates', label: t('issues.updates') || 'Updates', icon: MessageSquare, badge: updates.length },
{ key: 'attachments', label: t('issues.attachments') || 'Attachments', icon: Paperclip, badge: attachments.length },
]
return (
<>
<SlidePanel
<TabbedModal
onClose={onClose}
maxWidth="600px"
size="lg"
header={
<div className="p-4 border-b border-border bg-surface-secondary">
<div className="flex items-start justify-between gap-3 mb-3">
<h2 className="text-lg font-bold text-text-primary flex-1">{issueData.title}</h2>
<button onClick={onClose} className="p-1 hover:bg-surface-tertiary rounded">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<>
<h2 className="text-lg font-bold text-text-primary">{issueData.title}</h2>
<div className="flex items-center gap-2 flex-wrap mt-2">
<span className={`text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1.5 ${statusConfig.bg} ${statusConfig.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${statusConfig.dot}`}></span>
{statusConfig.label}
@@ -234,195 +236,207 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
</span>
)}
</div>
</div>
</>
}
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={
<>
<button
onClick={copyTrackingLink}
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors flex items-center gap-2"
>
<Copy className="w-4 h-4" />
{t('issues.publicTrackingLink')}
</button>
<button
onClick={onClose}
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary transition-colors"
>
{t('common.close') || 'Close'}
</button>
</>
}
>
<div className="p-4 space-y-6">
{/* Submitter Info */}
<div className="bg-surface-secondary rounded-lg p-4">
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.submitterInfo')}</h3>
<div className="space-y-1 text-sm">
<div><span className="text-text-tertiary">{t('issues.nameLabel')}</span> <span className="text-text-primary font-medium">{issueData.submitter_name}</span></div>
<div><span className="text-text-tertiary">{t('issues.emailLabel')}</span> <span className="text-text-primary">{issueData.submitter_email}</span></div>
{issueData.submitter_phone && (
<div><span className="text-text-tertiary">{t('issues.phoneLabel')}</span> <span className="text-text-primary">{issueData.submitter_phone}</span></div>
)}
<div><span className="text-text-tertiary">{t('issues.submittedLabel')}</span> <span className="text-text-primary">{formatDate(issueData.created_at)}</span></div>
{/* Details Tab */}
{activeTab === 'details' && (
<div className="p-6 space-y-5">
{/* Submitter Info */}
<div className="bg-surface-secondary rounded-lg p-4">
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.submitterInfo')}</h3>
<div className="space-y-1 text-sm">
<div><span className="text-text-tertiary">{t('issues.nameLabel')}</span> <span className="text-text-primary font-medium">{issueData.submitter_name}</span></div>
<div><span className="text-text-tertiary">{t('issues.emailLabel')}</span> <span className="text-text-primary">{issueData.submitter_email}</span></div>
{issueData.submitter_phone && (
<div><span className="text-text-tertiary">{t('issues.phoneLabel')}</span> <span className="text-text-primary">{issueData.submitter_phone}</span></div>
)}
<div><span className="text-text-tertiary">{t('issues.submittedLabel')}</span> <span className="text-text-primary">{formatDate(issueData.created_at)}</span></div>
</div>
</div>
</div>
{/* Description */}
<div>
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.description')}</h3>
<p className="text-sm text-text-secondary whitespace-pre-wrap">{issueData.description || t('issues.noDescription')}</p>
</div>
{/* Assigned To */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.assignedTo')}</label>
<select
value={assignedTo}
onChange={(e) => handleAssignmentChange(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('issues.unassigned')}</option>
{teamMembers.map((member) => (
<option key={member.id || member._id} value={member.id || member._id}>
{member.name}
</option>
))}
</select>
</div>
{/* Team */}
{teams.length > 0 && (
{/* Description */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.team')}</label>
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.description')}</h3>
<p className="text-sm text-text-secondary whitespace-pre-wrap">{issueData.description || t('issues.noDescription')}</p>
</div>
{/* Assigned To */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.assignedTo')}</label>
<select
value={teamId}
onChange={async (e) => {
const val = e.target.value || null
setTeamId(val || '')
try {
await api.patch(`/issues/${issueId}`, { team_id: val })
await onUpdate()
await loadIssueDetails()
} catch (err) {
console.error('Failed to update team:', err)
}
}}
value={assignedTo}
onChange={(e) => handleAssignmentChange(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('issues.allTeams')}</option>
{teams.map((team) => (
<option key={team.id || team._id} value={team.id || team._id}>{team.name}</option>
<option value="">{t('issues.unassigned')}</option>
{teamMembers.map((member) => (
<option key={member.id || member._id} value={member.id || member._id}>
{member.name}
</option>
))}
</select>
</div>
)}
{/* Brand */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.brandLabel')}</label>
<select
value={issueData.brand_id || ''}
onChange={async (e) => {
const val = e.target.value || null;
try {
await api.patch(`/issues/${issueId}`, { brand_id: val });
loadIssueDetails();
onUpdate();
} catch {}
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('issues.noBrand')}</option>
{(brands || []).map((b) => (
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
))}
</select>
</div>
{/* Internal Notes */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2 flex items-center gap-2">
<Lock className="w-4 h-4" />
{t('issues.internalNotes')}
</label>
<textarea
value={internalNotes}
onChange={(e) => setInternalNotes(e.target.value)}
onBlur={handleNotesChange}
rows={4}
placeholder={t('issues.internalNotesPlaceholder')}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
</div>
{/* Resolution Summary (if resolved/declined) */}
{(issueData.status === 'resolved' || issueData.status === 'declined') && issueData.resolution_summary && (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
<h3 className="text-sm font-semibold text-emerald-900 mb-2 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" />
{t('issues.resolutionSummary')}
</h3>
<p className="text-sm text-emerald-800 whitespace-pre-wrap">{issueData.resolution_summary}</p>
{issueData.resolved_at && (
<p className="text-xs text-emerald-600 mt-2">{t('issues.resolvedOn')} {formatDate(issueData.resolved_at)}</p>
)}
</div>
)}
{/* Status Actions */}
{issueData.status !== 'resolved' && issueData.status !== 'declined' && (
<div className="flex gap-2 flex-wrap">
{issueData.status === 'new' && (
<button
onClick={() => handleUpdateStatus('acknowledged')}
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
{/* Team */}
{teams.length > 0 && (
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.team')}</label>
<select
value={teamId}
onChange={async (e) => {
const val = e.target.value || null
setTeamId(val || '')
try {
await api.patch(`/issues/${issueId}`, { team_id: val })
await onUpdate()
await loadIssueDetails()
} catch (err) {
console.error('Failed to update team:', err)
}
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<Check className="w-4 h-4 inline mr-1" />
{t('issues.acknowledge')}
</button>
)}
{(issueData.status === 'new' || issueData.status === 'acknowledged') && (
<button
onClick={() => handleUpdateStatus('in_progress')}
disabled={saving}
className="px-4 py-2 bg-amber-600 text-white rounded-lg text-sm font-medium hover:bg-amber-700 disabled:opacity-50"
>
<Clock className="w-4 h-4 inline mr-1" />
{t('issues.startWork')}
</button>
)}
<button
onClick={() => setShowResolveModal(true)}
disabled={saving}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50"
>
<CheckCircle2 className="w-4 h-4 inline mr-1" />
{t('issues.resolve')}
</button>
<button
onClick={() => setShowDeclineModal(true)}
disabled={saving}
className="px-4 py-2 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50"
>
<XCircle className="w-4 h-4 inline mr-1" />
{t('issues.decline')}
</button>
</div>
)}
<option value="">{t('issues.allTeams')}</option>
{teams.map((team) => (
<option key={team.id || team._id} value={team.id || team._id}>{team.name}</option>
))}
</select>
</div>
)}
{/* Tracking Link */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.publicTrackingLink')}</label>
<div className="flex gap-2">
<input
type="text"
value={`${window.location.origin}/track/${issueData.tracking_token}`}
readOnly
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-surface-secondary"
{/* Brand */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.brandLabel')}</label>
<select
value={issueData.brand_id || ''}
onChange={async (e) => {
const val = e.target.value || null;
try {
await api.patch(`/issues/${issueId}`, { brand_id: val });
loadIssueDetails();
onUpdate();
} catch {}
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('issues.noBrand')}</option>
{(brands || []).map((b) => (
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
))}
</select>
</div>
{/* Internal Notes */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2 flex items-center gap-2">
<Lock className="w-4 h-4" />
{t('issues.internalNotes')}
</label>
<textarea
value={internalNotes}
onChange={(e) => setInternalNotes(e.target.value)}
onBlur={handleNotesChange}
rows={4}
placeholder={t('issues.internalNotesPlaceholder')}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
<button
onClick={copyTrackingLink}
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors"
>
<Copy className="w-4 h-4" />
</button>
</div>
{/* Resolution Summary (if resolved/declined) */}
{(issueData.status === 'resolved' || issueData.status === 'declined') && issueData.resolution_summary && (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
<h3 className="text-sm font-semibold text-emerald-900 mb-2 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" />
{t('issues.resolutionSummary')}
</h3>
<p className="text-sm text-emerald-800 whitespace-pre-wrap">{issueData.resolution_summary}</p>
{issueData.resolved_at && (
<p className="text-xs text-emerald-600 mt-2">{t('issues.resolvedOn')} {formatDate(issueData.resolved_at)}</p>
)}
</div>
)}
</div>
)}
{/* Updates Timeline */}
<div>
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
{t('issues.updatesTimeline')}
<span className="text-xs text-text-tertiary font-normal">({updates.length})</span>
</h3>
{/* Actions Tab */}
{activeTab === 'actions' && (
<div className="p-6 space-y-5">
{issueData.status !== 'resolved' && issueData.status !== 'declined' ? (
<div className="space-y-3">
{issueData.status === 'new' && (
<button
onClick={() => handleUpdateStatus('acknowledged')}
disabled={saving}
className="w-full px-4 py-3 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<Check className="w-4 h-4" />
{t('issues.acknowledge')}
</button>
)}
{(issueData.status === 'new' || issueData.status === 'acknowledged') && (
<button
onClick={() => handleUpdateStatus('in_progress')}
disabled={saving}
className="w-full px-4 py-3 bg-amber-600 text-white rounded-lg text-sm font-medium hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<Clock className="w-4 h-4" />
{t('issues.startWork')}
</button>
)}
<button
onClick={() => setShowResolveModal(true)}
disabled={saving}
className="w-full px-4 py-3 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<CheckCircle2 className="w-4 h-4" />
{t('issues.resolve')}
</button>
<button
onClick={() => setShowDeclineModal(true)}
disabled={saving}
className="w-full px-4 py-3 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<XCircle className="w-4 h-4" />
{t('issues.decline')}
</button>
</div>
) : (
<div className="text-center py-12">
<CheckCircle2 className="w-10 h-10 mx-auto mb-3 text-text-tertiary" />
<p className="text-sm text-text-tertiary">
{issueData.status === 'resolved' ? t('issues.issueResolved') || 'This issue has been resolved.' : t('issues.issueDeclined') || 'This issue has been declined.'}
</p>
</div>
)}
</div>
)}
{/* Updates Tab */}
{activeTab === 'updates' && (
<div className="p-6 space-y-5">
{/* Add Update */}
<div className="bg-surface-secondary rounded-lg p-3 mb-4">
<div className="bg-surface-secondary rounded-lg p-3">
<textarea
value={newUpdate}
onChange={(e) => setNewUpdate(e.target.value)}
@@ -481,16 +495,13 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
)}
</div>
</div>
)}
{/* Attachments */}
<div>
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
{t('issues.attachments')}
<span className="text-xs text-text-tertiary font-normal">({attachments.length})</span>
</h3>
{/* Attachments Tab */}
{activeTab === 'attachments' && (
<div className="p-6 space-y-5">
{/* Upload */}
<label className="block mb-3">
<label className="block">
<input type="file" onChange={handleFileUpload} disabled={uploadingFile} className="hidden" />
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center cursor-pointer hover:bg-surface-secondary transition-colors">
<Upload className="w-6 h-6 mx-auto mb-2 text-text-tertiary" />
@@ -509,7 +520,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{att.original_name}</p>
<p className="text-xs text-text-tertiary">
{formatFileSize(att.size)} {att.uploaded_by}
{formatFileSize(att.size)} &bull; {att.uploaded_by}
</p>
</div>
</div>
@@ -533,8 +544,8 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
)}
</div>
</div>
</div>
</SlidePanel>
)}
</TabbedModal>
{/* Resolve Modal */}
{showResolveModal && (