feat: add category-driven ticket workflows
This commit is contained in:
@@ -17,20 +17,12 @@ import { formatDateTime } from '@/lib/format'
|
||||
|
||||
import { getTicket, listTicketEvents, transitionTicket } from './api'
|
||||
import { TicketComments } from './TicketComments'
|
||||
import { getTicketCategoryLabel, getTicketNextStatuses } from './ticketCategories'
|
||||
import { TicketMetaPanel } from './TicketMetaPanel'
|
||||
import { TicketStatusTimeline } from './TicketStatusTimeline'
|
||||
import { TicketTransitionDialog } from './TicketTransitionDialog'
|
||||
import type { Ticket, TicketEvent, TicketStatus } from './types'
|
||||
|
||||
const NEXT_STATUSES: Record<TicketStatus, TicketStatus[]> = {
|
||||
pending_info: ['pending', 'closed'],
|
||||
pending: ['in_progress', 'closed'],
|
||||
in_progress: ['pending_confirm'],
|
||||
pending_confirm: ['resolved', 'in_progress', 'closed'],
|
||||
resolved: ['closed'],
|
||||
closed: []
|
||||
}
|
||||
|
||||
const STATUS_ACTIONS: Partial<Record<TicketStatus, { label: string; icon: React.ComponentType<{ className?: string }> }>> = {
|
||||
pending: { label: '转为待处理', icon: CircleDot },
|
||||
in_progress: { label: '开始处理', icon: CircleDot },
|
||||
@@ -93,10 +85,18 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
setTransitionDialogOpen(true)
|
||||
}
|
||||
|
||||
async function handleTransition(status: TicketStatus, remark: string) {
|
||||
async function handleTransition(
|
||||
status: TicketStatus,
|
||||
values: {
|
||||
remark: string
|
||||
resolution: string
|
||||
is_resolved: boolean
|
||||
replied_to_customer: boolean
|
||||
}
|
||||
) {
|
||||
if (!ticket) return
|
||||
try {
|
||||
const updated = await transitionTicket(ticket.id, status, remark)
|
||||
const updated = await transitionTicket(ticket.id, status, values)
|
||||
setTicket(updated)
|
||||
setTransitionError('')
|
||||
refreshEvents()
|
||||
@@ -122,6 +122,8 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
const nextStatuses = getTicketNextStatuses(ticket.category_code, ticket.status)
|
||||
|
||||
return (
|
||||
<AppPage maxWidth="6xl">
|
||||
<AppPageHeader
|
||||
@@ -135,7 +137,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{NEXT_STATUSES[ticket.status].map((status) => {
|
||||
{nextStatuses.map((status) => {
|
||||
const action = STATUS_ACTIONS[status]
|
||||
const Icon = action?.icon
|
||||
return (
|
||||
@@ -153,10 +155,10 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
}
|
||||
/>
|
||||
<TicketOverviewCard ticket={ticket} />
|
||||
{NEXT_STATUSES[ticket.status].length ? (
|
||||
{nextStatuses.length ? (
|
||||
<Card>
|
||||
<CardContent className="grid gap-3 p-4 md:grid-cols-2">
|
||||
{NEXT_STATUSES[ticket.status].map((status) => {
|
||||
{nextStatuses.map((status) => {
|
||||
const action = STATUS_ACTIONS[status]
|
||||
const Icon = action?.icon ?? CircleDot
|
||||
return (
|
||||
@@ -202,7 +204,37 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
}
|
||||
>
|
||||
<AppSection title="问题描述">
|
||||
<p className="whitespace-pre-wrap text-sm leading-6">{ticket.description}</p>
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">问题分类</p>
|
||||
<p className="mt-1 text-sm font-medium text-foreground">{getTicketCategoryLabel(ticket.category_code)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">问题表现</p>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm leading-6">{ticket.problem_performance || '未填写'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">问题描述</p>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm leading-6">{ticket.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppSection>
|
||||
<AppSection title="解决方案">
|
||||
{ticket.resolution ? (
|
||||
<div className="grid gap-3">
|
||||
<p className="whitespace-pre-wrap text-sm leading-6">{ticket.resolution}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant={ticket.is_resolved ? 'success' : 'outline'}>
|
||||
{ticket.is_resolved ? '已解决' : '未解决'}
|
||||
</Badge>
|
||||
<Badge variant={ticket.replied_to_customer ? 'info' : 'outline'}>
|
||||
{ticket.replied_to_customer ? '已回复客户' : '未回复客户'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<AppEmptyState title="暂无解决方案" description="解决工单后将在这里展示处理结论。" />
|
||||
)}
|
||||
</AppSection>
|
||||
<TicketStatusTimeline ticket={ticket} events={events} />
|
||||
<TicketComments ticketId={ticket.id} onCommentAdded={refreshEvents} />
|
||||
@@ -221,6 +253,10 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
|
||||
function TicketOverviewCard({ ticket }: { ticket: Ticket }) {
|
||||
const items = [
|
||||
{ label: '问题分类', value: getTicketCategoryLabel(ticket.category_code) },
|
||||
{ label: '问题对象', value: ticket.problem_object || '未填写' },
|
||||
{ label: '提出人', value: ticket.submitter_name || displayUser(ticket.requester) },
|
||||
{ label: '提出机构', value: ticket.submitter_org || '未填写' },
|
||||
{ label: '请求人', value: displayUser(ticket.requester) },
|
||||
{ label: '处理人', value: displayUser(ticket.assignee) },
|
||||
{ label: '关联系统', value: ticket.related_system || '未填写' },
|
||||
|
||||
Reference in New Issue
Block a user