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:
62
frontend/src/components/tickets/AiTicketParseForm.tsx
Normal file
62
frontend/src/components/tickets/AiTicketParseForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user