feat: assign base

This commit is contained in:
2026-06-25 14:15:10 +05:30
parent 8665762ba2
commit 88d3bd58bc
27 changed files with 893 additions and 328 deletions

2
next-env.d.ts vendored
View File

@@ -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
View 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
}
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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} />;
}

View File

@@ -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}
/>
</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
{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>
) : null}
<Button className="w-full" onClick={() => setOpen(true)}>
<UserPlus />
Manage Assignments
</Button>
</div>
<TicketAssignmentSheet
ticket={ticket}
open={open}
onOpenChange={setOpen}
/>
</TicketActionCard>
);
}

View File

@@ -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

View File

@@ -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}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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>
);
}

View File

@@ -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}
</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> */}
</>
{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>
) : (
<EmptyPanel
icon={MessageSquareQuote}

View File

@@ -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.'
}

View File

@@ -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',

View File

@@ -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,

View File

@@ -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,

View File

@@ -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'),

View File

@@ -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,
};

View File

@@ -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}
/>
);
}

View File

@@ -1,5 +1,8 @@
import type { RefObject } from 'react';
export interface AssignableWorkerSelectProps {
value: string;
onValueChange: (value: string) => void;
disabled?: boolean;
portalContainer?: RefObject<HTMLDivElement | null>;
}

View File

@@ -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`,

View File

@@ -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;

View File

@@ -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',
},
});

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;