Previous `resources`-string PATCH was silently ignored by CW — the `resources` field is server-derived from the ticket's owner + schedule entries, not freely writable. Status PATCH could also silently no-op when a cross-board status id was sent. - add_resource: when the ticket is unassigned, set the `owner` MemberReference (the canonical writable primary-assignee field). If already owned by someone else, append the identifier to the `resources` co-assignee string best-effort. - remove_resource: clear `owner` (with remove→replace:null fallback) if the target is the current owner, otherwise strip from `resources`. - list_resources: merge owner + resources string, deduped by member id, so the UI reflects both single-owner and multi-resource assignments. - update_ticket_status: verify CW applied the status by comparing the response body's status.id — raises PSAError with a clear message when CW silently rejects the change (e.g., status invalid for ticket's board), instead of reporting spurious success. - Frontend: surface the backend error detail in the toast so users see the real reason instead of a generic "Failed to update" message. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
81 lines
2.9 KiB
TypeScript
81 lines
2.9 KiB
TypeScript
import { useState } from 'react'
|
|
import axios from 'axios'
|
|
import { ticketsApi } from '@/api/tickets'
|
|
import { toast } from '@/lib/toast'
|
|
import type { PSATicketSearchResult, PSATicketStatusItem } from '@/types/integrations'
|
|
import type { PSATicketStatusUpdate } from '@/types/tickets'
|
|
|
|
interface Props {
|
|
ticket: PSATicketSearchResult
|
|
currentStatusId: number | null
|
|
currentStatusName: string | null
|
|
statuses: PSATicketStatusItem[]
|
|
onStatusUpdated: (ticketId: number, newStatus: string, newStatusId: number) => void
|
|
}
|
|
|
|
export function TicketDetailHeader({ ticket, currentStatusId, currentStatusName, statuses, onStatusUpdated }: Props) {
|
|
const [updating, setUpdating] = useState(false)
|
|
|
|
async function handleStatusChange(statusId: number) {
|
|
if (!ticket.id) return
|
|
setUpdating(true)
|
|
try {
|
|
const result: PSATicketStatusUpdate = await ticketsApi.updateStatus(Number(ticket.id), statusId)
|
|
onStatusUpdated(result.ticket_id, result.new_status, result.new_status_id)
|
|
toast.success(`Status updated to ${result.new_status}`)
|
|
} catch (err) {
|
|
const detail = axios.isAxiosError(err)
|
|
? (err.response?.data as { detail?: string })?.detail
|
|
: undefined
|
|
toast.error(detail || 'Failed to update status')
|
|
} finally {
|
|
setUpdating(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="p-4 border-b border-default space-y-3">
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-accent text-xs font-mono">#{ticket.id}</span>
|
|
{ticket.board_name && (
|
|
<span className="text-xs text-muted-foreground">{ticket.board_name}</span>
|
|
)}
|
|
</div>
|
|
<h2 className="font-heading font-semibold text-heading text-base leading-snug">
|
|
{ticket.summary}
|
|
</h2>
|
|
{ticket.company_name && (
|
|
<p className="text-sm text-muted-foreground mt-0.5">{ticket.company_name}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{statuses.length > 0 ? (
|
|
<select
|
|
disabled={updating}
|
|
value={currentStatusId ?? ''}
|
|
onChange={e => handleStatusChange(Number(e.target.value))}
|
|
className="bg-input border border-default rounded-[5px] px-2 py-1 text-xs text-primary focus:border-accent focus:outline-none"
|
|
>
|
|
{statuses.map(s => (
|
|
<option key={s.id} value={s.id}>{s.name}</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
currentStatusName && (
|
|
<span className="px-2 py-0.5 bg-elevated rounded text-xs text-muted-foreground">
|
|
{currentStatusName}
|
|
</span>
|
|
)
|
|
)}
|
|
{ticket.priority_name && (
|
|
<span className="px-2 py-0.5 bg-elevated rounded text-xs text-muted-foreground">
|
|
{ticket.priority_name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|