feat(ticket): support defect-class ticket workflows

This commit is contained in:
2026-06-26 00:50:52 +05:30
parent 8665762ba2
commit 1f8ca87bd0
21 changed files with 343 additions and 70 deletions

View File

@@ -0,0 +1,100 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { CheckCircle2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries';
interface TicketDefectClassTabsProps {
ticketId: string;
}
export function TicketDefectClassTabs({
ticketId,
}: TicketDefectClassTabsProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const defectClassParam = searchParams.get('defect_class');
const defectClassesQuery = useTicketDefectClassesQuery(ticketId);
const defectClasses = useMemo(
() => defectClassesQuery.data?.defect_classes ?? [],
[defectClassesQuery.data?.defect_classes],
);
const [selectedClass, setSelectedClass] = useState<string>();
useEffect(() => {
if (!defectClasses.length) return;
const hasUrlClass = defectClasses.some(
(item) => item.class_name === defectClassParam,
);
setSelectedClass((current) => {
if (hasUrlClass) return defectClassParam ?? undefined;
return current &&
defectClasses.some((item) => item.class_name === current)
? current
: defectClasses[0].class_name;
});
}, [defectClassParam, defectClasses]);
const handleClassChange = (className: string) => {
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set('defect_class', className);
setSelectedClass(className);
console.log(className);
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
};
if (defectClassesQuery.isLoading) {
return (
<Card>
<CardContent className="flex flex-col gap-2">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="h-9 w-full animate-pulse rounded-full bg-muted"
/>
))}
</CardContent>
</Card>
);
}
if (defectClassesQuery.isError || !defectClasses.length || !selectedClass) {
return null;
}
return (
<Tabs
value={selectedClass}
onValueChange={handleClassChange}
orientation="vertical"
className="w-full"
>
<Card className="w-full py-0">
<CardContent className="py-4">
<TabsList className="flex h-auto w-full flex-col items-stretch gap-2 bg-transparent p-0">
{defectClasses.map((item) => (
<TabsTrigger
key={item.class_name}
value={item.class_name}
className="group h-9 w-full flex-none justify-start gap-2 rounded-full border border-border bg-muted/50 px-3 text-sm text-muted-foreground shadow-none after:hidden data-[state=active]:border-border data-[state=active]:bg-muted data-[state=active]:font-medium data-[state=active]:text-foreground"
>
<CheckCircle2 className="size-3.5 shrink-0 text-muted-foreground group-data-[state=active]:text-foreground" />
{item.display_name}
</TabsTrigger>
))}
</TabsList>
</CardContent>
</Card>
</Tabs>
);
}

View File

