This commit is contained in:
fahed
2026-02-23 11:57:32 +03:00
parent 4522edeea8
commit 8436c49142
50 changed files with 6447 additions and 55 deletions
+584
View 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>
)}
</>
)
}