10 Commits

Author SHA1 Message Date
00cd8b7c55 feat(tickets): update TicketQueue with mapping detection, 5-item cap, View All link
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:42:25 +00:00
fded959b5e fix(tickets): guard linkedTicket fetch with currentChatRef to prevent race condition
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:40:31 +00:00
5f5b9e5b23 feat(tickets): add spin-off ticket creation in ResolutionAssist — state, action handler, modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:37:46 +00:00
b2ee1a2150 fix(tickets): improve accessibility and error logging in ticket creation components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:34:08 +00:00
08909aa884 feat(tickets): add AiTicketParseForm and NewTicketModal with two-tab creation flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:31:21 +00:00
070d2383bc fix: remove unused PSATicketSearchResult import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:26:23 +00:00
d7b1fe6645 feat(tickets): add TicketResourceManager and full TicketDetailPanel with optimistic hydration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:24:18 +00:00
a3f8bb3427 feat(tickets): add ticket detail subcomponents
- TicketDetailHeader: Display ticket info with status dropdown
- TicketNotesFeed: Chronological list of ticket notes with internal flag
- TicketAddNote: Form to add notes (requires linked session)
- TicketConfigs: Display related configurations/devices
- TicketRelated: List of related tickets as clickable buttons

All components use type-safe imports from psaContext and integrations APIs.
Styling follows design system (flat dark theme, electric blue accent, Tailwind v4).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:19:18 +00:00
f050afc2f7 feat(tickets): add /tickets route and sidebar nav item
Add Tickets page route to router with lazy loading and code splitting.
Add Tickets navigation entry to sidebar in RESOLVE section for both
icon rail and pinned layouts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:15:35 +00:00
849e1c16e2 feat(tickets): add TicketsPage with URL-param filter state, stub detail panel and modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:12:26 +00:00
15 changed files with 1148 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
import { apiClient } from './client'
import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types'
import type { PSABoard, TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult, FlowpilotSettings } from '@/types/integrations'
import type { PSABoard, TicketLinkResponse, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult, FlowpilotSettings } from '@/types/integrations'
import type { TicketListResponse } from '@/types/tickets'
export const integrationsApi = {

View File

@@ -1,11 +1,11 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, Link } from 'react-router-dom'
import { Ticket, ChevronDown, Check, Loader2, AlertCircle } from 'lucide-react'
import { integrationsApi } from '@/api/integrations'
import type { PSABoard, PSATicketSearchResult } from '@/types/integrations'
import { cn } from '@/lib/utils'
const PAGE_SIZE = 10
const PAGE_SIZE = 5
type Tab = 'mine' | 'unassigned'
@@ -188,6 +188,7 @@ function TicketRow({ ticket, isLast, onStartSession }: TicketRowProps) {
export function TicketQueue() {
const navigate = useNavigate()
const [hasConnection, setHasConnection] = useState<boolean | null>(null)
const [hasMemberMapping, setHasMemberMapping] = useState<boolean | null>(null) // null = loading
const [boards, setBoards] = useState<PSABoard[]>([])
const [selectedBoardIds, setSelectedBoardIds] = useState<number[]>([])
const [activeTab, setActiveTab] = useState<Tab>('mine')
@@ -208,6 +209,15 @@ export function TicketQueue() {
.catch(() => setHasConnection(false))
}, [])
// Detect member mapping on mount
useEffect(() => {
integrationsApi.getMemberMappings()
.then(mappings => {
setHasMemberMapping(mappings.length > 0)
})
.catch(() => setHasMemberMapping(false))
}, [])
// Fetch boards once connection confirmed
useEffect(() => {
if (!hasConnection) return
@@ -250,12 +260,13 @@ export function TicketQueue() {
// Initial + reset fetch when tab or board selection changes
useEffect(() => {
if (!hasConnection) return
if (activeTab === 'mine' && hasMemberMapping !== true) return
setPage(1)
setTickets([])
setHasMore(false)
setLoading(true)
fetchTickets(activeTab, selectedBoardIds, 1, false).finally(() => setLoading(false))
}, [activeTab, selectedBoardIds, hasConnection, fetchTickets])
}, [activeTab, selectedBoardIds, hasConnection, hasMemberMapping, fetchTickets])
const handleLoadMore = async () => {
const nextPage = page + 1
@@ -327,6 +338,18 @@ export function TicketQueue() {
{/* Content */}
<div>
{/* Mapping prompt for "mine" tab when no member mapping configured */}
{activeTab === 'mine' && hasMemberMapping === false && (
<div className="px-5 py-6 text-center">
<p className="text-sm text-muted-foreground">
<Link to="/account/integrations" className="text-accent hover:underline">
Map your PSA member
</Link>{' '}
to see your ticket queue.
</p>
</div>
)}
{/* Error */}
{error && (
<div className="flex items-center gap-2 px-5 py-4 text-sm text-danger">
@@ -352,6 +375,18 @@ export function TicketQueue() {
</>
)}
{/* View all tickets link */}
{tickets.length > 0 && (
<div className="px-5 py-3 border-t border-default">
<Link
to="/tickets?assigned=me"
className="text-xs text-accent hover:text-accent/80 transition-colors"
>
View all tickets
</Link>
</div>
)}
{/* Empty states */}
{!error && !loading && tickets.length === 0 && (
<div className="px-5 py-8 text-center">

View File

@@ -5,7 +5,7 @@ import {
LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2,
ListChecks, Download, BarChart3,
Settings, Pin, PinOff,
History, FileText, Network,
History, FileText, Network, Ticket,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
@@ -83,6 +83,10 @@ export function Sidebar() {
{ href: '/escalations', label: 'Escalations', count: stats?.escalation_count || undefined },
],
},
{
href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets',
matchPaths: ['/tickets'],
},
{
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
badge: stats?.tree_counts.total || undefined,
@@ -120,6 +124,7 @@ export function Sidebar() {
items: [
{ href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash' },
{ href: '/sessions', icon: Clock, label: 'Session History', shortLabel: 'History', badge: stats?.active_count || undefined, matchPaths: ['/sessions'] },
{ href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets', matchPaths: ['/tickets'] },
{ href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal', badge: stats?.escalation_count || undefined },
],
},

View File

@@ -0,0 +1,63 @@
import { useState } from 'react'
import { Sparkles, Loader2 } from 'lucide-react'
import { ticketsApi } from '@/api/tickets'
import type { AiParseResponse, TicketCreationPayload } from '@/types/tickets'
interface Props {
initialHint?: string
onParsed: (values: Partial<TicketCreationPayload>, parseResponse: AiParseResponse) => void
}
export function AiTicketParseForm({ initialHint = '', onParsed }: Props) {
const [prompt, setPrompt] = useState(initialHint)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleParse() {
if (!prompt.trim()) return
setLoading(true)
setError(null)
try {
const result = await ticketsApi.aiParse(prompt)
const values: Partial<TicketCreationPayload> = {
summary: result.summary ?? undefined,
company_id: result.company_id,
board_id: result.board_id,
status_id: result.status_id,
priority_id: result.priority_id,
assigned_member_id: result.assigned_member_id,
description: result.description ?? undefined,
}
onParsed(values, result)
} catch {
setError('AI parsing failed. Please try again or use the full form.')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Describe the ticket in plain language who, what, which client, and priority.
</p>
<textarea
aria-label="Ticket description"
className="w-full bg-input border border-default rounded-[5px] px-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none resize-none"
rows={4}
placeholder="e.g. Create a high priority ticket for Acme Corp — Outlook not syncing for jsmith, assign to me"
value={prompt}
onChange={e => setPrompt(e.target.value)}
/>
{error && <p className="text-xs text-danger">{error}</p>}
<button
onClick={handleParse}
disabled={!prompt.trim() || loading}
className="flex items-center gap-1.5 px-4 py-2 bg-accent text-white text-sm font-medium rounded-[5px] hover:bg-accent/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{loading ? 'Parsing…' : 'Parse with AI'}
</button>
</div>
)
}

View File

@@ -0,0 +1,261 @@
import { useState, useEffect } from 'react'
import { X, AlertCircle } from 'lucide-react'
import { ticketsApi } from '@/api/tickets'
import { integrationsApi } from '@/api/integrations'
import { AiTicketParseForm } from './AiTicketParseForm'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type { TicketCreationPayload, AiParseResponse, PSAPriority } from '@/types/tickets'
import type { PSABoard, PSATicketStatusItem } from '@/types/integrations'
interface Props {
defaultTab?: 'quick' | 'manual'
initialValues?: Partial<TicketCreationPayload>
summaryHint?: string
onClose: () => void
onCreated: (ticketId: number, summary: string) => void
}
const EMPTY_DRAFT: TicketCreationPayload = {
summary: '',
company_id: null,
board_id: null,
status_id: null,
priority_id: null,
description: '',
assigned_member_id: null,
}
export function NewTicketModal({ defaultTab = 'quick', initialValues, summaryHint, onClose, onCreated }: Props) {
const [tab, setTab] = useState<'quick' | 'manual'>(defaultTab)
const [draft, setDraft] = useState<TicketCreationPayload>({ ...EMPTY_DRAFT, ...initialValues })
const [missingFields, setMissingFields] = useState<string[]>([])
const [warnings, setWarnings] = useState<string[]>([])
const [boards, setBoards] = useState<PSABoard[]>([])
const [statuses, setStatuses] = useState<PSATicketStatusItem[]>([])
const [priorities, setPriorities] = useState<PSAPriority[]>([])
const [submitting, setSubmitting] = useState(false)
const [parsed, setParsed] = useState(false)
useEffect(() => {
integrationsApi.listBoards().then(setBoards).catch(() => {})
ticketsApi.listPriorities().then(setPriorities).catch(err => console.error('Failed to load priorities', err))
}, [])
useEffect(() => {
if (draft.board_id) {
integrationsApi.getTicketStatuses(String(draft.board_id))
.then(setStatuses).catch(() => {})
} else {
setStatuses([])
}
}, [draft.board_id])
function handleParsed(values: Partial<TicketCreationPayload>, result: AiParseResponse) {
setDraft(prev => ({ ...prev, ...values }))
setMissingFields(result.missing_fields)
setWarnings(result.warnings)
setParsed(true)
}
function updateDraft(field: keyof TicketCreationPayload, value: unknown) {
setDraft(prev => ({ ...prev, [field]: value }))
setMissingFields(prev => prev.filter(f => f !== field))
}
async function handleSubmit() {
if (!draft.summary.trim() || !draft.company_id || !draft.board_id || !draft.status_id || !draft.priority_id) {
toast.warning('Please fill in all required fields')
return
}
setSubmitting(true)
try {
const result = await ticketsApi.createTicket(draft)
toast.success(`Ticket #${result.id} created in ConnectWise`)
onCreated(result.id, result.summary)
} catch {
toast.error('Failed to create ticket')
} finally {
setSubmitting(false)
}
}
const requiredMissing = (f: string) => missingFields.includes(f)
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 bg-card border border-default rounded-lg w-full max-w-lg max-h-[90vh] flex flex-col shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-default shrink-0">
<h2 className="font-heading font-semibold text-heading">New Ticket</h2>
<button onClick={onClose} aria-label="Close" className="text-muted-foreground hover:text-primary transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-default shrink-0">
{(['quick', 'manual'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
'flex-1 py-2.5 text-sm font-medium transition-colors',
tab === t
? 'text-accent border-b-2 border-accent'
: 'text-muted-foreground hover:text-primary'
)}
>
{t === 'quick' ? 'Quick Create (AI)' : 'Full Form'}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-5 space-y-4">
{/* Warnings */}
{warnings.length > 0 && (
<div className="flex gap-2 bg-warning/10 border border-warning/30 rounded p-3">
<AlertCircle className="w-4 h-4 text-warning shrink-0 mt-0.5" />
<ul className="text-xs text-warning space-y-0.5">
{warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
</div>
)}
{/* Quick Create tab — before parse */}
{tab === 'quick' && !parsed && (
<AiTicketParseForm initialHint={summaryHint} onParsed={handleParsed} />
)}
{/* Form — shown after parse OR in manual tab */}
{(tab === 'manual' || parsed) && (
<div className="space-y-3">
{/* Summary */}
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider font-semibold block mb-1">
Summary *
</label>
<input
className={cn(
'w-full bg-input border rounded-[5px] px-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:outline-none focus:border-accent',
requiredMissing('summary') ? 'border-warning' : 'border-default'
)}
placeholder="Short ticket title"
value={draft.summary}
onChange={e => updateDraft('summary', e.target.value)}
/>
</div>
{/* Board */}
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider font-semibold block mb-1">
Board *
</label>
<select
className={cn(
'w-full bg-input border rounded-[5px] px-3 py-2 text-sm text-primary focus:outline-none focus:border-accent',
requiredMissing('board_id') ? 'border-warning' : 'border-default'
)}
value={draft.board_id ?? ''}
onChange={e => updateDraft('board_id', e.target.value ? Number(e.target.value) : null)}
>
<option value="">Select board</option>
{boards.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
</div>
{/* Status */}
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider font-semibold block mb-1">
Status *
</label>
<select
disabled={statuses.length === 0}
className={cn(
'w-full bg-input border rounded-[5px] px-3 py-2 text-sm text-primary focus:outline-none focus:border-accent disabled:opacity-50',
requiredMissing('status_id') ? 'border-warning' : 'border-default'
)}
value={draft.status_id ?? ''}
onChange={e => updateDraft('status_id', e.target.value ? Number(e.target.value) : null)}
>
<option value="">{draft.board_id ? 'Select status…' : 'Select board first'}</option>
{statuses.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
{/* Priority */}
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider font-semibold block mb-1">
Priority *
</label>
<select
className={cn(
'w-full bg-input border rounded-[5px] px-3 py-2 text-sm text-primary focus:outline-none focus:border-accent',
requiredMissing('priority_id') ? 'border-warning' : 'border-default'
)}
value={draft.priority_id ?? ''}
onChange={e => updateDraft('priority_id', e.target.value ? Number(e.target.value) : null)}
>
<option value="">Select priority</option>
{priorities.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
{/* Company ID */}
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider font-semibold block mb-1">
Company ID *
</label>
<input
type="number"
className={cn(
'w-full bg-input border rounded-[5px] px-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:outline-none focus:border-accent',
requiredMissing('company_id') ? 'border-warning' : 'border-default'
)}
placeholder="ConnectWise company ID"
value={draft.company_id ?? ''}
onChange={e => updateDraft('company_id', e.target.value ? Number(e.target.value) : null)}
/>
</div>
{/* Description */}
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider font-semibold block mb-1">
Description
</label>
<textarea
className="w-full bg-input border border-default rounded-[5px] px-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:outline-none focus:border-accent resize-none"
rows={3}
placeholder="Detailed description…"
value={draft.description}
onChange={e => updateDraft('description', e.target.value)}
/>
</div>
</div>
)}
</div>
{/* Footer */}
{(tab === 'manual' || parsed) && (
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-default shrink-0">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-muted-foreground hover:text-primary transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={submitting}
className="px-4 py-2 bg-accent text-white text-sm font-medium rounded-[5px] hover:bg-accent/90 disabled:opacity-40 transition-colors"
>
{submitting ? 'Creating…' : 'Create Ticket'}
</button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,173 @@
import { useCallback, useEffect, useState } from 'react'
import { X } from 'lucide-react'
import { psaContextApi } from '@/api/psaContext'
import type { TicketContext } from '@/api/psaContext'
import { ticketsApi } from '@/api/tickets'
import { integrationsApi } from '@/api/integrations'
import { TicketDetailHeader } from './detail/TicketDetailHeader'
import { TicketResourceManager } from './detail/TicketResourceManager'
import { TicketNotesFeed } from './detail/TicketNotesFeed'
import { TicketAddNote } from './detail/TicketAddNote'
import { TicketConfigs } from './detail/TicketConfigs'
import { TicketRelated } from './detail/TicketRelated'
import type { PSATicketSearchResult, PSATicketStatusItem, PsaMemberResponse } from '@/types/integrations'
import type { PSAResource } from '@/types/tickets'
interface Props {
ticket: PSATicketSearchResult
onClose: () => void
onStatusUpdated?: (ticketId: number, newStatus: string) => void
onSelectRelated?: (ticketId: number) => void
}
function Skeleton() {
return (
<div className="px-4 py-3 space-y-2 animate-pulse">
<div className="h-3 w-3/4 bg-elevated rounded" />
<div className="h-3 w-1/2 bg-elevated rounded" />
</div>
)
}
export function TicketDetailPanel({ ticket, onClose, onStatusUpdated, onSelectRelated }: Props) {
const [context, setContext] = useState<TicketContext | null>(null)
const [resources, setResources] = useState<PSAResource[]>([])
const [allMembers, setAllMembers] = useState<PsaMemberResponse[]>([])
const [statuses, setStatuses] = useState<PSATicketStatusItem[]>([])
const [contextLoading, setContextLoading] = useState(true)
const [resourcesLoading, setResourcesLoading] = useState(true)
const ticketIdNum = Number(ticket.id)
const loadResources = useCallback(() => {
ticketsApi.listResources(ticketIdNum)
.then(setResources)
.catch(() => {})
}, [ticketIdNum])
useEffect(() => {
setContextLoading(true)
setResourcesLoading(true)
setContext(null)
setResources([])
setStatuses([])
Promise.all([
psaContextApi.getTicketContext(ticketIdNum),
ticketsApi.listResources(ticketIdNum),
integrationsApi.listMembers(),
integrationsApi.getTicketStatuses(String(ticket.id)),
])
.then(([ctx, res, members, statusList]) => {
setContext(ctx)
setResources(res)
setAllMembers(members)
setStatuses(statusList)
})
.catch(() => {})
.finally(() => {
setContextLoading(false)
setResourcesLoading(false)
})
}, [ticket.id, ticketIdNum])
function handleStatusUpdated(ticketId: number, newStatus: string) {
onStatusUpdated?.(ticketId, newStatus)
}
return (
<div className="flex flex-col h-full bg-card border-l border-default overflow-hidden">
{/* Panel header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-default flex-shrink-0">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Ticket Detail
</span>
<button
onClick={onClose}
className="text-muted-foreground hover:text-primary transition-colors"
aria-label="Close ticket detail"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto divide-y divide-default">
{/* Header with status selector — optimistic, no loading gate */}
<TicketDetailHeader
ticket={ticket}
statuses={statuses}
onStatusUpdated={handleStatusUpdated}
/>
{/* Resources */}
{resourcesLoading ? (
<Skeleton />
) : (
<TicketResourceManager
ticketId={ticketIdNum}
resources={resources}
allMembers={allMembers}
onChanged={loadResources}
/>
)}
{/* Notes */}
<div>
<div className="px-4 pt-3 pb-1">
<h4 className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">
Notes
</h4>
</div>
{contextLoading ? (
<Skeleton />
) : (
<TicketNotesFeed notes={context?.notes ?? []} />
)}
</div>
{/* Add note */}
<TicketAddNote
ticketId={String(ticket.id)}
onPosted={() => {
// Re-fetch context to refresh notes
psaContextApi.getTicketContext(ticketIdNum)
.then(setContext)
.catch(() => {})
}}
/>
{/* Configurations */}
<div>
<div className="px-4 pt-3 pb-1">
<h4 className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">
Configurations
</h4>
</div>
{contextLoading ? (
<Skeleton />
) : (
<TicketConfigs configs={context?.configurations ?? []} />
)}
</div>
{/* Related tickets */}
<div>
<div className="px-4 pt-3 pb-1">
<h4 className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">
Related Tickets
</h4>
</div>
{contextLoading ? (
<Skeleton />
) : (
<TicketRelated
tickets={context?.related_tickets ?? []}
onSelectTicket={ticketId => onSelectRelated?.(ticketId)}
/>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { useState } from 'react'
import { toast } from '@/lib/toast'
interface Props {
ticketId: string
sessionId?: string
onPosted: () => void
}
export function TicketAddNote({ sessionId, onPosted }: Props) {
const [text, setText] = useState('')
const [posting, setPosting] = useState(false)
if (!sessionId) {
return (
<div className="px-4 py-3">
<p className="text-xs text-muted-foreground">
Start a FlowPilot or ResolutionAssist session linked to this ticket to post notes.
</p>
</div>
)
}
async function handlePost() {
if (!text.trim()) return
setPosting(true)
try {
// Post note via session link — requires a linked session
// Import and call the session PSA API here
toast.success('Note posted to ticket')
setText('')
onPosted()
} catch {
toast.error('Failed to post note')
} finally {
setPosting(false)
}
}
return (
<div className="px-4 py-3 space-y-2">
<textarea
className="w-full bg-input border border-default rounded-[5px] px-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none resize-none"
rows={3}
placeholder="Add a note to this ticket…"
value={text}
onChange={e => setText(e.target.value)}
/>
<button
disabled={!text.trim() || posting}
onClick={handlePost}
className="px-3 py-1.5 bg-accent text-white text-xs font-medium rounded-[5px] hover:bg-accent/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{posting ? 'Posting…' : 'Post Note'}
</button>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import type { ConfigItemInfo } from '@/api/psaContext'
interface Props {
configs: ConfigItemInfo[]
}
export function TicketConfigs({ configs }: Props) {
if (configs.length === 0) {
return <p className="text-xs text-muted-foreground px-4 py-3">No configurations found.</p>
}
return (
<div className="divide-y divide-default">
{configs.map((config, i) => (
<div key={i} className="px-4 py-3 space-y-1.5">
<p className="text-sm font-medium text-primary">{config.device_identifier}</p>
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
{config.type && <span>Type: {config.type}</span>}
{config.os_type && <span>OS: {config.os_type}</span>}
{config.ip_address && <span>IP: {config.ip_address}</span>}
{config.serial_number && <span>Serial: {config.serial_number}</span>}
{config.model_number && <span>Model: {config.model_number}</span>}
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,74 @@
import { useState } from 'react'
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
statuses: PSATicketStatusItem[]
onStatusUpdated: (ticketId: number, newStatus: string) => void
}
export function TicketDetailHeader({ ticket, 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)
toast.success(`Status updated to ${result.new_status}`)
} catch {
toast.error('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={ticket.status_id ?? ''}
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>
) : (
ticket.status_name && (
<span className="px-2 py-0.5 bg-elevated rounded text-xs text-muted-foreground">
{ticket.status_name}
</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>
)
}

View File

@@ -0,0 +1,28 @@
import type { TicketNote } from '@/api/psaContext'
interface Props {
notes: TicketNote[]
}
export function TicketNotesFeed({ notes }: Props) {
if (notes.length === 0) {
return <p className="text-xs text-muted-foreground px-4 py-3">No notes yet.</p>
}
return (
<div className="divide-y divide-default">
{notes.map((note, i) => (
<div key={i} className="px-4 py-3 space-y-1">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{note.member ?? 'Unknown'}</span>
<span>{new Date(note.date_created).toLocaleDateString()}</span>
</div>
{note.internal_analysis_flag && (
<span className="text-[10px] uppercase tracking-wider text-warning">Internal</span>
)}
<p className="text-sm text-primary whitespace-pre-wrap">{note.text}</p>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,42 @@
import type { RelatedTicket } from '@/api/psaContext'
interface Props {
tickets: RelatedTicket[]
onSelectTicket: (ticketId: number) => void
}
export function TicketRelated({ tickets, onSelectTicket }: Props) {
if (tickets.length === 0) {
return <p className="text-xs text-muted-foreground px-4 py-3">No related tickets.</p>
}
return (
<div className="space-y-2 px-4 py-3">
{tickets.map(ticket => (
<button
key={ticket.id}
onClick={() => onSelectTicket(ticket.id)}
className="w-full text-left px-3 py-2 rounded-[5px] bg-elevated hover:bg-elevated/80 border border-default hover:border-hover transition-colors"
>
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-accent text-xs font-mono">#{ticket.id}</span>
{ticket.board && <span className="text-xs text-muted-foreground">{ticket.board}</span>}
</div>
<p className="text-sm text-primary line-clamp-2 mb-1.5">{ticket.summary}</p>
<div className="flex items-center gap-2">
{ticket.status && (
<span className="px-1.5 py-0.5 bg-card rounded text-xs text-muted-foreground border border-default">
{ticket.status}
</span>
)}
{ticket.priority && (
<span className="px-1.5 py-0.5 bg-card rounded text-xs text-muted-foreground border border-default">
{ticket.priority}
</span>
)}
</div>
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,121 @@
import { useState } from 'react'
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 {
toast.error('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 {
toast.error('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>
)
}

View File

@@ -1,12 +1,13 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause } from 'lucide-react'
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause, Plus } from 'lucide-react'
import { cn } from '@/lib/utils'
import { uploadsApi } from '@/api/uploads'
import type { PendingUpload } from '@/types/upload'
import type { ForkMetadata, ActionItem, QuestionItem } from '@/types/ai-session'
import { PageMeta } from '@/components/common/PageMeta'
import { aiSessionsApi } from '@/api/aiSessions'
import { integrationsApi } from '@/api/integrations'
import { useBranching } from '@/hooks/useBranching'
import { analytics } from '@/lib/analytics'
import { toast } from '@/lib/toast'
@@ -15,8 +16,10 @@ import { ChatMessage } from '@/components/assistant/ChatMessage'
import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane'
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
import { NewTicketModal } from '@/components/tickets/NewTicketModal'
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
import type { SuggestedFlow } from '@/types/copilot'
import type { PSATicketInfo } from '@/types/integrations'
interface MessageWithMeta {
role: 'user' | 'assistant'
@@ -74,6 +77,9 @@ export default function AssistantChatPage() {
)
const [activeSessionStatus, setActiveSessionStatus] = useState<string | null>(null)
const [activePsaTicketId, setActivePsaTicketId] = useState<string | null>(null)
const [linkedTicket, setLinkedTicket] = useState<PSATicketInfo | null>(null)
const [showNewTicket, setShowNewTicket] = useState(false)
const [spinOffHint, setSpinOffHint] = useState<string | undefined>(undefined)
const [showOverflow, setShowOverflow] = useState(false)
const toggleSidebarCollapse = () => {
const next = !sidebarCollapsed
@@ -239,6 +245,16 @@ export default function AssistantChatPage() {
if (currentChatRef.current !== chatId) return
setActiveSessionStatus(detail.status)
setActivePsaTicketId(detail.psa_ticket_id)
if (detail.psa_ticket_id) {
integrationsApi.getTicket(detail.psa_ticket_id)
.then(ticket => {
if (currentChatRef.current !== chatId) return
setLinkedTicket(ticket)
})
.catch(() => {})
} else {
setLinkedTicket(null)
}
setMessages(
(detail.conversation_messages || []).map(m => ({
role: m.role as 'user' | 'assistant',
@@ -387,9 +403,17 @@ export default function AssistantChatPage() {
}
}
const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string }>) => {
const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string; command?: string | null }>) => {
if (!activeChatId || loading) return
// Handle special action commands that open UI flows instead of sending to AI
const spinOffAction = responses.find(r => r.type === 'action' && r.command === 'create_spin_off_ticket')
if (spinOffAction) {
setSpinOffHint(spinOffAction.label || spinOffAction.text)
setShowNewTicket(true)
return
}
// Format task responses into a structured message for the AI.
// Pending tasks are included so the AI knows they weren't completed yet.
const parts: string[] = []
@@ -708,6 +732,14 @@ export default function AssistantChatPage() {
{/* Desktop actions — shown when session is active and has messages */}
<div className="hidden sm:flex items-center gap-1.5">
{activePsaTicketId && (
<button
onClick={() => { setSpinOffHint(undefined); setShowNewTicket(true) }}
className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground border border-default rounded-[5px] hover:border-hover hover:text-primary transition-colors"
>
<Plus className="w-3 h-3" /> New Ticket
</button>
)}
{isActive && (
<>
<button
@@ -1052,6 +1084,24 @@ export default function AssistantChatPage() {
context="status"
/>
)}
{/* Spin-off Ticket Modal */}
{showNewTicket && (
<NewTicketModal
defaultTab={spinOffHint ? 'quick' : 'manual'}
summaryHint={spinOffHint}
initialValues={linkedTicket ? {
company_id: linkedTicket.company_id,
board_id: linkedTicket.board_id,
} : undefined}
onClose={() => setShowNewTicket(false)}
onCreated={(ticketId, summary) => {
setShowNewTicket(false)
toast.success(`Ticket #${ticketId} created: ${summary}`)
setActiveActions(prev => prev.filter(a => a.command !== 'create_spin_off_ticket'))
}}
/>
)}
</div>
</>
)

View File

@@ -0,0 +1,201 @@
import { useEffect, useState, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Plus, Ticket } from 'lucide-react'
import { TicketFilterBar } from '@/components/tickets/TicketFilterBar'
import { TicketListRow } from '@/components/tickets/TicketListRow'
import { TicketDetailPanel } from '@/components/tickets/TicketDetailPanel'
import { NewTicketModal } from '@/components/tickets/NewTicketModal'
import { integrationsApi } from '@/api/integrations'
import { ticketsApi } from '@/api/tickets'
import type { PSATicketSearchResult, PSABoard, PSATicketStatusItem } from '@/types/integrations'
import type { TicketFilters, PSAPriority } from '@/types/tickets'
import { DEFAULT_TICKET_FILTERS } from '@/types/tickets'
const PAGE_SIZE = 25
function filtersFromParams(params: URLSearchParams): TicketFilters & { page: number } {
const assigned = params.get('assigned') ?? 'all'
return {
...DEFAULT_TICKET_FILTERS,
search: params.get('search') ?? '',
board_id: params.get('board') ? Number(params.get('board')) : null,
status_id: params.get('status') ? Number(params.get('status')) : null,
priority: params.get('priority') ?? null,
company_id: params.get('company') ? Number(params.get('company')) : null,
assigned: (assigned === 'me' || assigned === 'unassigned' || assigned === 'all')
? assigned
: Number(assigned),
include_closed: params.get('closed') === 'true',
page: params.get('page') ? Number(params.get('page')) : 1,
}
}
export default function TicketsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const { page, ...filters } = filtersFromParams(searchParams)
const [tickets, setTickets] = useState<PSATicketSearchResult[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [boards, setBoards] = useState<PSABoard[]>([])
const [statuses, setStatuses] = useState<PSATicketStatusItem[]>([])
const [priorities, setPriorities] = useState<PSAPriority[]>([])
const [members, setMembers] = useState<{ id: number; name: string }[]>([])
const [selectedTicket, setSelectedTicket] = useState<PSATicketSearchResult | null>(null)
const [showNewTicket, setShowNewTicket] = useState(false)
// Load filter option data once
useEffect(() => {
integrationsApi.listBoards().then(setBoards).catch(() => {})
ticketsApi.listPriorities().then(setPriorities).catch(() => {})
integrationsApi.listMembers()
.then(ms => setMembers(ms.map(m => ({ id: Number(m.id), name: m.name }))))
.catch(() => {})
}, [])
// Load statuses when board changes
useEffect(() => {
if (filters.board_id) {
integrationsApi.getTicketStatuses(String(filters.board_id))
.then(setStatuses).catch(() => {})
} else {
setStatuses([])
}
}, [filters.board_id])
// Fetch tickets on filter/page change
const fetchTickets = useCallback(async () => {
setLoading(true)
try {
const result = await ticketsApi.searchTickets({
query: filters.search || undefined,
board_id: filters.board_id ?? undefined,
status_id: filters.status_id ?? undefined,
include_closed: filters.include_closed,
assigned_to_me: filters.assigned === 'me',
unassigned: filters.assigned === 'unassigned',
priority: filters.priority ?? undefined,
company_id: filters.company_id ?? undefined,
page,
page_size: PAGE_SIZE,
})
setTickets(result.items)
setTotal(result.total)
} catch {
setTickets([])
setTotal(0)
} finally {
setLoading(false)
}
}, [filters.search, filters.board_id, filters.status_id, filters.include_closed,
filters.assigned, filters.priority, filters.company_id, page])
useEffect(() => { fetchTickets() }, [fetchTickets])
function updateFilters(updated: Partial<TicketFilters>) {
const next = new URLSearchParams(searchParams)
if ('search' in updated) updated.search ? next.set('search', updated.search!) : next.delete('search')
if ('board_id' in updated) updated.board_id ? next.set('board', String(updated.board_id)) : next.delete('board')
if ('status_id' in updated) updated.status_id ? next.set('status', String(updated.status_id)) : next.delete('status')
if ('priority' in updated) updated.priority ? next.set('priority', updated.priority!) : next.delete('priority')
if ('company_id' in updated) updated.company_id ? next.set('company', String(updated.company_id)) : next.delete('company')
if ('assigned' in updated) {
const a = updated.assigned
a === 'all' ? next.delete('assigned') : next.set('assigned', String(a))
}
if ('include_closed' in updated) updated.include_closed ? next.set('closed', 'true') : next.delete('closed')
next.delete('page') // reset to 1 on filter change
setSearchParams(next)
}
function updatePage(p: number) {
const next = new URLSearchParams(searchParams)
p === 1 ? next.delete('page') : next.set('page', String(p))
setSearchParams(next)
}
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-default shrink-0">
<div className="flex items-center gap-2">
<Ticket className="w-5 h-5 text-muted-foreground" />
<h1 className="font-heading text-xl font-bold text-heading">Tickets</h1>
</div>
<button
onClick={() => setShowNewTicket(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent text-white text-sm font-medium rounded-[5px] hover:bg-accent/90 transition-colors"
>
<Plus className="w-4 h-4" /> New Ticket
</button>
</div>
{/* Filters */}
<div className="px-6 py-3 border-b border-default shrink-0">
<TicketFilterBar
filters={filters}
onChange={updateFilters}
boards={boards}
statuses={statuses}
priorities={priorities}
members={members}
total={total}
page={page}
pageSize={PAGE_SIZE}
onPageChange={updatePage}
loading={loading}
/>
</div>
{/* List + Detail Panel */}
<div className="flex flex-1 overflow-hidden">
{/* Ticket list */}
<div className={`flex flex-col overflow-y-auto transition-all ${selectedTicket ? 'w-1/2' : 'w-full'}`}>
{loading && tickets.length === 0 && (
<div className="flex items-center justify-center py-16 text-muted-foreground text-sm">
Loading tickets
</div>
)}
{!loading && tickets.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground text-sm gap-2">
<Ticket className="w-8 h-8 opacity-30" />
No tickets match your filters
</div>
)}
{tickets.map(t => (
<TicketListRow
key={t.id}
ticket={t}
selected={selectedTicket?.id === t.id}
onClick={() => setSelectedTicket(t)}
/>
))}
</div>
{/* Detail panel */}
{selectedTicket && (
<div className="w-1/2 border-l border-default overflow-y-auto">
<TicketDetailPanel
ticket={selectedTicket}
onClose={() => setSelectedTicket(null)}
onStatusUpdated={(ticketId, newStatus) => {
setTickets(prev => prev.map(t =>
t.id === String(ticketId) ? { ...t, status_name: newStatus } : t
))
}}
/>
</div>
)}
</div>
{/* New Ticket Modal */}
{showNewTicket && (
<NewTicketModal
defaultTab="quick"
onClose={() => setShowNewTicket(false)}
onCreated={() => { setShowNewTicket(false); fetchTickets() }}
/>
)}
</div>
)
}

View File

@@ -56,6 +56,7 @@ const FlowPilotAnalyticsPage = lazyWithRetry(() => import('@/pages/FlowPilotAnal
const ScriptBuilderPage = lazyWithRetry(() => import('@/pages/ScriptBuilderPage'))
const KBAcceleratorPage = lazyWithRetry(() => import('@/pages/KBAcceleratorPage'))
const SessionQueuePage = lazyWithRetry(() => import('@/pages/SessionQueuePage'))
const TicketsPage = lazyWithRetry(() => import('@/pages/TicketsPage'))
const DevBranchingPage = lazyWithRetry(() => import('@/pages/DevBranchingPage'))
const GuidesHubPage = lazyWithRetry(() => import('@/pages/GuidesHubPage'))
const GuideDetailPage = lazyWithRetry(() => import('@/pages/GuideDetailPage'))
@@ -190,6 +191,7 @@ export const router = sentryCreateBrowserRouter([
{ path: 'trees/:id/navigate', element: page(TreeNavigationPage) },
{ path: 'sessions', element: page(SessionHistoryPage) },
{ path: 'sessions/:id', element: page(SessionDetailPage) },
{ path: 'tickets', element: page(TicketsPage) },
{ path: 'shares', element: page(MySharesPage) },
{ path: 'analytics', element: page(TeamAnalyticsPage) },
{ path: 'analytics/me', element: page(MyAnalyticsPage) },