feat(tickets): add AiTicketParseForm and NewTicketModal with two-tab creation flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 03:31:21 +00:00
parent 070d2383bc
commit 08909aa884
2 changed files with 316 additions and 5 deletions

View File

@@ -0,0 +1,62 @@
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
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

@@ -1,12 +1,261 @@
// Placeholder — full implementation in Task 17
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?: Record<string, unknown>
initialValues?: Partial<TicketCreationPayload>
summaryHint?: string
onClose: () => void
onCreated: (ticketId?: number, summary?: string) => void
onCreated: (ticketId: number, summary: string) => void
}
export function NewTicketModal(_props: Props) {
return null
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(() => {})
}, [])
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} 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>
)
}