Compare commits
2 Commits
a18fff4d6f
...
a567a52e26
| Author | SHA1 | Date | |
|---|---|---|---|
| a567a52e26 | |||
| 8c3d19a429 |
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { Loader2, TrendingUp } from 'lucide-react';
|
import { ArrowLeft, Loader2, TrendingUp } from 'lucide-react';
|
||||||
import VideoPlayerSection from '@/components/videoPlayerSection';
|
import VideoPlayerSection from '@/components/videoPlayerSection';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { CompletedVideoResult, DetectionType } from '@/types';
|
import { CompletedVideoResult, DetectionType } from '@/types';
|
||||||
import { getDetectionModeConfig } from '@/constants/detectionModeConfig';
|
import { getDetectionModeConfig } from '@/constants/detectionModeConfig';
|
||||||
import { useVideoResultsQuery } from '../hooks/useVideoResults';
|
import { useVideoResultsQuery } from '../hooks/useVideoResults';
|
||||||
@@ -21,6 +22,7 @@ const inferDetectionType = (data: CompletedVideoResult): DetectionType => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function VideoResultsPage() {
|
export default function VideoResultsPage() {
|
||||||
|
const router = useRouter();
|
||||||
const { videoId } = useParams() as { videoId: string };
|
const { videoId } = useParams() as { videoId: string };
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -31,7 +33,8 @@ export default function VideoResultsPage() {
|
|||||||
} = useVideoResultsQuery(videoId);
|
} = useVideoResultsQuery(videoId);
|
||||||
|
|
||||||
const detectionType = useMemo<DetectionType>(
|
const detectionType = useMemo<DetectionType>(
|
||||||
() => (detectionData ? inferDetectionType(detectionData) : 'pothole-detection'),
|
() =>
|
||||||
|
detectionData ? inferDetectionType(detectionData) : 'pothole-detection',
|
||||||
[detectionData],
|
[detectionData],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -68,6 +71,12 @@ export default function VideoResultsPage() {
|
|||||||
title={getTitle()}
|
title={getTitle()}
|
||||||
description={`Video ID: ${videoId}`}
|
description={`Video ID: ${videoId}`}
|
||||||
icon={TrendingUp}
|
icon={TrendingUp}
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" size="sm" onClick={() => router.back()}>
|
||||||
|
<ArrowLeft />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{detectionData && <VideoPlayerSection data={detectionData} />}
|
{detectionData && <VideoPlayerSection data={detectionData} />}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/app/(modules)/ticket/[ticketId]/page.tsx
Normal file
80
src/app/(modules)/ticket/[ticketId]/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { ArrowLeft, Loader2, Ticket } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
import { TicketHistoryCard } from './components/TicketHistoryCard';
|
||||||
|
import { TicketOverviewCard } from './components/TicketOverviewCard';
|
||||||
|
import { TicketRepairReportCard } from './components/TicketRepairReportCard';
|
||||||
|
import { TicketStatusActions } from './components/TicketStatusActions';
|
||||||
|
import { useTicketDetailEvents } from '../hooks/useTicketEvents';
|
||||||
|
import { useTicketDetailQuery } from '../hooks/useTicketQueries';
|
||||||
|
|
||||||
|
export default function TicketDetailPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { ticketId } = useParams() as { ticketId: string };
|
||||||
|
|
||||||
|
const ticketQuery = useTicketDetailQuery(ticketId);
|
||||||
|
const ticket = ticketQuery.data;
|
||||||
|
|
||||||
|
useTicketDetailEvents(ticketId);
|
||||||
|
|
||||||
|
if (ticketQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
|
<Loader2 className="size-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticketQuery.isError || !ticket) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3">
|
||||||
|
<p className="text-sm text-destructive">Failed to load ticket.</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push('/ticket')}
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
Back to Tickets
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative z-10 space-y-5">
|
||||||
|
<PageHeader
|
||||||
|
title="Ticket Detail"
|
||||||
|
description={`Ticket ID: ${ticket.id}`}
|
||||||
|
icon={Ticket}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push('/ticket')}
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<TicketOverviewCard ticket={ticket} />
|
||||||
|
<TicketRepairReportCard ticket={ticket} />
|
||||||
|
<TicketHistoryCard ticket={ticket} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<TicketStatusActions ticket={ticket} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/app/(modules)/ticket/components/TicketColumns.tsx
Normal file
60
src/app/(modules)/ticket/components/TicketColumns.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import type { TicketListItem } from '@/types';
|
||||||
|
|
||||||
|
import { TicketStatusBadge } from './TicketStatusBadge';
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined) {
|
||||||
|
if (!value) return '-';
|
||||||
|
return new Intl.DateTimeFormat('en-IN', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTicketColumns(): ColumnDef<TicketListItem>[] {
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'id',
|
||||||
|
header: 'Ticket',
|
||||||
|
size: 180,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-medium">{row.original.id}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
size: 150,
|
||||||
|
cell: ({ row }) => <TicketStatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'detection_count',
|
||||||
|
header: 'Detections',
|
||||||
|
size: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'assigned_to_email',
|
||||||
|
header: 'Assigned To',
|
||||||
|
size: 220,
|
||||||
|
cell: ({ row }) => row.original.assigned_to_email || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_by_email',
|
||||||
|
header: 'Uploaded By',
|
||||||
|
size: 220,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'updated_at',
|
||||||
|
header: 'Updated',
|
||||||
|
size: 180,
|
||||||
|
cell: ({ row }) => formatDate(row.original.updated_at),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/app/(modules)/ticket/components/TicketFilters.tsx
Normal file
60
src/app/(modules)/ticket/components/TicketFilters.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { SegmentSelect } from '@/components/lookups/SegmentSelect';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import type { TicketStatus } from '@/types';
|
||||||
|
|
||||||
|
import { formatTicketStatus } from './TicketStatusBadge';
|
||||||
|
|
||||||
|
const ticketStatuses: TicketStatus[] = [
|
||||||
|
'processing',
|
||||||
|
'unassigned',
|
||||||
|
'assigned',
|
||||||
|
'under_review',
|
||||||
|
'closed',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface TicketFiltersProps {
|
||||||
|
status: TicketStatus | 'all';
|
||||||
|
segmentId: string;
|
||||||
|
onStatusChange: (status: TicketStatus | 'all') => void;
|
||||||
|
onSegmentChange: (segmentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketFilters({
|
||||||
|
status,
|
||||||
|
segmentId,
|
||||||
|
onStatusChange,
|
||||||
|
onSegmentChange,
|
||||||
|
}: TicketFiltersProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
onValueChange={(value) => onStatusChange(value as TicketStatus | 'all')}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full sm:w-48">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
|
{ticketStatuses.map((item) => (
|
||||||
|
<SelectItem key={item} value={item}>
|
||||||
|
{formatTicketStatus(item)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="w-full sm:w-64">
|
||||||
|
<SegmentSelect value={segmentId} onValueChange={onSegmentChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/app/(modules)/ticket/components/TicketStatusBadge.tsx
Normal file
28
src/app/(modules)/ticket/components/TicketStatusBadge.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { TicketStatus } from '@/types';
|
||||||
|
|
||||||
|
const statusClasses: Record<TicketStatus, string> = {
|
||||||
|
processing: 'bg-sky-100 text-sky-700 border-sky-200',
|
||||||
|
unassigned: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||||
|
assigned: 'bg-indigo-100 text-indigo-700 border-indigo-200',
|
||||||
|
under_review: 'bg-violet-100 text-violet-700 border-violet-200',
|
||||||
|
closed: 'bg-stone-100 text-stone-700 border-stone-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatTicketStatus(status: TicketStatus) {
|
||||||
|
return status
|
||||||
|
.split('_')
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketStatusBadge({ status }: { status: TicketStatus }) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={cn('border', statusClasses[status])}>
|
||||||
|
{formatTicketStatus(status)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/app/(modules)/ticket/components/TicketTable.tsx
Normal file
69
src/app/(modules)/ticket/components/TicketTable.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { BarChart3, Eye, ChartPie } from 'lucide-react';
|
||||||
|
|
||||||
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import type { TicketListItem } from '@/types';
|
||||||
|
|
||||||
|
interface TicketTableProps {
|
||||||
|
columns: ColumnDef<TicketListItem>[];
|
||||||
|
tickets: TicketListItem[];
|
||||||
|
isLoading: boolean;
|
||||||
|
toolbar: ReactNode;
|
||||||
|
skip: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
onPageChange: (skip: number) => void;
|
||||||
|
onLimitChange: (limit: number) => void;
|
||||||
|
onView: (ticket: TicketListItem) => void;
|
||||||
|
onShowAnalytics: (ticket: TicketListItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketTable({
|
||||||
|
columns,
|
||||||
|
tickets,
|
||||||
|
isLoading,
|
||||||
|
toolbar,
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
onView,
|
||||||
|
onShowAnalytics,
|
||||||
|
}: TicketTableProps) {
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
title="Tickets"
|
||||||
|
columns={columns}
|
||||||
|
data={tickets}
|
||||||
|
isLoading={isLoading}
|
||||||
|
toolbar={toolbar}
|
||||||
|
emptyTitle="No tickets found."
|
||||||
|
emptyDescription="Ticket rows will appear after video processing creates them."
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'View',
|
||||||
|
icon: <Eye className="size-4" />,
|
||||||
|
onClick: onView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Show Analytics',
|
||||||
|
icon: <ChartPie className="size-4" />,
|
||||||
|
onClick: onShowAnalytics,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
pagination={{
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
totalItems: total,
|
||||||
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
}}
|
||||||
|
onRowClick={onView}
|
||||||
|
actionsSticky
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
src/app/(modules)/ticket/hooks/useTicketEvents.ts
Normal file
159
src/app/(modules)/ticket/hooks/useTicketEvents.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||||
|
import { ticketService } from '@/services/api';
|
||||||
|
import { sseService } from '@/services/sse';
|
||||||
|
import type {
|
||||||
|
TicketDetail,
|
||||||
|
TicketListResponse,
|
||||||
|
TicketProgressEvent,
|
||||||
|
TicketStatusEvent,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
import { ticketKeys } from '../queries/ticketKeys';
|
||||||
|
|
||||||
|
function getTicketEventId(event: TicketStatusEvent) {
|
||||||
|
return event.id ?? event.ticket_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeTicketDetail(
|
||||||
|
current: TicketDetail | undefined,
|
||||||
|
event: TicketStatusEvent,
|
||||||
|
) {
|
||||||
|
if (!current) return current;
|
||||||
|
|
||||||
|
const eventId = getTicketEventId(event);
|
||||||
|
if (eventId && eventId !== current.id) return current;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
...event,
|
||||||
|
id: current.id,
|
||||||
|
history: event.history ?? current.history,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchTicketLists(
|
||||||
|
current: TicketListResponse | undefined,
|
||||||
|
event: TicketStatusEvent,
|
||||||
|
) {
|
||||||
|
const eventId = getTicketEventId(event);
|
||||||
|
if (!current || !eventId) return current;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
items: current.items.map((ticket) =>
|
||||||
|
ticket.id === eventId
|
||||||
|
? {
|
||||||
|
...ticket,
|
||||||
|
status: event.status ?? ticket.status,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTenantTicketTableEvents(enabled = true) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
let closeConnection: (() => void) | undefined;
|
||||||
|
|
||||||
|
ticketService
|
||||||
|
.createTenantTicketEventsToken()
|
||||||
|
.then(({ sse_token }) => {
|
||||||
|
if (closed) return;
|
||||||
|
|
||||||
|
const connection = sseService.connect(
|
||||||
|
API_ROUTES.TICKET_EVENTS.TENANT(sse_token),
|
||||||
|
{
|
||||||
|
ticket_status: (event: TicketStatusEvent) => {
|
||||||
|
queryClient.setQueriesData<TicketListResponse>(
|
||||||
|
{ queryKey: ticketKeys.lists() },
|
||||||
|
(current) => patchTicketLists(current, event),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
closeConnection = connection.close;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closed = true;
|
||||||
|
closeConnection?.();
|
||||||
|
};
|
||||||
|
}, [enabled, queryClient]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTicketDetailEvents(
|
||||||
|
ticketId: string | undefined,
|
||||||
|
enabled = true,
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !ticketId) return;
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
let closeConnection: (() => void) | undefined;
|
||||||
|
|
||||||
|
ticketService
|
||||||
|
.createTicketEventsToken(ticketId)
|
||||||
|
.then(({ sse_token }) => {
|
||||||
|
if (closed) return;
|
||||||
|
|
||||||
|
const connection = sseService.connect(
|
||||||
|
API_ROUTES.TICKETS.DETAIL_EVENTS(ticketId, sse_token),
|
||||||
|
{
|
||||||
|
ticket_status: (event: TicketStatusEvent) => {
|
||||||
|
queryClient.setQueryData<TicketDetail>(
|
||||||
|
ticketKeys.detail(ticketId),
|
||||||
|
(current) => mergeTicketDetail(current, event),
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
},
|
||||||
|
progress: (event: TicketProgressEvent) => {
|
||||||
|
queryClient.setQueryData<TicketDetail>(
|
||||||
|
ticketKeys.detail(ticketId),
|
||||||
|
(current) =>
|
||||||
|
current && event.status
|
||||||
|
? { ...current, status: current.status }
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.detail(ticketId),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
closeConnection = connection.close;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.detail(ticketId),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closed = true;
|
||||||
|
closeConnection?.();
|
||||||
|
};
|
||||||
|
}, [enabled, queryClient, ticketId]);
|
||||||
|
}
|
||||||
189
src/app/(modules)/ticket/hooks/useTicketQueries.ts
Normal file
189
src/app/(modules)/ticket/hooks/useTicketQueries.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { ticketService } from '@/services/api';
|
||||||
|
import type {
|
||||||
|
AssignTicketPayload,
|
||||||
|
ReviewRepairPayload,
|
||||||
|
SubmitRepairPayload,
|
||||||
|
SubmitRepairUploadPayload,
|
||||||
|
TicketDetail,
|
||||||
|
TicketListParams,
|
||||||
|
TicketStatus,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
import { ticketKeys } from '../queries/ticketKeys';
|
||||||
|
|
||||||
|
interface UseTicketsQueryParams {
|
||||||
|
skip: number;
|
||||||
|
limit: number;
|
||||||
|
status?: TicketStatus | 'all';
|
||||||
|
chainageId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTicketListParams({
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
status,
|
||||||
|
chainageId,
|
||||||
|
}: UseTicketsQueryParams): TicketListParams {
|
||||||
|
return {
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
status: status === 'all' ? undefined : status,
|
||||||
|
chainage_id: chainageId || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTicketsQuery(params: UseTicketsQueryParams) {
|
||||||
|
const { skip, limit, status, chainageId } = params;
|
||||||
|
const listParams = useMemo(
|
||||||
|
() => buildTicketListParams({ skip, limit, status, chainageId }),
|
||||||
|
[chainageId, limit, skip, status],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ticketKeys.list(listParams),
|
||||||
|
queryFn: () => ticketService.getTickets(listParams),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTicketDetailQuery(ticketId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ticketKeys.detail(ticketId ?? ''),
|
||||||
|
queryFn: () => ticketService.getTicketDetail(ticketId as string),
|
||||||
|
enabled: Boolean(ticketId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssignableTicketUsersQuery(enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ticketKeys.assignableUsers(),
|
||||||
|
queryFn: () => ticketService.getAssignableUsers(),
|
||||||
|
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) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: AssignTicketPayload) =>
|
||||||
|
ticketService.assignTicket(ticketId, payload),
|
||||||
|
onSuccess: (ticket) => {
|
||||||
|
toast.success('Ticket assigned');
|
||||||
|
queryClient.setQueryData<TicketDetail>(
|
||||||
|
ticketKeys.detail(ticketId),
|
||||||
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to assign ticket'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStartTicketMutation(ticketId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => ticketService.startTicket(ticketId),
|
||||||
|
onSuccess: (ticket) => {
|
||||||
|
toast.success('Ticket started');
|
||||||
|
queryClient.setQueryData<TicketDetail>(
|
||||||
|
ticketKeys.detail(ticketId),
|
||||||
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to start ticket'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubmitRepairMutation(ticketId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: SubmitRepairPayload) =>
|
||||||
|
ticketService.submitRepair(ticketId, payload),
|
||||||
|
onSuccess: (ticket) => {
|
||||||
|
toast.success('Repair submitted');
|
||||||
|
queryClient.setQueryData<TicketDetail>(
|
||||||
|
ticketKeys.detail(ticketId),
|
||||||
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
|
);
|
||||||
|
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) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: ReviewRepairPayload) =>
|
||||||
|
ticketService.reviewRepair(ticketId, payload),
|
||||||
|
onSuccess: (ticket, payload) => {
|
||||||
|
toast.success(
|
||||||
|
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected',
|
||||||
|
);
|
||||||
|
queryClient.setQueryData<TicketDetail>(
|
||||||
|
ticketKeys.detail(ticketId),
|
||||||
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to review repair'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCloseTicketMutation(ticketId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => ticketService.closeTicket(ticketId),
|
||||||
|
onSuccess: (ticket) => {
|
||||||
|
toast.success('Ticket closed');
|
||||||
|
queryClient.setQueryData<TicketDetail>(
|
||||||
|
ticketKeys.detail(ticketId),
|
||||||
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to close ticket'),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,32 +1,94 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { Ticket } from 'lucide-react';
|
import { Ticket } from 'lucide-react';
|
||||||
|
import type { TicketListItem, TicketStatus } from '@/types';
|
||||||
|
|
||||||
|
import { TicketFilters } from './components/TicketFilters';
|
||||||
|
import { TicketTable } from './components/TicketTable';
|
||||||
|
import { useTicketColumns } from './components/TicketColumns';
|
||||||
|
import { useTenantTicketTableEvents } from './hooks/useTicketEvents';
|
||||||
|
import { useTicketsQuery } from './hooks/useTicketQueries';
|
||||||
|
|
||||||
export default function TicketPage() {
|
export default function TicketPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [skip, setSkip] = useState(0);
|
||||||
|
const [limit, setLimit] = useState(10);
|
||||||
|
const [status, setStatus] = useState<TicketStatus | 'all'>('all');
|
||||||
|
const [segmentId, setSegmentId] = useState('');
|
||||||
|
|
||||||
|
useTenantTicketTableEvents();
|
||||||
|
|
||||||
|
const ticketsQuery = useTicketsQuery({
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
status,
|
||||||
|
chainageId: segmentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useTicketColumns();
|
||||||
|
const tickets = ticketsQuery.data?.items ?? [];
|
||||||
|
const total = ticketsQuery.data?.total ?? 0;
|
||||||
|
|
||||||
|
const handleStatusChange = useCallback((nextStatus: TicketStatus | 'all') => {
|
||||||
|
setStatus(nextStatus);
|
||||||
|
setSkip(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSegmentChange = useCallback((nextSegmentId: string) => {
|
||||||
|
setSegmentId(nextSegmentId);
|
||||||
|
setSkip(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleView = useCallback(
|
||||||
|
(ticket: TicketListItem) => {
|
||||||
|
router.push(`/ticket/${ticket.id}`);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleShowAnalytics = useCallback(
|
||||||
|
(ticket: TicketListItem) => {
|
||||||
|
router.push(`/results/${ticket.video_id}`);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolbar = useMemo(
|
||||||
|
() => (
|
||||||
|
<TicketFilters
|
||||||
|
status={status}
|
||||||
|
segmentId={segmentId}
|
||||||
|
onStatusChange={handleStatusChange}
|
||||||
|
onSegmentChange={handleSegmentChange}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[handleSegmentChange, handleStatusChange, segmentId, status],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen pb-12">
|
<main className="relative z-10 space-y-5">
|
||||||
<main>
|
|
||||||
<div className="mb-10">
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Ticket"
|
title="Tickets"
|
||||||
description="Uploaded video details will be shown here."
|
description="Track detection, assignment, repair, review, and closure."
|
||||||
icon={Ticket}
|
icon={Ticket}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
<TicketTable
|
||||||
<CardHeader>
|
columns={columns}
|
||||||
<CardTitle>Ticket Page</CardTitle>
|
tickets={tickets}
|
||||||
</CardHeader>
|
isLoading={ticketsQuery.isLoading}
|
||||||
<CardContent>
|
toolbar={toolbar}
|
||||||
<p className="text-sm text-muted-foreground">
|
skip={skip}
|
||||||
This is ticket page for now.
|
limit={limit}
|
||||||
</p>
|
total={total}
|
||||||
</CardContent>
|
onPageChange={setSkip}
|
||||||
</Card>
|
onLimitChange={setLimit}
|
||||||
|
onView={handleView}
|
||||||
|
onShowAnalytics={handleShowAnalytics}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/app/(modules)/ticket/queries/ticketKeys.ts
Normal file
10
src/app/(modules)/ticket/queries/ticketKeys.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { TicketListParams } from '@/types';
|
||||||
|
|
||||||
|
export const ticketKeys = {
|
||||||
|
all: ['tickets'] as const,
|
||||||
|
lists: () => [...ticketKeys.all, 'list'] as const,
|
||||||
|
list: (params: TicketListParams) => [...ticketKeys.lists(), params] as const,
|
||||||
|
details: () => [...ticketKeys.all, 'detail'] as const,
|
||||||
|
detail: (ticketId: string) => [...ticketKeys.details(), ticketId] as const,
|
||||||
|
assignableUsers: () => [...ticketKeys.all, 'assignable-users'] as const,
|
||||||
|
};
|
||||||
@@ -272,7 +272,10 @@ function DataTableContent<TData, TValue>({
|
|||||||
Array.from({ length: 5 }).map((_, idx) => (
|
Array.from({ length: 5 }).map((_, idx) => (
|
||||||
<TableRow key={idx}>
|
<TableRow key={idx}>
|
||||||
{columns.map((_, colIdx) => (
|
{columns.map((_, colIdx) => (
|
||||||
<TableCell key={colIdx}>
|
<TableCell
|
||||||
|
key={colIdx}
|
||||||
|
className="border-b border-border"
|
||||||
|
>
|
||||||
<Skeleton className="h-4 w-full max-w-35" />
|
<Skeleton className="h-4 w-full max-w-35" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
Ticket,
|
||||||
Users,
|
Users,
|
||||||
Building2,
|
Building2,
|
||||||
BadgeIndianRupee,
|
BadgeIndianRupee,
|
||||||
@@ -35,6 +36,11 @@ export const menuItems: MenuItem[] = [
|
|||||||
path: ROUTES.UPLOAD,
|
path: ROUTES.UPLOAD,
|
||||||
icon: Plus,
|
icon: Plus,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Ticket',
|
||||||
|
path: ROUTES.TICKET,
|
||||||
|
icon: Ticket,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Project',
|
title: 'Project',
|
||||||
path: ROUTES.PROJECT,
|
path: ROUTES.PROJECT,
|
||||||
|
|||||||
@@ -55,4 +55,30 @@ export const API_ROUTES = {
|
|||||||
MY_UPLOADS: '/biz/api/v1/videos/me',
|
MY_UPLOADS: '/biz/api/v1/videos/me',
|
||||||
RESULTS: (id: string) => `/biz/api/v1/results/${id}/completed`,
|
RESULTS: (id: string) => `/biz/api/v1/results/${id}/completed`,
|
||||||
},
|
},
|
||||||
|
TICKETS: {
|
||||||
|
BASE: '/biz/api/v1/tickets',
|
||||||
|
DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
|
||||||
|
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`,
|
||||||
|
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`,
|
||||||
|
CLOSE: (id: string) => `/biz/api/v1/tickets/${id}/close`,
|
||||||
|
DETAIL_EVENTS_TOKEN: (id: string) =>
|
||||||
|
`/biz/api/v1/tickets/${id}/events/token`,
|
||||||
|
DETAIL_EVENTS: (id: string, token: string) =>
|
||||||
|
`/biz/api/v1/tickets/${id}/events?sse_token=${encodeURIComponent(token)}`,
|
||||||
|
ASSIGNABLE_USERS: '/biz/api/v1/tickets/assignable-users',
|
||||||
|
},
|
||||||
|
TICKET_EVENTS: {
|
||||||
|
TENANT_TOKEN: '/biz/api/v1/tenants/me/tickets/events/token',
|
||||||
|
TENANT: (token: string) =>
|
||||||
|
`/biz/api/v1/tenants/me/tickets/events?sse_token=${encodeURIComponent(
|
||||||
|
token,
|
||||||
|
)}`,
|
||||||
|
USER_TOKEN: '/biz/api/v1/users/me/events/token',
|
||||||
|
USER: (token: string) =>
|
||||||
|
`/biz/api/v1/users/me/events?sse_token=${encodeURIComponent(token)}`,
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,3 +11,5 @@ export * from './user.service';
|
|||||||
export * from './client.service';
|
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 './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;
|
||||||
|
},
|
||||||
|
};
|
||||||
149
src/services/api/ticket.service.ts
Normal file
149
src/services/api/ticket.service.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import axiosClient from '../axios/axios';
|
||||||
|
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||||
|
import type {
|
||||||
|
AssignTicketPayload,
|
||||||
|
AssignableTicketUsersResponse,
|
||||||
|
AssignableTicketUsersParams,
|
||||||
|
ReviewRepairPayload,
|
||||||
|
SseTokenResponse,
|
||||||
|
SubmitRepairPayload,
|
||||||
|
SubmitRepairUploadPayload,
|
||||||
|
TicketDetail,
|
||||||
|
TicketListParams,
|
||||||
|
TicketListResponse,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
export const ticketService = {
|
||||||
|
getTickets: async (
|
||||||
|
params?: TicketListParams,
|
||||||
|
): Promise<TicketListResponse> => {
|
||||||
|
const response = await axiosClient.get<TicketListResponse>(
|
||||||
|
API_ROUTES.TICKETS.BASE,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
skip: params?.skip ?? 0,
|
||||||
|
limit: params?.limit ?? 10,
|
||||||
|
status: params?.status,
|
||||||
|
chainage_id: params?.chainage_id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTicketDetail: async (ticketId: string): Promise<TicketDetail> => {
|
||||||
|
const response = await axiosClient.get<TicketDetail>(
|
||||||
|
API_ROUTES.TICKETS.DETAIL(ticketId),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAssignableUsers: async (
|
||||||
|
params?: AssignableTicketUsersParams,
|
||||||
|
): Promise<AssignableTicketUsersResponse> => {
|
||||||
|
const response = await axiosClient.get<AssignableTicketUsersResponse>(
|
||||||
|
API_ROUTES.TICKETS.ASSIGNABLE_USERS,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
skip: params?.skip ?? 0,
|
||||||
|
limit: params?.limit ?? 20,
|
||||||
|
search_term: params?.search_term,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
assignTicket: async (
|
||||||
|
ticketId: string,
|
||||||
|
payload: AssignTicketPayload,
|
||||||
|
): Promise<TicketDetail> => {
|
||||||
|
const response = await axiosClient.post<TicketDetail>(
|
||||||
|
API_ROUTES.TICKETS.ASSIGN(ticketId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
startTicket: async (ticketId: string): Promise<TicketDetail> => {
|
||||||
|
const response = await axiosClient.post<TicketDetail>(
|
||||||
|
API_ROUTES.TICKETS.START(ticketId),
|
||||||
|
);
|
||||||
|
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 (
|
||||||
|
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 (
|
||||||
|
ticketId: string,
|
||||||
|
payload: ReviewRepairPayload,
|
||||||
|
): Promise<TicketDetail> => {
|
||||||
|
const response = await axiosClient.post<TicketDetail>(
|
||||||
|
API_ROUTES.TICKETS.REVIEW(ticketId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeTicket: async (ticketId: string): Promise<TicketDetail> => {
|
||||||
|
const response = await axiosClient.post<TicketDetail>(
|
||||||
|
API_ROUTES.TICKETS.CLOSE(ticketId),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createTicketEventsToken: async (
|
||||||
|
ticketId: string,
|
||||||
|
): Promise<SseTokenResponse> => {
|
||||||
|
const response = await axiosClient.post<SseTokenResponse>(
|
||||||
|
API_ROUTES.TICKETS.DETAIL_EVENTS_TOKEN(ticketId),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createTenantTicketEventsToken: async (): Promise<SseTokenResponse> => {
|
||||||
|
const response = await axiosClient.post<SseTokenResponse>(
|
||||||
|
API_ROUTES.TICKET_EVENTS.TENANT_TOKEN,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createUserEventsToken: async (): Promise<SseTokenResponse> => {
|
||||||
|
const response = await axiosClient.post<SseTokenResponse>(
|
||||||
|
API_ROUTES.TICKET_EVENTS.USER_TOKEN,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
1
src/services/sse/index.ts
Normal file
1
src/services/sse/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './sse.service';
|
||||||
66
src/services/sse/sse.service.ts
Normal file
66
src/services/sse/sse.service.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { ENV_CONSTANT } from '@/constants/secrect.constant';
|
||||||
|
|
||||||
|
export type SseEventHandler<T = unknown> = (
|
||||||
|
data: T,
|
||||||
|
event: MessageEvent,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export type SseEventMap<TEvents extends Record<string, unknown>> = {
|
||||||
|
[EventName in keyof TEvents]: SseEventHandler<TEvents[EventName]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SseConnection {
|
||||||
|
source: EventSource;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSseUrl(path: string) {
|
||||||
|
if (/^https?:\/\//i.test(path)) return path;
|
||||||
|
|
||||||
|
const baseUrl = ENV_CONSTANT.BASE_API_URL ?? '';
|
||||||
|
const normalizedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
|
||||||
|
return `${normalizedBase}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEventData(event: MessageEvent) {
|
||||||
|
if (!event.data) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(event.data);
|
||||||
|
} catch {
|
||||||
|
return event.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sseService = {
|
||||||
|
connect: <TEvents extends Record<string, unknown>>(
|
||||||
|
path: string,
|
||||||
|
events: SseEventMap<TEvents>,
|
||||||
|
): SseConnection => {
|
||||||
|
const source = new EventSource(getSseUrl(path));
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
|
||||||
|
Object.entries(events).forEach(([eventName, handler]) => {
|
||||||
|
const listener = (event: Event) => {
|
||||||
|
const messageEvent = event as MessageEvent;
|
||||||
|
(handler as SseEventHandler)(
|
||||||
|
parseEventData(messageEvent),
|
||||||
|
messageEvent,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
source.addEventListener(eventName, listener);
|
||||||
|
cleanups.push(() => source.removeEventListener(eventName, listener));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
close: () => {
|
||||||
|
cleanups.forEach((cleanup) => cleanup());
|
||||||
|
source.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -13,3 +13,4 @@ export * from './user';
|
|||||||
export * from './client';
|
export * from './client';
|
||||||
export * from './plan';
|
export * from './plan';
|
||||||
export * from './tenant';
|
export * from './tenant';
|
||||||
|
export * from './ticket';
|
||||||
|
|||||||
170
src/types/ticket.ts
Normal file
170
src/types/ticket.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import type { PaginationParams } from './common';
|
||||||
|
|
||||||
|
export type TicketStatus =
|
||||||
|
| 'processing'
|
||||||
|
| 'unassigned'
|
||||||
|
| 'assigned'
|
||||||
|
| 'under_review'
|
||||||
|
| 'closed';
|
||||||
|
|
||||||
|
export interface TicketListParams extends PaginationParams {
|
||||||
|
status?: TicketStatus;
|
||||||
|
chainage_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketListItem {
|
||||||
|
id: string;
|
||||||
|
video_id: string;
|
||||||
|
chainage_id: string;
|
||||||
|
status: TicketStatus;
|
||||||
|
assigned_to_user_id: number | null;
|
||||||
|
assigned_to_email: string | null;
|
||||||
|
detection_count: number;
|
||||||
|
created_by_user_id: number;
|
||||||
|
created_by_email: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketHistoryItem {
|
||||||
|
id: number;
|
||||||
|
from_status: TicketStatus | null;
|
||||||
|
to_status: TicketStatus;
|
||||||
|
actor: TicketActor | null;
|
||||||
|
note: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketActor {
|
||||||
|
user_id: number | null;
|
||||||
|
name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketStatusMetadata {
|
||||||
|
reason: string | null;
|
||||||
|
label: string | null;
|
||||||
|
message: string | null;
|
||||||
|
detection_count?: number | null;
|
||||||
|
requires_action?: boolean;
|
||||||
|
review_comment?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketVideo {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
url: string | null;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketTenant {
|
||||||
|
root_tenant_id: number;
|
||||||
|
organization_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketTimestamps {
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketDetail {
|
||||||
|
id: string;
|
||||||
|
video_id: string;
|
||||||
|
chainage_id: string | null;
|
||||||
|
status: TicketStatus;
|
||||||
|
status_metadata: TicketStatusMetadata | null;
|
||||||
|
video: TicketVideo | null;
|
||||||
|
uploader: TicketActor | null;
|
||||||
|
worker: TicketWorker | null;
|
||||||
|
repair: TicketRepair | null;
|
||||||
|
reviewer: TicketReviewer | null;
|
||||||
|
ai_result: TicketAiResult | null;
|
||||||
|
tenant: TicketTenant | null;
|
||||||
|
timestamps: TicketTimestamps;
|
||||||
|
history: TicketHistoryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketListResponse {
|
||||||
|
items: TicketListItem[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignableTicketUser {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignableTicketUsersParams extends PaginationParams {
|
||||||
|
search_term?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignableTicketUsersResponse {
|
||||||
|
items: AssignableTicketUser[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignTicketPayload {
|
||||||
|
assigned_to_user_id: number;
|
||||||
|
assigned_to_email?: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitRepairPayload {
|
||||||
|
notes: string;
|
||||||
|
proof_path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitRepairUploadPayload {
|
||||||
|
notes: string;
|
||||||
|
video: File;
|
||||||
|
images: File[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewRepairPayload {
|
||||||
|
action: 'approve' | 'reject';
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SseTokenResponse {
|
||||||
|
sse_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TicketStatusEvent = Partial<TicketDetail> & {
|
||||||
|
id?: string;
|
||||||
|
ticket_id?: string;
|
||||||
|
status?: TicketStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TicketProgressEvent {
|
||||||
|
ticket_id?: string;
|
||||||
|
video_id?: string;
|
||||||
|
progress?: number;
|
||||||
|
percent?: number;
|
||||||
|
message?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user