300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
import api from '@/services/api'
|
|
import { useAuth } from '@/hooks/useAuth'
|
|
import CopyableCode from '@/components/CopyableCode'
|
|
|
|
interface SupportItem {
|
|
id: number
|
|
code: string | null
|
|
support_code: string | null
|
|
title: string
|
|
description: string | null
|
|
status: string
|
|
priority: string
|
|
project_id: number
|
|
project_code: string | null
|
|
milestone_id: number
|
|
milestone_code: string | null
|
|
reporter_id: number
|
|
assignee_id: number | null
|
|
taken_by: string | null
|
|
created_at: string
|
|
updated_at: string | null
|
|
}
|
|
|
|
const STATUS_OPTIONS = ['open', 'in_progress', 'resolved', 'closed']
|
|
const PRIORITY_OPTIONS = ['low', 'medium', 'high', 'critical']
|
|
|
|
export default function SupportDetailPage() {
|
|
const { supportCode } = useParams()
|
|
const navigate = useNavigate()
|
|
const { user } = useAuth()
|
|
const [support, setSupport] = useState<SupportItem | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
const [message, setMessage] = useState('')
|
|
const [editMode, setEditMode] = useState(false)
|
|
const [editForm, setEditForm] = useState({ title: '', description: '', priority: '' })
|
|
const [transitionStatus, setTransitionStatus] = useState('')
|
|
|
|
const fetchSupport = async () => {
|
|
try {
|
|
const { data } = await api.get<SupportItem>(`/supports/${supportCode}`)
|
|
setSupport(data)
|
|
setEditForm({
|
|
title: data.title,
|
|
description: data.description || '',
|
|
priority: data.priority,
|
|
})
|
|
} catch (err: any) {
|
|
setMessage(err.response?.data?.detail || 'Failed to load support ticket')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchSupport()
|
|
}, [supportCode])
|
|
|
|
const handleTake = async () => {
|
|
if (!support) return
|
|
setSaving(true)
|
|
setMessage('')
|
|
try {
|
|
const { data } = await api.post<SupportItem>(`/supports/${supportCode}/take`)
|
|
setSupport(data)
|
|
setMessage('You have taken this support ticket')
|
|
} catch (err: any) {
|
|
setMessage(err.response?.data?.detail || 'Failed to take support ticket')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleTransition = async () => {
|
|
if (!support || !transitionStatus) return
|
|
setSaving(true)
|
|
setMessage('')
|
|
try {
|
|
const { data } = await api.post<SupportItem>(`/supports/${supportCode}/transition`, {
|
|
status: transitionStatus,
|
|
})
|
|
setSupport(data)
|
|
setTransitionStatus('')
|
|
setMessage(`Status changed to ${transitionStatus}`)
|
|
} catch (err: any) {
|
|
setMessage(err.response?.data?.detail || 'Failed to transition support ticket')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
if (!support) return
|
|
setSaving(true)
|
|
setMessage('')
|
|
try {
|
|
const payload: Record<string, any> = {}
|
|
if (editForm.title.trim() !== support.title) payload.title = editForm.title.trim()
|
|
if ((editForm.description || '') !== (support.description || '')) payload.description = editForm.description || null
|
|
if (editForm.priority !== support.priority) payload.priority = editForm.priority
|
|
|
|
if (Object.keys(payload).length === 0) {
|
|
setEditMode(false)
|
|
return
|
|
}
|
|
|
|
const { data } = await api.patch<SupportItem>(`/supports/${supportCode}`, payload)
|
|
setSupport(data)
|
|
setEditMode(false)
|
|
setMessage('Support ticket updated')
|
|
} catch (err: any) {
|
|
setMessage(err.response?.data?.detail || 'Failed to update support ticket')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async () => {
|
|
if (!support) return
|
|
if (!confirm(`Delete support ticket ${support.support_code || support.id}? This cannot be undone.`)) return
|
|
setSaving(true)
|
|
try {
|
|
await api.delete(`/supports/${supportCode}`)
|
|
navigate(-1)
|
|
} catch (err: any) {
|
|
setMessage(err.response?.data?.detail || 'Failed to delete support ticket')
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
if (loading) return <div className="loading">Loading support ticket...</div>
|
|
if (!support) return <div className="loading">{message || 'Support ticket not found'}</div>
|
|
|
|
const canTake = user && (!support.assignee_id || support.assignee_id === user.id) && support.status !== 'closed' && support.status !== 'resolved'
|
|
const isMine = user && support.assignee_id === user.id
|
|
const availableTransitions = STATUS_OPTIONS.filter((s) => s !== support.status)
|
|
|
|
return (
|
|
<div className="section">
|
|
<button className="btn-back" onClick={() => navigate(-1)}>← Back</button>
|
|
|
|
<div className="task-header">
|
|
<h2>🎫 {support.support_code ? <CopyableCode code={support.support_code} /> : `#${support.id}`}</h2>
|
|
<div className="task-meta">
|
|
<span className={`badge status-${support.status}`}>{support.status}</span>
|
|
<span className={`badge priority-${support.priority}`}>{support.priority}</span>
|
|
{support.project_code && <span className="text-dim">Project: {support.project_code}</span>}
|
|
{support.milestone_code && <span className="text-dim">Milestone: {support.milestone_code}</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{message && (
|
|
<div
|
|
style={{
|
|
padding: '10px 12px',
|
|
marginBottom: '16px',
|
|
borderRadius: '8px',
|
|
background: message.toLowerCase().includes('fail') || message.toLowerCase().includes('error')
|
|
? 'rgba(239,68,68,.12)'
|
|
: 'rgba(16,185,129,.12)',
|
|
border: `1px solid ${
|
|
message.toLowerCase().includes('fail') || message.toLowerCase().includes('error')
|
|
? 'rgba(239,68,68,.35)'
|
|
: 'rgba(16,185,129,.35)'
|
|
}`,
|
|
}}
|
|
>
|
|
{message}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: '20px', alignItems: 'start' }}>
|
|
{/* Main content */}
|
|
<div className="monitor-card">
|
|
{editMode ? (
|
|
<div className="task-create-form">
|
|
<label>
|
|
Title
|
|
<input
|
|
value={editForm.title}
|
|
onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Description
|
|
<textarea
|
|
value={editForm.description}
|
|
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
|
rows={6}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Priority
|
|
<select
|
|
value={editForm.priority}
|
|
onChange={(e) => setEditForm({ ...editForm, priority: e.target.value })}
|
|
>
|
|
{PRIORITY_OPTIONS.map((p) => (
|
|
<option key={p} value={p}>{p}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button className="btn-primary" disabled={saving} onClick={handleSave}>
|
|
{saving ? 'Saving...' : 'Save'}
|
|
</button>
|
|
<button className="btn-back" onClick={() => setEditMode(false)}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<h3>{support.title}</h3>
|
|
{support.description && (
|
|
<p style={{ whiteSpace: 'pre-wrap', marginTop: 12 }}>{support.description}</p>
|
|
)}
|
|
{!support.description && <p className="text-dim">No description provided.</p>}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sidebar */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
{/* Ownership */}
|
|
<div className="monitor-card">
|
|
<h4 style={{ marginBottom: 8 }}>Assigned To</h4>
|
|
{support.taken_by ? (
|
|
<div>
|
|
<span className="badge">{support.taken_by}</span>
|
|
{isMine && <span className="text-dim" style={{ marginLeft: 8 }}>(you)</span>}
|
|
</div>
|
|
) : (
|
|
<span className="text-dim">Unassigned</span>
|
|
)}
|
|
{canTake && !isMine && (
|
|
<button
|
|
className="btn-primary"
|
|
style={{ marginTop: 8, width: '100%' }}
|
|
disabled={saving}
|
|
onClick={handleTake}
|
|
>
|
|
Take
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Transition */}
|
|
<div className="monitor-card">
|
|
<h4 style={{ marginBottom: 8 }}>Transition</h4>
|
|
<select
|
|
value={transitionStatus}
|
|
onChange={(e) => setTransitionStatus(e.target.value)}
|
|
style={{ width: '100%', marginBottom: 8 }}
|
|
>
|
|
<option value="">Select status...</option>
|
|
{availableTransitions.map((s) => (
|
|
<option key={s} value={s}>{s}</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
className="btn-primary"
|
|
style={{ width: '100%' }}
|
|
disabled={saving || !transitionStatus}
|
|
onClick={handleTransition}
|
|
>
|
|
Transition
|
|
</button>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="monitor-card">
|
|
<h4 style={{ marginBottom: 8 }}>Actions</h4>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{!editMode && (
|
|
<button className="btn-secondary" onClick={() => setEditMode(true)}>
|
|
Edit
|
|
</button>
|
|
)}
|
|
<button className="btn-danger" disabled={saving} onClick={handleDelete}>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="monitor-card">
|
|
<h4 style={{ marginBottom: 8 }}>Info</h4>
|
|
<div className="text-dim" style={{ fontSize: '0.9em' }}>
|
|
<div>Created: {new Date(support.created_at).toLocaleString()}</div>
|
|
{support.updated_at && <div>Updated: {new Date(support.updated_at).toLocaleString()}</div>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|