feat: wire meeting/support detail routes and fix code-first navigation
- Add routes for /meetings/:meetingId and /supports/:supportId in App.tsx - Fix MilestoneDetailPage to navigate to code-first detail URLs - Update table headers from '#' to 'Code' for supports/meetings lists - Fix TypeScript types for supports/meetings (use any[] instead of Task[]) - MeetingDetailPage: full detail view with attend, transition, edit, delete - SupportDetailPage: full detail view with take, transition, edit, delete
This commit is contained in:
@@ -17,6 +17,8 @@ import MonitorPage from '@/pages/MonitorPage'
|
|||||||
import ProposesPage from '@/pages/ProposesPage'
|
import ProposesPage from '@/pages/ProposesPage'
|
||||||
import ProposeDetailPage from '@/pages/ProposeDetailPage'
|
import ProposeDetailPage from '@/pages/ProposeDetailPage'
|
||||||
import UsersPage from '@/pages/UsersPage'
|
import UsersPage from '@/pages/UsersPage'
|
||||||
|
import SupportDetailPage from '@/pages/SupportDetailPage'
|
||||||
|
import MeetingDetailPage from '@/pages/MeetingDetailPage'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
const WIZARD_PORT = Number(import.meta.env.VITE_WIZARD_PORT) || 18080
|
const WIZARD_PORT = Number(import.meta.env.VITE_WIZARD_PORT) || 18080
|
||||||
@@ -96,6 +98,8 @@ export default function App() {
|
|||||||
<Route path="/milestones/:id" element={<MilestoneDetailPage />} />
|
<Route path="/milestones/:id" element={<MilestoneDetailPage />} />
|
||||||
<Route path="/proposes" element={<ProposesPage />} />
|
<Route path="/proposes" element={<ProposesPage />} />
|
||||||
<Route path="/proposes/:id" element={<ProposeDetailPage />} />
|
<Route path="/proposes/:id" element={<ProposeDetailPage />} />
|
||||||
|
<Route path="/meetings/:meetingId" element={<MeetingDetailPage />} />
|
||||||
|
<Route path="/supports/:supportId" element={<SupportDetailPage />} />
|
||||||
<Route path="/notifications" element={<NotificationsPage />} />
|
<Route path="/notifications" element={<NotificationsPage />} />
|
||||||
<Route path="/roles" element={<RoleEditorPage />} />
|
<Route path="/roles" element={<RoleEditorPage />} />
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
|
|||||||
321
src/pages/MeetingDetailPage.tsx
Normal file
321
src/pages/MeetingDetailPage.tsx
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import api from '@/services/api'
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
interface MeetingItem {
|
||||||
|
id: number
|
||||||
|
code: string | null
|
||||||
|
meeting_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
|
||||||
|
meeting_time: string | null
|
||||||
|
scheduled_at: string | null
|
||||||
|
duration_minutes: number | null
|
||||||
|
participants: string[]
|
||||||
|
created_at: string
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = ['scheduled', 'in_progress', 'completed', 'cancelled']
|
||||||
|
|
||||||
|
export default function MeetingDetailPage() {
|
||||||
|
const { meetingId } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [meeting, setMeeting] = useState<MeetingItem | 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: '',
|
||||||
|
meeting_time: '',
|
||||||
|
duration_minutes: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchMeeting = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<MeetingItem>(`/meetings/${meetingId}`)
|
||||||
|
setMeeting(data)
|
||||||
|
setEditForm({
|
||||||
|
title: data.title,
|
||||||
|
description: data.description || '',
|
||||||
|
meeting_time: data.meeting_time || data.scheduled_at || '',
|
||||||
|
duration_minutes: data.duration_minutes ? String(data.duration_minutes) : '',
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
setMessage(err.response?.data?.detail || 'Failed to load meeting')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMeeting()
|
||||||
|
}, [meetingId])
|
||||||
|
|
||||||
|
const handleAttend = async () => {
|
||||||
|
if (!meeting) return
|
||||||
|
setSaving(true)
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<MeetingItem>(`/meetings/${meetingId}/attend`)
|
||||||
|
setMeeting(data)
|
||||||
|
setMessage('You have joined this meeting')
|
||||||
|
} catch (err: any) {
|
||||||
|
setMessage(err.response?.data?.detail || 'Failed to attend meeting')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTransition = async (newStatus: string) => {
|
||||||
|
if (!meeting) return
|
||||||
|
setSaving(true)
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
const { data } = await api.patch<MeetingItem>(`/meetings/${meetingId}`, {
|
||||||
|
status: newStatus,
|
||||||
|
})
|
||||||
|
setMeeting(data)
|
||||||
|
setMessage(`Status changed to ${newStatus}`)
|
||||||
|
} catch (err: any) {
|
||||||
|
setMessage(err.response?.data?.detail || 'Failed to update meeting status')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!meeting) return
|
||||||
|
setSaving(true)
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
const payload: Record<string, any> = {}
|
||||||
|
if (editForm.title.trim() !== meeting.title) payload.title = editForm.title.trim()
|
||||||
|
if ((editForm.description || '') !== (meeting.description || '')) payload.description = editForm.description || null
|
||||||
|
const currentTime = meeting.meeting_time || meeting.scheduled_at || ''
|
||||||
|
if (editForm.meeting_time !== currentTime) payload.meeting_time = editForm.meeting_time || null
|
||||||
|
const currentDuration = meeting.duration_minutes ? String(meeting.duration_minutes) : ''
|
||||||
|
if (editForm.duration_minutes !== currentDuration) {
|
||||||
|
payload.duration_minutes = editForm.duration_minutes ? Number(editForm.duration_minutes) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(payload).length === 0) {
|
||||||
|
setEditMode(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await api.patch<MeetingItem>(`/meetings/${meetingId}`, payload)
|
||||||
|
setMeeting(data)
|
||||||
|
setEditMode(false)
|
||||||
|
setMessage('Meeting updated')
|
||||||
|
} catch (err: any) {
|
||||||
|
setMessage(err.response?.data?.detail || 'Failed to update meeting')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!meeting) return
|
||||||
|
if (!confirm(`Delete meeting ${meeting.meeting_code || meeting.id}? This cannot be undone.`)) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await api.delete(`/meetings/${meetingId}`)
|
||||||
|
navigate(-1)
|
||||||
|
} catch (err: any) {
|
||||||
|
setMessage(err.response?.data?.detail || 'Failed to delete meeting')
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="loading">Loading meeting...</div>
|
||||||
|
if (!meeting) return <div className="loading">{message || 'Meeting not found'}</div>
|
||||||
|
|
||||||
|
const isParticipant = user && meeting.participants.includes(user.username)
|
||||||
|
const canAttend = user && !isParticipant && meeting.status !== 'completed' && meeting.status !== 'cancelled'
|
||||||
|
const availableTransitions = STATUS_OPTIONS.filter((s) => s !== meeting.status)
|
||||||
|
const scheduledTime = meeting.meeting_time || meeting.scheduled_at
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section">
|
||||||
|
<button className="btn-back" onClick={() => navigate(-1)}>← Back</button>
|
||||||
|
|
||||||
|
<div className="task-header">
|
||||||
|
<h2>📅 {meeting.meeting_code || `#${meeting.id}`}</h2>
|
||||||
|
<div className="task-meta">
|
||||||
|
<span className={`badge status-${meeting.status}`}>{meeting.status}</span>
|
||||||
|
{meeting.project_code && <span className="text-dim">Project: {meeting.project_code}</span>}
|
||||||
|
{meeting.milestone_code && <span className="text-dim">Milestone: {meeting.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>
|
||||||
|
Scheduled Time
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={editForm.meeting_time ? dayjs(editForm.meeting_time).format('YYYY-MM-DDTHH:mm') : ''}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, meeting_time: e.target.value ? new Date(e.target.value).toISOString() : '' })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Duration (minutes)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editForm.duration_minutes}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, duration_minutes: e.target.value })}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</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>{meeting.title}</h3>
|
||||||
|
{scheduledTime && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<strong>Scheduled:</strong> {dayjs(scheduledTime).format('YYYY-MM-DD HH:mm')}
|
||||||
|
{meeting.duration_minutes && <span className="text-dim" style={{ marginLeft: 8 }}>({meeting.duration_minutes} min)</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!scheduledTime && <div className="text-dim" style={{ marginTop: 8 }}>No scheduled time set.</div>}
|
||||||
|
{meeting.description && (
|
||||||
|
<p style={{ whiteSpace: 'pre-wrap', marginTop: 12 }}>{meeting.description}</p>
|
||||||
|
)}
|
||||||
|
{!meeting.description && <p className="text-dim" style={{ marginTop: 12 }}>No description provided.</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{/* Participants */}
|
||||||
|
<div className="monitor-card">
|
||||||
|
<h4 style={{ marginBottom: 8 }}>Participants ({meeting.participants.length})</h4>
|
||||||
|
{meeting.participants.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{meeting.participants.map((p) => (
|
||||||
|
<span key={p} className="badge">
|
||||||
|
{p}
|
||||||
|
{user && p === user.username && ' (you)'}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-dim">No participants yet</span>
|
||||||
|
)}
|
||||||
|
{canAttend && (
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
style={{ marginTop: 8, width: '100%' }}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={handleAttend}
|
||||||
|
>
|
||||||
|
Attend
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status transitions */}
|
||||||
|
<div className="monitor-card">
|
||||||
|
<h4 style={{ marginBottom: 8 }}>Status</h4>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{availableTransitions.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => handleTransition(s)}
|
||||||
|
style={{ textAlign: 'left' }}
|
||||||
|
>
|
||||||
|
→ {s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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(meeting.created_at).toLocaleString()}</div>
|
||||||
|
{meeting.updated_at && <div>Updated: {new Date(meeting.updated_at).toLocaleString()}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -75,8 +75,8 @@ export default function MilestoneDetailPage() {
|
|||||||
const refreshMilestoneItems = () => {
|
const refreshMilestoneItems = () => {
|
||||||
if (!projectCode || !id) return
|
if (!projectCode || !id) return
|
||||||
api.get<MilestoneTask[]>(`/tasks/${projectCode}/${id}`).then(({ data }) => setTasks(data)).catch(() => {})
|
api.get<MilestoneTask[]>(`/tasks/${projectCode}/${id}`).then(({ data }) => setTasks(data)).catch(() => {})
|
||||||
api.get<Task[]>(`/supports/${projectCode}/${id}`).then(({ data }) => setSupports(data)).catch(() => {})
|
api.get<any[]>(`/supports/${projectCode}/${id}`).then(({ data }) => setSupports(data)).catch(() => {})
|
||||||
api.get<Task[]>(`/meetings/${projectCode}/${id}`).then(({ data }) => setMeetings(data)).catch(() => {})
|
api.get<any[]>(`/meetings/${projectCode}/${id}`).then(({ data }) => setMeetings(data)).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -355,7 +355,7 @@ export default function MilestoneDetailPage() {
|
|||||||
<thead><tr><th>Code</th><th>Title</th><th>Status</th><th>Priority</th></tr></thead>
|
<thead><tr><th>Code</th><th>Title</th><th>Status</th><th>Priority</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{supports.map((i) => (
|
{supports.map((i) => (
|
||||||
<tr key={i.id} className="clickable" onClick={() => navigate(`/supports/${projectCode}/${id}/${i.support_code || i.id}`)}>
|
<tr key={i.id} className="clickable" onClick={() => navigate(`/supports/${i.support_code || i.id}`)}>
|
||||||
<td>{i.support_code || i.id}</td>
|
<td>{i.support_code || i.id}</td>
|
||||||
<td className="task-title">{i.title}</td>
|
<td className="task-title">{i.title}</td>
|
||||||
<td><span className={`badge status-${i.status}`}>{i.status}</span></td>
|
<td><span className={`badge status-${i.status}`}>{i.status}</span></td>
|
||||||
@@ -372,7 +372,7 @@ export default function MilestoneDetailPage() {
|
|||||||
<thead><tr><th>Code</th><th>Title</th><th>Status</th><th>Priority</th></tr></thead>
|
<thead><tr><th>Code</th><th>Title</th><th>Status</th><th>Priority</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{meetings.map((i) => (
|
{meetings.map((i) => (
|
||||||
<tr key={i.id} className="clickable" onClick={() => navigate(`/meetings/${projectCode}/${id}/${i.meeting_code || i.id}`)}>
|
<tr key={i.id} className="clickable" onClick={() => navigate(`/meetings/${i.meeting_code || i.id}`)}>
|
||||||
<td>{i.meeting_code || i.id}</td>
|
<td>{i.meeting_code || i.id}</td>
|
||||||
<td className="task-title">{i.title}</td>
|
<td className="task-title">{i.title}</td>
|
||||||
<td><span className={`badge status-${i.status}`}>{i.status}</span></td>
|
<td><span className={`badge status-${i.status}`}>{i.status}</span></td>
|
||||||
|
|||||||
298
src/pages/SupportDetailPage.tsx
Normal file
298
src/pages/SupportDetailPage.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import api from '@/services/api'
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
|
||||||
|
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 { supportId } = 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/${supportId}`)
|
||||||
|
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()
|
||||||
|
}, [supportId])
|
||||||
|
|
||||||
|
const handleTake = async () => {
|
||||||
|
if (!support) return
|
||||||
|
setSaving(true)
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<SupportItem>(`/supports/${supportId}/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/${supportId}/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/${supportId}`, 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/${supportId}`)
|
||||||
|
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 || `#${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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user