feat(ticket): refine repair report and history timeline UI

This commit is contained in:
2026-06-19 11:22:41 +05:30
parent 8c3d19a429
commit a567a52e26
27 changed files with 1550 additions and 363 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,101 +1,27 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, Check, Loader2, Send, Ticket, X } from 'lucide-react';
import { ArrowLeft, Loader2, Ticket } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import type { AssignableTicketUser } from '@/types';
import {
TicketStatusBadge,
formatTicketStatus,
} from '../components/TicketStatusBadge';
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 {
useAssignableTicketUsersQuery,
useAssignTicketMutation,
useReviewRepairMutation,
useSubmitRepairMutation,
useTicketDetailQuery,
} from '../hooks/useTicketQueries';
function formatDate(value: string | null | undefined) {
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
function Field({
label,
value,
}: {
label: string;
value: string | number | null | undefined;
}) {
return (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="break-words text-sm font-medium">{value ?? '-'}</p>
</div>
);
}
import { useTicketDetailQuery } from '../hooks/useTicketQueries';
export default function TicketDetailPage() {
const router = useRouter();
const { ticketId } = useParams() as { ticketId: string };
const [selectedUserId, setSelectedUserId] = useState('');
const [assignNote, setAssignNote] = useState('');
const [repairNotes, setRepairNotes] = useState('');
const [proofPath, setProofPath] = useState('');
const [reviewComment, setReviewComment] = useState('');
const ticketQuery = useTicketDetailQuery(ticketId);
const ticket = ticketQuery.data;
const assignableUsersQuery = useAssignableTicketUsersQuery(
ticket?.status === 'unassigned',
);
const assignMutation = useAssignTicketMutation(ticketId);
const submitRepairMutation = useSubmitRepairMutation(ticketId);
const reviewRepairMutation = useReviewRepairMutation(ticketId);
useTicketDetailEvents(ticketId);
useEffect(() => {
if (!ticket) return;
setRepairNotes(ticket.repair?.notes ?? '');
setProofPath(ticket.repair?.proof_path ?? '');
setReviewComment(ticket.reviewer?.review_comment ?? '');
}, [ticket]);
const assignableUsers = assignableUsersQuery.data?.items ?? [];
const selectedUser = useMemo(
() =>
assignableUsers.find((user) => String(user.id) === selectedUserId) as
| AssignableTicketUser
| undefined,
[assignableUsers, selectedUserId],
);
const isBusy =
assignMutation.isPending ||
submitRepairMutation.isPending ||
reviewRepairMutation.isPending;
if (ticketQuery.isLoading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
@@ -120,10 +46,6 @@ export default function TicketDetailPage() {
);
}
const canAssign = ticket.status === 'unassigned';
const canSubmitRepair = ticket.status === 'assigned';
const canReview = ticket.status === 'under_review';
return (
<main className="relative z-10 space-y-5">
<PageHeader
@@ -144,258 +66,13 @@ export default function TicketDetailPage() {
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_380px]">
<div className="space-y-5">
<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="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Field
label="Video"
value={ticket.video?.name ?? ticket.video_id}
/>
<Field
label="Video ID"
value={ticket.video?.id ?? ticket.video_id}
/>
<Field label="Segment ID" value={ticket.chainage_id} />
<Field
label="Detections"
value={ticket.ai_result?.detection_count}
/>
<Field
label="Uploaded By"
value={ticket.uploader?.name ?? ticket.uploader?.email}
/>
<Field
label="Created At"
value={formatDate(ticket.timestamps.created_at)}
/>
<Field
label="Updated At"
value={formatDate(ticket.timestamps.updated_at)}
/>
<Field
label="AI Completed At"
value={formatDate(ticket.ai_result?.completed_at)}
/>
<Field
label="Assigned To"
value={ticket.worker?.name ?? ticket.worker?.email}
/>
<Field
label="Assigned At"
value={formatDate(ticket.worker?.assigned_at)}
/>
<Field
label="Assigned By"
value={ticket.worker?.assigned_by_name}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Repair Review</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<Field label="Repair Notes" value={ticket.repair?.notes} />
<Field label="Proof Path" value={ticket.repair?.proof_path} />
<Field
label="Repair Submitted At"
value={formatDate(ticket.repair?.submitted_at)}
/>
<Field
label="Reviewer"
value={ticket.reviewer?.name ?? ticket.reviewer?.email}
/>
<Field
label="Review Comment"
value={ticket.reviewer?.review_comment}
/>
<Field
label="Reviewed At"
value={formatDate(ticket.reviewer?.reviewed_at)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">History</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{ticket.history.length ? (
ticket.history.map((item) => (
<div
key={item.id}
className="border-l-2 border-border pl-4"
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">
{item.from_status
? `${formatTicketStatus(item.from_status)} -> `
: ''}
{formatTicketStatus(item.to_status)}
</span>
<span className="text-xs text-muted-foreground">
{formatDate(item.created_at)}
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{item.note || '-'}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{item.actor?.name || item.actor?.email || 'System'}
</p>
</div>
))
) : (
<p className="text-sm text-muted-foreground">
No history yet.
</p>
)}
</div>
</CardContent>
</Card>
<TicketOverviewCard ticket={ticket} />
<TicketRepairReportCard ticket={ticket} />
<TicketHistoryCard ticket={ticket} />
</div>
<div className="space-y-5">
{canAssign && (
<Card>
<CardHeader>
<CardTitle className="text-base">Assign Ticket</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Worker</Label>
<Select
value={selectedUserId}
onValueChange={setSelectedUserId}
>
<SelectTrigger>
<SelectValue placeholder="Select worker" />
</SelectTrigger>
<SelectContent>
{assignableUsers.map((user) => (
<SelectItem key={user.id} value={String(user.id)}>
{user.name || user.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Note</Label>
<Textarea
value={assignNote}
onChange={(event) => setAssignNote(event.target.value)}
placeholder="Please inspect and repair"
/>
</div>
<Button
disabled={!selectedUser || isBusy}
onClick={() => {
if (!selectedUser) return;
assignMutation.mutate({
assigned_to_user_id: selectedUser.id,
assigned_to_email: selectedUser.email,
note: assignNote || undefined,
});
}}
className="w-full"
>
<Send />
Assign
</Button>
</CardContent>
</Card>
)}
{canSubmitRepair && (
<Card>
<CardHeader>
<CardTitle className="text-base">Submit Repair</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Notes</Label>
<Textarea
value={repairNotes}
onChange={(event) => setRepairNotes(event.target.value)}
placeholder="Repair notes"
/>
</div>
<div className="space-y-2">
<Label>Proof Path</Label>
<Input
value={proofPath}
onChange={(event) => setProofPath(event.target.value)}
placeholder="/some/uploaded/proof.jpg"
/>
</div>
<Button
disabled={!repairNotes || !proofPath || isBusy}
onClick={() =>
submitRepairMutation.mutate({
notes: repairNotes,
proof_path: proofPath,
})
}
className="w-full"
>
<Send />
Submit Repair
</Button>
</CardContent>
</Card>
)}
{canReview && (
<Card>
<CardHeader>
<CardTitle className="text-base">Review Repair</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Comment</Label>
<Textarea
value={reviewComment}
onChange={(event) => setReviewComment(event.target.value)}
placeholder="Repair verified"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
disabled={!reviewComment || isBusy}
onClick={() =>
reviewRepairMutation.mutate({
action: 'approve',
comment: reviewComment,
})
}
>
<Check />
Approve
</Button>
<Button
variant="destructive"
disabled={!reviewComment || isBusy}
onClick={() =>
reviewRepairMutation.mutate({
action: 'reject',
comment: reviewComment,
})
}
>
<X />
Reject
</Button>
</div>
</CardContent>
</Card>
)}
<div>
<TicketStatusActions ticket={ticket} />
</div>
</div>
</main>

View File

@@ -49,8 +49,10 @@ function patchTicketLists(
ticket.id === eventId
? {
...ticket,
...event,
id: ticket.id,
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,
),

View File

@@ -9,6 +9,8 @@ import type {
AssignTicketPayload,
ReviewRepairPayload,
SubmitRepairPayload,
SubmitRepairUploadPayload,
TicketDetail,
TicketListParams,
TicketStatus,
} from '@/types';
@@ -60,11 +62,24 @@ export function useTicketDetailQuery(ticketId: string | undefined) {
export function useAssignableTicketUsersQuery(enabled: boolean) {
return useQuery({
queryKey: ticketKeys.assignableUsers(),
queryFn: ticketService.getAssignableUsers,
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();
@@ -73,7 +88,10 @@ export function useAssignTicketMutation(ticketId: string) {
ticketService.assignTicket(ticketId, payload),
onSuccess: (ticket) => {
toast.success('Ticket assigned');
queryClient.setQueryData(ticketKeys.detail(ticketId), ticket);
queryClient.setQueryData<TicketDetail>(
ticketKeys.detail(ticketId),
(current) => mergeTicketDetailResponse(current, ticket),
);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to assign ticket'),
@@ -87,7 +105,10 @@ export function useStartTicketMutation(ticketId: string) {
mutationFn: () => ticketService.startTicket(ticketId),
onSuccess: (ticket) => {
toast.success('Ticket started');
queryClient.setQueryData(ticketKeys.detail(ticketId), ticket);
queryClient.setQueryData<TicketDetail>(
ticketKeys.detail(ticketId),
(current) => mergeTicketDetailResponse(current, ticket),
);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to start ticket'),
@@ -102,13 +123,34 @@ export function useSubmitRepairMutation(ticketId: string) {
ticketService.submitRepair(ticketId, payload),
onSuccess: (ticket) => {
toast.success('Repair submitted');
queryClient.setQueryData(ticketKeys.detail(ticketId), ticket);
queryClient.setQueryData<TicketDetail>(
ticketKeys.detail(ticketId),
(current) => mergeTicketDetailResponse(current, ticket),
);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
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();
@@ -119,7 +161,10 @@ export function useReviewRepairMutation(ticketId: string) {
toast.success(
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected',
);
queryClient.setQueryData(ticketKeys.detail(ticketId), ticket);
queryClient.setQueryData<TicketDetail>(
ticketKeys.detail(ticketId),
(current) => mergeTicketDetailResponse(current, ticket),
);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to review repair'),
@@ -133,7 +178,10 @@ export function useCloseTicketMutation(ticketId: string) {
mutationFn: () => ticketService.closeTicket(ticketId),
onSuccess: (ticket) => {
toast.success('Ticket closed');
queryClient.setQueryData(ticketKeys.detail(ticketId), ticket);
queryClient.setQueryData<TicketDetail>(
ticketKeys.detail(ticketId),
(current) => mergeTicketDetailResponse(current, ticket),
);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to close ticket'),

View 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."
/>
);
}

View File

@@ -0,0 +1,5 @@
export interface AssignableWorkerSelectProps {
value: string;
onValueChange: (value: string) => void;
disabled?: boolean;
}

View File

@@ -1,24 +1,28 @@
'use client';
"use client"
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className,
"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
)}
{...props}
/>
);
)
}
function AvatarImage({
@@ -28,10 +32,10 @@ function AvatarImage({
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn('aspect-square size-full', className)}
className={cn("aspect-square size-full", className)}
{...props}
/>
);
)
}
function AvatarFallback({
@@ -42,12 +46,64 @@ function AvatarFallback({
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
className,
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...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,
}

View File

@@ -61,6 +61,8 @@ export const API_ROUTES = {
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) =>

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

View File

@@ -12,3 +12,4 @@ export * from './client.service';
export * from './plan.service';
export * from './tenant.service';
export * from './ticket.service';
export * from './media.service';

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

View File

@@ -3,9 +3,11 @@ import { API_ROUTES } from '@/constants/apiRoutes';
import type {
AssignTicketPayload,
AssignableTicketUsersResponse,
AssignableTicketUsersParams,
ReviewRepairPayload,
SseTokenResponse,
SubmitRepairPayload,
SubmitRepairUploadPayload,
TicketDetail,
TicketListParams,
TicketListResponse,
@@ -36,9 +38,18 @@ export const ticketService = {
return response.data;
},
getAssignableUsers: async (): Promise<AssignableTicketUsersResponse> => {
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;
},
@@ -72,6 +83,29 @@ export const ticketService = {
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,

View File

@@ -36,7 +36,7 @@ export interface TicketHistoryItem {
}
export interface TicketActor {
user_id: number;
user_id: number | null;
name: string | null;
email: string | null;
}
@@ -45,6 +45,9 @@ 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 {
@@ -56,13 +59,13 @@ export interface TicketVideo {
export interface TicketWorker extends TicketActor {
assigned_at: string | null;
assigned_by_user_id: number | null;
assigned_by_name: string | null;
assigned_by: TicketActor | null;
}
export interface TicketRepair {
notes: string | null;
proof_path: string | null;
proof_video_url: string | null;
proof_image_urls: string[];
submitted_at: string | null;
}
@@ -89,7 +92,7 @@ export interface TicketTimestamps {
export interface TicketDetail {
id: string;
video_id: string;
chainage_id: string;
chainage_id: string | null;
status: TicketStatus;
status_metadata: TicketStatusMetadata | null;
video: TicketVideo | null;
@@ -115,6 +118,10 @@ export interface AssignableTicketUser {
username: string;
}
export interface AssignableTicketUsersParams extends PaginationParams {
search_term?: string;
}
export interface AssignableTicketUsersResponse {
items: AssignableTicketUser[];
total: number;
@@ -122,7 +129,7 @@ export interface AssignableTicketUsersResponse {
export interface AssignTicketPayload {
assigned_to_user_id: number;
assigned_to_email: string;
assigned_to_email?: string;
note?: string;
}
@@ -131,6 +138,12 @@ export interface SubmitRepairPayload {
proof_path: string;
}
export interface SubmitRepairUploadPayload {
notes: string;
video: File;
images: File[];
}
export interface ReviewRepairPayload {
action: 'approve' | 'reject';
comment: string;