Files
resolutionflow/frontend/src/components/tickets/detail/TicketResourceManager.tsx
Michael Chihlas f6a24ea4e1
Some checks failed
Mirror to GitHub / mirror (push) Successful in 2s
CI / backend (pull_request) Failing after 15m32s
CI / frontend (pull_request) Failing after 45s
CI / e2e (pull_request) Has been skipped
fix(psa): resource assignment targets CW owner, status PATCH verifies apply
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>
2026-04-16 21:39:21 +00:00

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>
)
}