feat(ticket): refine repair report and history timeline UI
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { History } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TicketDetail, TicketHistoryItem, TicketStatus } from '@/types';
|
||||
|
||||
import { formatTicketStatus } from '../../components/TicketStatusBadge';
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
if (!value) return '-';
|
||||
return new Intl.DateTimeFormat('en-IN', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function getActorLabel(item: TicketHistoryItem) {
|
||||
return item.actor?.name || item.actor?.email || 'System';
|
||||
}
|
||||
|
||||
function getLifecycleTitle(status: TicketStatus) {
|
||||
const titles: Record<TicketStatus, string> = {
|
||||
processing: 'Incident Ticket Created',
|
||||
unassigned: 'Awaiting Assignment',
|
||||
assigned: 'Dispatched to Maintenance',
|
||||
under_review: 'Maintenance Finalized',
|
||||
closed: 'Ticket Closed',
|
||||
};
|
||||
|
||||
return titles[status] ?? formatTicketStatus(status);
|
||||
}
|
||||
|
||||
function HistoryEntry({
|
||||
item,
|
||||
isLast,
|
||||
}: {
|
||||
item: TicketHistoryItem;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
const isClosed = item.to_status === 'closed';
|
||||
|
||||
return (
|
||||
<div className="relative flex gap-4 pb-8 last:pb-0">
|
||||
{!isLast ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-6 left-[11px] h-[calc(100%-10px)] w-px bg-border"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'relative z-10 mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border-2 bg-background',
|
||||
isClosed
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-primary/60',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'size-2 rounded-full bg-primary',
|
||||
isClosed &&
|
||||
'h-1.5 w-2.5 rotate-[-45deg] rounded-none border-b-2 border-l-2 border-current bg-transparent',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{getLifecycleTitle(item.to_status)}
|
||||
</h3>
|
||||
<time
|
||||
dateTime={item.created_at}
|
||||
className="text-xs font-semibold text-muted-foreground tabular-nums"
|
||||
>
|
||||
{formatDate(item.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<p className="text-sm leading-relaxed text-foreground">
|
||||
{item.note ||
|
||||
`${formatTicketStatus(item.to_status)} by ${getActorLabel(item)}.`}
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-muted-foreground">{getActorLabel(item)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) {
|
||||
const history = ticket.history ?? [];
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border bg-card p-5">
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<History className="size-5 text-primary" />
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
Audit & Lifecycle Timeline
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{history.length ? (
|
||||
<div className={cn('pl-0.5')}>
|
||||
{history.map((item, index) => (
|
||||
<HistoryEntry
|
||||
key={item.id}
|
||||
item={item}
|
||||
isLast={index === history.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
No timeline entries yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { TicketActor, TicketDetail } from '@/types';
|
||||
|
||||
import { TicketStatusBadge } from '../../components/TicketStatusBadge';
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
if (!value) return '-';
|
||||
return new Intl.DateTimeFormat('en-IN', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function OverviewMeta({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number | null | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-sm font-medium">{value ?? '-'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonMeta({
|
||||
label,
|
||||
person,
|
||||
}: {
|
||||
label: string;
|
||||
person: TicketActor | null | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
{person ? (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{person.name || '-'}</p>
|
||||
{person.email && (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{person.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm font-medium">-</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<CardTitle className="text-base">Overview</CardTitle>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-5 md:grid-cols-[180px_minmax(0,1fr)]">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">AI Detections</p>
|
||||
<p className="text-3xl font-semibold leading-none">
|
||||
{ticket.ai_result?.detection_count ?? '-'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Completed {formatDate(ticket.ai_result?.completed_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<PersonMeta label="Uploaded By" person={ticket.uploader} />
|
||||
<PersonMeta label="Assigned To" person={ticket.worker} />
|
||||
<PersonMeta
|
||||
label="Assigned By"
|
||||
person={ticket.worker?.assigned_by}
|
||||
/>
|
||||
<OverviewMeta
|
||||
label="Assigned At"
|
||||
value={formatDate(ticket.worker?.assigned_at)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { RepairNotesCard } from './repair-report/RepairNotesCard';
|
||||
import { RepairProofImagesGrid } from './repair-report/RepairProofImagesGrid';
|
||||
import { RepairProofVideoCard } from './repair-report/RepairProofVideoCard';
|
||||
import { ReviewerCommentsCard } from './repair-report/ReviewerCommentsCard';
|
||||
|
||||
export function TicketRepairReportCard({ ticket }: { ticket: TicketDetail }) {
|
||||
const repair = ticket.repair;
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
Evidence & Progress
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<RepairProofVideoCard
|
||||
ticketId={ticket.id}
|
||||
proofVideoUrl={repair?.proof_video_url ?? null}
|
||||
hasRepair={Boolean(repair)}
|
||||
/>
|
||||
|
||||
<RepairProofImagesGrid
|
||||
imageUrls={repair?.proof_image_urls ?? []}
|
||||
submittedAt={repair?.submitted_at ?? null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<RepairNotesCard notes={repair?.notes ?? null} worker={ticket.worker} />
|
||||
<ReviewerCommentsCard
|
||||
reviewer={ticket.reviewer}
|
||||
status={ticket.status}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { AssignTicketAction } from './actions/AssignTicketAction';
|
||||
import { NoTicketAction } from './actions/NoTicketAction';
|
||||
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
||||
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
||||
|
||||
export function TicketStatusActions({ ticket }: { ticket: TicketDetail }) {
|
||||
if (ticket.status === 'unassigned') {
|
||||
return <AssignTicketAction ticket={ticket} />;
|
||||
}
|
||||
|
||||
if (ticket.status === 'assigned') {
|
||||
return <SubmitRepairAction ticket={ticket} />;
|
||||
}
|
||||
|
||||
if (ticket.status === 'under_review') {
|
||||
return <ReviewRepairAction ticket={ticket} />;
|
||||
}
|
||||
|
||||
return <NoTicketAction status={ticket.status} />;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Send, UserPlus } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
const [assignNote, setAssignNote] = useState('');
|
||||
|
||||
const assignMutation = useAssignTicketMutation(ticket.id);
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={UserPlus} title="Assign Ticket">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Worker</Label>
|
||||
<AssignableWorkerSelect
|
||||
value={selectedUserId}
|
||||
onValueChange={setSelectedUserId}
|
||||
disabled={assignMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Note</Label>
|
||||
<Textarea
|
||||
value={assignNote}
|
||||
onChange={(event) => setAssignNote(event.target.value)}
|
||||
placeholder="Please inspect and repair"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!selectedUserId || assignMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!selectedUserId) return;
|
||||
assignMutation.mutate({
|
||||
assigned_to_user_id: Number(selectedUserId),
|
||||
note: assignNote || undefined,
|
||||
});
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Send />
|
||||
Assign
|
||||
</Button>
|
||||
</div>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { CircleOff, Clock, Lock } from 'lucide-react';
|
||||
|
||||
import type { TicketStatus } from '@/types';
|
||||
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
const statusConfig: Record<
|
||||
'processing' | 'closed',
|
||||
{ icon: typeof Clock; title: string; message: string }
|
||||
> = {
|
||||
processing: {
|
||||
icon: Clock,
|
||||
title: 'Processing',
|
||||
message: 'This ticket is still being processed. Check back shortly.',
|
||||
},
|
||||
closed: {
|
||||
icon: Lock,
|
||||
title: 'Ticket Closed',
|
||||
message: 'This ticket is closed. No further actions are available.',
|
||||
},
|
||||
};
|
||||
|
||||
export function NoTicketAction({ status }: { status: TicketStatus }) {
|
||||
const config =
|
||||
status === 'processing' || status === 'closed'
|
||||
? statusConfig[status]
|
||||
: {
|
||||
icon: CircleOff,
|
||||
title: 'No Actions',
|
||||
message: 'No action available for this ticket.',
|
||||
};
|
||||
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={Icon} title={config.title}>
|
||||
<p className="text-sm text-muted-foreground">{config.message}</p>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { ImageIcon, Loader2, Send, Video, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useSubmitRepairUploadMutation } from '../../../hooks/useTicketQueries';
|
||||
|
||||
const MAX_IMAGES = 5;
|
||||
|
||||
function RequiredMark() {
|
||||
return <span className="text-destructive">*</span>;
|
||||
}
|
||||
|
||||
interface MediaDropzoneProps {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
accept: string;
|
||||
file: File | null;
|
||||
onFileChange: (file: File | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function MediaDropzone({
|
||||
icon,
|
||||
label,
|
||||
accept,
|
||||
file,
|
||||
onFileChange,
|
||||
disabled = false,
|
||||
}: MediaDropzoneProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg border border-dashed px-4 py-3 text-left transition-colors',
|
||||
'hover:border-primary/50 hover:bg-muted/30',
|
||||
disabled && 'pointer-events-none opacity-60',
|
||||
file && 'border-primary/40 bg-muted/20',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
onFileChange(event.target.files?.[0] ?? null);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{file ? file.name : label}
|
||||
</span>
|
||||
{file ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Click to replace
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ImageSlotProps {
|
||||
file: File | null;
|
||||
previewUrl: string | null;
|
||||
disabled?: boolean;
|
||||
onAdd: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function ImageSlot({
|
||||
file,
|
||||
previewUrl,
|
||||
disabled = false,
|
||||
onAdd,
|
||||
onRemove,
|
||||
}: ImageSlotProps) {
|
||||
if (file && previewUrl) {
|
||||
return (
|
||||
<div className="group relative aspect-square overflow-hidden rounded-lg border bg-muted/30">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={file.name}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onRemove}
|
||||
className="absolute top-1 right-1 flex size-5 items-center justify-center rounded-full bg-background/90 text-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 disabled:pointer-events-none"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onAdd}
|
||||
className={cn(
|
||||
'flex aspect-square items-center justify-center rounded-lg border border-dashed bg-muted/20 text-muted-foreground transition-colors',
|
||||
'hover:border-primary/50 hover:bg-muted/40 hover:text-primary',
|
||||
disabled && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
aria-label="Add image"
|
||||
>
|
||||
<ImageIcon className="size-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface RepairEvidenceFormProps {
|
||||
ticketId: string;
|
||||
}
|
||||
|
||||
export function RepairEvidenceForm({ ticketId }: RepairEvidenceFormProps) {
|
||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submitRepairUploadMutation = useSubmitRepairUploadMutation(ticketId);
|
||||
const isSubmitting = submitRepairUploadMutation.isPending;
|
||||
const canSubmit = Boolean(notes.trim() && videoFile);
|
||||
|
||||
useEffect(() => {
|
||||
const urls = imageFiles.map((file) => URL.createObjectURL(file));
|
||||
setImagePreviews(urls);
|
||||
return () => {
|
||||
urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [imageFiles]);
|
||||
|
||||
const addImages = (files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
|
||||
const remaining = MAX_IMAGES - imageFiles.length;
|
||||
if (remaining <= 0) {
|
||||
setError(`Only ${MAX_IMAGES} images can be uploaded.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextImages = [
|
||||
...imageFiles,
|
||||
...Array.from(files).slice(0, remaining),
|
||||
];
|
||||
setImageFiles(nextImages);
|
||||
setError(
|
||||
files.length > remaining
|
||||
? `Only ${MAX_IMAGES} images can be uploaded.`
|
||||
: null,
|
||||
);
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setImageFiles((current) =>
|
||||
current.filter((_, itemIndex) => itemIndex !== index),
|
||||
);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!notes.trim() || !videoFile) {
|
||||
setError('Repair notes and video are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
try {
|
||||
await submitRepairUploadMutation.mutateAsync({
|
||||
notes: notes.trim(),
|
||||
video: videoFile,
|
||||
images: imageFiles,
|
||||
});
|
||||
setNotes('');
|
||||
setVideoFile(null);
|
||||
setImageFiles([]);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Upload failed');
|
||||
}
|
||||
};
|
||||
|
||||
const imageSlots = Array.from({ length: MAX_IMAGES }, (_, index) => ({
|
||||
file: imageFiles[index] ?? null,
|
||||
previewUrl: imagePreviews[index] ?? null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repair-notes">
|
||||
Repair Notes <RequiredMark />
|
||||
</Label>
|
||||
<Textarea
|
||||
id="repair-notes"
|
||||
value={notes}
|
||||
onChange={(event) => {
|
||||
setNotes(event.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="Describe the repair work performed..."
|
||||
disabled={isSubmitting}
|
||||
className="min-h-24 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-muted-foreground">Media Upload</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-normal">
|
||||
Repair Video <RequiredMark />
|
||||
</Label>
|
||||
<MediaDropzone
|
||||
icon={<Video className="size-4" />}
|
||||
label="Upload Repair Video (MP4, max 50MB)"
|
||||
accept="video/mp4,video/*"
|
||||
file={videoFile}
|
||||
onFileChange={(file) => {
|
||||
setVideoFile(file);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-normal">
|
||||
Upload Images (Max {MAX_IMAGES})
|
||||
</Label>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
disabled={isSubmitting || imageFiles.length >= MAX_IMAGES}
|
||||
onChange={(event) => {
|
||||
addImages(event.target.files);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{imageSlots.map((slot, index) => (
|
||||
<ImageSlot
|
||||
key={index}
|
||||
file={slot.file}
|
||||
previewUrl={slot.previewUrl}
|
||||
disabled={isSubmitting || (!slot.file && imageFiles.length >= MAX_IMAGES)}
|
||||
onAdd={() => imageInputRef.current?.click()}
|
||||
onRemove={() => removeImage(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="size-4" />
|
||||
Submit for Review
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, ClipboardCheck, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { useReviewRepairMutation } from '../../../hooks/useTicketQueries';
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
export function ReviewRepairAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const [reviewComment, setReviewComment] = useState('');
|
||||
const reviewRepairMutation = useReviewRepairMutation(ticket.id);
|
||||
|
||||
useEffect(() => {
|
||||
setReviewComment(ticket.reviewer?.review_comment ?? '');
|
||||
}, [ticket]);
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={ClipboardCheck} title="Review Repair">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Comment</Label>
|
||||
<Textarea
|
||||
value={reviewComment}
|
||||
onChange={(event) => setReviewComment(event.target.value)}
|
||||
placeholder="Repair verified"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
disabled={!reviewComment || reviewRepairMutation.isPending}
|
||||
onClick={() =>
|
||||
reviewRepairMutation.mutate({
|
||||
action: 'approve',
|
||||
comment: reviewComment,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Check />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!reviewComment || reviewRepairMutation.isPending}
|
||||
onClick={() =>
|
||||
reviewRepairMutation.mutate({
|
||||
action: 'reject',
|
||||
comment: reviewComment,
|
||||
})
|
||||
}
|
||||
>
|
||||
<X />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { Wrench } from 'lucide-react';
|
||||
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { RepairEvidenceForm } from './RepairEvidenceForm';
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
|
||||
return (
|
||||
<TicketActionCard icon={Wrench} title="Submit Repair Evidence">
|
||||
<RepairEvidenceForm ticketId={ticket.id} />
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface TicketActionCardProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TicketActionCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
children,
|
||||
}: TicketActionCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Icon className="size-4 text-primary" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { MessageSquareQuote, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import {
|
||||
EmptyPanel,
|
||||
getPersonLabel,
|
||||
ReportSurface,
|
||||
SectionLabel,
|
||||
} from './repairReportUtils';
|
||||
|
||||
export function RepairNotesCard({
|
||||
notes,
|
||||
worker,
|
||||
}: {
|
||||
notes: string | null;
|
||||
worker: TicketDetail['worker'];
|
||||
}) {
|
||||
return (
|
||||
<ReportSurface className="flex min-h-48 flex-col gap-4">
|
||||
<SectionLabel>Repair Notes</SectionLabel>
|
||||
{notes ? (
|
||||
<>
|
||||
<div className="flex-1 rounded-lg border bg-muted/15 px-4 py-3">
|
||||
<p className="text-sm leading-relaxed text-foreground/90">
|
||||
{notes}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="size-3.5 text-primary" />
|
||||
<span>Self-certified by {getPersonLabel(worker)}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyPanel
|
||||
icon={MessageSquareQuote}
|
||||
title="No repair notes added yet"
|
||||
className="min-h-28 flex-1"
|
||||
/>
|
||||
)}
|
||||
</ReportSurface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { ImageIcon } from 'lucide-react';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { formatRepairDate, ReportSurface } from './repairReportUtils';
|
||||
|
||||
const MAX_MEDIA_SLOTS = 5;
|
||||
|
||||
const imageBadgeStyles = [
|
||||
'bg-slate-700/90 text-white',
|
||||
'bg-violet-600/90 text-white',
|
||||
'bg-amber-600/90 text-white',
|
||||
'bg-stone-600/90 text-white',
|
||||
'bg-indigo-600/90 text-white',
|
||||
] as const;
|
||||
|
||||
export function RepairProofImagesGrid({
|
||||
imageUrls,
|
||||
submittedAt,
|
||||
}: {
|
||||
imageUrls: string[];
|
||||
submittedAt: string | null;
|
||||
}) {
|
||||
const imageSlots = Array.from(
|
||||
{ length: MAX_MEDIA_SLOTS },
|
||||
(_, index) => imageUrls[index] ?? null,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{imageSlots.map((url, index) => (
|
||||
<RepairProofImageSlot
|
||||
key={index}
|
||||
url={url}
|
||||
index={index}
|
||||
submittedAt={submittedAt}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairProofImageSlot({
|
||||
url,
|
||||
index,
|
||||
submittedAt,
|
||||
}: {
|
||||
url: string | null;
|
||||
index: number;
|
||||
submittedAt: string | null;
|
||||
}) {
|
||||
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(url);
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<ReportSurface className="space-y-2 p-2">
|
||||
<div className="flex aspect-4/3 items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/10 text-muted-foreground/70">
|
||||
<ImageIcon className="size-4" />
|
||||
</div>
|
||||
<p className="truncate px-1 text-[11px] text-muted-foreground/70">
|
||||
No image added
|
||||
</p>
|
||||
</ReportSurface>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<ReportSurface className="space-y-2 p-2">
|
||||
<Skeleton className="aspect-4/3 w-full rounded-lg" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</ReportSurface>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !objectUrl) {
|
||||
return (
|
||||
<ReportSurface className="space-y-2 p-2">
|
||||
<div className="flex aspect-4/3 items-center justify-center rounded-lg border border-dashed bg-muted/10 text-xs text-muted-foreground">
|
||||
Unavailable
|
||||
</div>
|
||||
<p className="truncate px-1 text-[11px] text-muted-foreground">
|
||||
Image {index + 1}
|
||||
</p>
|
||||
</ReportSurface>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReportSurface className="space-y-2 p-2">
|
||||
<a
|
||||
href={objectUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="group relative block overflow-hidden rounded-lg border bg-muted/20"
|
||||
>
|
||||
<img
|
||||
src={objectUrl}
|
||||
alt={`Repair proof ${index + 1}`}
|
||||
className="aspect-[4/3] w-full object-cover transition-transform group-hover:scale-[1.02]"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute top-2 left-2 rounded px-1.5 py-0.5 text-[10px] font-semibold tracking-wide uppercase',
|
||||
imageBadgeStyles[index],
|
||||
)}
|
||||
>
|
||||
Proof {index + 1}
|
||||
</span>
|
||||
</a>
|
||||
<p className="truncate px-1 text-[11px] text-muted-foreground">
|
||||
{submittedAt ? formatRepairDate(submittedAt) : `Image ${index + 1}`}
|
||||
</p>
|
||||
</ReportSurface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { CircleDashed, Play, Video } from 'lucide-react';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
|
||||
|
||||
import { EmptyPanel, ReportSurface } from './repairReportUtils';
|
||||
|
||||
interface RepairProofVideoCardProps {
|
||||
ticketId: string;
|
||||
proofVideoUrl: string | null;
|
||||
hasRepair: boolean;
|
||||
}
|
||||
|
||||
export function RepairProofVideoCard({
|
||||
ticketId,
|
||||
proofVideoUrl,
|
||||
hasRepair,
|
||||
}: RepairProofVideoCardProps) {
|
||||
if (!proofVideoUrl) {
|
||||
return (
|
||||
<ReportSurface className="p-2">
|
||||
<EmptyPanel
|
||||
icon={hasRepair ? Video : CircleDashed}
|
||||
title={
|
||||
hasRepair ? 'No repair video uploaded' : 'No evidence submitted yet'
|
||||
}
|
||||
description={
|
||||
hasRepair
|
||||
? 'The assigned worker has not uploaded a repair video yet.'
|
||||
: 'Repair video will appear here once the assigned worker uploads proof.'
|
||||
}
|
||||
className="aspect-[16/9]"
|
||||
/>
|
||||
</ReportSurface>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRepairVideo
|
||||
url={proofVideoUrl}
|
||||
label={`Repair Clip #${ticketId.slice(0, 8).toUpperCase()}`}
|
||||
meta="Repair video - MP4 - Field upload"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProtectedRepairVideo({
|
||||
url,
|
||||
label,
|
||||
meta,
|
||||
}: {
|
||||
url: string;
|
||||
label: string;
|
||||
meta: string;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(url);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="aspect-[16/9] w-full rounded-xl border" />;
|
||||
}
|
||||
|
||||
if (isError || !objectUrl) {
|
||||
return (
|
||||
<ReportSurface className="p-2">
|
||||
<EmptyPanel
|
||||
icon={Video}
|
||||
title="Video unavailable"
|
||||
description="The repair video could not be loaded right now."
|
||||
className="aspect-[16/9]"
|
||||
/>
|
||||
</ReportSurface>
|
||||
);
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (video.paused) {
|
||||
void video.play();
|
||||
setIsPlaying(true);
|
||||
return;
|
||||
}
|
||||
|
||||
video.pause();
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-xl border bg-black/90">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={objectUrl}
|
||||
className="aspect-[16/9] w-full object-cover"
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
playsInline
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-black/30" />
|
||||
|
||||
<span className="absolute top-3 left-3 rounded bg-black/60 px-2.5 py-1 text-[11px] font-semibold text-white backdrop-blur-sm">
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{!isPlaying ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlay}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
aria-label="Play video"
|
||||
>
|
||||
<span className="flex size-16 items-center justify-center rounded-full border border-white/45 bg-white/20 text-white backdrop-blur-sm transition-transform group-hover:scale-105">
|
||||
<Play className="ml-1 size-7 fill-current" />
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlay}
|
||||
className="absolute inset-0"
|
||||
aria-label="Pause video"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="absolute right-0 bottom-0 left-0 flex items-center justify-between gap-3 px-4 py-3 text-xs text-white/85">
|
||||
<span>{meta}</span>
|
||||
<Video className="size-4 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { Clock3 } from 'lucide-react';
|
||||
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import {
|
||||
EmptyPanel,
|
||||
formatRepairDate,
|
||||
getPersonLabel,
|
||||
ReportSurface,
|
||||
SectionLabel,
|
||||
} from './repairReportUtils';
|
||||
|
||||
export function ReviewerCommentsCard({
|
||||
reviewer,
|
||||
status,
|
||||
}: {
|
||||
reviewer: TicketDetail['reviewer'];
|
||||
status: TicketDetail['status'];
|
||||
}) {
|
||||
const hasReview = Boolean(reviewer?.review_comment || reviewer?.reviewed_at);
|
||||
|
||||
return (
|
||||
<ReportSurface className="flex min-h-48 flex-col gap-4">
|
||||
<SectionLabel>Reviewer Comments</SectionLabel>
|
||||
{hasReview ? (
|
||||
<>
|
||||
<div className="flex-1 rounded-lg border bg-muted/15 px-4 py-3">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{getPersonLabel(reviewer)}
|
||||
</p>
|
||||
{reviewer?.email ? (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{reviewer.email}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<time className="shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
||||
{formatRepairDate(reviewer?.reviewed_at)}
|
||||
</time>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-foreground/90">
|
||||
{reviewer?.review_comment || 'No comment provided.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold tracking-[0.12em] text-amber-500 uppercase">
|
||||
<span className="size-2 rounded-sm bg-amber-500" />
|
||||
{status === 'closed' ? 'Repair approved' : 'Review completed'}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyPanel
|
||||
icon={Clock3}
|
||||
title="No comments added yet"
|
||||
className="min-h-28 flex-1"
|
||||
/>
|
||||
)}
|
||||
</ReportSurface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TicketActor } from '@/types';
|
||||
|
||||
export function formatRepairDate(value: string | null | undefined) {
|
||||
if (!value) return '-';
|
||||
return new Intl.DateTimeFormat('en-IN', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function getPersonLabel(person: TicketActor | null | undefined) {
|
||||
return person?.name || person?.email || 'Unknown';
|
||||
}
|
||||
|
||||
export function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="text-[11px] font-semibold tracking-[0.12em] text-foreground uppercase">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportSurface({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('rounded-xl border bg-card p-4', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyPanel({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center rounded-lg border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className="mb-3 size-6 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
{description ? (
|
||||
<p className="mt-1 max-w-xs text-xs text-muted-foreground/80">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +1,27 @@
|
||||
'use client';
|
||||
|
||||
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>
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
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 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,
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
39
src/hooks/useProtectedMedia.ts
Normal file
39
src/hooks/useProtectedMedia.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { protectedMediaService } from '@/services/api';
|
||||
|
||||
export function useProtectedMediaObjectUrl(url: string | null | undefined) {
|
||||
const query = useQuery({
|
||||
queryKey: ['protected-media', url],
|
||||
queryFn: () => protectedMediaService.getBlob(url as string),
|
||||
enabled: Boolean(url),
|
||||
staleTime: Infinity,
|
||||
gcTime: 1000 * 60 * 30,
|
||||
});
|
||||
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.data) {
|
||||
setObjectUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUrl = URL.createObjectURL(query.data);
|
||||
setObjectUrl(nextUrl);
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(nextUrl);
|
||||
};
|
||||
}, [query.data]);
|
||||
|
||||
return {
|
||||
objectUrl,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
@@ -12,3 +12,4 @@ export * from './client.service';
|
||||
export * from './plan.service';
|
||||
export * from './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;
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user