feat(ticket): refine repair report and history timeline UI
This commit is contained in:
@@ -0,0 +1,125 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { History } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { TicketDetail, TicketHistoryItem, TicketStatus } from '@/types';
|
||||||
|
|
||||||
|
import { formatTicketStatus } from '../../components/TicketStatusBadge';
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined) {
|
||||||
|
if (!value) return '-';
|
||||||
|
return new Intl.DateTimeFormat('en-IN', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActorLabel(item: TicketHistoryItem) {
|
||||||
|
return item.actor?.name || item.actor?.email || 'System';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLifecycleTitle(status: TicketStatus) {
|
||||||
|
const titles: Record<TicketStatus, string> = {
|
||||||
|
processing: 'Incident Ticket Created',
|
||||||
|
unassigned: 'Awaiting Assignment',
|
||||||
|
assigned: 'Dispatched to Maintenance',
|
||||||
|
under_review: 'Maintenance Finalized',
|
||||||
|
closed: 'Ticket Closed',
|
||||||
|
};
|
||||||
|
|
||||||
|
return titles[status] ?? formatTicketStatus(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryEntry({
|
||||||
|
item,
|
||||||
|
isLast,
|
||||||
|
}: {
|
||||||
|
item: TicketHistoryItem;
|
||||||
|
isLast: boolean;
|
||||||
|
}) {
|
||||||
|
const isClosed = item.to_status === 'closed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex gap-4 pb-8 last:pb-0">
|
||||||
|
{!isLast ? (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute top-6 left-[11px] h-[calc(100%-10px)] w-px bg-border"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
'relative z-10 mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border-2 bg-background',
|
||||||
|
isClosed
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: 'border-primary/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'size-2 rounded-full bg-primary',
|
||||||
|
isClosed &&
|
||||||
|
'h-1.5 w-2.5 rotate-[-45deg] rounded-none border-b-2 border-l-2 border-current bg-transparent',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
{getLifecycleTitle(item.to_status)}
|
||||||
|
</h3>
|
||||||
|
<time
|
||||||
|
dateTime={item.created_at}
|
||||||
|
className="text-xs font-semibold text-muted-foreground tabular-nums"
|
||||||
|
>
|
||||||
|
{formatDate(item.created_at)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm leading-relaxed text-foreground">
|
||||||
|
{item.note ||
|
||||||
|
`${formatTicketStatus(item.to_status)} by ${getActorLabel(item)}.`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">{getActorLabel(item)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
const history = ticket.history ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border bg-card p-5">
|
||||||
|
<div className="mb-5 flex items-center gap-2">
|
||||||
|
<History className="size-5 text-primary" />
|
||||||
|
<h2 className="text-base font-semibold text-foreground">
|
||||||
|
Audit & Lifecycle Timeline
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{history.length ? (
|
||||||
|
<div className={cn('pl-0.5')}>
|
||||||
|
{history.map((item, index) => (
|
||||||
|
<HistoryEntry
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isLast={index === history.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
No timeline entries yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import type { TicketActor, TicketDetail } from '@/types';
|
||||||
|
|
||||||
|
import { TicketStatusBadge } from '../../components/TicketStatusBadge';
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined) {
|
||||||
|
if (!value) return '-';
|
||||||
|
return new Intl.DateTimeFormat('en-IN', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewMeta({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | number | null | undefined;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className="text-sm font-medium">{value ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonMeta({
|
||||||
|
label,
|
||||||
|
person,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
person: TicketActor | null | undefined;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
{person ? (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{person.name || '-'}</p>
|
||||||
|
{person.email && (
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{person.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-medium">-</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||||
|
<CardTitle className="text-base">Overview</CardTitle>
|
||||||
|
<TicketStatusBadge status={ticket.status} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid gap-5 md:grid-cols-[180px_minmax(0,1fr)]">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground">AI Detections</p>
|
||||||
|
<p className="text-3xl font-semibold leading-none">
|
||||||
|
{ticket.ai_result?.detection_count ?? '-'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Completed {formatDate(ticket.ai_result?.completed_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<PersonMeta label="Uploaded By" person={ticket.uploader} />
|
||||||
|
<PersonMeta label="Assigned To" person={ticket.worker} />
|
||||||
|
<PersonMeta
|
||||||
|
label="Assigned By"
|
||||||
|
person={ticket.worker?.assigned_by}
|
||||||
|
/>
|
||||||
|
<OverviewMeta
|
||||||
|
label="Assigned At"
|
||||||
|
value={formatDate(ticket.worker?.assigned_at)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
|
import { RepairNotesCard } from './repair-report/RepairNotesCard';
|
||||||
|
import { RepairProofImagesGrid } from './repair-report/RepairProofImagesGrid';
|
||||||
|
import { RepairProofVideoCard } from './repair-report/RepairProofVideoCard';
|
||||||
|
import { ReviewerCommentsCard } from './repair-report/ReviewerCommentsCard';
|
||||||
|
|
||||||
|
export function TicketRepairReportCard({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
const repair = ticket.repair;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-base font-semibold text-foreground">
|
||||||
|
Evidence & Progress
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<RepairProofVideoCard
|
||||||
|
ticketId={ticket.id}
|
||||||
|
proofVideoUrl={repair?.proof_video_url ?? null}
|
||||||
|
hasRepair={Boolean(repair)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RepairProofImagesGrid
|
||||||
|
imageUrls={repair?.proof_image_urls ?? []}
|
||||||
|
submittedAt={repair?.submitted_at ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<RepairNotesCard notes={repair?.notes ?? null} worker={ticket.worker} />
|
||||||
|
<ReviewerCommentsCard
|
||||||
|
reviewer={ticket.reviewer}
|
||||||
|
status={ticket.status}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
|
import { AssignTicketAction } from './actions/AssignTicketAction';
|
||||||
|
import { NoTicketAction } from './actions/NoTicketAction';
|
||||||
|
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
||||||
|
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
||||||
|
|
||||||
|
export function TicketStatusActions({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
if (ticket.status === 'unassigned') {
|
||||||
|
return <AssignTicketAction ticket={ticket} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.status === 'assigned') {
|
||||||
|
return <SubmitRepairAction ticket={ticket} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.status === 'under_review') {
|
||||||
|
return <ReviewRepairAction ticket={ticket} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <NoTicketAction status={ticket.status} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Send, 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 { TicketActionCard } from './TicketActionCard';
|
||||||
|
|
||||||
|
export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState('');
|
||||||
|
const [assignNote, setAssignNote] = useState('');
|
||||||
|
|
||||||
|
const assignMutation = useAssignTicketMutation(ticket.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TicketActionCard icon={UserPlus} title="Assign Ticket">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Worker</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
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TicketActionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CircleOff, Clock, Lock } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { TicketStatus } from '@/types';
|
||||||
|
|
||||||
|
import { TicketActionCard } from './TicketActionCard';
|
||||||
|
|
||||||
|
const statusConfig: Record<
|
||||||
|
'processing' | 'closed',
|
||||||
|
{ icon: typeof Clock; title: string; message: string }
|
||||||
|
> = {
|
||||||
|
processing: {
|
||||||
|
icon: Clock,
|
||||||
|
title: 'Processing',
|
||||||
|
message: 'This ticket is still being processed. Check back shortly.',
|
||||||
|
},
|
||||||
|
closed: {
|
||||||
|
icon: Lock,
|
||||||
|
title: 'Ticket Closed',
|
||||||
|
message: 'This ticket is closed. No further actions are available.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NoTicketAction({ status }: { status: TicketStatus }) {
|
||||||
|
const config =
|
||||||
|
status === 'processing' || status === 'closed'
|
||||||
|
? statusConfig[status]
|
||||||
|
: {
|
||||||
|
icon: CircleOff,
|
||||||
|
title: 'No Actions',
|
||||||
|
message: 'No action available for this ticket.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TicketActionCard icon={Icon} title={config.title}>
|
||||||
|
<p className="text-sm text-muted-foreground">{config.message}</p>
|
||||||
|
</TicketActionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { ImageIcon, Loader2, Send, Video, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { useSubmitRepairUploadMutation } from '../../../hooks/useTicketQueries';
|
||||||
|
|
||||||
|
const MAX_IMAGES = 5;
|
||||||
|
|
||||||
|
function RequiredMark() {
|
||||||
|
return <span className="text-destructive">*</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaDropzoneProps {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
accept: string;
|
||||||
|
file: File | null;
|
||||||
|
onFileChange: (file: File | null) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaDropzone({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
accept,
|
||||||
|
file,
|
||||||
|
onFileChange,
|
||||||
|
disabled = false,
|
||||||
|
}: MediaDropzoneProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-3 rounded-lg border border-dashed px-4 py-3 text-left transition-colors',
|
||||||
|
'hover:border-primary/50 hover:bg-muted/30',
|
||||||
|
disabled && 'pointer-events-none opacity-60',
|
||||||
|
file && 'border-primary/40 bg-muted/20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
className="hidden"
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
onFileChange(event.target.files?.[0] ?? null);
|
||||||
|
event.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-medium">
|
||||||
|
{file ? file.name : label}
|
||||||
|
</span>
|
||||||
|
{file ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Click to replace
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageSlotProps {
|
||||||
|
file: File | null;
|
||||||
|
previewUrl: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
onAdd: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageSlot({
|
||||||
|
file,
|
||||||
|
previewUrl,
|
||||||
|
disabled = false,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
}: ImageSlotProps) {
|
||||||
|
if (file && previewUrl) {
|
||||||
|
return (
|
||||||
|
<div className="group relative aspect-square overflow-hidden rounded-lg border bg-muted/30">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={file.name}
|
||||||
|
className="size-full object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onRemove}
|
||||||
|
className="absolute top-1 right-1 flex size-5 items-center justify-center rounded-full bg-background/90 text-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 disabled:pointer-events-none"
|
||||||
|
aria-label={`Remove ${file.name}`}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onAdd}
|
||||||
|
className={cn(
|
||||||
|
'flex aspect-square items-center justify-center rounded-lg border border-dashed bg-muted/20 text-muted-foreground transition-colors',
|
||||||
|
'hover:border-primary/50 hover:bg-muted/40 hover:text-primary',
|
||||||
|
disabled && 'pointer-events-none opacity-60',
|
||||||
|
)}
|
||||||
|
aria-label="Add image"
|
||||||
|
>
|
||||||
|
<ImageIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RepairEvidenceFormProps {
|
||||||
|
ticketId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepairEvidenceForm({ ticketId }: 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 isSubmitting = submitRepairUploadMutation.isPending;
|
||||||
|
const canSubmit = Boolean(notes.trim() && videoFile);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urls = imageFiles.map((file) => URL.createObjectURL(file));
|
||||||
|
setImagePreviews(urls);
|
||||||
|
return () => {
|
||||||
|
urls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
};
|
||||||
|
}, [imageFiles]);
|
||||||
|
|
||||||
|
const addImages = (files: FileList | null) => {
|
||||||
|
if (!files?.length) return;
|
||||||
|
|
||||||
|
const remaining = MAX_IMAGES - imageFiles.length;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
setError(`Only ${MAX_IMAGES} images can be uploaded.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextImages = [
|
||||||
|
...imageFiles,
|
||||||
|
...Array.from(files).slice(0, remaining),
|
||||||
|
];
|
||||||
|
setImageFiles(nextImages);
|
||||||
|
setError(
|
||||||
|
files.length > remaining
|
||||||
|
? `Only ${MAX_IMAGES} images can be uploaded.`
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeImage = (index: number) => {
|
||||||
|
setImageFiles((current) =>
|
||||||
|
current.filter((_, itemIndex) => itemIndex !== index),
|
||||||
|
);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!notes.trim() || !videoFile) {
|
||||||
|
setError('Repair notes and video are required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await submitRepairUploadMutation.mutateAsync({
|
||||||
|
notes: notes.trim(),
|
||||||
|
video: videoFile,
|
||||||
|
images: imageFiles,
|
||||||
|
});
|
||||||
|
setNotes('');
|
||||||
|
setVideoFile(null);
|
||||||
|
setImageFiles([]);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Upload failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageSlots = Array.from({ length: MAX_IMAGES }, (_, index) => ({
|
||||||
|
file: imageFiles[index] ?? null,
|
||||||
|
previewUrl: imagePreviews[index] ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="repair-notes">
|
||||||
|
Repair Notes <RequiredMark />
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="repair-notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(event) => {
|
||||||
|
setNotes(event.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder="Describe the repair work performed..."
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="min-h-24 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-muted-foreground">Media Upload</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-normal">
|
||||||
|
Repair Video <RequiredMark />
|
||||||
|
</Label>
|
||||||
|
<MediaDropzone
|
||||||
|
icon={<Video className="size-4" />}
|
||||||
|
label="Upload Repair Video (MP4, max 50MB)"
|
||||||
|
accept="video/mp4,video/*"
|
||||||
|
file={videoFile}
|
||||||
|
onFileChange={(file) => {
|
||||||
|
setVideoFile(file);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-normal">
|
||||||
|
Upload Images (Max {MAX_IMAGES})
|
||||||
|
</Label>
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
disabled={isSubmitting || imageFiles.length >= MAX_IMAGES}
|
||||||
|
onChange={(event) => {
|
||||||
|
addImages(event.target.files);
|
||||||
|
event.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{imageSlots.map((slot, index) => (
|
||||||
|
<ImageSlot
|
||||||
|
key={index}
|
||||||
|
file={slot.file}
|
||||||
|
previewUrl={slot.previewUrl}
|
||||||
|
disabled={isSubmitting || (!slot.file && imageFiles.length >= MAX_IMAGES)}
|
||||||
|
onAdd={() => imageInputRef.current?.click()}
|
||||||
|
onRemove={() => removeImage(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!canSubmit || isSubmitting}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Submitting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="size-4" />
|
||||||
|
Submit for Review
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'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';
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Wrench } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
|
import { RepairEvidenceForm } from './RepairEvidenceForm';
|
||||||
|
import { TicketActionCard } from './TicketActionCard';
|
||||||
|
|
||||||
|
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
return (
|
||||||
|
<TicketActionCard icon={Wrench} title="Submit Repair Evidence">
|
||||||
|
<RepairEvidenceForm ticketId={ticket.id} />
|
||||||
|
</TicketActionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
interface TicketActionCardProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketActionCard({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: TicketActionCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Icon className="size-4 text-primary" />
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>{children}</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { MessageSquareQuote, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EmptyPanel,
|
||||||
|
getPersonLabel,
|
||||||
|
ReportSurface,
|
||||||
|
SectionLabel,
|
||||||
|
} from './repairReportUtils';
|
||||||
|
|
||||||
|
export function RepairNotesCard({
|
||||||
|
notes,
|
||||||
|
worker,
|
||||||
|
}: {
|
||||||
|
notes: string | null;
|
||||||
|
worker: TicketDetail['worker'];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ReportSurface className="flex min-h-48 flex-col gap-4">
|
||||||
|
<SectionLabel>Repair Notes</SectionLabel>
|
||||||
|
{notes ? (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 rounded-lg border bg-muted/15 px-4 py-3">
|
||||||
|
<p className="text-sm leading-relaxed text-foreground/90">
|
||||||
|
{notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<ShieldCheck className="size-3.5 text-primary" />
|
||||||
|
<span>Self-certified by {getPersonLabel(worker)}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyPanel
|
||||||
|
icon={MessageSquareQuote}
|
||||||
|
title="No repair notes added yet"
|
||||||
|
className="min-h-28 flex-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ReportSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ImageIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { formatRepairDate, ReportSurface } from './repairReportUtils';
|
||||||
|
|
||||||
|
const MAX_MEDIA_SLOTS = 5;
|
||||||
|
|
||||||
|
const imageBadgeStyles = [
|
||||||
|
'bg-slate-700/90 text-white',
|
||||||
|
'bg-violet-600/90 text-white',
|
||||||
|
'bg-amber-600/90 text-white',
|
||||||
|
'bg-stone-600/90 text-white',
|
||||||
|
'bg-indigo-600/90 text-white',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function RepairProofImagesGrid({
|
||||||
|
imageUrls,
|
||||||
|
submittedAt,
|
||||||
|
}: {
|
||||||
|
imageUrls: string[];
|
||||||
|
submittedAt: string | null;
|
||||||
|
}) {
|
||||||
|
const imageSlots = Array.from(
|
||||||
|
{ length: MAX_MEDIA_SLOTS },
|
||||||
|
(_, index) => imageUrls[index] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
{imageSlots.map((url, index) => (
|
||||||
|
<RepairProofImageSlot
|
||||||
|
key={index}
|
||||||
|
url={url}
|
||||||
|
index={index}
|
||||||
|
submittedAt={submittedAt}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RepairProofImageSlot({
|
||||||
|
url,
|
||||||
|
index,
|
||||||
|
submittedAt,
|
||||||
|
}: {
|
||||||
|
url: string | null;
|
||||||
|
index: number;
|
||||||
|
submittedAt: string | null;
|
||||||
|
}) {
|
||||||
|
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(url);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return (
|
||||||
|
<ReportSurface className="space-y-2 p-2">
|
||||||
|
<div className="flex aspect-4/3 items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/10 text-muted-foreground/70">
|
||||||
|
<ImageIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<p className="truncate px-1 text-[11px] text-muted-foreground/70">
|
||||||
|
No image added
|
||||||
|
</p>
|
||||||
|
</ReportSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<ReportSurface className="space-y-2 p-2">
|
||||||
|
<Skeleton className="aspect-4/3 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
</ReportSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !objectUrl) {
|
||||||
|
return (
|
||||||
|
<ReportSurface className="space-y-2 p-2">
|
||||||
|
<div className="flex aspect-4/3 items-center justify-center rounded-lg border border-dashed bg-muted/10 text-xs text-muted-foreground">
|
||||||
|
Unavailable
|
||||||
|
</div>
|
||||||
|
<p className="truncate px-1 text-[11px] text-muted-foreground">
|
||||||
|
Image {index + 1}
|
||||||
|
</p>
|
||||||
|
</ReportSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReportSurface className="space-y-2 p-2">
|
||||||
|
<a
|
||||||
|
href={objectUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="group relative block overflow-hidden rounded-lg border bg-muted/20"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={objectUrl}
|
||||||
|
alt={`Repair proof ${index + 1}`}
|
||||||
|
className="aspect-[4/3] w-full object-cover transition-transform group-hover:scale-[1.02]"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute top-2 left-2 rounded px-1.5 py-0.5 text-[10px] font-semibold tracking-wide uppercase',
|
||||||
|
imageBadgeStyles[index],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Proof {index + 1}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<p className="truncate px-1 text-[11px] text-muted-foreground">
|
||||||
|
{submittedAt ? formatRepairDate(submittedAt) : `Image ${index + 1}`}
|
||||||
|
</p>
|
||||||
|
</ReportSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { CircleDashed, Play, Video } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
|
||||||
|
|
||||||
|
import { EmptyPanel, ReportSurface } from './repairReportUtils';
|
||||||
|
|
||||||
|
interface RepairProofVideoCardProps {
|
||||||
|
ticketId: string;
|
||||||
|
proofVideoUrl: string | null;
|
||||||
|
hasRepair: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepairProofVideoCard({
|
||||||
|
ticketId,
|
||||||
|
proofVideoUrl,
|
||||||
|
hasRepair,
|
||||||
|
}: RepairProofVideoCardProps) {
|
||||||
|
if (!proofVideoUrl) {
|
||||||
|
return (
|
||||||
|
<ReportSurface className="p-2">
|
||||||
|
<EmptyPanel
|
||||||
|
icon={hasRepair ? Video : CircleDashed}
|
||||||
|
title={
|
||||||
|
hasRepair ? 'No repair video uploaded' : 'No evidence submitted yet'
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
hasRepair
|
||||||
|
? 'The assigned worker has not uploaded a repair video yet.'
|
||||||
|
: 'Repair video will appear here once the assigned worker uploads proof.'
|
||||||
|
}
|
||||||
|
className="aspect-[16/9]"
|
||||||
|
/>
|
||||||
|
</ReportSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProtectedRepairVideo
|
||||||
|
url={proofVideoUrl}
|
||||||
|
label={`Repair Clip #${ticketId.slice(0, 8).toUpperCase()}`}
|
||||||
|
meta="Repair video - MP4 - Field upload"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProtectedRepairVideo({
|
||||||
|
url,
|
||||||
|
label,
|
||||||
|
meta,
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
label: string;
|
||||||
|
meta: string;
|
||||||
|
}) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(url);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton className="aspect-[16/9] w-full rounded-xl border" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !objectUrl) {
|
||||||
|
return (
|
||||||
|
<ReportSurface className="p-2">
|
||||||
|
<EmptyPanel
|
||||||
|
icon={Video}
|
||||||
|
title="Video unavailable"
|
||||||
|
description="The repair video could not be loaded right now."
|
||||||
|
className="aspect-[16/9]"
|
||||||
|
/>
|
||||||
|
</ReportSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
if (video.paused) {
|
||||||
|
void video.play();
|
||||||
|
setIsPlaying(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.pause();
|
||||||
|
setIsPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative overflow-hidden rounded-xl border bg-black/90">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={objectUrl}
|
||||||
|
className="aspect-[16/9] w-full object-cover"
|
||||||
|
onPlay={() => setIsPlaying(true)}
|
||||||
|
onPause={() => setIsPlaying(false)}
|
||||||
|
onEnded={() => setIsPlaying(false)}
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-black/30" />
|
||||||
|
|
||||||
|
<span className="absolute top-3 left-3 rounded bg-black/60 px-2.5 py-1 text-[11px] font-semibold text-white backdrop-blur-sm">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{!isPlaying ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={togglePlay}
|
||||||
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
|
aria-label="Play video"
|
||||||
|
>
|
||||||
|
<span className="flex size-16 items-center justify-center rounded-full border border-white/45 bg-white/20 text-white backdrop-blur-sm transition-transform group-hover:scale-105">
|
||||||
|
<Play className="ml-1 size-7 fill-current" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={togglePlay}
|
||||||
|
className="absolute inset-0"
|
||||||
|
aria-label="Pause video"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute right-0 bottom-0 left-0 flex items-center justify-between gap-3 px-4 py-3 text-xs text-white/85">
|
||||||
|
<span>{meta}</span>
|
||||||
|
<Video className="size-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Clock3 } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EmptyPanel,
|
||||||
|
formatRepairDate,
|
||||||
|
getPersonLabel,
|
||||||
|
ReportSurface,
|
||||||
|
SectionLabel,
|
||||||
|
} from './repairReportUtils';
|
||||||
|
|
||||||
|
export function ReviewerCommentsCard({
|
||||||
|
reviewer,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
reviewer: TicketDetail['reviewer'];
|
||||||
|
status: TicketDetail['status'];
|
||||||
|
}) {
|
||||||
|
const hasReview = Boolean(reviewer?.review_comment || reviewer?.reviewed_at);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReportSurface className="flex min-h-48 flex-col gap-4">
|
||||||
|
<SectionLabel>Reviewer Comments</SectionLabel>
|
||||||
|
{hasReview ? (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 rounded-lg border bg-muted/15 px-4 py-3">
|
||||||
|
<div className="mb-3 flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">
|
||||||
|
{getPersonLabel(reviewer)}
|
||||||
|
</p>
|
||||||
|
{reviewer?.email ? (
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{reviewer.email}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<time className="shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
{formatRepairDate(reviewer?.reviewed_at)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed text-foreground/90">
|
||||||
|
{reviewer?.review_comment || 'No comment provided.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] font-semibold tracking-[0.12em] text-amber-500 uppercase">
|
||||||
|
<span className="size-2 rounded-sm bg-amber-500" />
|
||||||
|
{status === 'closed' ? 'Repair approved' : 'Review completed'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyPanel
|
||||||
|
icon={Clock3}
|
||||||
|
title="No comments added yet"
|
||||||
|
className="min-h-28 flex-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ReportSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { TicketActor } from '@/types';
|
||||||
|
|
||||||
|
export function formatRepairDate(value: string | null | undefined) {
|
||||||
|
if (!value) return '-';
|
||||||
|
return new Intl.DateTimeFormat('en-IN', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPersonLabel(person: TicketActor | null | undefined) {
|
||||||
|
return person?.name || person?.email || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p className="text-[11px] font-semibold tracking-[0.12em] text-foreground uppercase">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportSurface({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('rounded-xl border bg-card p-4', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyPanel({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center rounded-lg border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="mb-3 size-6 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||||
|
{description ? (
|
||||||
|
<p className="mt-1 max-w-xs text-xs text-muted-foreground/80">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,101 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { ArrowLeft, Check, Loader2, Send, Ticket, X } from 'lucide-react';
|
import { ArrowLeft, Loader2, Ticket } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import type { AssignableTicketUser } from '@/types';
|
|
||||||
|
|
||||||
import {
|
import { TicketHistoryCard } from './components/TicketHistoryCard';
|
||||||
TicketStatusBadge,
|
import { TicketOverviewCard } from './components/TicketOverviewCard';
|
||||||
formatTicketStatus,
|
import { TicketRepairReportCard } from './components/TicketRepairReportCard';
|
||||||
} from '../components/TicketStatusBadge';
|
import { TicketStatusActions } from './components/TicketStatusActions';
|
||||||
import { useTicketDetailEvents } from '../hooks/useTicketEvents';
|
import { useTicketDetailEvents } from '../hooks/useTicketEvents';
|
||||||
import {
|
import { useTicketDetailQuery } from '../hooks/useTicketQueries';
|
||||||
useAssignableTicketUsersQuery,
|
|
||||||
useAssignTicketMutation,
|
|
||||||
useReviewRepairMutation,
|
|
||||||
useSubmitRepairMutation,
|
|
||||||
useTicketDetailQuery,
|
|
||||||
} from '../hooks/useTicketQueries';
|
|
||||||
|
|
||||||
function formatDate(value: string | null | undefined) {
|
|
||||||
if (!value) return '-';
|
|
||||||
return new Intl.DateTimeFormat('en-IN', {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: 'short',
|
|
||||||
}).format(new Date(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string | number | null | undefined;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
|
||||||
<p className="break-words text-sm font-medium">{value ?? '-'}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TicketDetailPage() {
|
export default function TicketDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { ticketId } = useParams() as { ticketId: string };
|
const { ticketId } = useParams() as { ticketId: string };
|
||||||
const [selectedUserId, setSelectedUserId] = useState('');
|
|
||||||
const [assignNote, setAssignNote] = useState('');
|
|
||||||
const [repairNotes, setRepairNotes] = useState('');
|
|
||||||
const [proofPath, setProofPath] = useState('');
|
|
||||||
const [reviewComment, setReviewComment] = useState('');
|
|
||||||
|
|
||||||
const ticketQuery = useTicketDetailQuery(ticketId);
|
const ticketQuery = useTicketDetailQuery(ticketId);
|
||||||
const ticket = ticketQuery.data;
|
const ticket = ticketQuery.data;
|
||||||
const assignableUsersQuery = useAssignableTicketUsersQuery(
|
|
||||||
ticket?.status === 'unassigned',
|
|
||||||
);
|
|
||||||
const assignMutation = useAssignTicketMutation(ticketId);
|
|
||||||
const submitRepairMutation = useSubmitRepairMutation(ticketId);
|
|
||||||
const reviewRepairMutation = useReviewRepairMutation(ticketId);
|
|
||||||
|
|
||||||
useTicketDetailEvents(ticketId);
|
useTicketDetailEvents(ticketId);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ticket) return;
|
|
||||||
setRepairNotes(ticket.repair?.notes ?? '');
|
|
||||||
setProofPath(ticket.repair?.proof_path ?? '');
|
|
||||||
setReviewComment(ticket.reviewer?.review_comment ?? '');
|
|
||||||
}, [ticket]);
|
|
||||||
|
|
||||||
const assignableUsers = assignableUsersQuery.data?.items ?? [];
|
|
||||||
const selectedUser = useMemo(
|
|
||||||
() =>
|
|
||||||
assignableUsers.find((user) => String(user.id) === selectedUserId) as
|
|
||||||
| AssignableTicketUser
|
|
||||||
| undefined,
|
|
||||||
[assignableUsers, selectedUserId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isBusy =
|
|
||||||
assignMutation.isPending ||
|
|
||||||
submitRepairMutation.isPending ||
|
|
||||||
reviewRepairMutation.isPending;
|
|
||||||
|
|
||||||
if (ticketQuery.isLoading) {
|
if (ticketQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[60vh] items-center justify-center">
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
@@ -120,10 +46,6 @@ export default function TicketDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const canAssign = ticket.status === 'unassigned';
|
|
||||||
const canSubmitRepair = ticket.status === 'assigned';
|
|
||||||
const canReview = ticket.status === 'under_review';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="relative z-10 space-y-5">
|
<main className="relative z-10 space-y-5">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -144,258 +66,13 @@ export default function TicketDetailPage() {
|
|||||||
|
|
||||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_380px]">
|
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<Card>
|
<TicketOverviewCard ticket={ticket} />
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
<TicketRepairReportCard ticket={ticket} />
|
||||||
<CardTitle className="text-base">Overview</CardTitle>
|
<TicketHistoryCard ticket={ticket} />
|
||||||
<TicketStatusBadge status={ticket.status} />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<Field
|
|
||||||
label="Video"
|
|
||||||
value={ticket.video?.name ?? ticket.video_id}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Video ID"
|
|
||||||
value={ticket.video?.id ?? ticket.video_id}
|
|
||||||
/>
|
|
||||||
<Field label="Segment ID" value={ticket.chainage_id} />
|
|
||||||
<Field
|
|
||||||
label="Detections"
|
|
||||||
value={ticket.ai_result?.detection_count}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Uploaded By"
|
|
||||||
value={ticket.uploader?.name ?? ticket.uploader?.email}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Created At"
|
|
||||||
value={formatDate(ticket.timestamps.created_at)}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Updated At"
|
|
||||||
value={formatDate(ticket.timestamps.updated_at)}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="AI Completed At"
|
|
||||||
value={formatDate(ticket.ai_result?.completed_at)}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Assigned To"
|
|
||||||
value={ticket.worker?.name ?? ticket.worker?.email}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Assigned At"
|
|
||||||
value={formatDate(ticket.worker?.assigned_at)}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Assigned By"
|
|
||||||
value={ticket.worker?.assigned_by_name}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Repair Review</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<Field label="Repair Notes" value={ticket.repair?.notes} />
|
|
||||||
<Field label="Proof Path" value={ticket.repair?.proof_path} />
|
|
||||||
<Field
|
|
||||||
label="Repair Submitted At"
|
|
||||||
value={formatDate(ticket.repair?.submitted_at)}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Reviewer"
|
|
||||||
value={ticket.reviewer?.name ?? ticket.reviewer?.email}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Review Comment"
|
|
||||||
value={ticket.reviewer?.review_comment}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Reviewed At"
|
|
||||||
value={formatDate(ticket.reviewer?.reviewed_at)}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">History</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{ticket.history.length ? (
|
|
||||||
ticket.history.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="border-l-2 border-border pl-4"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{item.from_status
|
|
||||||
? `${formatTicketStatus(item.from_status)} -> `
|
|
||||||
: ''}
|
|
||||||
{formatTicketStatus(item.to_status)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatDate(item.created_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
{item.note || '-'}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{item.actor?.name || item.actor?.email || 'System'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No history yet.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div>
|
||||||
{canAssign && (
|
<TicketStatusActions ticket={ticket} />
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Assign Ticket</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Worker</Label>
|
|
||||||
<Select
|
|
||||||
value={selectedUserId}
|
|
||||||
onValueChange={setSelectedUserId}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select worker" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{assignableUsers.map((user) => (
|
|
||||||
<SelectItem key={user.id} value={String(user.id)}>
|
|
||||||
{user.name || user.email}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</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={!selectedUser || isBusy}
|
|
||||||
onClick={() => {
|
|
||||||
if (!selectedUser) return;
|
|
||||||
assignMutation.mutate({
|
|
||||||
assigned_to_user_id: selectedUser.id,
|
|
||||||
assigned_to_email: selectedUser.email,
|
|
||||||
note: assignNote || undefined,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Send />
|
|
||||||
Assign
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canSubmitRepair && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Submit Repair</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Notes</Label>
|
|
||||||
<Textarea
|
|
||||||
value={repairNotes}
|
|
||||||
onChange={(event) => setRepairNotes(event.target.value)}
|
|
||||||
placeholder="Repair notes"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Proof Path</Label>
|
|
||||||
<Input
|
|
||||||
value={proofPath}
|
|
||||||
onChange={(event) => setProofPath(event.target.value)}
|
|
||||||
placeholder="/some/uploaded/proof.jpg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
disabled={!repairNotes || !proofPath || isBusy}
|
|
||||||
onClick={() =>
|
|
||||||
submitRepairMutation.mutate({
|
|
||||||
notes: repairNotes,
|
|
||||||
proof_path: proofPath,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Send />
|
|
||||||
Submit Repair
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canReview && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Review Repair</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent 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 || isBusy}
|
|
||||||
onClick={() =>
|
|
||||||
reviewRepairMutation.mutate({
|
|
||||||
action: 'approve',
|
|
||||||
comment: reviewComment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Check />
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={!reviewComment || isBusy}
|
|
||||||
onClick={() =>
|
|
||||||
reviewRepairMutation.mutate({
|
|
||||||
action: 'reject',
|
|
||||||
comment: reviewComment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<X />
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ function patchTicketLists(
|
|||||||
ticket.id === eventId
|
ticket.id === eventId
|
||||||
? {
|
? {
|
||||||
...ticket,
|
...ticket,
|
||||||
...event,
|
status: event.status ?? ticket.status,
|
||||||
id: ticket.id,
|
updated_at: event.timestamps?.updated_at ?? ticket.updated_at,
|
||||||
|
chainage_id: event.chainage_id ?? ticket.chainage_id,
|
||||||
|
video_id: event.video_id ?? ticket.video_id,
|
||||||
}
|
}
|
||||||
: ticket,
|
: ticket,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
AssignTicketPayload,
|
AssignTicketPayload,
|
||||||
ReviewRepairPayload,
|
ReviewRepairPayload,
|
||||||
SubmitRepairPayload,
|
SubmitRepairPayload,
|
||||||
|
SubmitRepairUploadPayload,
|
||||||
|
TicketDetail,
|
||||||
TicketListParams,
|
TicketListParams,
|
||||||
TicketStatus,
|
TicketStatus,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
@@ -60,11 +62,24 @@ export function useTicketDetailQuery(ticketId: string | undefined) {
|
|||||||
export function useAssignableTicketUsersQuery(enabled: boolean) {
|
export function useAssignableTicketUsersQuery(enabled: boolean) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ticketKeys.assignableUsers(),
|
queryKey: ticketKeys.assignableUsers(),
|
||||||
queryFn: ticketService.getAssignableUsers,
|
queryFn: () => ticketService.getAssignableUsers(),
|
||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeTicketDetailResponse(
|
||||||
|
current: TicketDetail | undefined,
|
||||||
|
next: TicketDetail,
|
||||||
|
) {
|
||||||
|
if (!current) return next;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
...next,
|
||||||
|
history: next.history ?? current.history,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useAssignTicketMutation(ticketId: string) {
|
export function useAssignTicketMutation(ticketId: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -73,7 +88,10 @@ export function useAssignTicketMutation(ticketId: string) {
|
|||||||
ticketService.assignTicket(ticketId, payload),
|
ticketService.assignTicket(ticketId, payload),
|
||||||
onSuccess: (ticket) => {
|
onSuccess: (ticket) => {
|
||||||
toast.success('Ticket assigned');
|
toast.success('Ticket assigned');
|
||||||
queryClient.setQueryData(ticketKeys.detail(ticketId), ticket);
|
queryClient.setQueryData<TicketDetail>(
|
||||||
|
ticketKeys.detail(ticketId),
|
||||||
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
|
);
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
},
|
},
|
||||||
onError: () => toast.error('Failed to assign ticket'),
|
onError: () => toast.error('Failed to assign ticket'),
|
||||||
@@ -87,7 +105,10 @@ export function useStartTicketMutation(ticketId: string) {
|
|||||||
mutationFn: () => ticketService.startTicket(ticketId),
|
mutationFn: () => ticketService.startTicket(ticketId),
|
||||||
onSuccess: (ticket) => {
|
onSuccess: (ticket) => {
|
||||||
toast.success('Ticket started');
|
toast.success('Ticket started');
|
||||||
queryClient.setQueryData(ticketKeys.detail(ticketId), ticket);
|
queryClient.setQueryData<TicketDetail>(
|
||||||
|
ticketKeys.detail(ticketId),
|
||||||
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
|
);
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
},
|
},
|
||||||
onError: () => toast.error('Failed to start ticket'),
|
onError: () => toast.error('Failed to start ticket'),
|
||||||
@@ -102,13 +123,34 @@ export function useSubmitRepairMutation(ticketId: string) {
|
|||||||
ticketService.submitRepair(ticketId, payload),
|
ticketService.submitRepair(ticketId, payload),
|
||||||
onSuccess: (ticket) => {
|
onSuccess: (ticket) => {
|
||||||
toast.success('Repair submitted');
|
toast.success('Repair submitted');
|
||||||
queryClient.setQueryData(ticketKeys.detail(ticketId), ticket);
|
queryClient.setQueryData<TicketDetail>(
|
||||||
|
ticketKeys.detail(ticketId),
|
||||||
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
|
);
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
},
|
},
|
||||||
onError: () => toast.error('Failed to submit repair'),
|
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 useReviewRepairMutation(ticketId: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -119,7 +161,10 @@ export function useReviewRepairMutation(ticketId: string) {
|
|||||||
toast.success(
|
toast.success(
|
||||||
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected',
|
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected',
|
||||||
);
|
);
|
||||||
queryClient.setQueryData(ticketKeys.detail(ticketId), ticket);
|
queryClient.setQueryData<TicketDetail>(
|
||||||
|
ticketKeys.detail(ticketId),
|
||||||
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
|
);
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
},
|
},
|
||||||
onError: () => toast.error('Failed to review repair'),
|
onError: () => toast.error('Failed to review repair'),
|
||||||
@@ -133,7 +178,10 @@ export function useCloseTicketMutation(ticketId: string) {
|
|||||||
mutationFn: () => ticketService.closeTicket(ticketId),
|
mutationFn: () => ticketService.closeTicket(ticketId),
|
||||||
onSuccess: (ticket) => {
|
onSuccess: (ticket) => {
|
||||||
toast.success('Ticket closed');
|
toast.success('Ticket closed');
|
||||||
queryClient.setQueryData(ticketKeys.detail(ticketId), ticket);
|
queryClient.setQueryData<TicketDetail>(
|
||||||
|
ticketKeys.detail(ticketId),
|
||||||
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
|
);
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
},
|
},
|
||||||
onError: () => toast.error('Failed to close ticket'),
|
onError: () => toast.error('Failed to close ticket'),
|
||||||
|
|||||||
71
src/components/lookups/AssignableWorkerSelect.tsx
Normal file
71
src/components/lookups/AssignableWorkerSelect.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AsyncSelect, usePaginatedSelect } from '@/components/async-select';
|
||||||
|
import type { AsyncSelectOption } from '@/components/async-select';
|
||||||
|
import { ticketKeys } from '@/app/(modules)/ticket/queries/ticketKeys';
|
||||||
|
import { ticketService } from '@/services/api';
|
||||||
|
import type { AssignableTicketUser } from '@/types';
|
||||||
|
|
||||||
|
import type { AssignableWorkerSelectProps } from './AssignableWorkerSelect.types';
|
||||||
|
|
||||||
|
const WORKER_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
function mapWorkerOption(user: AssignableTicketUser): AsyncSelectOption {
|
||||||
|
return {
|
||||||
|
value: String(user.id),
|
||||||
|
label: user.name || user.email || user.username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssignableWorkerSelect({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
disabled = false,
|
||||||
|
}: AssignableWorkerSelectProps) {
|
||||||
|
const lookup = usePaginatedSelect<AssignableTicketUser>({
|
||||||
|
selectedValue: value,
|
||||||
|
pageSize: WORKER_PAGE_SIZE,
|
||||||
|
queryKey: (searchTerm) =>
|
||||||
|
[
|
||||||
|
...ticketKeys.assignableUsers(),
|
||||||
|
'async-lookup',
|
||||||
|
searchTerm,
|
||||||
|
WORKER_PAGE_SIZE,
|
||||||
|
] as const,
|
||||||
|
queryFn: async ({ searchTerm, skip, limit }) => {
|
||||||
|
const data = await ticketService.getAssignableUsers({
|
||||||
|
search_term: searchTerm || undefined,
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items: data.items, total: data.total };
|
||||||
|
},
|
||||||
|
mapOption: mapWorkerOption,
|
||||||
|
resolveSelected: async (workerId) => {
|
||||||
|
if (!workerId) return null;
|
||||||
|
|
||||||
|
const data = await ticketService.getAssignableUsers({
|
||||||
|
skip: 0,
|
||||||
|
limit: WORKER_PAGE_SIZE,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
data.items.find((worker) => String(worker.id) === workerId) ?? null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
resolveSelectedQueryKey: (workerId) =>
|
||||||
|
[...ticketKeys.assignableUsers(), 'selected', workerId] as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsyncSelect
|
||||||
|
lookup={lookup}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="Select worker"
|
||||||
|
searchPlaceholder="Search workers..."
|
||||||
|
emptyMessage="No assignable workers found."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/components/lookups/AssignableWorkerSelect.types.ts
Normal file
5
src/components/lookups/AssignableWorkerSelect.types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface AssignableWorkerSelectProps {
|
||||||
|
value: string;
|
||||||
|
onValueChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
@@ -1,24 +1,28 @@
|
|||||||
'use client';
|
"use client"
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react"
|
||||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Avatar({
|
function Avatar({
|
||||||
className,
|
className,
|
||||||
|
size = "default",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||||
|
size?: "default" | "sm" | "lg"
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<AvatarPrimitive.Root
|
<AvatarPrimitive.Root
|
||||||
data-slot="avatar"
|
data-slot="avatar"
|
||||||
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
|
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AvatarImage({
|
function AvatarImage({
|
||||||
@@ -28,10 +32,10 @@ function AvatarImage({
|
|||||||
return (
|
return (
|
||||||
<AvatarPrimitive.Image
|
<AvatarPrimitive.Image
|
||||||
data-slot="avatar-image"
|
data-slot="avatar-image"
|
||||||
className={cn('aspect-square size-full', className)}
|
className={cn("aspect-square size-full", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AvatarFallback({
|
function AvatarFallback({
|
||||||
@@ -42,12 +46,64 @@ function AvatarFallback({
|
|||||||
<AvatarPrimitive.Fallback
|
<AvatarPrimitive.Fallback
|
||||||
data-slot="avatar-fallback"
|
data-slot="avatar-fallback"
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-muted flex size-full items-center justify-center rounded-full',
|
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Avatar, AvatarImage, AvatarFallback };
|
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none",
|
||||||
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||||
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||||
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group"
|
||||||
|
className={cn(
|
||||||
|
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarBadge,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export const API_ROUTES = {
|
|||||||
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`,
|
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`,
|
||||||
START: (id: string) => `/biz/api/v1/tickets/${id}/start`,
|
START: (id: string) => `/biz/api/v1/tickets/${id}/start`,
|
||||||
SUBMIT_REPAIR: (id: string) => `/biz/api/v1/tickets/${id}/submit-repair`,
|
SUBMIT_REPAIR: (id: string) => `/biz/api/v1/tickets/${id}/submit-repair`,
|
||||||
|
SUBMIT_REPAIR_UPLOAD: (id: string) =>
|
||||||
|
`/biz/api/v1/tickets/${id}/submit-repair/upload`,
|
||||||
REVIEW: (id: string) => `/biz/api/v1/tickets/${id}/review`,
|
REVIEW: (id: string) => `/biz/api/v1/tickets/${id}/review`,
|
||||||
CLOSE: (id: string) => `/biz/api/v1/tickets/${id}/close`,
|
CLOSE: (id: string) => `/biz/api/v1/tickets/${id}/close`,
|
||||||
DETAIL_EVENTS_TOKEN: (id: string) =>
|
DETAIL_EVENTS_TOKEN: (id: string) =>
|
||||||
|
|||||||
39
src/hooks/useProtectedMedia.ts
Normal file
39
src/hooks/useProtectedMedia.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { protectedMediaService } from '@/services/api';
|
||||||
|
|
||||||
|
export function useProtectedMediaObjectUrl(url: string | null | undefined) {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['protected-media', url],
|
||||||
|
queryFn: () => protectedMediaService.getBlob(url as string),
|
||||||
|
enabled: Boolean(url),
|
||||||
|
staleTime: Infinity,
|
||||||
|
gcTime: 1000 * 60 * 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query.data) {
|
||||||
|
setObjectUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUrl = URL.createObjectURL(query.data);
|
||||||
|
setObjectUrl(nextUrl);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
URL.revokeObjectURL(nextUrl);
|
||||||
|
};
|
||||||
|
}, [query.data]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
objectUrl,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
isError: query.isError,
|
||||||
|
error: query.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,3 +12,4 @@ export * from './client.service';
|
|||||||
export * from './plan.service';
|
export * from './plan.service';
|
||||||
export * from './tenant.service';
|
export * from './tenant.service';
|
||||||
export * from './ticket.service';
|
export * from './ticket.service';
|
||||||
|
export * from './media.service';
|
||||||
|
|||||||
10
src/services/api/media.service.ts
Normal file
10
src/services/api/media.service.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import axiosClient from '../axios/axios';
|
||||||
|
|
||||||
|
export const protectedMediaService = {
|
||||||
|
getBlob: async (url: string): Promise<Blob> => {
|
||||||
|
const response = await axiosClient.get<Blob>(url, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -3,9 +3,11 @@ import { API_ROUTES } from '@/constants/apiRoutes';
|
|||||||
import type {
|
import type {
|
||||||
AssignTicketPayload,
|
AssignTicketPayload,
|
||||||
AssignableTicketUsersResponse,
|
AssignableTicketUsersResponse,
|
||||||
|
AssignableTicketUsersParams,
|
||||||
ReviewRepairPayload,
|
ReviewRepairPayload,
|
||||||
SseTokenResponse,
|
SseTokenResponse,
|
||||||
SubmitRepairPayload,
|
SubmitRepairPayload,
|
||||||
|
SubmitRepairUploadPayload,
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
TicketListParams,
|
TicketListParams,
|
||||||
TicketListResponse,
|
TicketListResponse,
|
||||||
@@ -36,9 +38,18 @@ export const ticketService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getAssignableUsers: async (): Promise<AssignableTicketUsersResponse> => {
|
getAssignableUsers: async (
|
||||||
|
params?: AssignableTicketUsersParams,
|
||||||
|
): Promise<AssignableTicketUsersResponse> => {
|
||||||
const response = await axiosClient.get<AssignableTicketUsersResponse>(
|
const response = await axiosClient.get<AssignableTicketUsersResponse>(
|
||||||
API_ROUTES.TICKETS.ASSIGNABLE_USERS,
|
API_ROUTES.TICKETS.ASSIGNABLE_USERS,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
skip: params?.skip ?? 0,
|
||||||
|
limit: params?.limit ?? 20,
|
||||||
|
search_term: params?.search_term,
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -72,6 +83,29 @@ export const ticketService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
submitRepairUpload: async (
|
||||||
|
ticketId: string,
|
||||||
|
payload: SubmitRepairUploadPayload,
|
||||||
|
): Promise<TicketDetail> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('notes', payload.notes);
|
||||||
|
formData.append('video', payload.video);
|
||||||
|
payload.images.forEach((image) => {
|
||||||
|
formData.append('images', image);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await axiosClient.post<TicketDetail>(
|
||||||
|
API_ROUTES.TICKETS.SUBMIT_REPAIR_UPLOAD(ticketId),
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
reviewRepair: async (
|
reviewRepair: async (
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
payload: ReviewRepairPayload,
|
payload: ReviewRepairPayload,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export interface TicketHistoryItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketActor {
|
export interface TicketActor {
|
||||||
user_id: number;
|
user_id: number | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
}
|
}
|
||||||
@@ -45,6 +45,9 @@ export interface TicketStatusMetadata {
|
|||||||
reason: string | null;
|
reason: string | null;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
message: string | null;
|
message: string | null;
|
||||||
|
detection_count?: number | null;
|
||||||
|
requires_action?: boolean;
|
||||||
|
review_comment?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketVideo {
|
export interface TicketVideo {
|
||||||
@@ -56,13 +59,13 @@ export interface TicketVideo {
|
|||||||
|
|
||||||
export interface TicketWorker extends TicketActor {
|
export interface TicketWorker extends TicketActor {
|
||||||
assigned_at: string | null;
|
assigned_at: string | null;
|
||||||
assigned_by_user_id: number | null;
|
assigned_by: TicketActor | null;
|
||||||
assigned_by_name: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketRepair {
|
export interface TicketRepair {
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
proof_path: string | null;
|
proof_video_url: string | null;
|
||||||
|
proof_image_urls: string[];
|
||||||
submitted_at: string | null;
|
submitted_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +92,7 @@ export interface TicketTimestamps {
|
|||||||
export interface TicketDetail {
|
export interface TicketDetail {
|
||||||
id: string;
|
id: string;
|
||||||
video_id: string;
|
video_id: string;
|
||||||
chainage_id: string;
|
chainage_id: string | null;
|
||||||
status: TicketStatus;
|
status: TicketStatus;
|
||||||
status_metadata: TicketStatusMetadata | null;
|
status_metadata: TicketStatusMetadata | null;
|
||||||
video: TicketVideo | null;
|
video: TicketVideo | null;
|
||||||
@@ -115,6 +118,10 @@ export interface AssignableTicketUser {
|
|||||||
username: string;
|
username: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AssignableTicketUsersParams extends PaginationParams {
|
||||||
|
search_term?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AssignableTicketUsersResponse {
|
export interface AssignableTicketUsersResponse {
|
||||||
items: AssignableTicketUser[];
|
items: AssignableTicketUser[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -122,7 +129,7 @@ export interface AssignableTicketUsersResponse {
|
|||||||
|
|
||||||
export interface AssignTicketPayload {
|
export interface AssignTicketPayload {
|
||||||
assigned_to_user_id: number;
|
assigned_to_user_id: number;
|
||||||
assigned_to_email: string;
|
assigned_to_email?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +138,12 @@ export interface SubmitRepairPayload {
|
|||||||
proof_path: string;
|
proof_path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubmitRepairUploadPayload {
|
||||||
|
notes: string;
|
||||||
|
video: File;
|
||||||
|
images: File[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReviewRepairPayload {
|
export interface ReviewRepairPayload {
|
||||||
action: 'approve' | 'reject';
|
action: 'approve' | 'reject';
|
||||||
comment: string;
|
comment: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user