fix(tickets): status filter dropdown and CW resource assignment
- Status filter: aggregate statuses across all boards (deduped by name)
when no board is selected. Backend accepts status_name and filters by
status/name so the same status matches across boards.
- Resource assignment: CW has no /service/tickets/{id}/members endpoint —
assignees live in the ticket's comma-separated `resources` string field.
Rewrote list/add/remove to read/PATCH that field via member identifier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -389,6 +389,7 @@ async def search_tickets(
|
|||||||
query: str = "",
|
query: str = "",
|
||||||
board_id: int | None = None,
|
board_id: int | None = None,
|
||||||
status_id: int | None = None,
|
status_id: int | None = None,
|
||||||
|
status_name: str | None = None,
|
||||||
include_closed: bool = False,
|
include_closed: bool = False,
|
||||||
assigned_to_me: bool = False,
|
assigned_to_me: bool = False,
|
||||||
unassigned: bool = False,
|
unassigned: bool = False,
|
||||||
@@ -448,6 +449,7 @@ async def search_tickets(
|
|||||||
query,
|
query,
|
||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
status_id=status_id,
|
status_id=status_id,
|
||||||
|
status_name=status_name,
|
||||||
include_closed=include_closed,
|
include_closed=include_closed,
|
||||||
member_identifier=member_identifier,
|
member_identifier=member_identifier,
|
||||||
unassigned=unassigned,
|
unassigned=unassigned,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from app.services.psa.base import PSAProvider
|
from app.services.psa.base import PSAProvider
|
||||||
from app.services.psa.cache import psa_cache
|
from app.services.psa.cache import psa_cache
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
from app.services.psa.types import (
|
from app.services.psa.types import (
|
||||||
ConnectionTestResult,
|
ConnectionTestResult,
|
||||||
PSATicket,
|
PSATicket,
|
||||||
@@ -81,6 +82,9 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
conditions.append(f"board/id = {filters['board_id']}")
|
conditions.append(f"board/id = {filters['board_id']}")
|
||||||
if filters.get("status_id"):
|
if filters.get("status_id"):
|
||||||
conditions.append(f"status/id = {filters['status_id']}")
|
conditions.append(f"status/id = {filters['status_id']}")
|
||||||
|
elif filters.get("status_name"):
|
||||||
|
safe_status = str(filters["status_name"]).replace("'", "")
|
||||||
|
conditions.append(f"status/name = '{safe_status}'")
|
||||||
if not filters.get("include_closed", False):
|
if not filters.get("include_closed", False):
|
||||||
conditions.append("closedFlag = false")
|
conditions.append("closedFlag = false")
|
||||||
if filters.get("member_identifier") is not None:
|
if filters.get("member_identifier") is not None:
|
||||||
@@ -627,44 +631,81 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
|
|
||||||
# ── Resource management ───────────────────────────────────────────
|
# ── Resource management ───────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _get_ticket_resource_identifiers(self, ticket_id: int) -> list[str]:
|
||||||
|
"""Fetch the ticket's `resources` field (comma-separated identifiers)."""
|
||||||
|
data = await self.client.get(
|
||||||
|
f"/service/tickets/{ticket_id}",
|
||||||
|
params={"fields": "id,resources"},
|
||||||
|
)
|
||||||
|
raw = data.get("resources") if isinstance(data, dict) else None
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
return [p.strip() for p in str(raw).split(",") if p.strip()]
|
||||||
|
|
||||||
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
|
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
|
||||||
"""List members assigned to a CW ticket."""
|
"""List members assigned to a CW ticket via the `resources` string field."""
|
||||||
data = await self.client.get(f"/service/tickets/{ticket_id}/members")
|
identifiers = await self._get_ticket_resource_identifiers(ticket_id)
|
||||||
results = []
|
if not identifiers:
|
||||||
for m in (data if isinstance(data, list) else []):
|
return []
|
||||||
member = m.get("member") or {}
|
members = await self.list_members()
|
||||||
results.append(PSAResource(
|
by_identifier = {m.identifier: m for m in members if m.identifier}
|
||||||
member_id=member.get("id", 0),
|
results: list[PSAResource] = []
|
||||||
member_name=member.get("name", ""),
|
for ident in identifiers:
|
||||||
member_identifier=member.get("identifier", ""),
|
m = by_identifier.get(ident)
|
||||||
))
|
if m:
|
||||||
|
results.append(PSAResource(
|
||||||
|
member_id=int(m.id),
|
||||||
|
member_name=m.name,
|
||||||
|
member_identifier=m.identifier,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# Unknown identifier — surface it so the UI doesn't silently drop it
|
||||||
|
results.append(PSAResource(
|
||||||
|
member_id=0,
|
||||||
|
member_name=ident,
|
||||||
|
member_identifier=ident,
|
||||||
|
))
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
|
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
|
||||||
"""Assign a member to a CW ticket."""
|
"""Assign a member to a CW ticket by updating the ticket's `resources` field."""
|
||||||
data = await self.client.post(
|
members = await self.list_members()
|
||||||
f"/service/tickets/{ticket_id}/members",
|
target = next((m for m in members if str(m.id) == str(member_id)), None)
|
||||||
json_body={"member": {"id": member_id}},
|
if target is None or not target.identifier:
|
||||||
|
raise PSAError(f"Member {member_id} not found or has no identifier")
|
||||||
|
|
||||||
|
current = await self._get_ticket_resource_identifiers(ticket_id)
|
||||||
|
if target.identifier not in current:
|
||||||
|
current.append(target.identifier)
|
||||||
|
new_value = ",".join(current)
|
||||||
|
|
||||||
|
await self.client.patch(
|
||||||
|
f"/service/tickets/{ticket_id}",
|
||||||
|
json_body=[{"op": "replace", "path": "resources", "value": new_value}],
|
||||||
)
|
)
|
||||||
member = (data.get("member") or {}) if isinstance(data, dict) else {}
|
|
||||||
return PSAResource(
|
return PSAResource(
|
||||||
member_id=member.get("id", member_id),
|
member_id=int(target.id),
|
||||||
member_name=member.get("name", ""),
|
member_name=target.name,
|
||||||
member_identifier=member.get("identifier", ""),
|
member_identifier=target.identifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
|
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
|
||||||
"""Remove a member from a CW ticket (idempotent)."""
|
"""Remove a member from a CW ticket by updating the `resources` field (idempotent)."""
|
||||||
# CW DELETE requires the member record id (junction record), not the member's id
|
members = await self.list_members()
|
||||||
members_data = await self.client.get(f"/service/tickets/{ticket_id}/members")
|
target = next((m for m in members if str(m.id) == str(member_id)), None)
|
||||||
record_id = None
|
if target is None or not target.identifier:
|
||||||
for m in (members_data if isinstance(members_data, list) else []):
|
return # Unknown member — treat as idempotent
|
||||||
if (m.get("member") or {}).get("id") == member_id:
|
|
||||||
record_id = m.get("id")
|
current = await self._get_ticket_resource_identifiers(ticket_id)
|
||||||
break
|
if target.identifier not in current:
|
||||||
if record_id is None:
|
|
||||||
return # Already not assigned — idempotent
|
return # Already not assigned — idempotent
|
||||||
await self.client.delete(f"/service/tickets/{ticket_id}/members/{record_id}")
|
new_list = [i for i in current if i != target.identifier]
|
||||||
|
new_value = ",".join(new_list)
|
||||||
|
|
||||||
|
await self.client.patch(
|
||||||
|
f"/service/tickets/{ticket_id}",
|
||||||
|
json_body=[{"op": "replace", "path": "resources", "value": new_value}],
|
||||||
|
)
|
||||||
|
|
||||||
# ── Ticket creation ───────────────────────────────────────────────
|
# ── Ticket creation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const ticketsApi = {
|
|||||||
query?: string
|
query?: string
|
||||||
board_id?: number | null
|
board_id?: number | null
|
||||||
status_id?: number | null
|
status_id?: number | null
|
||||||
|
status_name?: string | null
|
||||||
include_closed?: boolean
|
include_closed?: boolean
|
||||||
assigned_to_me?: boolean
|
assigned_to_me?: boolean
|
||||||
unassigned?: boolean
|
unassigned?: boolean
|
||||||
|
|||||||
@@ -55,25 +55,49 @@ export default function TicketsPage() {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load statuses when board changes
|
// Load statuses when board changes. If no board is selected, aggregate statuses
|
||||||
|
// across all boards (deduped by name) so the filter is useful before the user
|
||||||
|
// picks a board.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
if (filters.board_id) {
|
if (filters.board_id) {
|
||||||
integrationsApi.getBoardStatuses(filters.board_id)
|
integrationsApi.getBoardStatuses(filters.board_id)
|
||||||
.then(setStatuses).catch(() => {})
|
.then(s => { if (!cancelled) setStatuses(s) })
|
||||||
|
.catch(() => { if (!cancelled) setStatuses([]) })
|
||||||
|
} else if (boards.length > 0) {
|
||||||
|
Promise.all(boards.map(b =>
|
||||||
|
integrationsApi.getBoardStatuses(b.id).catch(() => [] as PSATicketStatusItem[])
|
||||||
|
))
|
||||||
|
.then(lists => {
|
||||||
|
if (cancelled) return
|
||||||
|
const byName = new Map<string, PSATicketStatusItem>()
|
||||||
|
lists.flat().forEach(s => {
|
||||||
|
if (!byName.has(s.name)) byName.set(s.name, s)
|
||||||
|
})
|
||||||
|
setStatuses(Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name)))
|
||||||
|
})
|
||||||
|
.catch(() => { if (!cancelled) setStatuses([]) })
|
||||||
} else {
|
} else {
|
||||||
setStatuses([])
|
setStatuses([])
|
||||||
}
|
}
|
||||||
}, [filters.board_id])
|
return () => { cancelled = true }
|
||||||
|
}, [filters.board_id, boards])
|
||||||
|
|
||||||
// Fetch tickets on filter/page change
|
// Fetch tickets on filter/page change
|
||||||
const fetchTickets = useCallback(async () => {
|
const fetchTickets = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setPsaError(null)
|
setPsaError(null)
|
||||||
try {
|
try {
|
||||||
|
// When no board is selected, statuses are aggregated across boards — filter by
|
||||||
|
// name instead of id so we match the same status across every board.
|
||||||
|
const selectedStatusName = filters.status_id
|
||||||
|
? statuses.find(s => s.id === filters.status_id)?.name
|
||||||
|
: undefined
|
||||||
const result = await ticketsApi.searchTickets({
|
const result = await ticketsApi.searchTickets({
|
||||||
query: filters.search || undefined,
|
query: filters.search || undefined,
|
||||||
board_id: filters.board_id ?? undefined,
|
board_id: filters.board_id ?? undefined,
|
||||||
status_id: filters.status_id ?? undefined,
|
status_id: filters.board_id && filters.status_id ? filters.status_id : undefined,
|
||||||
|
status_name: !filters.board_id && selectedStatusName ? selectedStatusName : undefined,
|
||||||
include_closed: filters.include_closed,
|
include_closed: filters.include_closed,
|
||||||
assigned_to_me: filters.assigned === 'me',
|
assigned_to_me: filters.assigned === 'me',
|
||||||
unassigned: filters.assigned === 'unassigned',
|
unassigned: filters.assigned === 'unassigned',
|
||||||
@@ -111,7 +135,7 @@ export default function TicketsPage() {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [filters.search, filters.board_id, filters.status_id, filters.include_closed,
|
}, [filters.search, filters.board_id, filters.status_id, filters.include_closed,
|
||||||
filters.assigned, filters.priority, filters.company_id, page])
|
filters.assigned, filters.priority, filters.company_id, page, statuses])
|
||||||
|
|
||||||
useEffect(() => { fetchTickets() }, [fetchTickets])
|
useEffect(() => { fetchTickets() }, [fetchTickets])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user