refactor(ticket): remove web repair upload flow

This commit is contained in:
2026-07-22 15:41:38 +05:30
parent e94da54aa9
commit e077467bce
11 changed files with 35 additions and 661 deletions

View File

@@ -9,8 +9,8 @@ import { AssignTicketAction } from './actions/AssignTicketAction';
import { ClosedTicketAction } from './actions/ClosedTicketAction';
import { NoTicketAction } from './actions/NoTicketAction';
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
import { RequestExtensionAction } from './actions/RequestExtensionAction';
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
import { SubmitRepairAction } from './actions/SubmitRepairAction';
interface TicketStatusActionsProps {
ticket: TicketDetail;
@@ -37,7 +37,7 @@ function getTicketAction(ticket: TicketDetail) {
permissions={PERMISSIONS.TICKET.WORK}
fallback={<NoTicketAction />}
>
<SubmitRepairAction ticket={ticket} />
<RequestExtensionAction ticket={ticket} />
</PermissionGuard>
}
>

View File

@@ -1,485 +0,0 @@
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
Camera,
ImageIcon,
Loader2,
Play,
Plus,
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 = 30;
const MAX_NOTES = 500;
function RequiredMark() {
return <span className="text-destructive">*</span>;
}
function formatVideoDuration(seconds: number) {
const totalSeconds = Math.floor(seconds);
const minutes = Math.floor(totalSeconds / 60);
const remainingSeconds = totalSeconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
}
interface MediaSectionHeaderProps {
icon: ReactNode;
title: string;
addLabel: string;
onAdd: () => void;
disabled?: boolean;
}
function MediaSectionHeader({
icon,
title,
addLabel,
onAdd,
disabled = false,
}: MediaSectionHeaderProps) {
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-primary">{icon}</span>
<span className="text-sm font-semibold">{title}</span>
</div>
<Button
type="button"
variant="secondary"
size="sm"
disabled={disabled}
onClick={onAdd}
className="shrink-0"
>
<Plus className="size-3.5" />
{addLabel}
</Button>
</div>
);
}
function MediaHint({ children }: { children: ReactNode }) {
return (
<p className="rounded-lg bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
{children}
</p>
);
}
interface EmptyMediaPlaceholderProps {
icon: ReactNode;
title: string;
description: string;
className?: string;
disabled?: boolean;
onClick: () => void;
}
function EmptyMediaPlaceholder({
icon,
title,
description,
className,
disabled = false,
onClick,
}: EmptyMediaPlaceholderProps) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
'flex w-full min-w-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border/80 bg-muted/15 px-4 py-6 text-center transition-colors',
'hover:border-primary/40 hover:bg-primary/5',
disabled && 'cursor-not-allowed opacity-60',
className,
)}
>
<span className="flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary">
{icon}
</span>
<span className="text-sm font-medium text-foreground">{title}</span>
<span className="max-w-xs text-xs text-muted-foreground">
{description}
</span>
</button>
);
}
interface ImageThumbnailProps {
file: File;
previewUrl: string;
disabled?: boolean;
onRemove: () => void;
}
function ImageThumbnail({
file,
previewUrl,
disabled = false,
onRemove,
}: ImageThumbnailProps) {
return (
<div className="group relative size-20 shrink-0 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 cursor-pointer items-center justify-center rounded-full bg-foreground/80 text-background shadow-sm transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Remove ${file.name}`}
>
<X className="size-3" />
</button>
</div>
);
}
interface VideoThumbnailProps {
file: File;
previewUrl: string;
duration: string | null;
disabled?: boolean;
onRemove: () => void;
}
function VideoThumbnail({
file,
previewUrl,
duration,
disabled = false,
onRemove,
}: VideoThumbnailProps) {
return (
<div className="group relative h-20 w-36 shrink-0 overflow-hidden rounded-lg border bg-muted/30">
<video
src={previewUrl}
className="size-full object-cover"
muted
playsInline
preload="metadata"
aria-label={file.name}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20">
<span className="flex size-8 items-center justify-center rounded-full bg-background/90 text-foreground shadow-sm">
<Play className="size-3.5 fill-current" />
</span>
</div>
{duration ? (
<span className="pointer-events-none absolute bottom-1.5 right-1.5 rounded-lg bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white">
{duration}
</span>
) : null}
<button
type="button"
disabled={disabled}
onClick={onRemove}
className="absolute top-1 right-1 flex size-5 cursor-pointer items-center justify-center rounded-full bg-foreground/80 text-background shadow-sm transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Remove ${file.name}`}
>
<X className="size-3" />
</button>
</div>
);
}
interface RepairEvidenceFormProps {
ticketId: string;
defectClass: string;
}
export function RepairEvidenceForm({
ticketId,
defectClass,
}: RepairEvidenceFormProps) {
const imageInputRef = useRef<HTMLInputElement | null>(null);
const videoInputRef = useRef<HTMLInputElement | null>(null);
const [notes, setNotes] = useState('');
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoPreview, setVideoPreview] = useState<string | null>(null);
const [videoDuration, setVideoDuration] = useState<string | 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);
const canAddMoreImages = imageFiles.length < MAX_IMAGES;
useEffect(() => {
const urls = imageFiles.map((file) => URL.createObjectURL(file));
setImagePreviews(urls);
return () => {
urls.forEach((url) => URL.revokeObjectURL(url));
};
}, [imageFiles]);
useEffect(() => {
if (!videoFile) {
setVideoPreview(null);
setVideoDuration(null);
return;
}
const url = URL.createObjectURL(videoFile);
setVideoPreview(url);
setVideoDuration(null);
let cancelled = false;
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
if (cancelled || !Number.isFinite(video.duration)) return;
setVideoDuration(formatVideoDuration(video.duration));
};
video.src = url;
return () => {
cancelled = true;
URL.revokeObjectURL(url);
};
}, [videoFile]);
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 selectVideo = (files: FileList | null) => {
setVideoFile(files?.[0] ?? null);
setError(null);
};
const removeVideo = () => {
setVideoFile(null);
setError(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 a video are required.');
return;
}
setError(null);
try {
await submitRepairUploadMutation.mutateAsync({
defect_class: defectClass,
notes: notes.trim(),
video: videoFile,
images: imageFiles,
});
setNotes('');
setVideoFile(null);
setImageFiles([]);
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed');
}
};
const openImagePicker = () => {
if (!isSubmitting && canAddMoreImages) {
imageInputRef.current?.click();
}
};
const openVideoPicker = () => {
if (!isSubmitting) {
videoInputRef.current?.click();
}
};
return (
<div className="space-y-5">
<div className="flex gap-3 rounded-lg border border-primary/15 bg-primary/5 p-4">
<Camera className="mt-0.5 size-5 shrink-0 text-primary" />
<div className="space-y-1">
<p className="text-sm font-semibold">Instructions</p>
<p className="text-sm text-muted-foreground">
Upload clear photos and one video of the repaired area and provide
relevant details.
</p>
</div>
</div>
<input
ref={imageInputRef}
type="file"
accept="image/jpeg,image/png,image/*"
multiple
className="hidden"
disabled={isSubmitting || !canAddMoreImages}
onChange={(event) => {
addImages(event.target.files);
event.target.value = '';
}}
/>
<input
ref={videoInputRef}
type="file"
accept="video/mp4,video/quicktime,video/x-msvideo,video/*"
className="hidden"
disabled={isSubmitting}
onChange={(event) => {
selectVideo(event.target.files);
event.target.value = '';
}}
/>
<section className="space-y-3">
<MediaSectionHeader
icon={<Camera className="size-4" />}
title="Photos"
addLabel="Add Photos"
onAdd={openImagePicker}
disabled={isSubmitting || !canAddMoreImages}
/>
<div className="overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{imageFiles.length === 0 ? (
<EmptyMediaPlaceholder
icon={<ImageIcon className="size-5" />}
title="No photos added yet"
description="Tap to upload repair photos"
disabled={isSubmitting}
onClick={openImagePicker}
className="min-h-26"
/>
) : (
<div className="flex min-w-min gap-3">
{imageFiles.map((file, index) => (
<ImageThumbnail
key={`${file.name}-${file.lastModified}-${index}`}
file={file}
previewUrl={imagePreviews[index]}
disabled={isSubmitting}
onRemove={() => removeImage(index)}
/>
))}
</div>
)}
</div>
<MediaHint>
You can upload up to {MAX_IMAGES} images (JPG, PNG)
</MediaHint>
</section>
<section className="space-y-3">
<div className="flex items-center gap-2">
<Video className="size-4 text-primary" />
<span className="text-sm font-semibold">Video</span>
</div>
<div>
{!videoFile || !videoPreview ? (
<EmptyMediaPlaceholder
icon={<Video className="size-5" />}
title="No video added yet"
description="Tap to upload a repair video"
disabled={isSubmitting}
onClick={openVideoPicker}
className="min-h-[104px]"
/>
) : (
<VideoThumbnail
file={videoFile}
previewUrl={videoPreview}
duration={videoDuration}
disabled={isSubmitting}
onRemove={removeVideo}
/>
)}
</div>
<MediaHint>You can upload one video (MP4, MOV, AVI)</MediaHint>
</section>
<div className="space-y-2">
<Label htmlFor="repair-notes">
Repair Notes <RequiredMark />
</Label>
<div className="relative">
<Textarea
id="repair-notes"
value={notes}
onChange={(event) => {
setNotes(event.target.value.slice(0, MAX_NOTES));
setError(null);
}}
placeholder="Describe the repair work completed, materials used, and any additional observations."
disabled={isSubmitting}
className="min-h-28 resize-none pb-8"
/>
<span className="pointer-events-none absolute bottom-2 right-3 text-xs text-muted-foreground">
{notes.length} / {MAX_NOTES}
</span>
</div>
</div>
{error ? (
<div className="rounded-lg 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,14 @@
import { Clock3 } from 'lucide-react';
import type { TicketDetail } from '@/types';
import { TicketActionCard } from './TicketActionCard';
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
export function RequestExtensionAction({ ticket }: { ticket: TicketDetail }) {
return (
<TicketActionCard icon={Clock3} title="Request Extension">
<TicketExtensionRequestPanel ticket={ticket} />
</TicketActionCard>
);
}

View File

@@ -16,13 +16,9 @@ const MAX_REASON_LENGTH = 300;
interface RequestExtensionFormProps {
ticket: TicketDetail;
onCancel: () => void;
}
export function RequestExtensionForm({
ticket,
onCancel,
}: RequestExtensionFormProps) {
export function RequestExtensionForm({ ticket }: RequestExtensionFormProps) {
const currentDueAt = ticket.worker?.due_at;
const currentDueDate = useMemo(
() => (currentDueAt ? new Date(currentDueAt) : undefined),
@@ -66,7 +62,6 @@ export function RequestExtensionForm({
});
setRequestedDueDate(undefined);
setReason('');
onCancel();
};
return (
@@ -138,20 +133,15 @@ export function RequestExtensionForm({
This request will be sent to your manager for approval.
</p>
<div className="grid grid-cols-2 gap-3">
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={onCancel}
>
Cancel
</Button>
<Button type="button" disabled={!canSubmit} onClick={handleSubmit}>
{isPending ? <Loader2 className="animate-spin" /> : <Send />}
{isPending ? 'Submitting...' : 'Submit Request'}
</Button>
</div>
<Button
type="button"
className="w-full"
disabled={!canSubmit}
onClick={handleSubmit}
>
{isPending ? <Loader2 className="animate-spin" /> : <Send />}
{isPending ? 'Submitting...' : 'Submit Request'}
</Button>
</div>
);
}

View File

@@ -1,52 +0,0 @@
'use client';
import { useState } from 'react';
import { Wrench } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { TicketDetail } from '@/types';
import { RepairEvidenceForm } from './RepairEvidenceForm';
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
import { TicketActionCard } from './TicketActionCard';
type RepairActionTab = 'repair-proof' | 'request-extension';
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
const [activeTab, setActiveTab] = useState<RepairActionTab>('repair-proof');
if (ticket.repair?.submitted_at) {
return null;
}
return (
<TicketActionCard icon={Wrench} title="Repair Proof">
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as RepairActionTab)}
className="gap-5"
>
<TabsList variant="line" className="grid h-auto w-full grid-cols-2 p-0">
<TabsTrigger value="repair-proof" className="pb-3">
Upload Repair Proof
</TabsTrigger>
<TabsTrigger value="request-extension" className="pb-3">
Request Extension
</TabsTrigger>
</TabsList>
<TabsContent value="repair-proof">
<RepairEvidenceForm
ticketId={ticket.id}
defectClass={ticket.defect_class}
/>
</TabsContent>
<TabsContent value="request-extension">
<TicketExtensionRequestPanel
ticket={ticket}
onCancelRequest={() => setActiveTab('repair-proof')}
/>
</TabsContent>
</Tabs>
</TicketActionCard>
);
}

View File

@@ -23,11 +23,6 @@ import { formatDate } from '@/utils/date';
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
import { RequestExtensionForm } from './RequestExtensionForm';
interface TicketExtensionRequestPanelProps {
ticket: TicketDetail;
onCancelRequest: () => void;
}
interface DetailRowProps {
label: string;
value: string;
@@ -192,8 +187,9 @@ function ExtensionRequestDetails({
export function TicketExtensionRequestPanel({
ticket,
onCancelRequest,
}: TicketExtensionRequestPanelProps) {
}: {
ticket: TicketDetail;
}) {
const assignmentId = ticket.worker?.assignment_id;
const extensionRequestQuery = useTicketExtensionRequestQuery(
ticket.id,
@@ -241,7 +237,7 @@ export function TicketExtensionRequestPanel({
: null;
if (!latestRequest) {
return <RequestExtensionForm ticket={ticket} onCancel={onCancelRequest} />;
return <RequestExtensionForm ticket={ticket} />;
}
return <ExtensionRequestDetails request={latestRequest} />;