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