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>
129 lines
4.2 KiB
TypeScript
129 lines
4.2 KiB
TypeScript
import { useState } from 'react'
|
|
import axios from 'axios'
|
|
import { UserPlus, X, User } from 'lucide-react'
|
|
import { ticketsApi } from '@/api/tickets'
|
|
import { toast } from '@/lib/toast'
|
|
import type { PSAResource } from '@/types/tickets'
|
|
import type { PsaMemberResponse } from '@/types/integrations'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface Props {
|
|
ticketId: number
|
|
resources: PSAResource[]
|
|
allMembers: PsaMemberResponse[]
|
|
onChanged: () => void
|
|
}
|
|
|
|
export function TicketResourceManager({ ticketId, resources, allMembers, onChanged }: Props) {
|
|
const [adding, setAdding] = useState(false)
|
|
const [selectedMemberId, setSelectedMemberId] = useState<string>('')
|
|
const [busy, setBusy] = useState<number | null>(null)
|
|
|
|
async function handleAdd() {
|
|
if (!selectedMemberId) return
|
|
setBusy(Number(selectedMemberId))
|
|
try {
|
|
await ticketsApi.addResource(ticketId, Number(selectedMemberId))
|
|
toast.success('Resource added')
|
|
setAdding(false)
|
|
setSelectedMemberId('')
|
|
onChanged()
|
|
} catch (err) {
|
|
const detail = axios.isAxiosError(err)
|
|
? (err.response?.data as { detail?: string })?.detail
|
|
: undefined
|
|
toast.error(detail || 'Failed to add resource')
|
|
} finally {
|
|
setBusy(null)
|
|
}
|
|
}
|
|
|
|
async function handleRemove(memberId: number) {
|
|
setBusy(memberId)
|
|
try {
|
|
await ticketsApi.removeResource(ticketId, memberId)
|
|
toast.success('Resource removed')
|
|
onChanged()
|
|
} catch (err) {
|
|
const detail = axios.isAxiosError(err)
|
|
? (err.response?.data as { detail?: string })?.detail
|
|
: undefined
|
|
toast.error(detail || 'Failed to remove resource')
|
|
} finally {
|
|
setBusy(null)
|
|
}
|
|
}
|
|
|
|
const assignedIds = new Set(resources.map(r => r.member_id))
|
|
|
|
return (
|
|
<div className="px-4 py-3 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">
|
|
Resources
|
|
</h4>
|
|
<button
|
|
onClick={() => setAdding(!adding)}
|
|
className="flex items-center gap-1 text-xs text-accent hover:text-accent/80 transition-colors"
|
|
>
|
|
<UserPlus className="w-3.5 h-3.5" /> Assign
|
|
</button>
|
|
</div>
|
|
|
|
{adding && (
|
|
<div className="flex gap-2">
|
|
<select
|
|
className="flex-1 bg-input border border-default rounded-[5px] px-2 py-1 text-xs text-primary focus:border-accent focus:outline-none"
|
|
value={selectedMemberId}
|
|
onChange={e => setSelectedMemberId(e.target.value)}
|
|
>
|
|
<option value="">Select member…</option>
|
|
{allMembers
|
|
.filter(m => !assignedIds.has(Number(m.id)))
|
|
.map(m => (
|
|
<option key={m.id} value={m.id}>{m.name}</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
onClick={handleAdd}
|
|
disabled={!selectedMemberId || busy !== null}
|
|
className="px-2 py-1 bg-accent text-white text-xs rounded-[5px] disabled:opacity-40"
|
|
>
|
|
Add
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{resources.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">No resources assigned.</p>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{resources.map(r => (
|
|
<div key={r.member_id} className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1.5 text-xs text-primary">
|
|
<User className="w-3 h-3 text-muted-foreground" />
|
|
{r.member_name}
|
|
{r.is_rf_user && (
|
|
<span className="px-1 py-0.5 bg-accent/10 text-accent rounded text-[10px] font-medium">
|
|
RF
|
|
</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => handleRemove(r.member_id)}
|
|
disabled={busy === r.member_id}
|
|
className={cn(
|
|
'text-muted-foreground hover:text-danger transition-colors',
|
|
busy === r.member_id && 'opacity-40'
|
|
)}
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|