@@ -3,10 +3,6 @@
import type { TicketActor, TicketDetail } from '@/types';
import { formatDate } from '@/utils/date';
import {
TicketStatusBadge,
} from '../../components/TicketStatusBadge';
function formatCreatedDate(value: string | null | undefined) {
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium' }).format(
@@ -84,9 +80,7 @@ export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
return (
<section className="overflow-hidden rounded-xl border bg-card">
<div className="border-b px-4 py-2.5">
<h2 className="text-xs font-medium ">
Overview
</h2>
<h2 className="text-xs font-medium ">Overview</h2>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-6 p-4 md:grid-cols-4">
@@ -105,27 +99,10 @@ export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
</OverviewField>
<OverviewField label="Created Date">
<MetaValue>{formatCreatedDate(ticket.timestamps.created_at)}</MetaValue>
<MetaValue>
{formatCreatedDate(ticket.timestamps.created_at)}
</MetaValue>
</OverviewField>
<OverviewField label="Assigned To">
<PersonMetaValue
person={ticket.worker}
accentClassName="bg-secondary text-secondary-foreground"
/>
</OverviewField>
<OverviewField label="Reviewer">
<PersonMetaValue
person={ticket.reviewer}
accentClassName="bg-primary/20 text-primary"
/>
</OverviewField>
<OverviewField label="Assigned At">
<MetaValue>{formatDate(ticket.worker?.assigned_at)}</MetaValue>
</OverviewField>
</div>
{ticket.ai_result?.completed_at ? (

View File

@@ -11,16 +11,17 @@ import { NoTicketAction } from './actions/NoTicketAction';
import { ProcessingTicketAction } from './actions/ProcessingTicketAction';
import { ReviewRepairAction } from './actions/ReviewRepairAction';
import { SubmitRepairAction } from './actions/SubmitRepairAction';
import { TicketDefectClassTabs } from './TicketDefectClassTabs';
interface TicketStatusActionsProps {
ticket: TicketDetail;
liveState: TicketDetailLiveState;
}
export function TicketStatusActions({
ticket,
liveState,
}: TicketStatusActionsProps) {
function getTicketAction(
ticket: TicketDetail,
liveState: TicketDetailLiveState,
) {
if (ticket.status === 'processing') {
return (
<ProcessingTicketAction
@@ -30,7 +31,7 @@ export function TicketStatusActions({
);
}
if (ticket.status === 'unassigned') {
if (ticket.assignment_status === 'unassigned') {
return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN}
@@ -41,7 +42,7 @@ export function TicketStatusActions({
);
}
if (ticket.status === 'assigned') {
if (ticket.assignment_status === 'assigned') {
return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.WORK}
@@ -52,7 +53,7 @@ export function TicketStatusActions({
);
}
if (ticket.status === 'under_review') {
if (ticket.assignment_status === 'under_review') {
return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.REVIEW}
@@ -69,3 +70,15 @@ export function TicketStatusActions({
return <NoTicketAction status={ticket.status} />;
}
export function TicketStatusActions({
ticket,
liveState,
}: TicketStatusActionsProps) {
return (
<div className="space-y-3">
<TicketDefectClassTabs ticketId={ticket.id} />
{getTicketAction(ticket, liveState)}
</div>
);
}

View File

@@ -43,6 +43,7 @@ export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
if (!selectedUserId) return;
assignMutation.mutate({
assigned_to_user_id: Number(selectedUserId),
defect_class: ticket.defect_class,
note: assignNote || undefined,
});
}}

View File

@@ -131,9 +131,13 @@ function ImageSlot({
interface RepairEvidenceFormProps {
ticketId: string;
defectClass: string;
}
export function RepairEvidenceForm({ ticketId }: RepairEvidenceFormProps) {
export function RepairEvidenceForm({
ticketId,
defectClass,
}: RepairEvidenceFormProps) {
const imageInputRef = useRef<HTMLInputElement | null>(null);
const [notes, setNotes] = useState('');
const [videoFile, setVideoFile] = useState<File | null>(null);
@@ -189,6 +193,7 @@ export function RepairEvidenceForm({ ticketId }: RepairEvidenceFormProps) {
setError(null);
try {
await submitRepairUploadMutation.mutateAsync({
defect_class: defectClass,
notes: notes.trim(),
video: videoFile,
images: imageFiles,
@@ -267,7 +272,10 @@ export function RepairEvidenceForm({ ticketId }: RepairEvidenceFormProps) {
key={index}
file={slot.file}
previewUrl={slot.previewUrl}
disabled={isSubmitting || (!slot.file && imageFiles.length >= MAX_IMAGES)}
disabled={
isSubmitting ||
(!slot.file && imageFiles.length >= MAX_IMAGES)
}
onAdd={() => imageInputRef.current?.click()}
onRemove={() => removeImage(index)}
/>

View File

@@ -35,6 +35,7 @@ export function ReviewRepairAction({ ticket }: { ticket: TicketDetail }) {
disabled={!reviewComment || reviewRepairMutation.isPending}
onClick={() =>
reviewRepairMutation.mutate({
defect_class: ticket.defect_class,
action: 'approve',
comment: reviewComment,
})
@@ -48,6 +49,7 @@ export function ReviewRepairAction({ ticket }: { ticket: TicketDetail }) {
disabled={!reviewComment || reviewRepairMutation.isPending}
onClick={() =>
reviewRepairMutation.mutate({
defect_class: ticket.defect_class,
action: 'reject',
comment: reviewComment,
})

View File

@@ -10,7 +10,10 @@ import { TicketActionCard } from './TicketActionCard';
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
return (
<TicketActionCard icon={Wrench} title="Submit Repair Evidence">
<RepairEvidenceForm ticketId={ticket.id} />
<RepairEvidenceForm
ticketId={ticket.id}
defectClass={ticket.defect_class}
/>
</TicketActionCard>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useParams, useRouter } from 'next/navigation';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -17,9 +17,11 @@ import { useTicketDetailQuery } from '../hooks/useTicketQueries';
export default function TicketDetailPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { ticketId } = useParams() as { ticketId: string };
const defectClass = searchParams.get('defect_class') ?? undefined;
const ticketQuery = useTicketDetailQuery(ticketId);
const ticketQuery = useTicketDetailQuery(ticketId, defectClass);
const ticket = ticketQuery.data;
const liveState = useTicketDetailEvents(