feat(ticket): support defect-class ticket workflows
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -12,11 +12,11 @@ export function useTicketColumns(): ColumnDef<TicketListItem>[] {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
accessorKey: 'ticket_name',
|
||||
header: 'Ticket',
|
||||
size: 180,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.id}</span>
|
||||
<span className="font-medium">{row.original.ticket_name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -86,7 +86,9 @@ function buildTicketListItem(
|
||||
): TicketListItem | null {
|
||||
if (
|
||||
!event.id ||
|
||||
!event.ticket_name ||
|
||||
!event.status ||
|
||||
!event.default_defect_class ||
|
||||
event.detection_count === undefined ||
|
||||
!event.updated_at
|
||||
) {
|
||||
@@ -95,9 +97,11 @@ function buildTicketListItem(
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
ticket_name: event.ticket_name,
|
||||
chainage_id: event.chainage_id ?? null,
|
||||
chainage_name: event.chainage_name ?? null,
|
||||
status: event.status,
|
||||
default_defect_class: event.default_defect_class,
|
||||
assigned_to_name: event.assigned_to_name ?? null,
|
||||
detection_count: event.detection_count,
|
||||
created_by_name: event.created_by_name ?? null,
|
||||
@@ -112,9 +116,12 @@ function mergeTicketListItem(
|
||||
): TicketListItem {
|
||||
return {
|
||||
...current,
|
||||
ticket_name: event.ticket_name ?? current.ticket_name,
|
||||
chainage_id: event.chainage_id ?? current.chainage_id,
|
||||
chainage_name: event.chainage_name ?? current.chainage_name,
|
||||
status: event.status ?? current.status,
|
||||
default_defect_class:
|
||||
event.default_defect_class ?? current.default_defect_class,
|
||||
assigned_to_name: event.assigned_to_name ?? current.assigned_to_name,
|
||||
detection_count: event.detection_count ?? current.detection_count,
|
||||
created_by_name: event.created_by_name ?? current.created_by_name,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toast } from 'sonner';
|
||||
import { ticketService } from '@/services/api';
|
||||
import type {
|
||||
AssignTicketPayload,
|
||||
CloseTicketPayload,
|
||||
ReviewRepairPayload,
|
||||
SubmitRepairPayload,
|
||||
SubmitRepairUploadPayload,
|
||||
@@ -55,10 +56,22 @@ export function useTicketsQuery(params: UseTicketsQueryParams) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketDetailQuery(ticketId: string | undefined) {
|
||||
export function useTicketDetailQuery(
|
||||
ticketId: string | undefined,
|
||||
defectClass: string | undefined,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.detail(ticketId ?? ''),
|
||||
queryFn: () => ticketService.getTicketDetail(ticketId as string),
|
||||
queryKey: ticketKeys.detail(ticketId ?? '', defectClass),
|
||||
queryFn: () =>
|
||||
ticketService.getTicketDetail(ticketId as string, defectClass as string),
|
||||
enabled: Boolean(ticketId && defectClass),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketDefectClassesQuery(ticketId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.defectClasses(ticketId ?? ''),
|
||||
queryFn: () => ticketService.getTicketDefectClasses(ticketId as string),
|
||||
enabled: Boolean(ticketId),
|
||||
});
|
||||
}
|
||||
@@ -90,10 +103,10 @@ export function useAssignTicketMutation(ticketId: string) {
|
||||
return useMutation({
|
||||
mutationFn: (payload: AssignTicketPayload) =>
|
||||
ticketService.assignTicket(ticketId, payload),
|
||||
onSuccess: (ticket) => {
|
||||
onSuccess: (ticket, payload) => {
|
||||
toast.success('Ticket assigned');
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.detail(ticketId),
|
||||
ticketKeys.detail(ticketId, payload.defect_class),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
@@ -143,10 +156,10 @@ export function useSubmitRepairUploadMutation(ticketId: string) {
|
||||
return useMutation({
|
||||
mutationFn: (payload: SubmitRepairUploadPayload) =>
|
||||
ticketService.submitRepairUpload(ticketId, payload),
|
||||
onSuccess: (ticket) => {
|
||||
onSuccess: (ticket, payload) => {
|
||||
toast.success('Repair evidence uploaded');
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.detail(ticketId),
|
||||
ticketKeys.detail(ticketId, payload.defect_class),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
@@ -166,7 +179,7 @@ export function useReviewRepairMutation(ticketId: string) {
|
||||
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected',
|
||||
);
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.detail(ticketId),
|
||||
ticketKeys.detail(ticketId, payload.defect_class),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
@@ -179,11 +192,12 @@ export function useCloseTicketMutation(ticketId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => ticketService.closeTicket(ticketId),
|
||||
onSuccess: (ticket) => {
|
||||
mutationFn: (payload: CloseTicketPayload) =>
|
||||
ticketService.closeTicket(ticketId, payload),
|
||||
onSuccess: (ticket, payload) => {
|
||||
toast.success('Ticket closed');
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.detail(ticketId),
|
||||
ticketKeys.detail(ticketId, payload.defect_class),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
|
||||
@@ -44,7 +44,9 @@ export default function TicketPage() {
|
||||
|
||||
const handleView = useCallback(
|
||||
(ticket: TicketListItem) => {
|
||||
router.push(`/ticket/${ticket.id}`);
|
||||
router.push(
|
||||
`/ticket/${ticket.id}?defect_class=${encodeURIComponent(ticket.default_defect_class)}`,
|
||||
);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
@@ -5,6 +5,9 @@ export const ticketKeys = {
|
||||
lists: () => [...ticketKeys.all, 'list'] as const,
|
||||
list: (params: TicketListParams) => [...ticketKeys.lists(), params] as const,
|
||||
details: () => [...ticketKeys.all, 'detail'] as const,
|
||||
detail: (ticketId: string) => [...ticketKeys.details(), ticketId] as const,
|
||||
detail: (ticketId: string, defectClass?: string) =>
|
||||
[...ticketKeys.details(), ticketId, defectClass] as const,
|
||||
defectClasses: (ticketId: string) =>
|
||||
[...ticketKeys.detail(ticketId), 'defect-classes'] as const,
|
||||
assignableUsers: () => [...ticketKeys.all, 'assignable-users'] as const,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user