updates
This commit is contained in:
96
client/src/components/ArtefactVersionTimeline.jsx
Normal file
96
client/src/components/ArtefactVersionTimeline.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Check, Clock, User } from 'lucide-react'
|
||||
|
||||
export default function ArtefactVersionTimeline({ versions, activeVersionId, onSelectVersion, artefactType }) {
|
||||
if (!versions || versions.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-4 text-sm text-text-tertiary">
|
||||
No versions found
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{versions.map((version, idx) => {
|
||||
const isActive = version.Id === activeVersionId
|
||||
const isLatest = idx === versions.length - 1
|
||||
|
||||
return (
|
||||
<button
|
||||
key={version.Id}
|
||||
onClick={() => onSelectVersion(version)}
|
||||
className={`w-full text-left p-3 rounded-lg border transition-colors ${
|
||||
isActive
|
||||
? 'border-brand-primary bg-brand-primary/5'
|
||||
: 'border-border hover:border-brand-primary/30 bg-surface hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Version indicator */}
|
||||
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||
isActive
|
||||
? 'bg-brand-primary text-white'
|
||||
: 'bg-surface-secondary text-text-secondary'
|
||||
}`}>
|
||||
v{version.version_number}
|
||||
</div>
|
||||
|
||||
{/* Version details */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-sm font-medium ${isActive ? 'text-brand-primary' : 'text-text-primary'}`}>
|
||||
Version {version.version_number}
|
||||
</span>
|
||||
{isLatest && (
|
||||
<span className="text-xs px-2 py-0.5 bg-emerald-100 text-emerald-700 rounded-full font-medium">
|
||||
Latest
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{version.notes && (
|
||||
<p className="text-xs text-text-secondary line-clamp-2 mb-2">
|
||||
{version.notes}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-text-tertiary">
|
||||
{version.creator_name && (
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
<span>{version.creator_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{version.created_at && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{new Date(version.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active indicator */}
|
||||
{isActive && (
|
||||
<div className="flex-shrink-0">
|
||||
<Check className="w-5 h-5 text-brand-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail for image artefacts */}
|
||||
{artefactType === 'design' && version.thumbnail && (
|
||||
<div className="mt-2 ml-11">
|
||||
<img
|
||||
src={version.thumbnail}
|
||||
alt={`Version ${version.version_number}`}
|
||||
className="w-full h-20 object-cover rounded border border-border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,11 @@ export default function CollapsibleSection({ title, defaultOpen = true, badge, c
|
||||
{title}
|
||||
{badge}
|
||||
</button>
|
||||
{open && children}
|
||||
<div className={`collapsible-content ${open ? 'is-open' : ''}`}>
|
||||
<div className="collapsible-inner">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
56
client/src/components/IssueCard.jsx
Normal file
56
client/src/components/IssueCard.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
const PRIORITY_CONFIG = {
|
||||
low: { label: 'Low', dot: 'bg-text-tertiary' },
|
||||
medium: { label: 'Medium', dot: 'bg-blue-500' },
|
||||
high: { label: 'High', dot: 'bg-orange-500' },
|
||||
urgent: { label: 'Urgent', dot: 'bg-red-500' },
|
||||
}
|
||||
|
||||
const TYPE_LABELS = {
|
||||
request: 'Request',
|
||||
correction: 'Correction',
|
||||
complaint: 'Complaint',
|
||||
suggestion: 'Suggestion',
|
||||
other: 'Other',
|
||||
}
|
||||
|
||||
export default function IssueCard({ issue, onClick }) {
|
||||
const priority = PRIORITY_CONFIG[issue.priority] || PRIORITY_CONFIG.medium
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(issue)}
|
||||
className="bg-surface border border-border rounded-lg p-3 cursor-pointer hover:border-brand-primary/30 hover:shadow-sm transition-all"
|
||||
>
|
||||
{/* Priority dot + Title */}
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<span className={`w-2 h-2 rounded-full mt-1.5 shrink-0 ${priority.dot}`} />
|
||||
<h4 className="text-sm font-medium text-text-primary line-clamp-2">{issue.title}</h4>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{issue.category && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-secondary font-medium">
|
||||
{issue.category}
|
||||
</span>
|
||||
)}
|
||||
{issue.brand_name && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-brand-primary/10 text-brand-primary font-medium">
|
||||
{issue.brand_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between mt-2 text-[10px] text-text-tertiary">
|
||||
<span className="truncate max-w-[60%]">{issue.submitter_name}</span>
|
||||
<span>{formatDate(issue.created_at || issue.CreatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
584
client/src/components/IssueDetailPanel.jsx
Normal file
584
client/src/components/IssueDetailPanel.jsx
Normal file
@@ -0,0 +1,584 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { X, Copy, Eye, Lock, Send, Upload, FileText, Trash2, Check, Clock, CheckCircle2, XCircle } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import FormInput from './FormInput'
|
||||
import Modal from './Modal'
|
||||
import { AppContext } from '../App'
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
|
||||
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
|
||||
in_progress: { label: 'In Progress', bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500' },
|
||||
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
|
||||
declined: { label: 'Declined', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
|
||||
}
|
||||
|
||||
const PRIORITY_CONFIG = {
|
||||
low: { label: 'Low', bg: 'bg-surface-tertiary', text: 'text-text-secondary' },
|
||||
medium: { label: 'Medium', bg: 'bg-blue-100', text: 'text-blue-700' },
|
||||
high: { label: 'High', bg: 'bg-orange-100', text: 'text-orange-700' },
|
||||
urgent: { label: 'Urgent', bg: 'bg-red-100', text: 'text-red-700' },
|
||||
}
|
||||
|
||||
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers }) {
|
||||
const { brands } = useContext(AppContext)
|
||||
const [issueData, setIssueData] = useState(null)
|
||||
const [updates, setUpdates] = useState([])
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [initialLoading, setInitialLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
|
||||
// Form state
|
||||
const [assignedTo, setAssignedTo] = useState('')
|
||||
const [internalNotes, setInternalNotes] = useState('')
|
||||
const [resolutionSummary, setResolutionSummary] = useState('')
|
||||
const [newUpdate, setNewUpdate] = useState('')
|
||||
const [updateIsPublic, setUpdateIsPublic] = useState(false)
|
||||
|
||||
// Modals
|
||||
const [showResolveModal, setShowResolveModal] = useState(false)
|
||||
const [showDeclineModal, setShowDeclineModal] = useState(false)
|
||||
|
||||
const issueId = issue?.Id || issue?.id
|
||||
|
||||
useEffect(() => {
|
||||
if (issueId) loadIssueDetails()
|
||||
}, [issueId])
|
||||
|
||||
const loadIssueDetails = async () => {
|
||||
try {
|
||||
const data = await api.get(`/issues/${issueId}`)
|
||||
setIssueData(data)
|
||||
setUpdates(data.updates || [])
|
||||
setAttachments(data.attachments || [])
|
||||
setAssignedTo(data.assigned_to_id || '')
|
||||
setInternalNotes(data.internal_notes || '')
|
||||
setResolutionSummary(data.resolution_summary || '')
|
||||
} catch (err) {
|
||||
console.error('Failed to load issue:', err)
|
||||
} finally {
|
||||
setInitialLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateStatus = async (newStatus) => {
|
||||
if (saving) return
|
||||
try {
|
||||
setSaving(true)
|
||||
await api.patch(`/issues/${issueId}`, { status: newStatus })
|
||||
await onUpdate()
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to update status:', err)
|
||||
alert('Failed to update status')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResolve = async () => {
|
||||
if (saving || !resolutionSummary.trim()) return
|
||||
try {
|
||||
setSaving(true)
|
||||
await api.patch(`/issues/${issueId}`, { status: 'resolved', resolution_summary: resolutionSummary })
|
||||
await onUpdate()
|
||||
setShowResolveModal(false)
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to resolve issue:', err)
|
||||
alert('Failed to resolve issue')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDecline = async () => {
|
||||
if (saving || !resolutionSummary.trim()) return
|
||||
try {
|
||||
setSaving(true)
|
||||
await api.patch(`/issues/${issueId}`, { status: 'declined', resolution_summary: resolutionSummary })
|
||||
await onUpdate()
|
||||
setShowDeclineModal(false)
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to decline issue:', err)
|
||||
alert('Failed to decline issue')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAssignmentChange = async (newAssignedTo) => {
|
||||
try {
|
||||
setAssignedTo(newAssignedTo)
|
||||
await api.patch(`/issues/${issueId}`, { assigned_to_id: newAssignedTo || null })
|
||||
await onUpdate()
|
||||
} catch (err) {
|
||||
console.error('Failed to update assignment:', err)
|
||||
alert('Failed to update assignment')
|
||||
}
|
||||
}
|
||||
|
||||
const handleNotesChange = async () => {
|
||||
if (saving) return
|
||||
try {
|
||||
setSaving(true)
|
||||
await api.patch(`/issues/${issueId}`, { internal_notes: internalNotes })
|
||||
} catch (err) {
|
||||
console.error('Failed to save notes:', err)
|
||||
alert('Failed to save notes')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddUpdate = async () => {
|
||||
if (!newUpdate.trim() || saving) return
|
||||
try {
|
||||
setSaving(true)
|
||||
await api.post(`/issues/${issueId}/updates`, { message: newUpdate, is_public: updateIsPublic })
|
||||
setNewUpdate('')
|
||||
setUpdateIsPublic(false)
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to add update:', err)
|
||||
alert('Failed to add update')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
setUploadingFile(true)
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
await api.upload(`/issues/${issueId}/attachments`, formData)
|
||||
await loadIssueDetails()
|
||||
e.target.value = '' // Reset input
|
||||
} catch (err) {
|
||||
console.error('Failed to upload file:', err)
|
||||
alert('Failed to upload file')
|
||||
} finally {
|
||||
setUploadingFile(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAttachment = async (attachmentId) => {
|
||||
if (!confirm('Delete this attachment?')) return
|
||||
try {
|
||||
await api.delete(`/issue-attachments/${attachmentId}`)
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete attachment:', err)
|
||||
alert('Failed to delete attachment')
|
||||
}
|
||||
}
|
||||
|
||||
const copyTrackingLink = () => {
|
||||
const url = `${window.location.origin}/track/${issueData.tracking_token}`
|
||||
navigator.clipboard.writeText(url)
|
||||
alert('Tracking link copied to clipboard!')
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
if (initialLoading || !issueData) {
|
||||
return (
|
||||
<SlidePanel onClose={onClose} maxWidth="600px">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
const statusConfig = STATUS_CONFIG[issueData.status] || STATUS_CONFIG.new
|
||||
const priorityConfig = PRIORITY_CONFIG[issueData.priority] || PRIORITY_CONFIG.medium
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel
|
||||
onClose={onClose}
|
||||
maxWidth="600px"
|
||||
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">
|
||||
<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}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium ${priorityConfig.bg} ${priorityConfig.text}`}>
|
||||
{priorityConfig.label}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-surface-tertiary text-text-secondary capitalize">
|
||||
{issueData.type}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-surface-tertiary text-text-secondary">
|
||||
{issueData.category}
|
||||
</span>
|
||||
{issueData.brand_name && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-brand-primary/10 text-brand-primary font-medium">
|
||||
{issueData.brand_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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">Submitter Information</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div><span className="text-text-tertiary">Name:</span> <span className="text-text-primary font-medium">{issueData.submitter_name}</span></div>
|
||||
<div><span className="text-text-tertiary">Email:</span> <span className="text-text-primary">{issueData.submitter_email}</span></div>
|
||||
{issueData.submitter_phone && (
|
||||
<div><span className="text-text-tertiary">Phone:</span> <span className="text-text-primary">{issueData.submitter_phone}</span></div>
|
||||
)}
|
||||
<div><span className="text-text-tertiary">Submitted:</span> <span className="text-text-primary">{formatDate(issueData.created_at)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-2">Description</h3>
|
||||
<p className="text-sm text-text-secondary whitespace-pre-wrap">{issueData.description || 'No description provided'}</p>
|
||||
</div>
|
||||
|
||||
{/* Assigned To */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">Assigned To</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="">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">Brand</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="">No brand</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" />
|
||||
Internal Notes (Staff Only)
|
||||
</label>
|
||||
<textarea
|
||||
value={internalNotes}
|
||||
onChange={(e) => setInternalNotes(e.target.value)}
|
||||
onBlur={handleNotesChange}
|
||||
rows={4}
|
||||
placeholder="Internal notes not visible to submitter..."
|
||||
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" />
|
||||
Resolution Summary (Public)
|
||||
</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">Resolved on {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" />
|
||||
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" />
|
||||
Start Work
|
||||
</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" />
|
||||
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" />
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tracking Link */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">Public Tracking Link</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`${window.location.origin}/track/${issueData.tracking_token}`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-surface-secondary"
|
||||
/>
|
||||
<button
|
||||
onClick={copyTrackingLink}
|
||||
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Updates Timeline */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
|
||||
Updates Timeline
|
||||
<span className="text-xs text-text-tertiary font-normal">({updates.length})</span>
|
||||
</h3>
|
||||
|
||||
{/* Add Update */}
|
||||
<div className="bg-surface-secondary rounded-lg p-3 mb-4">
|
||||
<textarea
|
||||
value={newUpdate}
|
||||
onChange={(e) => setNewUpdate(e.target.value)}
|
||||
placeholder="Add an update..."
|
||||
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 mb-2"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={updateIsPublic}
|
||||
onChange={(e) => setUpdateIsPublic(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<Eye className="w-4 h-4" />
|
||||
Make public (visible to submitter)
|
||||
</label>
|
||||
<button
|
||||
onClick={handleAddUpdate}
|
||||
disabled={!newUpdate.trim() || 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 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
Add Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Updates List */}
|
||||
<div className="space-y-3">
|
||||
{updates.map((update) => (
|
||||
<div
|
||||
key={update.Id || update.id}
|
||||
className={`p-3 rounded-lg border ${update.is_public ? 'bg-brand-primary/5 border-brand-primary/20' : 'bg-surface-secondary border-border'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-text-primary">{update.author_name}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${update.author_type === 'staff' ? 'bg-purple-100 text-purple-700' : 'bg-surface-tertiary text-text-secondary'}`}>
|
||||
{update.author_type}
|
||||
</span>
|
||||
{update.is_public ? (
|
||||
<Eye className="w-3.5 h-3.5 text-blue-600" title="Public" />
|
||||
) : (
|
||||
<Lock className="w-3.5 h-3.5 text-text-secondary" title="Internal only" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-text-tertiary">{formatDate(update.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary whitespace-pre-wrap">{update.message}</p>
|
||||
</div>
|
||||
))}
|
||||
{updates.length === 0 && (
|
||||
<p className="text-sm text-text-tertiary text-center py-6">No updates yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
|
||||
Attachments
|
||||
<span className="text-xs text-text-tertiary font-normal">({attachments.length})</span>
|
||||
</h3>
|
||||
|
||||
{/* Upload */}
|
||||
<label className="block mb-3">
|
||||
<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" />
|
||||
<p className="text-sm text-text-secondary">
|
||||
{uploadingFile ? 'Uploading...' : 'Click to upload file'}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Attachments List */}
|
||||
<div className="space-y-2">
|
||||
{attachments.map((att) => (
|
||||
<div key={att.Id || att.id} className="flex items-center justify-between p-3 bg-surface-secondary rounded-lg">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<FileText className="w-5 h-5 text-text-tertiary shrink-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-xs text-text-tertiary">
|
||||
{formatFileSize(att.size)} • {att.uploaded_by}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={`/api/uploads/${att.filename}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-brand-primary hover:underline"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<button onClick={() => handleDeleteAttachment(att.Id || att.id)} className="p-1 hover:bg-surface-tertiary rounded">
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{attachments.length === 0 && (
|
||||
<p className="text-sm text-text-tertiary text-center py-4">No attachments</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SlidePanel>
|
||||
|
||||
{/* Resolve Modal */}
|
||||
{showResolveModal && (
|
||||
<Modal isOpen title="Resolve Issue" onClose={() => setShowResolveModal(false)}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">Provide a resolution summary that will be visible to the submitter.</p>
|
||||
<textarea
|
||||
value={resolutionSummary}
|
||||
onChange={(e) => setResolutionSummary(e.target.value)}
|
||||
placeholder="Explain how this issue was resolved..."
|
||||
rows={5}
|
||||
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 className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setShowResolveModal(false)}
|
||||
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResolve}
|
||||
disabled={!resolutionSummary.trim() || saving}
|
||||
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Resolving...' : 'Mark as Resolved'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Decline Modal */}
|
||||
{showDeclineModal && (
|
||||
<Modal isOpen title="Decline Issue" onClose={() => setShowDeclineModal(false)}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">Provide a reason for declining this issue. This will be visible to the submitter.</p>
|
||||
<textarea
|
||||
value={resolutionSummary}
|
||||
onChange={(e) => setResolutionSummary(e.target.value)}
|
||||
placeholder="Explain why this issue cannot be addressed..."
|
||||
rows={5}
|
||||
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 className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setShowDeclineModal(false)}
|
||||
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDecline}
|
||||
disabled={!resolutionSummary.trim() || saving}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Declining...' : 'Decline Issue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export default function Modal({
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center px-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm"
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function Modal({
|
||||
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] px-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm"
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, FileEdit, Image, Calendar, Wallet,
|
||||
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, ChevronDown,
|
||||
Sparkles, Shield, LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3
|
||||
Sparkles, Shield, LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3, Palette, CalendarDays, AlertCircle
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
@@ -22,6 +22,8 @@ const moduleGroups = [
|
||||
items: [
|
||||
{ to: '/campaigns', icon: Calendar, labelKey: 'nav.campaigns', tutorial: 'campaigns' },
|
||||
{ to: '/posts', icon: FileEdit, labelKey: 'nav.posts', tutorial: 'posts' },
|
||||
{ to: '/calendar', icon: CalendarDays, labelKey: 'nav.calendar' },
|
||||
{ to: '/artefacts', icon: Palette, labelKey: 'nav.artefacts' },
|
||||
{ to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' },
|
||||
{ to: '/brands', icon: Tag, labelKey: 'nav.brands' },
|
||||
],
|
||||
@@ -45,6 +47,14 @@ const moduleGroups = [
|
||||
{ to: '/budgets', icon: Receipt, labelKey: 'nav.budgets' },
|
||||
],
|
||||
},
|
||||
{
|
||||
module: 'issues',
|
||||
labelKey: 'modules.issues',
|
||||
icon: AlertCircle,
|
||||
items: [
|
||||
{ to: '/issues', icon: AlertCircle, labelKey: 'nav.issues' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const standaloneBottom = [
|
||||
|
||||
@@ -76,6 +76,49 @@ export function SkeletonKanbanBoard() {
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonCalendar() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden animate-pulse">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="h-6 bg-surface-tertiary rounded w-40"></div>
|
||||
<div className="h-8 bg-surface-tertiary rounded w-20"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 border-b border-border bg-surface-secondary">
|
||||
{[...Array(7)].map((_, i) => (
|
||||
<div key={i} className="text-center py-3">
|
||||
<div className="h-3 bg-surface-tertiary rounded w-8 mx-auto"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7">
|
||||
{[...Array(35)].map((_, i) => (
|
||||
<div key={i} className="border-r border-b border-border min-h-[100px] p-2">
|
||||
<div className="h-5 w-5 bg-surface-tertiary rounded-full mb-2"></div>
|
||||
<div className="space-y-1">
|
||||
<div className="h-3 bg-surface-tertiary rounded w-full"></div>
|
||||
{i % 3 === 0 && <div className="h-3 bg-surface-tertiary rounded w-3/4"></div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonAssetGrid({ count = 10 }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{[...Array(count)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="aspect-square bg-surface-tertiary rounded-xl"></div>
|
||||
<div className="mt-2 h-3 bg-surface-tertiary rounded w-3/4"></div>
|
||||
<div className="mt-1 h-3 bg-surface-tertiary rounded w-1/2"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonDashboard() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'
|
||||
export default function SlidePanel({ onClose, maxWidth = '420px', header, children }) {
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/20 z-[9998]" onClick={onClose} />
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} />
|
||||
<div
|
||||
className="fixed top-0 right-0 h-full w-full bg-white shadow-2xl z-[9998] flex flex-col animate-slide-in-right overflow-hidden"
|
||||
style={{ maxWidth }}
|
||||
|
||||
@@ -22,7 +22,7 @@ const ICON_COLORS = {
|
||||
warning: 'text-amber-500',
|
||||
}
|
||||
|
||||
export default function Toast({ message, type = 'info', onClose, duration = 4000 }) {
|
||||
export default function Toast({ message, type = 'info', onClose, duration = 4000, exiting = false }) {
|
||||
const Icon = TOAST_ICONS[type]
|
||||
const colorClass = TOAST_COLORS[type]
|
||||
const iconColor = ICON_COLORS[type]
|
||||
@@ -35,7 +35,7 @@ export default function Toast({ message, type = 'info', onClose, duration = 4000
|
||||
}, [duration, onClose])
|
||||
|
||||
return (
|
||||
<div className={`flex items-start gap-3 p-4 rounded-xl border shadow-lg ${colorClass} animate-slide-in min-w-[300px] max-w-md`}>
|
||||
<div className={`flex items-start gap-3 p-4 rounded-xl border shadow-lg ${colorClass} ${exiting ? 'animate-slide-out' : 'animate-slide-in'} min-w-[300px] max-w-md`}>
|
||||
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${iconColor}`} />
|
||||
<p className="flex-1 text-sm font-medium leading-snug">{message}</p>
|
||||
<button
|
||||
|
||||
@@ -20,7 +20,10 @@ export function ToastProvider({ children }) {
|
||||
}, [])
|
||||
|
||||
const removeToast = useCallback((id) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t))
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, 300)
|
||||
}, [])
|
||||
|
||||
const toast = {
|
||||
@@ -42,6 +45,7 @@ export function ToastProvider({ children }) {
|
||||
message={t.message}
|
||||
type={t.type}
|
||||
duration={t.duration}
|
||||
exiting={t.exiting}
|
||||
onClose={() => removeToast(t.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user