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 type { TicketActor, TicketDetail } from '@/types';
|
||||||
import { formatDate } from '@/utils/date';
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
import {
|
|
||||||
TicketStatusBadge,
|
|
||||||
} from '../../components/TicketStatusBadge';
|
|
||||||
|
|
||||||
function formatCreatedDate(value: string | null | undefined) {
|
function formatCreatedDate(value: string | null | undefined) {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
return new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium' }).format(
|
return new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium' }).format(
|
||||||
@@ -84,9 +80,7 @@ export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
|
|||||||
return (
|
return (
|
||||||
<section className="overflow-hidden rounded-xl border bg-card">
|
<section className="overflow-hidden rounded-xl border bg-card">
|
||||||
<div className="border-b px-4 py-2.5">
|
<div className="border-b px-4 py-2.5">
|
||||||
<h2 className="text-xs font-medium ">
|
<h2 className="text-xs font-medium ">Overview</h2>
|
||||||
Overview
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-6 p-4 md:grid-cols-4">
|
<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>
|
||||||
|
|
||||||
<OverviewField label="Created Date">
|
<OverviewField label="Created Date">
|
||||||
<MetaValue>{formatCreatedDate(ticket.timestamps.created_at)}</MetaValue>
|
<MetaValue>
|
||||||
|
{formatCreatedDate(ticket.timestamps.created_at)}
|
||||||
|
</MetaValue>
|
||||||
</OverviewField>
|
</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>
|
</div>
|
||||||
|
|
||||||
{ticket.ai_result?.completed_at ? (
|
{ticket.ai_result?.completed_at ? (
|
||||||
|
|||||||
@@ -11,16 +11,17 @@ import { NoTicketAction } from './actions/NoTicketAction';
|
|||||||
import { ProcessingTicketAction } from './actions/ProcessingTicketAction';
|
import { ProcessingTicketAction } from './actions/ProcessingTicketAction';
|
||||||
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
||||||
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
||||||
|
import { TicketDefectClassTabs } from './TicketDefectClassTabs';
|
||||||
|
|
||||||
interface TicketStatusActionsProps {
|
interface TicketStatusActionsProps {
|
||||||
ticket: TicketDetail;
|
ticket: TicketDetail;
|
||||||
liveState: TicketDetailLiveState;
|
liveState: TicketDetailLiveState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketStatusActions({
|
function getTicketAction(
|
||||||
ticket,
|
ticket: TicketDetail,
|
||||||
liveState,
|
liveState: TicketDetailLiveState,
|
||||||
}: TicketStatusActionsProps) {
|
) {
|
||||||
if (ticket.status === 'processing') {
|
if (ticket.status === 'processing') {
|
||||||
return (
|
return (
|
||||||
<ProcessingTicketAction
|
<ProcessingTicketAction
|
||||||
@@ -30,7 +31,7 @@ export function TicketStatusActions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticket.status === 'unassigned') {
|
if (ticket.assignment_status === 'unassigned') {
|
||||||
return (
|
return (
|
||||||
<PermissionGuard
|
<PermissionGuard
|
||||||
permissions={PERMISSIONS.TICKET.ASSIGN}
|
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||||
@@ -41,7 +42,7 @@ export function TicketStatusActions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticket.status === 'assigned') {
|
if (ticket.assignment_status === 'assigned') {
|
||||||
return (
|
return (
|
||||||
<PermissionGuard
|
<PermissionGuard
|
||||||
permissions={PERMISSIONS.TICKET.WORK}
|
permissions={PERMISSIONS.TICKET.WORK}
|
||||||
@@ -52,7 +53,7 @@ export function TicketStatusActions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticket.status === 'under_review') {
|
if (ticket.assignment_status === 'under_review') {
|
||||||
return (
|
return (
|
||||||
<PermissionGuard
|
<PermissionGuard
|
||||||
permissions={PERMISSIONS.TICKET.REVIEW}
|
permissions={PERMISSIONS.TICKET.REVIEW}
|
||||||
@@ -69,3 +70,15 @@ export function TicketStatusActions({
|
|||||||
|
|
||||||
return <NoTicketAction status={ticket.status} />;
|
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;
|
if (!selectedUserId) return;
|
||||||
assignMutation.mutate({
|
assignMutation.mutate({
|
||||||
assigned_to_user_id: Number(selectedUserId),
|
assigned_to_user_id: Number(selectedUserId),
|
||||||
|
defect_class: ticket.defect_class,
|
||||||
note: assignNote || undefined,
|
note: assignNote || undefined,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -131,9 +131,13 @@ function ImageSlot({
|
|||||||
|
|
||||||
interface RepairEvidenceFormProps {
|
interface RepairEvidenceFormProps {
|
||||||
ticketId: string;
|
ticketId: string;
|
||||||
|
defectClass: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RepairEvidenceForm({ ticketId }: RepairEvidenceFormProps) {
|
export function RepairEvidenceForm({
|
||||||
|
ticketId,
|
||||||
|
defectClass,
|
||||||
|
}: RepairEvidenceFormProps) {
|
||||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||||
@@ -189,6 +193,7 @@ export function RepairEvidenceForm({ ticketId }: RepairEvidenceFormProps) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await submitRepairUploadMutation.mutateAsync({
|
await submitRepairUploadMutation.mutateAsync({
|
||||||
|
defect_class: defectClass,
|
||||||
notes: notes.trim(),
|
notes: notes.trim(),
|
||||||
video: videoFile,
|
video: videoFile,
|
||||||
images: imageFiles,
|
images: imageFiles,
|
||||||
@@ -267,7 +272,10 @@ export function RepairEvidenceForm({ ticketId }: RepairEvidenceFormProps) {
|
|||||||
key={index}
|
key={index}
|
||||||
file={slot.file}
|
file={slot.file}
|
||||||
previewUrl={slot.previewUrl}
|
previewUrl={slot.previewUrl}
|
||||||
disabled={isSubmitting || (!slot.file && imageFiles.length >= MAX_IMAGES)}
|
disabled={
|
||||||
|
isSubmitting ||
|
||||||
|
(!slot.file && imageFiles.length >= MAX_IMAGES)
|
||||||
|
}
|
||||||
onAdd={() => imageInputRef.current?.click()}
|
onAdd={() => imageInputRef.current?.click()}
|
||||||
onRemove={() => removeImage(index)}
|
onRemove={() => removeImage(index)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function ReviewRepairAction({ ticket }: { ticket: TicketDetail }) {
|
|||||||
disabled={!reviewComment || reviewRepairMutation.isPending}
|
disabled={!reviewComment || reviewRepairMutation.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
reviewRepairMutation.mutate({
|
reviewRepairMutation.mutate({
|
||||||
|
defect_class: ticket.defect_class,
|
||||||
action: 'approve',
|
action: 'approve',
|
||||||
comment: reviewComment,
|
comment: reviewComment,
|
||||||
})
|
})
|
||||||
@@ -48,6 +49,7 @@ export function ReviewRepairAction({ ticket }: { ticket: TicketDetail }) {
|
|||||||
disabled={!reviewComment || reviewRepairMutation.isPending}
|
disabled={!reviewComment || reviewRepairMutation.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
reviewRepairMutation.mutate({
|
reviewRepairMutation.mutate({
|
||||||
|
defect_class: ticket.defect_class,
|
||||||
action: 'reject',
|
action: 'reject',
|
||||||
comment: reviewComment,
|
comment: reviewComment,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import { TicketActionCard } from './TicketActionCard';
|
|||||||
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
|
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
|
||||||
return (
|
return (
|
||||||
<TicketActionCard icon={Wrench} title="Submit Repair Evidence">
|
<TicketActionCard icon={Wrench} title="Submit Repair Evidence">
|
||||||
<RepairEvidenceForm ticketId={ticket.id} />
|
<RepairEvidenceForm
|
||||||
|
ticketId={ticket.id}
|
||||||
|
defectClass={ticket.defect_class}
|
||||||
|
/>
|
||||||
</TicketActionCard>
|
</TicketActionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { ArrowLeft } from 'lucide-react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -17,9 +17,11 @@ import { useTicketDetailQuery } from '../hooks/useTicketQueries';
|
|||||||
|
|
||||||
export default function TicketDetailPage() {
|
export default function TicketDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const { ticketId } = useParams() as { ticketId: string };
|
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 ticket = ticketQuery.data;
|
||||||
|
|
||||||
const liveState = useTicketDetailEvents(
|
const liveState = useTicketDetailEvents(
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ export function useTicketColumns(): ColumnDef<TicketListItem>[] {
|
|||||||
return useMemo(
|
return useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'id',
|
accessorKey: 'ticket_name',
|
||||||
header: 'Ticket',
|
header: 'Ticket',
|
||||||
size: 180,
|
size: 180,
|
||||||
cell: ({ row }) => (
|
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 {
|
): TicketListItem | null {
|
||||||
if (
|
if (
|
||||||
!event.id ||
|
!event.id ||
|
||||||
|
!event.ticket_name ||
|
||||||
!event.status ||
|
!event.status ||
|
||||||
|
!event.default_defect_class ||
|
||||||
event.detection_count === undefined ||
|
event.detection_count === undefined ||
|
||||||
!event.updated_at
|
!event.updated_at
|
||||||
) {
|
) {
|
||||||
@@ -95,9 +97,11 @@ function buildTicketListItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
|
ticket_name: event.ticket_name,
|
||||||
chainage_id: event.chainage_id ?? null,
|
chainage_id: event.chainage_id ?? null,
|
||||||
chainage_name: event.chainage_name ?? null,
|
chainage_name: event.chainage_name ?? null,
|
||||||
status: event.status,
|
status: event.status,
|
||||||
|
default_defect_class: event.default_defect_class,
|
||||||
assigned_to_name: event.assigned_to_name ?? null,
|
assigned_to_name: event.assigned_to_name ?? null,
|
||||||
detection_count: event.detection_count,
|
detection_count: event.detection_count,
|
||||||
created_by_name: event.created_by_name ?? null,
|
created_by_name: event.created_by_name ?? null,
|
||||||
@@ -112,9 +116,12 @@ function mergeTicketListItem(
|
|||||||
): TicketListItem {
|
): TicketListItem {
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
|
ticket_name: event.ticket_name ?? current.ticket_name,
|
||||||
chainage_id: event.chainage_id ?? current.chainage_id,
|
chainage_id: event.chainage_id ?? current.chainage_id,
|
||||||
chainage_name: event.chainage_name ?? current.chainage_name,
|
chainage_name: event.chainage_name ?? current.chainage_name,
|
||||||
status: event.status ?? current.status,
|
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,
|
assigned_to_name: event.assigned_to_name ?? current.assigned_to_name,
|
||||||
detection_count: event.detection_count ?? current.detection_count,
|
detection_count: event.detection_count ?? current.detection_count,
|
||||||
created_by_name: event.created_by_name ?? current.created_by_name,
|
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 { ticketService } from '@/services/api';
|
||||||
import type {
|
import type {
|
||||||
AssignTicketPayload,
|
AssignTicketPayload,
|
||||||
|
CloseTicketPayload,
|
||||||
ReviewRepairPayload,
|
ReviewRepairPayload,
|
||||||
SubmitRepairPayload,
|
SubmitRepairPayload,
|
||||||
SubmitRepairUploadPayload,
|
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({
|
return useQuery({
|
||||||
queryKey: ticketKeys.detail(ticketId ?? ''),
|
queryKey: ticketKeys.detail(ticketId ?? '', defectClass),
|
||||||
queryFn: () => ticketService.getTicketDetail(ticketId as string),
|
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),
|
enabled: Boolean(ticketId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -90,10 +103,10 @@ export function useAssignTicketMutation(ticketId: string) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (payload: AssignTicketPayload) =>
|
mutationFn: (payload: AssignTicketPayload) =>
|
||||||
ticketService.assignTicket(ticketId, payload),
|
ticketService.assignTicket(ticketId, payload),
|
||||||
onSuccess: (ticket) => {
|
onSuccess: (ticket, payload) => {
|
||||||
toast.success('Ticket assigned');
|
toast.success('Ticket assigned');
|
||||||
queryClient.setQueryData<TicketDetail>(
|
queryClient.setQueryData<TicketDetail>(
|
||||||
ticketKeys.detail(ticketId),
|
ticketKeys.detail(ticketId, payload.defect_class),
|
||||||
(current) => mergeTicketDetailResponse(current, ticket),
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
);
|
);
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
@@ -143,10 +156,10 @@ export function useSubmitRepairUploadMutation(ticketId: string) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (payload: SubmitRepairUploadPayload) =>
|
mutationFn: (payload: SubmitRepairUploadPayload) =>
|
||||||
ticketService.submitRepairUpload(ticketId, payload),
|
ticketService.submitRepairUpload(ticketId, payload),
|
||||||
onSuccess: (ticket) => {
|
onSuccess: (ticket, payload) => {
|
||||||
toast.success('Repair evidence uploaded');
|
toast.success('Repair evidence uploaded');
|
||||||
queryClient.setQueryData<TicketDetail>(
|
queryClient.setQueryData<TicketDetail>(
|
||||||
ticketKeys.detail(ticketId),
|
ticketKeys.detail(ticketId, payload.defect_class),
|
||||||
(current) => mergeTicketDetailResponse(current, ticket),
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
);
|
);
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
@@ -166,7 +179,7 @@ export function useReviewRepairMutation(ticketId: string) {
|
|||||||
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected',
|
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected',
|
||||||
);
|
);
|
||||||
queryClient.setQueryData<TicketDetail>(
|
queryClient.setQueryData<TicketDetail>(
|
||||||
ticketKeys.detail(ticketId),
|
ticketKeys.detail(ticketId, payload.defect_class),
|
||||||
(current) => mergeTicketDetailResponse(current, ticket),
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
);
|
);
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
@@ -179,11 +192,12 @@ export function useCloseTicketMutation(ticketId: string) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => ticketService.closeTicket(ticketId),
|
mutationFn: (payload: CloseTicketPayload) =>
|
||||||
onSuccess: (ticket) => {
|
ticketService.closeTicket(ticketId, payload),
|
||||||
|
onSuccess: (ticket, payload) => {
|
||||||
toast.success('Ticket closed');
|
toast.success('Ticket closed');
|
||||||
queryClient.setQueryData<TicketDetail>(
|
queryClient.setQueryData<TicketDetail>(
|
||||||
ticketKeys.detail(ticketId),
|
ticketKeys.detail(ticketId, payload.defect_class),
|
||||||
(current) => mergeTicketDetailResponse(current, ticket),
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
);
|
);
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ export default function TicketPage() {
|
|||||||
|
|
||||||
const handleView = useCallback(
|
const handleView = useCallback(
|
||||||
(ticket: TicketListItem) => {
|
(ticket: TicketListItem) => {
|
||||||
router.push(`/ticket/${ticket.id}`);
|
router.push(
|
||||||
|
`/ticket/${ticket.id}?defect_class=${encodeURIComponent(ticket.default_defect_class)}`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[router],
|
[router],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ export const ticketKeys = {
|
|||||||
lists: () => [...ticketKeys.all, 'list'] as const,
|
lists: () => [...ticketKeys.all, 'list'] as const,
|
||||||
list: (params: TicketListParams) => [...ticketKeys.lists(), params] as const,
|
list: (params: TicketListParams) => [...ticketKeys.lists(), params] as const,
|
||||||
details: () => [...ticketKeys.all, 'detail'] 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,
|
assignableUsers: () => [...ticketKeys.all, 'assignable-users'] as const,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||||
import { Slot } from 'radix-ui';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -31,17 +31,11 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BreadcrumbLink({
|
type BreadcrumbLinkProps = React.ComponentProps<typeof Link>;
|
||||||
asChild,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<'a'> & {
|
|
||||||
asChild?: boolean;
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot.Root : 'a';
|
|
||||||
|
|
||||||
|
function BreadcrumbLink({ className, ...props }: BreadcrumbLinkProps) {
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Link
|
||||||
data-slot="breadcrumb-link"
|
data-slot="breadcrumb-link"
|
||||||
className={cn('transition-colors hover:text-foreground', className)}
|
className={cn('transition-colors hover:text-foreground', className)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
91
src/components/ui/tabs.tsx
Normal file
91
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
data-orientation={orientation}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsListVariants = cva(
|
||||||
|
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-muted",
|
||||||
|
line: "gap-1 bg-transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||||
|
VariantProps<typeof tabsListVariants>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||||
|
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
|
||||||
|
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||||
@@ -54,12 +54,14 @@ export const API_ROUTES = {
|
|||||||
},
|
},
|
||||||
TICKETS: {
|
TICKETS: {
|
||||||
BASE: '/biz/api/v1/tickets',
|
BASE: '/biz/api/v1/tickets',
|
||||||
DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
|
DETAIL: (id: string, defectClass: string) =>
|
||||||
|
`/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`,
|
||||||
|
DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`,
|
||||||
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`,
|
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`,
|
||||||
START: (id: string) => `/biz/api/v1/tickets/${id}/start`,
|
START: (id: string) => `/biz/api/v1/tickets/${id}/start`,
|
||||||
SUBMIT_REPAIR: (id: string) => `/biz/api/v1/tickets/${id}/submit-repair`,
|
SUBMIT_REPAIR: (id: string) => `/biz/api/v1/tickets/${id}/submit-repair`,
|
||||||
SUBMIT_REPAIR_UPLOAD: (id: string) =>
|
SUBMIT_REPAIR_UPLOAD: (id: string) =>
|
||||||
`/biz/api/v1/tickets/${id}/submit-repair/upload`,
|
`/biz/api/v1/tickets/${id}/repair-proof`,
|
||||||
REVIEW: (id: string) => `/biz/api/v1/tickets/${id}/review`,
|
REVIEW: (id: string) => `/biz/api/v1/tickets/${id}/review`,
|
||||||
CLOSE: (id: string) => `/biz/api/v1/tickets/${id}/close`,
|
CLOSE: (id: string) => `/biz/api/v1/tickets/${id}/close`,
|
||||||
DETAIL_EVENTS_TOKEN: (id: string) =>
|
DETAIL_EVENTS_TOKEN: (id: string) =>
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import type {
|
|||||||
AssignTicketPayload,
|
AssignTicketPayload,
|
||||||
AssignableTicketUsersResponse,
|
AssignableTicketUsersResponse,
|
||||||
AssignableTicketUsersParams,
|
AssignableTicketUsersParams,
|
||||||
|
CloseTicketPayload,
|
||||||
ReviewRepairPayload,
|
ReviewRepairPayload,
|
||||||
SseTokenResponse,
|
SseTokenResponse,
|
||||||
SubmitRepairPayload,
|
SubmitRepairPayload,
|
||||||
SubmitRepairUploadPayload,
|
SubmitRepairUploadPayload,
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
|
TicketDefectClassesResponse,
|
||||||
TicketListParams,
|
TicketListParams,
|
||||||
TicketListResponse,
|
TicketListResponse,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
@@ -31,9 +33,21 @@ export const ticketService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getTicketDetail: async (ticketId: string): Promise<TicketDetail> => {
|
getTicketDetail: async (
|
||||||
|
ticketId: string,
|
||||||
|
defectClass: string,
|
||||||
|
): Promise<TicketDetail> => {
|
||||||
const response = await axiosClient.get<TicketDetail>(
|
const response = await axiosClient.get<TicketDetail>(
|
||||||
API_ROUTES.TICKETS.DETAIL(ticketId),
|
API_ROUTES.TICKETS.DETAIL(ticketId, defectClass),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTicketDefectClasses: async (
|
||||||
|
ticketId: string,
|
||||||
|
): Promise<TicketDefectClassesResponse> => {
|
||||||
|
const response = await axiosClient.get<TicketDefectClassesResponse>(
|
||||||
|
API_ROUTES.TICKETS.DEFECT_CLASSES(ticketId),
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -88,6 +102,7 @@ export const ticketService = {
|
|||||||
payload: SubmitRepairUploadPayload,
|
payload: SubmitRepairUploadPayload,
|
||||||
): Promise<TicketDetail> => {
|
): Promise<TicketDetail> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
formData.append('defect_class', payload.defect_class);
|
||||||
formData.append('notes', payload.notes);
|
formData.append('notes', payload.notes);
|
||||||
formData.append('video', payload.video);
|
formData.append('video', payload.video);
|
||||||
payload.images.forEach((image) => {
|
payload.images.forEach((image) => {
|
||||||
@@ -117,9 +132,13 @@ export const ticketService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
closeTicket: async (ticketId: string): Promise<TicketDetail> => {
|
closeTicket: async (
|
||||||
|
ticketId: string,
|
||||||
|
payload: CloseTicketPayload,
|
||||||
|
): Promise<TicketDetail> => {
|
||||||
const response = await axiosClient.post<TicketDetail>(
|
const response = await axiosClient.post<TicketDetail>(
|
||||||
API_ROUTES.TICKETS.CLOSE(ticketId),
|
API_ROUTES.TICKETS.CLOSE(ticketId),
|
||||||
|
payload,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface AssignableTicketUsersResponse {
|
|||||||
|
|
||||||
export interface AssignTicketPayload {
|
export interface AssignTicketPayload {
|
||||||
assigned_to_user_id: number;
|
assigned_to_user_id: number;
|
||||||
assigned_to_email?: string;
|
defect_class: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,12 +28,38 @@ export interface SubmitRepairPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmitRepairUploadPayload {
|
export interface SubmitRepairUploadPayload {
|
||||||
|
defect_class: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
video: File;
|
video: File;
|
||||||
images: File[];
|
images: File[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewRepairPayload {
|
export interface ReviewRepairPayload {
|
||||||
|
defect_class: string;
|
||||||
action: 'approve' | 'reject';
|
action: 'approve' | 'reject';
|
||||||
comment: string;
|
comment: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CloseTicketPayload {
|
||||||
|
defect_class: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TicketDefectClassTicketStatus =
|
||||||
|
| 'unassigned'
|
||||||
|
| 'assigned'
|
||||||
|
| 'under_review'
|
||||||
|
| 'approved'
|
||||||
|
| 'rejected';
|
||||||
|
|
||||||
|
export interface TicketDefectClassItem {
|
||||||
|
class_name: string;
|
||||||
|
display_name: string;
|
||||||
|
assignment_status: TicketDefectClassTicketStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketDefectClassesResponse {
|
||||||
|
ticket_id: string;
|
||||||
|
video_id: string;
|
||||||
|
ticket_status: TicketDefectClassTicketStatus;
|
||||||
|
defect_classes: TicketDefectClassItem[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { TicketDefectClassTicketStatus } from './actions';
|
||||||
import type { TicketStatus } from './status';
|
import type { TicketStatus } from './status';
|
||||||
|
|
||||||
export interface TicketActor {
|
export interface TicketActor {
|
||||||
@@ -52,6 +53,7 @@ export interface TicketReviewer extends TicketActor {
|
|||||||
export interface TicketAiResult {
|
export interface TicketAiResult {
|
||||||
detection_count: number;
|
detection_count: number;
|
||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
|
available_defect_classes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketTenant {
|
export interface TicketTenant {
|
||||||
@@ -69,6 +71,9 @@ export interface TicketDetail {
|
|||||||
video_id: string;
|
video_id: string;
|
||||||
chainage_id: string | null;
|
chainage_id: string | null;
|
||||||
status: TicketStatus;
|
status: TicketStatus;
|
||||||
|
defect_class: string;
|
||||||
|
defect_display_name: string;
|
||||||
|
assignment_status: TicketDefectClassTicketStatus;
|
||||||
status_metadata: TicketStatusMetadata | null;
|
status_metadata: TicketStatusMetadata | null;
|
||||||
video: TicketVideo | null;
|
video: TicketVideo | null;
|
||||||
uploader: TicketActor | null;
|
uploader: TicketActor | null;
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ export type TicketTableStatusEvent = {
|
|||||||
type?: 'ticket_status';
|
type?: 'ticket_status';
|
||||||
kind?: 'created' | 'transitioned';
|
kind?: 'created' | 'transitioned';
|
||||||
id: string;
|
id: string;
|
||||||
|
ticket_name?: string;
|
||||||
chainage_id?: string | null;
|
chainage_id?: string | null;
|
||||||
chainage_name?: string | null;
|
chainage_name?: string | null;
|
||||||
status?: TicketStatus;
|
status?: TicketStatus;
|
||||||
|
default_defect_class?: string;
|
||||||
assigned_to_name?: string | null;
|
assigned_to_name?: string | null;
|
||||||
detection_count?: number;
|
detection_count?: number;
|
||||||
created_by_name?: string | null;
|
created_by_name?: string | null;
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ export interface TicketListParams extends PaginationParams {
|
|||||||
|
|
||||||
export interface TicketListItem {
|
export interface TicketListItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
ticket_name: string;
|
||||||
chainage_id: string | null;
|
chainage_id: string | null;
|
||||||
chainage_name: string | null;
|
chainage_name: string | null;
|
||||||
status: TicketStatus;
|
status: TicketStatus;
|
||||||
|
default_defect_class: string;
|
||||||
assigned_to_name: string | null;
|
assigned_to_name: string | null;
|
||||||
detection_count: number;
|
detection_count: number;
|
||||||
created_by_name: string | null;
|
created_by_name: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user