Merge main into feat/flowpilot-migration
Brings in PR #141 (PSA ticket management) so FlowPilot can ship on top of a unified main. Two manual conflict resolutions: 1. CLAUDE.md — kept the FlowPilot ai-handoff rewrite (`.ai/`-driven protocol). The pre-rewrite reference content (CW integration notes, lessons archive, env vars table) lives in `docs/connectwise/`, `docs/LESSONS-ARCHIVE.md`, and DEV-ENV.md by design. 2. frontend/src/pages/AssistantChatPage.tsx — both conflict regions were purely additive. Concatenated FlowPilot's Phase 2-9 state hooks (facts, activeFix, preview*, scriptPanelOpen, templatizeQueue) with PSA's spin-off ticket state (linkedTicket, showNewTicket, spinOffHint). Both modal mounts (TemplatizePrompt, ShortcutsHelpOverlay, NewTicketModal) kept. All setters wired by either branch are intact. Verification: - `tsc -b` clean across the merged tree. - Browser smoke-test (Session B fixture): Phase 9 ProposalBanner ("Run AI-drafted PowerShell to recover SSL VPN") renders alongside PSA's new Tickets sidebar icon. Console clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Ticket, ChevronDown, Check, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Ticket, ChevronDown, Check, 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,14 +188,12 @@ 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')
|
||||
const [tickets, setTickets] = useState<PSATicketSearchResult[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Check connection on mount
|
||||
@@ -208,6 +206,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
|
||||
@@ -217,9 +224,9 @@ export function TicketQueue() {
|
||||
}, [hasConnection])
|
||||
|
||||
const fetchTickets = useCallback(
|
||||
async (tab: Tab, boardIds: number[], pageNum: number, append: boolean) => {
|
||||
async (tab: Tab, boardIds: number[]) => {
|
||||
const params: Parameters<typeof integrationsApi.searchTicketsQueue>[0] = {
|
||||
page: pageNum,
|
||||
page: 1,
|
||||
page_size: PAGE_SIZE,
|
||||
}
|
||||
if (tab === 'mine') {
|
||||
@@ -233,12 +240,7 @@ export function TicketQueue() {
|
||||
|
||||
try {
|
||||
const results = await integrationsApi.searchTicketsQueue(params)
|
||||
if (append) {
|
||||
setTickets((prev) => [...prev, ...results])
|
||||
} else {
|
||||
setTickets(results)
|
||||
}
|
||||
setHasMore(results.length === PAGE_SIZE)
|
||||
setTickets(results.items)
|
||||
setError(null)
|
||||
} catch {
|
||||
setError('Failed to load tickets. Check your PSA connection.')
|
||||
@@ -250,20 +252,11 @@ export function TicketQueue() {
|
||||
// Initial + reset fetch when tab or board selection changes
|
||||
useEffect(() => {
|
||||
if (!hasConnection) return
|
||||
setPage(1)
|
||||
if (activeTab === 'mine' && hasMemberMapping !== true) return
|
||||
setTickets([])
|
||||
setHasMore(false)
|
||||
setLoading(true)
|
||||
fetchTickets(activeTab, selectedBoardIds, 1, false).finally(() => setLoading(false))
|
||||
}, [activeTab, selectedBoardIds, hasConnection, fetchTickets])
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
setLoadingMore(true)
|
||||
await fetchTickets(activeTab, selectedBoardIds, nextPage, true)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
fetchTickets(activeTab, selectedBoardIds).finally(() => setLoading(false))
|
||||
}, [activeTab, selectedBoardIds, hasConnection, hasMemberMapping, fetchTickets])
|
||||
|
||||
const handleStartSession = (ticket: PSATicketSearchResult) => {
|
||||
navigate('/pilot', {
|
||||
@@ -327,6 +320,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">
|
||||
@@ -345,13 +350,25 @@ export function TicketQueue() {
|
||||
<TicketRow
|
||||
key={ticket.id}
|
||||
ticket={ticket}
|
||||
isLast={i === tickets.length - 1 && !hasMore}
|
||||
isLast={i === tickets.length - 1}
|
||||
onStartSession={handleStartSession}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
@@ -369,28 +386,6 @@ export function TicketQueue() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{!error && !loading && hasMore && (
|
||||
<div
|
||||
className="px-5 py-3"
|
||||
style={{ borderTop: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-[rgba(255,255,255,0.08)] bg-transparent py-2 text-xs text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.14)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load more'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
@@ -94,6 +94,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,
|
||||
@@ -132,6 +136,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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -56,7 +56,7 @@ export function TicketPickerModal({ open, onClose, sessionId, onLinked, onSelect
|
||||
query: query.trim(),
|
||||
include_closed: closed,
|
||||
})
|
||||
setSearchResults(results)
|
||||
setSearchResults(results.items)
|
||||
setHasSearched(true)
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
|
||||
63
frontend/src/components/tickets/AiTicketParseForm.tsx
Normal file
63
frontend/src/components/tickets/AiTicketParseForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
261
frontend/src/components/tickets/NewTicketModal.tsx
Normal file
261
frontend/src/components/tickets/NewTicketModal.tsx
Normal 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.getBoardStatuses(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>
|
||||
)
|
||||
}
|
||||
185
frontend/src/components/tickets/TicketDetailPanel.tsx
Normal file
185
frontend/src/components/tickets/TicketDetailPanel.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
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, newStatusId: number) => 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)
|
||||
|
||||
// Local status state so the select reflects updates immediately, independent
|
||||
// of the parent list's stale `selectedTicket` snapshot.
|
||||
const [currentStatusId, setCurrentStatusId] = useState<number | null>(ticket.status_id ?? null)
|
||||
const [currentStatusName, setCurrentStatusName] = useState<string | null>(ticket.status_name ?? null)
|
||||
|
||||
const ticketIdNum = Number(ticket.id)
|
||||
|
||||
const loadResources = useCallback(() => {
|
||||
ticketsApi.listResources(ticketIdNum)
|
||||
.then(setResources)
|
||||
.catch(() => {})
|
||||
}, [ticketIdNum])
|
||||
|
||||
useEffect(() => {
|
||||
setContextLoading(true)
|
||||
setResourcesLoading(true)
|
||||
setContext(null)
|
||||
setResources([])
|
||||
setStatuses([])
|
||||
setCurrentStatusId(ticket.status_id ?? null)
|
||||
setCurrentStatusName(ticket.status_name ?? null)
|
||||
|
||||
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)
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ticket.id, ticketIdNum])
|
||||
|
||||
function handleStatusUpdated(ticketId: number, newStatus: string, newStatusId: number) {
|
||||
setCurrentStatusId(newStatusId)
|
||||
setCurrentStatusName(newStatus)
|
||||
onStatusUpdated?.(ticketId, newStatus, newStatusId)
|
||||
}
|
||||
|
||||
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}
|
||||
currentStatusId={currentStatusId}
|
||||
currentStatusName={currentStatusName}
|
||||
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>
|
||||
)
|
||||
}
|
||||
207
frontend/src/components/tickets/TicketFilterBar.tsx
Normal file
207
frontend/src/components/tickets/TicketFilterBar.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
// frontend/src/components/tickets/TicketFilterBar.tsx
|
||||
import { useState } from 'react'
|
||||
import { Search, X, User } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TicketFilters, PSAPriority } from '@/types/tickets'
|
||||
import type { PSABoard, PSATicketStatusItem } from '@/types/integrations'
|
||||
|
||||
interface TicketFilterBarProps {
|
||||
filters: TicketFilters
|
||||
onChange: (updated: Partial<TicketFilters>) => void
|
||||
boards: PSABoard[]
|
||||
statuses: PSATicketStatusItem[]
|
||||
priorities: PSAPriority[]
|
||||
members: { id: number; name: string }[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
onPageChange: (page: number) => void
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export function TicketFilterBar({
|
||||
filters, onChange, boards, statuses, priorities, members,
|
||||
total, page, pageSize, onPageChange, loading,
|
||||
}: TicketFilterBarProps) {
|
||||
const start = (page - 1) * pageSize + 1
|
||||
const end = Math.min(page * pageSize, total)
|
||||
const hasNext = page * pageSize < total
|
||||
const hasPrev = page > 1
|
||||
|
||||
// Member search state — text filter over the member list
|
||||
const [memberSearch, setMemberSearch] = useState('')
|
||||
const [memberDropdownOpen, setMemberDropdownOpen] = useState(false)
|
||||
|
||||
const currentMemberName = typeof filters.assigned === 'number'
|
||||
? (members.find(m => m.id === filters.assigned)?.name ?? `Member ${filters.assigned}`)
|
||||
: null
|
||||
|
||||
const filteredMembers = members.filter(m =>
|
||||
m.name.toLowerCase().includes(memberSearch.toLowerCase())
|
||||
)
|
||||
|
||||
function handleMemberSelect(memberId: number | 'all' | 'me' | 'unassigned') {
|
||||
onChange({ assigned: memberId })
|
||||
setMemberDropdownOpen(false)
|
||||
setMemberSearch('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Filter row */}
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
className="bg-input border border-default rounded-[5px] pl-8 pr-3 py-1.5 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none w-48"
|
||||
placeholder="Search tickets..."
|
||||
value={filters.search}
|
||||
onChange={e => onChange({ search: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Assignment — searchable member picker */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => { setMemberDropdownOpen(v => !v); setMemberSearch('') }}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 bg-input border rounded-[5px] px-3 py-1.5 text-sm focus:border-accent focus:outline-none',
|
||||
filters.assigned === 'all' ? 'text-muted-foreground border-default' : 'text-primary border-accent'
|
||||
)}
|
||||
>
|
||||
<User className="w-3.5 h-3.5" />
|
||||
{filters.assigned === 'all' && 'All Tickets'}
|
||||
{filters.assigned === 'me' && 'My Tickets'}
|
||||
{filters.assigned === 'unassigned' && 'Unassigned'}
|
||||
{currentMemberName}
|
||||
</button>
|
||||
{memberDropdownOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setMemberDropdownOpen(false)} />
|
||||
<div className="absolute left-0 top-full mt-1 z-20 w-52 bg-card border border-default rounded-[5px] shadow-lg overflow-hidden">
|
||||
<div className="p-2 border-b border-default">
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full bg-input border border-default rounded-[5px] px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
||||
placeholder="Search member..."
|
||||
value={memberSearch}
|
||||
onChange={e => setMemberSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto py-1">
|
||||
{!memberSearch && (
|
||||
<>
|
||||
<button onClick={() => handleMemberSelect('all')} className={cn('w-full text-left px-3 py-1.5 text-xs hover:bg-elevated transition-colors', filters.assigned === 'all' && 'text-accent')}>All Tickets</button>
|
||||
<button onClick={() => handleMemberSelect('me')} className={cn('w-full text-left px-3 py-1.5 text-xs hover:bg-elevated transition-colors', filters.assigned === 'me' && 'text-accent')}>My Tickets</button>
|
||||
<button onClick={() => handleMemberSelect('unassigned')} className={cn('w-full text-left px-3 py-1.5 text-xs hover:bg-elevated transition-colors', filters.assigned === 'unassigned' && 'text-accent')}>Unassigned</button>
|
||||
{members.length > 0 && <div className="border-t border-default mx-2 my-1" />}
|
||||
</>
|
||||
)}
|
||||
{filteredMembers.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => handleMemberSelect(m.id)}
|
||||
className={cn('w-full text-left px-3 py-1.5 text-xs hover:bg-elevated transition-colors truncate', filters.assigned === m.id && 'text-accent')}
|
||||
>
|
||||
{m.name}
|
||||
</button>
|
||||
))}
|
||||
{memberSearch && filteredMembers.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">No members found</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Board */}
|
||||
<select
|
||||
className="bg-input border border-default rounded-[5px] px-3 py-1.5 text-sm text-primary focus:border-accent focus:outline-none"
|
||||
value={filters.board_id ?? ''}
|
||||
onChange={e => onChange({ board_id: e.target.value ? Number(e.target.value) : null })}
|
||||
>
|
||||
<option value="">All Boards</option>
|
||||
{boards.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||
</select>
|
||||
|
||||
{/* Status */}
|
||||
<select
|
||||
className="bg-input border border-default rounded-[5px] px-3 py-1.5 text-sm text-primary focus:border-accent focus:outline-none"
|
||||
value={filters.status_id ?? ''}
|
||||
onChange={e => onChange({ status_id: e.target.value ? Number(e.target.value) : null })}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{statuses.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
|
||||
{/* Priority */}
|
||||
<select
|
||||
className="bg-input border border-default rounded-[5px] px-3 py-1.5 text-sm text-primary focus:border-accent focus:outline-none"
|
||||
value={filters.priority ?? ''}
|
||||
onChange={e => onChange({ priority: e.target.value || null })}
|
||||
>
|
||||
<option value="">All Priorities</option>
|
||||
{priorities.map(p => <option key={p.id} value={p.name}>{p.name}</option>)}
|
||||
</select>
|
||||
|
||||
{/* Include closed */}
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent"
|
||||
checked={filters.include_closed}
|
||||
onChange={e => onChange({ include_closed: e.target.checked })}
|
||||
/>
|
||||
Include closed
|
||||
</label>
|
||||
|
||||
{/* Clear filters */}
|
||||
{(filters.search || filters.board_id || filters.status_id || filters.priority || filters.assigned !== 'all' || filters.include_closed) && (
|
||||
<button
|
||||
onClick={() => onChange({ search: '', board_id: null, status_id: null, priority: null, assigned: 'all', include_closed: false, company_id: null })}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" /> Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination row */}
|
||||
{total > 0 && (
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{loading ? 'Loading…' : `Showing ${start}–${end} of ${total} tickets`}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
disabled={!hasPrev}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded border text-xs transition-colors',
|
||||
hasPrev
|
||||
? 'border-default text-primary hover:border-hover'
|
||||
: 'border-default text-muted-foreground opacity-40 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
disabled={!hasNext}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded border text-xs transition-colors',
|
||||
hasNext
|
||||
? 'border-default text-primary hover:border-hover'
|
||||
: 'border-default text-muted-foreground opacity-40 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
frontend/src/components/tickets/TicketListRow.tsx
Normal file
72
frontend/src/components/tickets/TicketListRow.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
// frontend/src/components/tickets/TicketListRow.tsx
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PSATicketSearchResult } from '@/types/integrations'
|
||||
|
||||
interface TicketListRowProps {
|
||||
ticket: PSATicketSearchResult
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const PRIORITY_STYLES: Record<string, string> = {
|
||||
Critical: 'text-danger',
|
||||
High: 'text-danger',
|
||||
Medium: 'text-warning',
|
||||
Low: 'text-muted-foreground',
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; text: string }> = {
|
||||
New: { bg: 'bg-accent/10', text: 'text-accent' },
|
||||
'In Progress': { bg: 'bg-warning/10', text: 'text-warning' },
|
||||
Waiting: { bg: 'bg-success/10', text: 'text-success' },
|
||||
Resolved: { bg: 'bg-elevated/50', text: 'text-muted-foreground' },
|
||||
}
|
||||
|
||||
function statusStyle(name: string | null) {
|
||||
if (!name) return { bg: 'bg-elevated', text: 'text-muted-foreground' }
|
||||
return STATUS_STYLES[name] ?? { bg: 'bg-elevated', text: 'text-muted-foreground' }
|
||||
}
|
||||
|
||||
export function TicketListRow({ ticket, selected, onClick }: TicketListRowProps) {
|
||||
const { bg, text } = statusStyle(ticket.status_name)
|
||||
const priorityClass = PRIORITY_STYLES[ticket.priority_name ?? ''] ?? 'text-muted-foreground'
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={e => e.key === 'Enter' && onClick()}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors border-b border-default text-sm',
|
||||
selected ? 'bg-accent/5' : 'hover:bg-elevated'
|
||||
)}
|
||||
>
|
||||
{/* ID */}
|
||||
<span className="w-12 shrink-0 text-accent text-xs font-mono">#{ticket.id}</span>
|
||||
|
||||
{/* Summary */}
|
||||
<span className="flex-1 truncate text-primary font-medium">{ticket.summary}</span>
|
||||
|
||||
{/* Company */}
|
||||
<span className="w-32 shrink-0 truncate text-muted-foreground text-xs hidden md:block">
|
||||
{ticket.company_name ?? '—'}
|
||||
</span>
|
||||
|
||||
{/* Board */}
|
||||
<span className="w-28 shrink-0 truncate text-muted-foreground text-xs hidden lg:block">
|
||||
{ticket.board_name ?? '—'}
|
||||
</span>
|
||||
|
||||
{/* Status badge */}
|
||||
<span className={cn('shrink-0 px-1.5 py-0.5 rounded text-[11px] font-medium', bg, text)}>
|
||||
{ticket.status_name ?? '—'}
|
||||
</span>
|
||||
|
||||
{/* Priority */}
|
||||
<span className={cn('w-14 shrink-0 text-xs text-right', priorityClass)}>
|
||||
{ticket.priority_name ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
frontend/src/components/tickets/detail/TicketAddNote.tsx
Normal file
58
frontend/src/components/tickets/detail/TicketAddNote.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
frontend/src/components/tickets/detail/TicketConfigs.tsx
Normal file
28
frontend/src/components/tickets/detail/TicketConfigs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
28
frontend/src/components/tickets/detail/TicketNotesFeed.tsx
Normal file
28
frontend/src/components/tickets/detail/TicketNotesFeed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
frontend/src/components/tickets/detail/TicketRelated.tsx
Normal file
42
frontend/src/components/tickets/detail/TicketRelated.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
frontend/src/components/tickets/detail/TicketResourceManager.tsx
Normal file
128
frontend/src/components/tickets/detail/TicketResourceManager.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user