feat: add ticket workflow pages
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { DesignSystemPreview } from '@/features/design-system/DesignSystemPreview'
|
||||
import { TicketCreatePage } from '@/features/tickets/TicketCreatePage'
|
||||
import { TicketDetailPage } from '@/features/tickets/TicketDetailPage'
|
||||
import { TicketListPage } from '@/features/tickets/TicketListPage'
|
||||
|
||||
function getRoute() {
|
||||
return window.location.hash.replace(/^#/, '') || '/tickets'
|
||||
@@ -19,12 +22,14 @@ export function Router() {
|
||||
return <DesignSystemPreview />
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<p className="text-sm text-muted-foreground">Route</p>
|
||||
<h1 className="mt-2 text-xl font-semibold">{route}</h1>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
if (route === '/tickets/new') {
|
||||
return <TicketCreatePage />
|
||||
}
|
||||
|
||||
const ticketMatch = route.match(/^\/tickets\/(\d+)$/)
|
||||
if (ticketMatch) {
|
||||
return <TicketDetailPage ticketId={Number(ticketMatch[1])} />
|
||||
}
|
||||
|
||||
return <TicketListPage />
|
||||
}
|
||||
|
||||
13
frontend/src/components/app/AppEmptyState.tsx
Normal file
13
frontend/src/components/app/AppEmptyState.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
type AppEmptyStateProps = {
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function AppEmptyState({ title, description }: AppEmptyStateProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface px-4 py-8 text-center">
|
||||
<p className="text-sm font-medium text-foreground">{title}</p>
|
||||
{description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
frontend/src/components/app/AppField.tsx
Normal file
15
frontend/src/components/app/AppField.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type * as React from 'react'
|
||||
|
||||
type AppFieldProps = {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function AppField({ label, children }: AppFieldProps) {
|
||||
return (
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
15
frontend/src/components/ui/input.tsx
Normal file
15
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
'h-9 w-full rounded-md border border-border bg-surface px-3 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
15
frontend/src/components/ui/select.tsx
Normal file
15
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
export function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
'h-9 w-full rounded-md border border-border bg-surface px-3 text-sm text-foreground outline-none transition-colors focus:border-primary disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
15
frontend/src/components/ui/textarea.tsx
Normal file
15
frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
export function Textarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'min-h-28 w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
39
frontend/src/features/tickets/TicketComments.tsx
Normal file
39
frontend/src/features/tickets/TicketComments.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import { addTicketComment } from './api'
|
||||
|
||||
export function TicketComments({ ticketId }: { ticketId: number }) {
|
||||
const [content, setContent] = React.useState('')
|
||||
const [lastComment, setLastComment] = React.useState('')
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!content.trim()) return
|
||||
const comment = await addTicketComment(ticketId, content)
|
||||
setLastComment(comment.content)
|
||||
setContent('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-sm font-medium">Comments</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="grid gap-3" onSubmit={handleSubmit}>
|
||||
<Textarea value={content} onChange={(event) => setContent(event.target.value)} />
|
||||
{lastComment ? <p className="text-sm text-muted-foreground">Latest: {lastComment}</p> : null}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="secondary">
|
||||
Add Comment
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
88
frontend/src/features/tickets/TicketCreatePage.tsx
Normal file
88
frontend/src/features/tickets/TicketCreatePage.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { AppField } from '@/components/app/AppField'
|
||||
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import { createTicket } from './api'
|
||||
import type { TicketInput } from './types'
|
||||
|
||||
const initialForm: TicketInput = {
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
impact_scope: '',
|
||||
environment: '',
|
||||
related_system: ''
|
||||
}
|
||||
|
||||
export function TicketCreatePage() {
|
||||
const [form, setForm] = React.useState<TicketInput>(initialForm)
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
function updateField<Key extends keyof TicketInput>(field: Key, value: TicketInput[Key]) {
|
||||
setForm((current) => ({ ...current, [field]: value }))
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
try {
|
||||
const ticket = await createTicket(form)
|
||||
window.location.hash = `/tickets/${ticket.id}`
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<AppPageHeader title="Create Ticket" description="Record a confirmed support request." />
|
||||
<Card className="mt-4">
|
||||
<CardContent>
|
||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||
<AppField label="Title">
|
||||
<Input required value={form.title} onChange={(event) => updateField('title', event.target.value)} />
|
||||
</AppField>
|
||||
<AppField label="Description">
|
||||
<Textarea
|
||||
required
|
||||
value={form.description}
|
||||
onChange={(event) => updateField('description', event.target.value)}
|
||||
/>
|
||||
</AppField>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AppField label="Priority">
|
||||
<Select value={form.priority} onChange={(event) => updateField('priority', event.target.value as TicketInput['priority'])}>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</Select>
|
||||
</AppField>
|
||||
<AppField label="Related System">
|
||||
<Input value={form.related_system} onChange={(event) => updateField('related_system', event.target.value)} />
|
||||
</AppField>
|
||||
<AppField label="Impact Scope">
|
||||
<Input value={form.impact_scope} onChange={(event) => updateField('impact_scope', event.target.value)} />
|
||||
</AppField>
|
||||
<AppField label="Environment">
|
||||
<Input value={form.environment} onChange={(event) => updateField('environment', event.target.value)} />
|
||||
</AppField>
|
||||
</div>
|
||||
{error ? <p className="text-sm text-danger">{error}</p> : null}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit">Create</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
93
frontend/src/features/tickets/TicketDetailPage.tsx
Normal file
93
frontend/src/features/tickets/TicketDetailPage.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
||||
import { AppField } from '@/components/app/AppField'
|
||||
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
||||
import { AppPriorityBadge } from '@/components/app/AppPriorityBadge'
|
||||
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
|
||||
import { getTicket, transitionTicket } from './api'
|
||||
import { TicketComments } from './TicketComments'
|
||||
import { TicketStatusTimeline } from './TicketStatusTimeline'
|
||||
import type { Ticket, TicketStatus } from './types'
|
||||
|
||||
const NEXT_STATUSES: TicketStatus[] = ['in_progress', 'pending_confirm', 'resolved', 'closed']
|
||||
|
||||
export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
const [ticket, setTicket] = React.useState<Ticket | null>(null)
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
getTicket(ticketId)
|
||||
.then(setTicket)
|
||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
|
||||
}, [ticketId])
|
||||
|
||||
async function handleTransition(status: TicketStatus) {
|
||||
if (!ticket) return
|
||||
const updated = await transitionTicket(ticket.id, status, `Move to ${status}`)
|
||||
setTicket(updated)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||
<AppEmptyState title="Unable to load ticket" description={error} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
return (
|
||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||
<AppEmptyState title="Loading ticket" />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-4">
|
||||
<AppPageHeader
|
||||
title={ticket.title}
|
||||
description={ticket.ticket_no}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{NEXT_STATUSES.map((status) => (
|
||||
<Button key={status} variant="secondary" onClick={() => void handleTransition(status)}>
|
||||
{status}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<CardContent className="grid gap-4 md:grid-cols-3">
|
||||
<AppField label="Status">
|
||||
<AppStatusBadge status={ticket.status} />
|
||||
</AppField>
|
||||
<AppField label="Priority">
|
||||
<AppPriorityBadge priority={ticket.priority} />
|
||||
</AppField>
|
||||
<AppField label="System">
|
||||
<span className="text-sm">{ticket.related_system || '-'}</span>
|
||||
</AppField>
|
||||
<AppField label="Environment">
|
||||
<span className="text-sm">{ticket.environment || '-'}</span>
|
||||
</AppField>
|
||||
<AppField label="Impact">
|
||||
<span className="text-sm">{ticket.impact_scope || '-'}</span>
|
||||
</AppField>
|
||||
<AppField label="Description">
|
||||
<span className="text-sm">{ticket.description}</span>
|
||||
</AppField>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TicketStatusTimeline ticket={ticket} />
|
||||
<TicketComments ticketId={ticket.id} />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
68
frontend/src/features/tickets/TicketListPage.tsx
Normal file
68
frontend/src/features/tickets/TicketListPage.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
||||
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
||||
import { AppPriorityBadge } from '@/components/app/AppPriorityBadge'
|
||||
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { formatDateTime } from '@/lib/format'
|
||||
|
||||
import { listTickets } from './api'
|
||||
import type { Ticket } from './types'
|
||||
|
||||
export function TicketListPage() {
|
||||
const [tickets, setTickets] = React.useState<Ticket[]>([])
|
||||
const [status, setStatus] = React.useState('')
|
||||
const [q, setQ] = React.useState('')
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
listTickets({ status, q })
|
||||
.then(setTickets)
|
||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
|
||||
}, [status, q])
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-4">
|
||||
<AppPageHeader
|
||||
title="Tickets"
|
||||
description="Search, filter, and review support tickets."
|
||||
actions={<Button onClick={() => (window.location.hash = '/tickets/new')}>Create Ticket</Button>}
|
||||
/>
|
||||
<div className="grid gap-2 md:grid-cols-[1fr_180px]">
|
||||
<Input onChange={(event) => setQ(event.target.value)} placeholder="Search ticket no or title" value={q} />
|
||||
<Select onChange={(event) => setStatus(event.target.value)} value={status}>
|
||||
<option value="">All statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="in_progress">In progress</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="closed">Closed</option>
|
||||
</Select>
|
||||
</div>
|
||||
{error ? <AppEmptyState title="Unable to load tickets" description={error} /> : null}
|
||||
{!error && tickets.length === 0 ? <AppEmptyState title="No tickets" description="Create the first support ticket." /> : null}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{tickets.map((ticket) => (
|
||||
<a
|
||||
className="grid gap-3 border-b border-border px-4 py-3 text-sm last:border-b-0 hover:bg-muted md:grid-cols-[150px_1fr_120px_100px_140px]"
|
||||
href={`#/tickets/${ticket.id}`}
|
||||
key={ticket.id}
|
||||
>
|
||||
<span className="font-medium text-foreground">{ticket.ticket_no}</span>
|
||||
<span>{ticket.title}</span>
|
||||
<AppStatusBadge status={ticket.status} />
|
||||
<AppPriorityBadge priority={ticket.priority} />
|
||||
<span className="text-muted-foreground">{formatDateTime(ticket.created_at)}</span>
|
||||
</a>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
16
frontend/src/features/tickets/TicketStatusTimeline.tsx
Normal file
16
frontend/src/features/tickets/TicketStatusTimeline.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
|
||||
import type { Ticket } from './types'
|
||||
|
||||
export function TicketStatusTimeline({ ticket }: { ticket: Ticket }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-sm font-medium">Timeline</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm text-muted-foreground">Current status: {ticket.status}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
36
frontend/src/features/tickets/api.ts
Normal file
36
frontend/src/features/tickets/api.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { apiRequest } from '@/lib/api'
|
||||
|
||||
import type { Ticket, TicketComment, TicketInput, TicketStatus } from './types'
|
||||
|
||||
export function listTickets(params: { status?: string; q?: string } = {}) {
|
||||
const query = new URLSearchParams()
|
||||
if (params.status) query.set('status', params.status)
|
||||
if (params.q) query.set('q', params.q)
|
||||
const suffix = query.toString() ? `?${query}` : ''
|
||||
return apiRequest<Ticket[]>(`/tickets${suffix}`)
|
||||
}
|
||||
|
||||
export function createTicket(payload: TicketInput) {
|
||||
return apiRequest<Ticket>('/tickets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
export function getTicket(ticketId: number) {
|
||||
return apiRequest<Ticket>(`/tickets/${ticketId}`)
|
||||
}
|
||||
|
||||
export function transitionTicket(ticketId: number, status: TicketStatus, remark = '') {
|
||||
return apiRequest<Ticket>(`/tickets/${ticketId}/status`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ status, remark })
|
||||
})
|
||||
}
|
||||
|
||||
export function addTicketComment(ticketId: number, content: string) {
|
||||
return apiRequest<TicketComment>(`/tickets/${ticketId}/comments`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content, comment_type: 'internal_note', visibility: 'internal' })
|
||||
})
|
||||
}
|
||||
41
frontend/src/features/tickets/types.ts
Normal file
41
frontend/src/features/tickets/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export type TicketStatus =
|
||||
| 'pending_info'
|
||||
| 'pending'
|
||||
| 'in_progress'
|
||||
| 'pending_confirm'
|
||||
| 'resolved'
|
||||
| 'closed'
|
||||
|
||||
export type TicketPriority = 'low' | 'medium' | 'high' | 'urgent'
|
||||
|
||||
export type Ticket = {
|
||||
id: number
|
||||
ticket_no: string
|
||||
title: string
|
||||
description: string
|
||||
status: TicketStatus
|
||||
priority: TicketPriority
|
||||
impact_scope: string
|
||||
environment: string
|
||||
related_system: string
|
||||
assignee_id: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type TicketInput = {
|
||||
title: string
|
||||
description: string
|
||||
priority: TicketPriority
|
||||
impact_scope: string
|
||||
environment: string
|
||||
related_system: string
|
||||
}
|
||||
|
||||
export type TicketComment = {
|
||||
id: number
|
||||
content: string
|
||||
comment_type: string
|
||||
visibility: string
|
||||
created_at: string
|
||||
}
|
||||
Reference in New Issue
Block a user