feat: assign base
This commit is contained in:
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
10
opencode.json
Normal file
10
opencode.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"dual-graph": {
|
||||
"type": "remote",
|
||||
"url": "http://127.0.0.1:8080/mcp",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import type { TicketActor, TicketDetail } from '@/types';
|
||||
import type { 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(
|
||||
@@ -14,20 +10,6 @@ function formatCreatedDate(value: string | null | undefined) {
|
||||
);
|
||||
}
|
||||
|
||||
function getInitials(person: TicketActor | null | undefined) {
|
||||
const source = person?.name || person?.email || '?';
|
||||
return source
|
||||
.split(/[\s@._-]+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part.charAt(0).toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
function getPersonLabel(person: TicketActor | null | undefined) {
|
||||
return person?.name || person?.email || '-';
|
||||
}
|
||||
|
||||
function MetaLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground">
|
||||
@@ -40,29 +22,6 @@ function MetaValue({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-sm font-semibold text-foreground">{children}</p>;
|
||||
}
|
||||
|
||||
function PersonMetaValue({
|
||||
person,
|
||||
accentClassName = 'bg-primary/15 text-primary',
|
||||
}: {
|
||||
person: TicketActor | null | undefined;
|
||||
accentClassName?: string;
|
||||
}) {
|
||||
if (!person) {
|
||||
return <MetaValue>-</MetaValue>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold ${accentClassName}`}
|
||||
>
|
||||
{getInitials(person)}
|
||||
</span>
|
||||
<p className="truncate text-sm font-medium">{getPersonLabel(person)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewField({
|
||||
label,
|
||||
children,
|
||||
@@ -80,6 +39,22 @@ function OverviewField({
|
||||
|
||||
export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
|
||||
const detectionCount = ticket.ai_result?.detection_count;
|
||||
const assignments = ticket.assignments ?? [];
|
||||
const primaryAssignment = assignments[0] ?? null;
|
||||
const assignedNames = Array.from(
|
||||
new Set(
|
||||
assignments
|
||||
.map((assignment) => assignment.assigned_to_name)
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
const reviewerNames = Array.from(
|
||||
new Set(
|
||||
assignments
|
||||
.map((assignment) => assignment.reviewed_by_name)
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-xl border bg-card">
|
||||
@@ -101,7 +76,9 @@ export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
|
||||
</OverviewField>
|
||||
|
||||
<OverviewField label="Uploaded By">
|
||||
<PersonMetaValue person={ticket.uploader} />
|
||||
<MetaValue>
|
||||
{ticket.uploader?.name || ticket.uploader?.email || '-'}
|
||||
</MetaValue>
|
||||
</OverviewField>
|
||||
|
||||
<OverviewField label="Created Date">
|
||||
@@ -109,21 +86,27 @@ export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
|
||||
</OverviewField>
|
||||
|
||||
<OverviewField label="Assigned To">
|
||||
<PersonMetaValue
|
||||
person={ticket.worker}
|
||||
accentClassName="bg-secondary text-secondary-foreground"
|
||||
/>
|
||||
<MetaValue>
|
||||
{assignedNames.length ? assignedNames.join(', ') : '-'}
|
||||
</MetaValue>
|
||||
</OverviewField>
|
||||
|
||||
<OverviewField label="Reviewer">
|
||||
<PersonMetaValue
|
||||
person={ticket.reviewer}
|
||||
accentClassName="bg-primary/20 text-primary"
|
||||
/>
|
||||
<MetaValue>
|
||||
{reviewerNames.length ? reviewerNames.join(', ') : '-'}
|
||||
</MetaValue>
|
||||
</OverviewField>
|
||||
|
||||
<OverviewField label="Assigned At">
|
||||
<MetaValue>{formatDate(ticket.worker?.assigned_at)}</MetaValue>
|
||||
<MetaValue>{formatDate(primaryAssignment?.assigned_at)}</MetaValue>
|
||||
</OverviewField>
|
||||
|
||||
<OverviewField label="Coverage">
|
||||
<MetaValue>
|
||||
{ticket.coverage
|
||||
? `${ticket.coverage.assigned_count}/${ticket.coverage.total_count} Classes`
|
||||
: '-'}
|
||||
</MetaValue>
|
||||
</OverviewField>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -9,12 +9,23 @@ import { RepairReportPanel } from './repair-report/RepairReportPanel';
|
||||
import { RepairVideoComparison } from './repair-report/RepairVideoComparison';
|
||||
|
||||
export function TicketRepairReportCard({ ticket }: { ticket: TicketDetail }) {
|
||||
const repair = ticket.repair;
|
||||
const repairedAssignments = (ticket.assignments ?? []).filter(
|
||||
(assignment) =>
|
||||
assignment.repair_submitted_at ||
|
||||
assignment.repair_notes ||
|
||||
assignment.repair_proof_video_url ||
|
||||
assignment.repair_proof_image_urls.length,
|
||||
);
|
||||
|
||||
if (!repair) {
|
||||
if (!repairedAssignments.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageUrls = repairedAssignments.flatMap(
|
||||
(assignment) => assignment.repair_proof_image_urls,
|
||||
);
|
||||
const submittedAt = repairedAssignments[0]?.repair_submitted_at ?? null;
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<RepairReportPanel ticket={ticket} />
|
||||
@@ -28,8 +39,8 @@ export function TicketRepairReportCard({ ticket }: { ticket: TicketDetail }) {
|
||||
</h3>
|
||||
|
||||
<RepairProofImagesGrid
|
||||
imageUrls={repair?.proof_image_urls ?? []}
|
||||
submittedAt={repair?.submitted_at ?? null}
|
||||
imageUrls={imageUrls}
|
||||
submittedAt={submittedAt}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -9,8 +9,6 @@ import { AssignTicketAction } from './actions/AssignTicketAction';
|
||||
import { ClosedTicketAction } from './actions/ClosedTicketAction';
|
||||
import { NoTicketAction } from './actions/NoTicketAction';
|
||||
import { ProcessingTicketAction } from './actions/ProcessingTicketAction';
|
||||
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
||||
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
||||
|
||||
interface TicketStatusActionsProps {
|
||||
ticket: TicketDetail;
|
||||
@@ -30,10 +28,18 @@ export function TicketStatusActions({
|
||||
);
|
||||
}
|
||||
|
||||
if (ticket.status === 'unassigned') {
|
||||
if (
|
||||
ticket.status === 'unassigned' ||
|
||||
ticket.status === 'assigned' ||
|
||||
ticket.status === 'under_review'
|
||||
) {
|
||||
return (
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||
permissions={[
|
||||
PERMISSIONS.TICKET.ASSIGN,
|
||||
PERMISSIONS.TICKET.WORK,
|
||||
PERMISSIONS.TICKET.REVIEW,
|
||||
]}
|
||||
fallback={<NoTicketAction status={ticket.status} />}
|
||||
>
|
||||
<AssignTicketAction ticket={ticket} />
|
||||
@@ -41,28 +47,6 @@ export function TicketStatusActions({
|
||||
);
|
||||
}
|
||||
|
||||
if (ticket.status === 'assigned') {
|
||||
return (
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.WORK}
|
||||
fallback={<NoTicketAction status={ticket.status} />}
|
||||
>
|
||||
<SubmitRepairAction ticket={ticket} />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
if (ticket.status === 'under_review') {
|
||||
return (
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.REVIEW}
|
||||
fallback={<NoTicketAction status={ticket.status} />}
|
||||
>
|
||||
<ReviewRepairAction ticket={ticket} />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
if (ticket.status === 'closed') {
|
||||
return <ClosedTicketAction ticket={ticket} />;
|
||||
}
|
||||
|
||||
@@ -1,57 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Send, UserPlus } from 'lucide-react';
|
||||
import { UserPlus } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
|
||||
import { TicketAssignmentSheet } from './TicketAssignmentSheet';
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
const [assignNote, setAssignNote] = useState('');
|
||||
|
||||
const assignMutation = useAssignTicketMutation(ticket.id);
|
||||
const [open, setOpen] = useState(false);
|
||||
const coverage = ticket.coverage;
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={UserPlus} title="Assign Ticket">
|
||||
<TicketActionCard icon={UserPlus} title="Assignments">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Contractor</Label>
|
||||
<AssignableWorkerSelect
|
||||
value={selectedUserId}
|
||||
onValueChange={setSelectedUserId}
|
||||
disabled={assignMutation.isPending}
|
||||
/>
|
||||
{coverage ? (
|
||||
<div className="rounded-lg border bg-muted/15 px-3 py-2">
|
||||
<p className="text-sm font-medium">
|
||||
{coverage.assigned_count}/{coverage.total_count} classes assigned
|
||||
</p>
|
||||
{coverage.unassigned_defect_classes.length ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Still needs a contractor:{' '}
|
||||
{coverage.unassigned_defect_classes.join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Note</Label>
|
||||
<Textarea
|
||||
value={assignNote}
|
||||
onChange={(event) => setAssignNote(event.target.value)}
|
||||
placeholder="Please inspect and repair"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!selectedUserId || assignMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!selectedUserId) return;
|
||||
assignMutation.mutate({
|
||||
assigned_to_user_id: Number(selectedUserId),
|
||||
note: assignNote || undefined,
|
||||
});
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Send />
|
||||
Assign
|
||||
) : null}
|
||||
<Button className="w-full" onClick={() => setOpen(true)}>
|
||||
<UserPlus />
|
||||
Manage Assignments
|
||||
</Button>
|
||||
</div>
|
||||
<TicketAssignmentSheet
|
||||
ticket={ticket}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TicketDetail } from '@/types';
|
||||
import type { TicketAssignmentSummary, TicketDetail } from '@/types';
|
||||
|
||||
function getInitials(name?: string | null, email?: string | null) {
|
||||
const source = name || email || '?';
|
||||
@@ -25,8 +25,8 @@ function getInitials(name?: string | null, email?: string | null) {
|
||||
.join('');
|
||||
}
|
||||
|
||||
function getPersonLabel(person: TicketDetail['reviewer']) {
|
||||
return person?.name || person?.email || 'Unknown';
|
||||
function getPersonLabel(assignment: TicketAssignmentSummary | null) {
|
||||
return assignment?.reviewed_by_name || 'Unknown';
|
||||
}
|
||||
|
||||
function formatReviewDate(value: string | null | undefined) {
|
||||
@@ -42,7 +42,8 @@ function getClosedAt(ticket: TicketDetail) {
|
||||
);
|
||||
return (
|
||||
closedEntry?.created_at ??
|
||||
ticket.reviewer?.reviewed_at ??
|
||||
(ticket.assignments ?? []).find((assignment) => assignment.reviewed_at)
|
||||
?.reviewed_at ??
|
||||
ticket.timestamps.updated_at
|
||||
);
|
||||
}
|
||||
@@ -94,8 +95,13 @@ function FinalCommentsPanel({
|
||||
}
|
||||
|
||||
export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const reviewer = ticket.reviewer;
|
||||
const reviewer = (ticket.assignments ?? []).find(
|
||||
(assignment) => assignment.reviewed_by_name || assignment.reviewed_at,
|
||||
) ?? null;
|
||||
const reviewedAt = reviewer?.reviewed_at ?? getClosedAt(ticket);
|
||||
const reviewComments = (ticket.assignments ?? [])
|
||||
.map((assignment) => assignment.review_comment)
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden border bg-card/80">
|
||||
@@ -112,16 +118,11 @@ export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar className="size-10 border border-primary/30">
|
||||
<AvatarFallback className="bg-primary/15 text-sm text-primary">
|
||||
{getInitials(reviewer?.name, reviewer?.email)}
|
||||
{getInitials(reviewer?.reviewed_by_name, null)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate">{getPersonLabel(reviewer)}</p>
|
||||
{reviewer?.email ? (
|
||||
<p className="mt-0.5 truncate text-xs font-medium text-muted-foreground">
|
||||
{reviewer.email}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -137,7 +138,7 @@ export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground">
|
||||
Review Comment
|
||||
</p>
|
||||
<FinalCommentsPanel comment={reviewer?.review_comment} />
|
||||
<FinalCommentsPanel comment={reviewComments.join('\n\n')} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -8,9 +8,9 @@ import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useSubmitRepairUploadMutation } from '../../../hooks/useTicketQueries';
|
||||
import { useSubmitAssignmentRepairUploadMutation } from '../../../hooks/useTicketQueries';
|
||||
|
||||
const MAX_IMAGES = 5;
|
||||
const MAX_IMAGES = 20;
|
||||
|
||||
function RequiredMark() {
|
||||
return <span className="text-destructive">*</span>;
|
||||
@@ -131,16 +131,23 @@ function ImageSlot({
|
||||
|
||||
interface RepairEvidenceFormProps {
|
||||
ticketId: string;
|
||||
assignmentId: number;
|
||||
}
|
||||
|
||||
export function RepairEvidenceForm({ ticketId }: RepairEvidenceFormProps) {
|
||||
export function RepairEvidenceForm({
|
||||
ticketId,
|
||||
assignmentId,
|
||||
}: RepairEvidenceFormProps) {
|
||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submitRepairUploadMutation = useSubmitRepairUploadMutation(ticketId);
|
||||
const submitRepairUploadMutation = useSubmitAssignmentRepairUploadMutation(
|
||||
ticketId,
|
||||
assignmentId,
|
||||
);
|
||||
const isSubmitting = submitRepairUploadMutation.isPending;
|
||||
const canSubmit = Boolean(notes.trim() && videoFile);
|
||||
|
||||
@@ -261,7 +268,7 @@ export function RepairEvidenceForm({ ticketId }: RepairEvidenceFormProps) {
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
<div className="grid grid-cols-5 gap-2 md:grid-cols-10">
|
||||
{imageSlots.map((slot, index) => (
|
||||
<ImageSlot
|
||||
key={index}
|
||||
|
||||
@@ -1,63 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, ClipboardCheck, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { useReviewRepairMutation } from '../../../hooks/useTicketQueries';
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
import { AssignTicketAction } from './AssignTicketAction';
|
||||
|
||||
export function ReviewRepairAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const [reviewComment, setReviewComment] = useState('');
|
||||
const reviewRepairMutation = useReviewRepairMutation(ticket.id);
|
||||
|
||||
useEffect(() => {
|
||||
setReviewComment(ticket.reviewer?.review_comment ?? '');
|
||||
}, [ticket]);
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={ClipboardCheck} title="Review Repair">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Comment</Label>
|
||||
<Textarea
|
||||
value={reviewComment}
|
||||
onChange={(event) => setReviewComment(event.target.value)}
|
||||
placeholder="Repair verified"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
disabled={!reviewComment || reviewRepairMutation.isPending}
|
||||
onClick={() =>
|
||||
reviewRepairMutation.mutate({
|
||||
action: 'approve',
|
||||
comment: reviewComment,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Check />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!reviewComment || reviewRepairMutation.isPending}
|
||||
onClick={() =>
|
||||
reviewRepairMutation.mutate({
|
||||
action: 'reject',
|
||||
comment: reviewComment,
|
||||
})
|
||||
}
|
||||
>
|
||||
<X />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TicketActionCard>
|
||||
);
|
||||
return <AssignTicketAction ticket={ticket} />;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Wrench } from 'lucide-react';
|
||||
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { RepairEvidenceForm } from './RepairEvidenceForm';
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
import { AssignTicketAction } from './AssignTicketAction';
|
||||
|
||||
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
|
||||
return (
|
||||
<TicketActionCard icon={Wrench} title="Submit Repair Evidence">
|
||||
<RepairEvidenceForm ticketId={ticket.id} />
|
||||
</TicketActionCard>
|
||||
);
|
||||
return <AssignTicketAction ticket={ticket} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, ClipboardCheck, Loader2, Send, Wrench, X } from 'lucide-react';
|
||||
|
||||
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import type {
|
||||
AssignmentStatus,
|
||||
TicketAssignmentSummary,
|
||||
TicketDefectClass,
|
||||
TicketDetail,
|
||||
} from '@/types';
|
||||
|
||||
import {
|
||||
useReplaceTicketAssignmentsMutation,
|
||||
useReviewAssignmentRepairMutation,
|
||||
useTicketAssignmentsQuery,
|
||||
useTicketDefectClassesQuery,
|
||||
} from '../../../hooks/useTicketQueries';
|
||||
import { RepairEvidenceForm } from './RepairEvidenceForm';
|
||||
|
||||
interface TicketAssignmentSheetProps {
|
||||
ticket: TicketDetail;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface AssignmentRowState {
|
||||
defectClass: string;
|
||||
displayName: string;
|
||||
selectedUserId: string;
|
||||
note: string;
|
||||
assignment: TicketAssignmentSummary | null;
|
||||
}
|
||||
|
||||
const statusStyles: Record<AssignmentStatus, string> = {
|
||||
assigned: 'border-blue-200 bg-blue-50 text-blue-700',
|
||||
under_review: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
approved: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
rejected: 'border-red-200 bg-red-50 text-red-700',
|
||||
};
|
||||
|
||||
const EMPTY_ASSIGNMENTS: TicketAssignmentSummary[] = [];
|
||||
|
||||
function titleCase(value: string) {
|
||||
return value
|
||||
.split('_')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function formatStatus(status: AssignmentStatus | null | undefined) {
|
||||
return status ? titleCase(status) : 'Unassigned';
|
||||
}
|
||||
|
||||
function getFallbackClasses(ticket: TicketDetail): TicketDefectClass[] {
|
||||
const assignments = ticket.assignments ?? [];
|
||||
const names =
|
||||
ticket.coverage?.total_defect_classes ??
|
||||
ticket.ai_result?.available_defect_classes ??
|
||||
assignments.map((assignment) => assignment.defect_class);
|
||||
|
||||
return names.map((className) => {
|
||||
const existing = assignments.find(
|
||||
(assignment) => assignment.defect_class === className,
|
||||
);
|
||||
|
||||
return {
|
||||
class_name: className,
|
||||
display_name: existing?.defect_display_name ?? titleCase(className),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildRows(
|
||||
classes: TicketDefectClass[],
|
||||
assignments: TicketAssignmentSummary[],
|
||||
): AssignmentRowState[] {
|
||||
const classMap = new Map<string, TicketDefectClass>();
|
||||
classes.forEach((item) => classMap.set(item.class_name, item));
|
||||
assignments.forEach((assignment) => {
|
||||
if (!classMap.has(assignment.defect_class)) {
|
||||
classMap.set(assignment.defect_class, {
|
||||
class_name: assignment.defect_class,
|
||||
display_name: assignment.defect_display_name,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(classMap.values()).map((item) => {
|
||||
const assignment = assignments.find(
|
||||
(current) => current.defect_class === item.class_name,
|
||||
);
|
||||
|
||||
return {
|
||||
defectClass: item.class_name,
|
||||
displayName: item.display_name,
|
||||
selectedUserId: assignment?.assigned_to_user_id
|
||||
? String(assignment.assigned_to_user_id)
|
||||
: '',
|
||||
note: assignment?.note ?? '',
|
||||
assignment: assignment ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function AssignmentStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: AssignmentStatus | null | undefined;
|
||||
}) {
|
||||
if (!status) return <Badge variant="outline">Unassigned</Badge>;
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={statusStyles[status]}>
|
||||
{formatStatus(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentReviewControls({
|
||||
ticketId,
|
||||
assignment,
|
||||
}: {
|
||||
ticketId: string;
|
||||
assignment: TicketAssignmentSummary;
|
||||
}) {
|
||||
const [comment, setComment] = useState(assignment.review_comment ?? '');
|
||||
const reviewMutation = useReviewAssignmentRepairMutation(
|
||||
ticketId,
|
||||
assignment.id,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setComment(assignment.review_comment ?? '');
|
||||
}, [assignment.review_comment]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border bg-muted/15 p-3">
|
||||
<Label className="text-xs">Review Comment</Label>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="Repair verified"
|
||||
className="min-h-20 resize-none"
|
||||
disabled={reviewMutation.isPending}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!comment.trim() || reviewMutation.isPending}
|
||||
onClick={() =>
|
||||
reviewMutation.mutate({
|
||||
action: 'approve',
|
||||
comment: comment.trim(),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Check className="size-4" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={!comment.trim() || reviewMutation.isPending}
|
||||
onClick={() =>
|
||||
reviewMutation.mutate({
|
||||
action: 'reject',
|
||||
comment: comment.trim(),
|
||||
})
|
||||
}
|
||||
>
|
||||
<X className="size-4" />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentProofSummary({
|
||||
assignment,
|
||||
}: {
|
||||
assignment: TicketAssignmentSummary;
|
||||
}) {
|
||||
if (!assignment.repair_submitted_at && !assignment.review_comment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 text-xs text-muted-foreground md:grid-cols-2">
|
||||
{assignment.repair_submitted_at ? (
|
||||
<div className="rounded-md border bg-muted/10 px-3 py-2">
|
||||
<p className="font-medium text-foreground">Repair submitted</p>
|
||||
<p>{assignment.repair_submitted_at}</p>
|
||||
<p>
|
||||
{assignment.repair_proof_video_url ? '1 video' : 'No video'} /{' '}
|
||||
{assignment.repair_proof_image_urls.length} images
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{assignment.review_comment ? (
|
||||
<div className="rounded-md border bg-muted/10 px-3 py-2">
|
||||
<p className="font-medium text-foreground">Review comment</p>
|
||||
<p className="line-clamp-2">{assignment.review_comment}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketAssignmentSheet({
|
||||
ticket,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TicketAssignmentSheetProps) {
|
||||
const sheetContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const fallbackClasses = useMemo(() => getFallbackClasses(ticket), [ticket]);
|
||||
const defectClassesQuery = useTicketDefectClassesQuery(ticket.id, open);
|
||||
const assignmentsQuery = useTicketAssignmentsQuery(ticket.id, open);
|
||||
const replaceAssignmentsMutation = useReplaceTicketAssignmentsMutation(
|
||||
ticket.id,
|
||||
);
|
||||
const [rows, setRows] = useState<AssignmentRowState[]>([]);
|
||||
|
||||
const defectClasses = defectClassesQuery.data?.defect_classes.length
|
||||
? defectClassesQuery.data.defect_classes
|
||||
: fallbackClasses;
|
||||
const assignments =
|
||||
assignmentsQuery.data?.items ?? ticket.assignments ?? EMPTY_ASSIGNMENTS;
|
||||
const coverage = ticket.coverage;
|
||||
const isLoading = defectClassesQuery.isLoading || assignmentsQuery.isLoading;
|
||||
const assignedRows = rows.filter((row) => row.selectedUserId);
|
||||
const canSave =
|
||||
assignedRows.length > 0 && !replaceAssignmentsMutation.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setRows(buildRows(defectClasses, assignments));
|
||||
}, [assignments, defectClasses, open]);
|
||||
|
||||
const updateRow = (
|
||||
defectClass: string,
|
||||
patch: Partial<Pick<AssignmentRowState, 'selectedUserId' | 'note'>>,
|
||||
) => {
|
||||
setRows((current) =>
|
||||
current.map((row) =>
|
||||
row.defectClass === defectClass ? { ...row, ...patch } : row,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
await replaceAssignmentsMutation.mutateAsync({
|
||||
assignments: assignedRows.map((row) => ({
|
||||
assigned_to_user_id: Number(row.selectedUserId),
|
||||
defect_class: row.defectClass,
|
||||
note: row.note.trim() || undefined,
|
||||
})),
|
||||
note: 'Updated ticket assignments',
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
ref={sheetContentRef}
|
||||
side="right"
|
||||
className="flex h-full flex-col gap-0 p-0 sm:max-w-3xl lg:max-w-4xl"
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
Manage Assignments
|
||||
</SheetTitle>
|
||||
{coverage ? (
|
||||
<Badge
|
||||
variant={coverage.is_fully_assigned ? 'default' : 'outline'}
|
||||
>
|
||||
{coverage.assigned_count}/{coverage.total_count} assigned
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<SheetDescription className="text-sm">
|
||||
Assign workers, submit repair evidence, and review each defect
|
||||
class.
|
||||
</SheetDescription>
|
||||
{coverage?.unassigned_defect_classes.length ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Still needs a worker:{' '}
|
||||
{coverage.unassigned_defect_classes.join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
</SheetHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
{isLoading && rows.length === 0 ? (
|
||||
<div className="rounded-md border p-6 text-sm text-muted-foreground">
|
||||
Loading assignments...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => {
|
||||
const assignment = row.assignment;
|
||||
const canSubmitRepair =
|
||||
assignment?.status === 'assigned' ||
|
||||
assignment?.status === 'rejected';
|
||||
const canReview = assignment?.status === 'under_review';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.defectClass}
|
||||
className="space-y-4 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(140px,1fr)_minmax(220px,1.2fr)_120px]">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Defect Class
|
||||
</Label>
|
||||
<p className="text-sm font-semibold">
|
||||
{row.displayName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Worker
|
||||
</Label>
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||
fallback={
|
||||
<p className="rounded-md border px-3 py-2 text-sm">
|
||||
{assignment?.assigned_to_name ?? 'Unassigned'}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<AssignableWorkerSelect
|
||||
value={row.selectedUserId}
|
||||
onValueChange={(selectedUserId) =>
|
||||
updateRow(row.defectClass, { selectedUserId })
|
||||
}
|
||||
disabled={replaceAssignmentsMutation.isPending}
|
||||
portalContainer={sheetContentRef}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Status
|
||||
</Label>
|
||||
<div className="flex h-9 items-center">
|
||||
<AssignmentStatusBadge status={assignment?.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Note
|
||||
</Label>
|
||||
<Textarea
|
||||
value={row.note}
|
||||
onChange={(event) =>
|
||||
updateRow(row.defectClass, {
|
||||
note: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Optional assignment note"
|
||||
className="min-h-20 resize-none"
|
||||
disabled={replaceAssignmentsMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</PermissionGuard>
|
||||
|
||||
{assignment ? (
|
||||
<AssignmentProofSummary assignment={assignment} />
|
||||
) : null}
|
||||
|
||||
{assignment && canSubmitRepair ? (
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.WORK}>
|
||||
<div className="space-y-3 rounded-md border bg-muted/10 p-3">
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
<Wrench className="size-4 text-primary" />
|
||||
Submit repair for {row.displayName}
|
||||
</p>
|
||||
<RepairEvidenceForm
|
||||
ticketId={ticket.id}
|
||||
assignmentId={assignment.id}
|
||||
/>
|
||||
</div>
|
||||
</PermissionGuard>
|
||||
) : null}
|
||||
|
||||
{assignment && canReview ? (
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.REVIEW}>
|
||||
<div className="space-y-3">
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
<ClipboardCheck className="size-4 text-primary" />
|
||||
Review repair for {row.displayName}
|
||||
</p>
|
||||
<AssignmentReviewControls
|
||||
ticketId={ticket.id}
|
||||
assignment={assignment}
|
||||
/>
|
||||
</div>
|
||||
</PermissionGuard>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!rows.length ? (
|
||||
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
No defect classes are available for this ticket.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||
<Button type="button" disabled={!canSave} onClick={handleSave}>
|
||||
{replaceAssignmentsMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
Save Assignments
|
||||
</Button>
|
||||
</PermissionGuard>
|
||||
<SheetClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={replaceAssignmentsMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { FileText, MessageSquareQuote, BadgeCheck } from 'lucide-react';
|
||||
import { FileText, MessageSquareQuote } from 'lucide-react';
|
||||
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import {
|
||||
EmptyPanel,
|
||||
formatRepairDate,
|
||||
getPersonLabel,
|
||||
} from './repairReportUtils';
|
||||
|
||||
function MetaLabel({ children }: { children: React.ReactNode }) {
|
||||
@@ -34,9 +33,12 @@ function ReportField({
|
||||
}
|
||||
|
||||
export function RepairReportPanel({ ticket }: { ticket: TicketDetail }) {
|
||||
const repair = ticket.repair;
|
||||
const repairs = (ticket.assignments ?? []).filter(
|
||||
(assignment) => assignment.repair_submitted_at || assignment.repair_notes,
|
||||
);
|
||||
const firstRepair = repairs[0] ?? null;
|
||||
|
||||
if (!repair) {
|
||||
if (!firstRepair) {
|
||||
return (
|
||||
<section className="space-y-4 rounded-xl border bg-card p-4 lg:p-6">
|
||||
<div className="flex items-center gap-2 border-b border-border pb-4">
|
||||
@@ -60,37 +62,46 @@ export function RepairReportPanel({ ticket }: { ticket: TicketDetail }) {
|
||||
<FileText className="size-5 text-primary" />
|
||||
Repair Report
|
||||
</h3>
|
||||
{repair.submitted_at ? (
|
||||
{firstRepair.repair_submitted_at ? (
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
Completed {formatRepairDate(repair.submitted_at)}
|
||||
Completed {formatRepairDate(firstRepair.repair_submitted_at)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<ReportField label="Technician" value={getPersonLabel(ticket.worker)} />
|
||||
<ReportField
|
||||
label="Submitted Classes"
|
||||
value={`${repairs.length} of ${(ticket.assignments ?? []).length}`}
|
||||
/>
|
||||
<ReportField
|
||||
label="Latest Contractor"
|
||||
value={firstRepair.assigned_to_name ?? firstRepair.assigned_to_email}
|
||||
/>
|
||||
<ReportField
|
||||
label="Submitted At"
|
||||
value={
|
||||
repair.submitted_at ? formatRepairDate(repair.submitted_at) : null
|
||||
}
|
||||
value={formatRepairDate(firstRepair.repair_submitted_at)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-1">
|
||||
<MetaLabel>Repair Summary</MetaLabel>
|
||||
{repair.notes?.trim() ? (
|
||||
<>
|
||||
<div className="rounded-lg border bg-muted/15 px-4 py-3">
|
||||
<p className="text-sm leading-relaxed text-foreground/90">
|
||||
{repair.notes}
|
||||
{repairs.some((repair) => repair.repair_notes?.trim()) ? (
|
||||
<div className="space-y-2">
|
||||
{repairs.map((repair) => (
|
||||
<div
|
||||
key={repair.id}
|
||||
className="rounded-lg border bg-muted/15 px-4 py-3"
|
||||
>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{repair.defect_display_name}
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-relaxed text-foreground/90">
|
||||
{repair.repair_notes || '-'}
|
||||
</p>
|
||||
</div>
|
||||
{/* <div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<BadgeCheck className="size-3.5 text-primary" />
|
||||
<span>Self-certified by {getPersonLabel(ticket.worker)}</span>
|
||||
</div> */}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyPanel
|
||||
icon={MessageSquareQuote}
|
||||
|
||||
@@ -7,7 +7,10 @@ import type { TicketDetail } from '@/types';
|
||||
|
||||
export function RepairVideoComparison({ ticket }: { ticket: TicketDetail }) {
|
||||
const originalVideoUrl = ticket.video?.url ?? null;
|
||||
const repairVideoUrl = ticket.repair?.proof_video_url ?? null;
|
||||
const repairedAssignment = (ticket.assignments ?? []).find(
|
||||
(assignment) => assignment.repair_proof_video_url,
|
||||
);
|
||||
const repairVideoUrl = repairedAssignment?.repair_proof_video_url ?? null;
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
@@ -31,12 +34,12 @@ export function RepairVideoComparison({ ticket }: { ticket: TicketDetail }) {
|
||||
videoUrl={repairVideoUrl}
|
||||
meta="Contractor evidence video"
|
||||
emptyTitle={
|
||||
ticket.repair
|
||||
repairedAssignment
|
||||
? 'No repair video uploaded'
|
||||
: 'No evidence submitted yet'
|
||||
}
|
||||
emptyDescription={
|
||||
ticket.repair
|
||||
repairedAssignment
|
||||
? 'The assigned worker has not uploaded a repair video yet.'
|
||||
: 'Repair video will appear here once evidence is submitted.'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,17 @@ import { TicketStatusBadge } from './TicketStatusBadge';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
export function useTicketColumns(): ColumnDef<TicketListItem>[] {
|
||||
const getAssignedNames = (ticket: TicketListItem) => {
|
||||
const names = Array.from(
|
||||
new Set(
|
||||
(ticket.assignments ?? [])
|
||||
.map((assignment) => assignment.assigned_to_name)
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
return names.length ? names.join(', ') : 'N/A';
|
||||
};
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -37,10 +48,19 @@ export function useTicketColumns(): ColumnDef<TicketListItem>[] {
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
accessorKey: 'assigned_to_name',
|
||||
header: 'Assigned To',
|
||||
accessorKey: 'coverage',
|
||||
header: 'Coverage',
|
||||
size: 120,
|
||||
cell: ({ row }) =>
|
||||
row.original.coverage
|
||||
? `${row.original.coverage.assigned_count}/${row.original.coverage.total_count}`
|
||||
: 'N/A',
|
||||
},
|
||||
{
|
||||
accessorKey: 'assignments',
|
||||
header: 'Contractors',
|
||||
size: 220,
|
||||
cell: ({ row }) => row.original.assigned_to_name || 'N/A',
|
||||
cell: ({ row }) => getAssignedNames(row.original),
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_by_name',
|
||||
|
||||
@@ -20,18 +20,19 @@ function patchTicketLists(
|
||||
event: TicketTableStatusEvent,
|
||||
params?: TicketListParams,
|
||||
) {
|
||||
if (!current || !event.id) return current;
|
||||
const ticketId = event.id ?? event.ticket_id;
|
||||
if (!current || !ticketId) return current;
|
||||
|
||||
const nextRow = buildTicketListItem(event);
|
||||
const existingIndex = current.items.findIndex(
|
||||
(ticket) => ticket.id === event.id,
|
||||
(ticket) => ticket.id === ticketId,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
return {
|
||||
...current,
|
||||
items: current.items.map((ticket) =>
|
||||
ticket.id === event.id ? mergeTicketListItem(ticket, event) : ticket,
|
||||
ticket.id === ticketId ? mergeTicketListItem(ticket, event) : ticket,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -84,8 +85,9 @@ function getTicketListParams(queryKey: readonly unknown[]) {
|
||||
function buildTicketListItem(
|
||||
event: TicketTableStatusEvent,
|
||||
): TicketListItem | null {
|
||||
const ticketId = event.id ?? event.ticket_id;
|
||||
if (
|
||||
!event.id ||
|
||||
!ticketId ||
|
||||
!event.status ||
|
||||
event.detection_count === undefined ||
|
||||
!event.updated_at
|
||||
@@ -94,11 +96,12 @@ function buildTicketListItem(
|
||||
}
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
id: ticketId,
|
||||
chainage_id: event.chainage_id ?? null,
|
||||
chainage_name: event.chainage_name ?? null,
|
||||
status: event.status,
|
||||
assigned_to_name: event.assigned_to_name ?? null,
|
||||
assignments: event.assignments ?? [],
|
||||
coverage: event.coverage ?? null,
|
||||
detection_count: event.detection_count,
|
||||
created_by_name: event.created_by_name ?? null,
|
||||
created_at: event.created_at ?? event.updated_at,
|
||||
@@ -115,7 +118,8 @@ function mergeTicketListItem(
|
||||
chainage_id: event.chainage_id ?? current.chainage_id,
|
||||
chainage_name: event.chainage_name ?? current.chainage_name,
|
||||
status: event.status ?? current.status,
|
||||
assigned_to_name: event.assigned_to_name ?? current.assigned_to_name,
|
||||
assignments: event.assignments ?? current.assignments,
|
||||
coverage: event.coverage ?? current.coverage,
|
||||
detection_count: event.detection_count ?? current.detection_count,
|
||||
created_by_name: event.created_by_name ?? current.created_by_name,
|
||||
created_at: event.created_at ?? current.created_at,
|
||||
|
||||
@@ -79,9 +79,9 @@ export function useTicketDetailEvents(
|
||||
});
|
||||
refetchTicket();
|
||||
},
|
||||
error: (event: TicketStreamErrorEvent) => {
|
||||
error: (event: TicketStreamErrorEvent | null) => {
|
||||
setErrorMessage(
|
||||
event.message ?? 'Live ticket updates disconnected. Reconnecting...',
|
||||
event?.message ?? 'Live ticket updates disconnected. Reconnecting...',
|
||||
);
|
||||
},
|
||||
heartbeat: () => undefined,
|
||||
|
||||
@@ -6,10 +6,9 @@ import { toast } from 'sonner';
|
||||
|
||||
import { ticketService } from '@/services/api';
|
||||
import type {
|
||||
AssignTicketPayload,
|
||||
ReviewRepairPayload,
|
||||
SubmitRepairPayload,
|
||||
SubmitRepairUploadPayload,
|
||||
TicketAssignmentsPayload,
|
||||
TicketDetail,
|
||||
TicketListParams,
|
||||
TicketStatus,
|
||||
@@ -71,6 +70,28 @@ export function useAssignableTicketUsersQuery(enabled: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketDefectClassesQuery(
|
||||
ticketId: string | undefined,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.defectClasses(ticketId ?? ''),
|
||||
queryFn: () => ticketService.getTicketDefectClasses(ticketId as string),
|
||||
enabled: Boolean(enabled && ticketId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketAssignmentsQuery(
|
||||
ticketId: string | undefined,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.assignments(ticketId ?? ''),
|
||||
queryFn: () => ticketService.getTicketAssignments(ticketId as string),
|
||||
enabled: Boolean(enabled && ticketId),
|
||||
});
|
||||
}
|
||||
|
||||
function mergeTicketDetailResponse(
|
||||
current: TicketDetail | undefined,
|
||||
next: TicketDetail,
|
||||
@@ -84,21 +105,24 @@ function mergeTicketDetailResponse(
|
||||
};
|
||||
}
|
||||
|
||||
export function useAssignTicketMutation(ticketId: string) {
|
||||
export function useReplaceTicketAssignmentsMutation(ticketId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: AssignTicketPayload) =>
|
||||
ticketService.assignTicket(ticketId, payload),
|
||||
mutationFn: (payload: TicketAssignmentsPayload) =>
|
||||
ticketService.replaceTicketAssignments(ticketId, payload),
|
||||
onSuccess: (ticket) => {
|
||||
toast.success('Ticket assigned');
|
||||
toast.success('Assignments saved');
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.detail(ticketId),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
onError: () => toast.error('Failed to assign ticket'),
|
||||
onError: () => toast.error('Failed to save assignments'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,48 +143,39 @@ export function useStartTicketMutation(ticketId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmitRepairMutation(ticketId: string) {
|
||||
export function useSubmitAssignmentRepairUploadMutation(
|
||||
ticketId: string,
|
||||
assignmentId: number,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: SubmitRepairPayload) =>
|
||||
ticketService.submitRepair(ticketId, payload),
|
||||
mutationFn: (payload: SubmitRepairUploadPayload) =>
|
||||
ticketService.submitAssignmentRepairUpload(ticketId, assignmentId, payload),
|
||||
onSuccess: (ticket) => {
|
||||
toast.success('Repair submitted');
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.detail(ticketId),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
onError: () => toast.error('Failed to submit repair'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmitRepairUploadMutation(ticketId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: SubmitRepairUploadPayload) =>
|
||||
ticketService.submitRepairUpload(ticketId, payload),
|
||||
onSuccess: (ticket) => {
|
||||
toast.success('Repair evidence uploaded');
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.detail(ticketId),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
onError: () => toast.error('Failed to upload repair evidence'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useReviewRepairMutation(ticketId: string) {
|
||||
export function useReviewAssignmentRepairMutation(
|
||||
ticketId: string,
|
||||
assignmentId: number,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: ReviewRepairPayload) =>
|
||||
ticketService.reviewRepair(ticketId, payload),
|
||||
ticketService.reviewRepair(ticketId, assignmentId, payload),
|
||||
onSuccess: (ticket, payload) => {
|
||||
toast.success(
|
||||
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected',
|
||||
@@ -169,6 +184,9 @@ export function useReviewRepairMutation(ticketId: string) {
|
||||
ticketKeys.detail(ticketId),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
onError: () => toast.error('Failed to review repair'),
|
||||
|
||||
@@ -7,4 +7,8 @@ export const ticketKeys = {
|
||||
details: () => [...ticketKeys.all, 'detail'] as const,
|
||||
detail: (ticketId: string) => [...ticketKeys.details(), ticketId] as const,
|
||||
assignableUsers: () => [...ticketKeys.all, 'assignable-users'] as const,
|
||||
defectClasses: (ticketId: string) =>
|
||||
[...ticketKeys.detail(ticketId), 'defect-classes'] as const,
|
||||
assignments: (ticketId: string) =>
|
||||
[...ticketKeys.detail(ticketId), 'assignments'] as const,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ export function AssignableWorkerSelect({
|
||||
value,
|
||||
onValueChange,
|
||||
disabled = false,
|
||||
portalContainer,
|
||||
}: AssignableWorkerSelectProps) {
|
||||
const lookup = usePaginatedSelect<AssignableTicketUser>({
|
||||
selectedValue: value,
|
||||
@@ -66,6 +67,7 @@ export function AssignableWorkerSelect({
|
||||
placeholder="Select contractor"
|
||||
searchPlaceholder="Search contractors"
|
||||
emptyMessage="No assignable contractors found."
|
||||
portalContainer={portalContainer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { RefObject } from 'react';
|
||||
|
||||
export interface AssignableWorkerSelectProps {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
portalContainer?: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
@@ -55,12 +55,19 @@ export const API_ROUTES = {
|
||||
TICKETS: {
|
||||
BASE: '/biz/api/v1/tickets',
|
||||
DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
|
||||
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`,
|
||||
DEFECT_CLASSES: (id: string) =>
|
||||
`/biz/api/v1/tickets/${id}/defect-classes`,
|
||||
ASSIGNMENTS: (id: string) => `/biz/api/v1/tickets/${id}/assignments`,
|
||||
ASSIGNMENT_DETAIL: (ticketId: string, assignmentId: number) =>
|
||||
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}`,
|
||||
START: (id: string) => `/biz/api/v1/tickets/${id}/start`,
|
||||
SUBMIT_REPAIR: (id: string) => `/biz/api/v1/tickets/${id}/submit-repair`,
|
||||
SUBMIT_REPAIR_UPLOAD: (id: string) =>
|
||||
`/biz/api/v1/tickets/${id}/submit-repair/upload`,
|
||||
REVIEW: (id: string) => `/biz/api/v1/tickets/${id}/review`,
|
||||
SUBMIT_ASSIGNMENT_REPAIR_UPLOAD: (
|
||||
ticketId: string,
|
||||
assignmentId: number,
|
||||
) =>
|
||||
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/submit-repair/upload`,
|
||||
REVIEW_ASSIGNMENT: (ticketId: string, assignmentId: number) =>
|
||||
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/review`,
|
||||
CLOSE: (id: string) => `/biz/api/v1/tickets/${id}/close`,
|
||||
DETAIL_EVENTS_TOKEN: (id: string) =>
|
||||
`/biz/api/v1/tickets/${id}/events/token`,
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import axiosClient from '../axios/axios';
|
||||
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||
import type {
|
||||
AssignTicketPayload,
|
||||
AssignableTicketUsersResponse,
|
||||
AssignableTicketUsersParams,
|
||||
ReviewRepairPayload,
|
||||
SseTokenResponse,
|
||||
SubmitRepairPayload,
|
||||
SubmitRepairUploadPayload,
|
||||
TicketAssignmentListResponse,
|
||||
TicketAssignmentsPayload,
|
||||
TicketAssignmentSummary,
|
||||
TicketDefectClassesResponse,
|
||||
TicketDetail,
|
||||
TicketListParams,
|
||||
TicketListResponse,
|
||||
UpdateTicketAssignmentPayload,
|
||||
} from '@/types';
|
||||
|
||||
export const ticketService = {
|
||||
@@ -54,17 +57,78 @@ export const ticketService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
assignTicket: async (
|
||||
getTicketDefectClasses: async (
|
||||
ticketId: string,
|
||||
payload: AssignTicketPayload,
|
||||
): Promise<TicketDefectClassesResponse> => {
|
||||
const response = await axiosClient.get<TicketDefectClassesResponse>(
|
||||
API_ROUTES.TICKETS.DEFECT_CLASSES(ticketId),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTicketAssignments: async (
|
||||
ticketId: string,
|
||||
): Promise<TicketAssignmentListResponse> => {
|
||||
const response = await axiosClient.get<TicketAssignmentListResponse>(
|
||||
API_ROUTES.TICKETS.ASSIGNMENTS(ticketId),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTicketAssignment: async (
|
||||
ticketId: string,
|
||||
assignmentId: number,
|
||||
): Promise<TicketAssignmentSummary> => {
|
||||
const response = await axiosClient.get<TicketAssignmentSummary>(
|
||||
API_ROUTES.TICKETS.ASSIGNMENT_DETAIL(ticketId, assignmentId),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
addTicketAssignments: async (
|
||||
ticketId: string,
|
||||
payload: TicketAssignmentsPayload,
|
||||
): Promise<TicketDetail> => {
|
||||
const response = await axiosClient.post<TicketDetail>(
|
||||
API_ROUTES.TICKETS.ASSIGN(ticketId),
|
||||
API_ROUTES.TICKETS.ASSIGNMENTS(ticketId),
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
replaceTicketAssignments: async (
|
||||
ticketId: string,
|
||||
payload: TicketAssignmentsPayload,
|
||||
): Promise<TicketDetail> => {
|
||||
const response = await axiosClient.put<TicketDetail>(
|
||||
API_ROUTES.TICKETS.ASSIGNMENTS(ticketId),
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTicketAssignment: async (
|
||||
ticketId: string,
|
||||
assignmentId: number,
|
||||
payload: UpdateTicketAssignmentPayload,
|
||||
): Promise<TicketDetail> => {
|
||||
const response = await axiosClient.patch<TicketDetail>(
|
||||
API_ROUTES.TICKETS.ASSIGNMENT_DETAIL(ticketId, assignmentId),
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteTicketAssignment: async (
|
||||
ticketId: string,
|
||||
assignmentId: number,
|
||||
): Promise<TicketDetail> => {
|
||||
const response = await axiosClient.delete<TicketDetail>(
|
||||
API_ROUTES.TICKETS.ASSIGNMENT_DETAIL(ticketId, assignmentId),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
startTicket: async (ticketId: string): Promise<TicketDetail> => {
|
||||
const response = await axiosClient.post<TicketDetail>(
|
||||
API_ROUTES.TICKETS.START(ticketId),
|
||||
@@ -72,19 +136,9 @@ export const ticketService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
submitRepair: async (
|
||||
ticketId: string,
|
||||
payload: SubmitRepairPayload,
|
||||
): Promise<TicketDetail> => {
|
||||
const response = await axiosClient.post<TicketDetail>(
|
||||
API_ROUTES.TICKETS.SUBMIT_REPAIR(ticketId),
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
submitRepairUpload: async (
|
||||
submitAssignmentRepairUpload: async (
|
||||
ticketId: string,
|
||||
assignmentId: number,
|
||||
payload: SubmitRepairUploadPayload,
|
||||
): Promise<TicketDetail> => {
|
||||
const formData = new FormData();
|
||||
@@ -95,7 +149,10 @@ export const ticketService = {
|
||||
});
|
||||
|
||||
const response = await axiosClient.post<TicketDetail>(
|
||||
API_ROUTES.TICKETS.SUBMIT_REPAIR_UPLOAD(ticketId),
|
||||
API_ROUTES.TICKETS.SUBMIT_ASSIGNMENT_REPAIR_UPLOAD(
|
||||
ticketId,
|
||||
assignmentId,
|
||||
),
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
@@ -108,10 +165,11 @@ export const ticketService = {
|
||||
|
||||
reviewRepair: async (
|
||||
ticketId: string,
|
||||
assignmentId: number,
|
||||
payload: ReviewRepairPayload,
|
||||
): Promise<TicketDetail> => {
|
||||
const response = await axiosClient.post<TicketDetail>(
|
||||
API_ROUTES.TICKETS.REVIEW(ticketId),
|
||||
API_ROUTES.TICKETS.REVIEW_ASSIGNMENT(ticketId, assignmentId),
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
|
||||
@@ -8,7 +8,7 @@ const BASE_URL = ENV_CONSTANT.BASE_API_URL;
|
||||
const axiosClient = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// 'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { PaginationParams } from '../common';
|
||||
|
||||
export type AssignmentStatus =
|
||||
| 'assigned'
|
||||
| 'under_review'
|
||||
| 'approved'
|
||||
| 'rejected';
|
||||
|
||||
export interface AssignableTicketUser {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -16,15 +22,70 @@ export interface AssignableTicketUsersResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface AssignTicketPayload {
|
||||
export interface TicketDefectClass {
|
||||
class_name: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface TicketDefectClassesResponse {
|
||||
ticket_id: string;
|
||||
video_id: string;
|
||||
defect_classes: TicketDefectClass[];
|
||||
}
|
||||
|
||||
export interface TicketCoverage {
|
||||
total_defect_classes: string[];
|
||||
assigned_defect_classes: string[];
|
||||
unassigned_defect_classes: string[];
|
||||
is_fully_assigned: boolean;
|
||||
assigned_count: number;
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export interface TicketAssignmentSummary {
|
||||
id: number;
|
||||
assigned_to_user_id: number;
|
||||
assigned_to_email: string | null;
|
||||
assigned_to_name: string | null;
|
||||
defect_class: string;
|
||||
defect_display_name: string;
|
||||
status: AssignmentStatus;
|
||||
note: string | null;
|
||||
assigned_by_user_id: number | null;
|
||||
assigned_by_name: string | null;
|
||||
assigned_at: string | null;
|
||||
repair_notes: string | null;
|
||||
repair_proof_video_url: string | null;
|
||||
repair_proof_image_urls: string[];
|
||||
repair_submitted_at: string | null;
|
||||
reviewed_by_user_id: number | null;
|
||||
reviewed_by_name: string | null;
|
||||
review_comment: string | null;
|
||||
reviewed_at: string | null;
|
||||
}
|
||||
|
||||
export interface TicketAssignmentListResponse {
|
||||
items: TicketAssignmentSummary[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TicketAssignmentInput {
|
||||
assigned_to_user_id: number;
|
||||
assigned_to_email?: string;
|
||||
defect_class: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface SubmitRepairPayload {
|
||||
notes: string;
|
||||
proof_path: string;
|
||||
export interface TicketAssignmentsPayload {
|
||||
assignments: TicketAssignmentInput[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTicketAssignmentPayload {
|
||||
assigned_to_user_id?: number;
|
||||
assigned_to_email?: string;
|
||||
defect_class?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface SubmitRepairUploadPayload {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TicketAssignmentSummary, TicketCoverage } from './actions';
|
||||
import type { TicketStatus } from './status';
|
||||
|
||||
export interface TicketActor {
|
||||
@@ -32,26 +33,10 @@ export interface TicketVideo {
|
||||
thumbnail: string | null;
|
||||
}
|
||||
|
||||
export interface TicketWorker extends TicketActor {
|
||||
assigned_at: string | null;
|
||||
assigned_by: TicketActor | null;
|
||||
}
|
||||
|
||||
export interface TicketRepair {
|
||||
notes: string | null;
|
||||
proof_video_url: string | null;
|
||||
proof_image_urls: string[];
|
||||
submitted_at: string | null;
|
||||
}
|
||||
|
||||
export interface TicketReviewer extends TicketActor {
|
||||
review_comment: string | null;
|
||||
reviewed_at: string | null;
|
||||
}
|
||||
|
||||
export interface TicketAiResult {
|
||||
detection_count: number;
|
||||
completed_at: string | null;
|
||||
available_defect_classes?: string[];
|
||||
}
|
||||
|
||||
export interface TicketTenant {
|
||||
@@ -68,13 +53,13 @@ export interface TicketDetail {
|
||||
id: string;
|
||||
video_id: string;
|
||||
chainage_id: string | null;
|
||||
chainage_name?: string | null;
|
||||
status: TicketStatus;
|
||||
status_metadata: TicketStatusMetadata | null;
|
||||
video: TicketVideo | null;
|
||||
uploader: TicketActor | null;
|
||||
worker: TicketWorker | null;
|
||||
repair: TicketRepair | null;
|
||||
reviewer: TicketReviewer | null;
|
||||
assignments: TicketAssignmentSummary[];
|
||||
coverage: TicketCoverage | null;
|
||||
ai_result: TicketAiResult | null;
|
||||
tenant: TicketTenant | null;
|
||||
timestamps: TicketTimestamps;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TicketDetail } from './detail';
|
||||
import type { TicketAssignmentSummary, TicketCoverage } from './actions';
|
||||
import type { TicketStatus } from './status';
|
||||
|
||||
export interface SseTokenResponse {
|
||||
@@ -8,12 +9,15 @@ export interface SseTokenResponse {
|
||||
|
||||
export type TicketTableStatusEvent = {
|
||||
type?: 'ticket_status';
|
||||
kind?: 'created' | 'transitioned';
|
||||
id: string;
|
||||
kind?: 'created' | 'transitioned' | 'snapshot';
|
||||
id?: string;
|
||||
ticket_id?: string;
|
||||
chainage_id?: string | null;
|
||||
chainage_name?: string | null;
|
||||
status?: TicketStatus;
|
||||
assigned_to_name?: string | null;
|
||||
assignments?: TicketAssignmentSummary[];
|
||||
coverage?: TicketCoverage | null;
|
||||
assignment_summary?: Partial<Record<string, number>>;
|
||||
detection_count?: number;
|
||||
created_by_name?: string | null;
|
||||
created_at?: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PaginationParams } from '../common';
|
||||
import type { TicketAssignmentSummary, TicketCoverage } from './actions';
|
||||
import type { TicketStatus } from './status';
|
||||
|
||||
export interface TicketListParams extends PaginationParams {
|
||||
@@ -11,7 +12,8 @@ export interface TicketListItem {
|
||||
chainage_id: string | null;
|
||||
chainage_name: string | null;
|
||||
status: TicketStatus;
|
||||
assigned_to_name: string | null;
|
||||
assignments: TicketAssignmentSummary[];
|
||||
coverage: TicketCoverage | null;
|
||||
detection_count: number;
|
||||
created_by_name: string | null;
|
||||
created_at: string;
|
||||
|
||||
Reference in New Issue
Block a user