refactor(ticket): remove web repair upload flow
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { CalendarCheck2, CheckCircle2, ImageIcon, MapPin } from 'lucide-react';
|
import { CalendarCheck2, ImageIcon, MapPin, Smartphone } from 'lucide-react';
|
||||||
|
|
||||||
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -130,8 +130,8 @@ export function IssueEvidencePanel({
|
|||||||
</p>
|
</p>
|
||||||
{repairProof?.submitted ? (
|
{repairProof?.submitted ? (
|
||||||
<Badge className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
<Badge className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||||
<CheckCircle2 />
|
<Smartphone />
|
||||||
Repair Submitted
|
Uploaded from App
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +151,8 @@ export function IssueEvidencePanel({
|
|||||||
{repairProof.submitted_at ? (
|
{repairProof.submitted_at ? (
|
||||||
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-4 py-3">
|
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-4 py-3">
|
||||||
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-300">
|
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-300">
|
||||||
Submitted {formatDate(repairProof.submitted_at)}
|
Uploaded from the field app on{' '}
|
||||||
|
{formatDate(repairProof.submitted_at)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { AssignTicketAction } from './actions/AssignTicketAction';
|
|||||||
import { ClosedTicketAction } from './actions/ClosedTicketAction';
|
import { ClosedTicketAction } from './actions/ClosedTicketAction';
|
||||||
import { NoTicketAction } from './actions/NoTicketAction';
|
import { NoTicketAction } from './actions/NoTicketAction';
|
||||||
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
|
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
|
||||||
|
import { RequestExtensionAction } from './actions/RequestExtensionAction';
|
||||||
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
||||||
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
|
||||||
|
|
||||||
interface TicketStatusActionsProps {
|
interface TicketStatusActionsProps {
|
||||||
ticket: TicketDetail;
|
ticket: TicketDetail;
|
||||||
@@ -37,7 +37,7 @@ function getTicketAction(ticket: TicketDetail) {
|
|||||||
permissions={PERMISSIONS.TICKET.WORK}
|
permissions={PERMISSIONS.TICKET.WORK}
|
||||||
fallback={<NoTicketAction />}
|
fallback={<NoTicketAction />}
|
||||||
>
|
>
|
||||||
<SubmitRepairAction ticket={ticket} />
|
<RequestExtensionAction ticket={ticket} />
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,13 +16,9 @@ const MAX_REASON_LENGTH = 300;
|
|||||||
|
|
||||||
interface RequestExtensionFormProps {
|
interface RequestExtensionFormProps {
|
||||||
ticket: TicketDetail;
|
ticket: TicketDetail;
|
||||||
onCancel: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RequestExtensionForm({
|
export function RequestExtensionForm({ ticket }: RequestExtensionFormProps) {
|
||||||
ticket,
|
|
||||||
onCancel,
|
|
||||||
}: RequestExtensionFormProps) {
|
|
||||||
const currentDueAt = ticket.worker?.due_at;
|
const currentDueAt = ticket.worker?.due_at;
|
||||||
const currentDueDate = useMemo(
|
const currentDueDate = useMemo(
|
||||||
() => (currentDueAt ? new Date(currentDueAt) : undefined),
|
() => (currentDueAt ? new Date(currentDueAt) : undefined),
|
||||||
@@ -66,7 +62,6 @@ export function RequestExtensionForm({
|
|||||||
});
|
});
|
||||||
setRequestedDueDate(undefined);
|
setRequestedDueDate(undefined);
|
||||||
setReason('');
|
setReason('');
|
||||||
onCancel();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -138,20 +133,15 @@ export function RequestExtensionForm({
|
|||||||
This request will be sent to your manager for approval.
|
This request will be sent to your manager for approval.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
className="w-full"
|
||||||
variant="outline"
|
disabled={!canSubmit}
|
||||||
disabled={isPending}
|
onClick={handleSubmit}
|
||||||
onClick={onCancel}
|
>
|
||||||
>
|
{isPending ? <Loader2 className="animate-spin" /> : <Send />}
|
||||||
Cancel
|
{isPending ? 'Submitting...' : 'Submit Request'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" disabled={!canSubmit} onClick={handleSubmit}>
|
|
||||||
{isPending ? <Loader2 className="animate-spin" /> : <Send />}
|
|
||||||
{isPending ? 'Submitting...' : 'Submit Request'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -23,11 +23,6 @@ import { formatDate } from '@/utils/date';
|
|||||||
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
|
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
|
||||||
import { RequestExtensionForm } from './RequestExtensionForm';
|
import { RequestExtensionForm } from './RequestExtensionForm';
|
||||||
|
|
||||||
interface TicketExtensionRequestPanelProps {
|
|
||||||
ticket: TicketDetail;
|
|
||||||
onCancelRequest: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DetailRowProps {
|
interface DetailRowProps {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -192,8 +187,9 @@ function ExtensionRequestDetails({
|
|||||||
|
|
||||||
export function TicketExtensionRequestPanel({
|
export function TicketExtensionRequestPanel({
|
||||||
ticket,
|
ticket,
|
||||||
onCancelRequest,
|
}: {
|
||||||
}: TicketExtensionRequestPanelProps) {
|
ticket: TicketDetail;
|
||||||
|
}) {
|
||||||
const assignmentId = ticket.worker?.assignment_id;
|
const assignmentId = ticket.worker?.assignment_id;
|
||||||
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
||||||
ticket.id,
|
ticket.id,
|
||||||
@@ -241,7 +237,7 @@ export function TicketExtensionRequestPanel({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!latestRequest) {
|
if (!latestRequest) {
|
||||||
return <RequestExtensionForm ticket={ticket} onCancel={onCancelRequest} />;
|
return <RequestExtensionForm ticket={ticket} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ExtensionRequestDetails request={latestRequest} />;
|
return <ExtensionRequestDetails request={latestRequest} />;
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import type {
|
|||||||
RequestTicketExtensionPayload,
|
RequestTicketExtensionPayload,
|
||||||
ReviewDetectionRepairProofPayload,
|
ReviewDetectionRepairProofPayload,
|
||||||
ReviewTicketExtensionPayload,
|
ReviewTicketExtensionPayload,
|
||||||
SubmitRepairPayload,
|
|
||||||
SubmitRepairUploadPayload,
|
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
TicketListParams,
|
TicketListParams,
|
||||||
DetectionProofStatus,
|
DetectionProofStatus,
|
||||||
@@ -296,42 +294,6 @@ export function useAssignTicketMutation(ticketId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSubmitRepairMutation(ticketId: string) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (payload: SubmitRepairPayload) =>
|
|
||||||
ticketService.submitRepair(ticketId, payload),
|
|
||||||
onSuccess: (ticket) => {
|
|
||||||
toast.success('Repair submitted');
|
|
||||||
queryClient.setQueryData<TicketDetail>(
|
|
||||||
ticketKeys.classDetail(ticketId, ticket.defect_class),
|
|
||||||
(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, payload) => {
|
|
||||||
toast.success('Repair evidence uploaded');
|
|
||||||
queryClient.setQueryData<TicketDetail>(
|
|
||||||
ticketKeys.classDetail(ticketId, payload.defect_class),
|
|
||||||
(current) => mergeTicketDetailResponse(current, ticket),
|
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
|
||||||
},
|
|
||||||
onError: () => toast.error('Failed to upload repair evidence'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRequestTicketExtensionMutation(
|
export function useRequestTicketExtensionMutation(
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
assignmentId: number | undefined,
|
assignmentId: number | undefined,
|
||||||
|
|||||||
@@ -68,9 +68,6 @@ export const API_ROUTES = {
|
|||||||
`/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`,
|
`/biz/api/v1/tickets/${id}/class-detail?defect_class=${defectClass}`,
|
||||||
DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`,
|
DEFECT_CLASSES: (id: string) => `/biz/api/v1/tickets/${id}/defect-classes`,
|
||||||
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`,
|
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`,
|
||||||
SUBMIT_REPAIR: (id: string) => `/biz/api/v1/tickets/${id}/submit-repair`,
|
|
||||||
SUBMIT_REPAIR_UPLOAD: (id: string) =>
|
|
||||||
`/biz/api/v1/tickets/${id}/repair-proof`,
|
|
||||||
REQUEST_EXTENSION: (ticketId: string, assignmentId: number) =>
|
REQUEST_EXTENSION: (ticketId: string, assignmentId: number) =>
|
||||||
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-requests`,
|
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-requests`,
|
||||||
EXTENSION_REQUESTS: (ticketId: string) =>
|
EXTENSION_REQUESTS: (ticketId: string) =>
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import type {
|
|||||||
RequestTicketExtensionPayload,
|
RequestTicketExtensionPayload,
|
||||||
ReviewTicketExtensionPayload,
|
ReviewTicketExtensionPayload,
|
||||||
SseTokenResponse,
|
SseTokenResponse,
|
||||||
SubmitRepairPayload,
|
|
||||||
SubmitRepairUploadPayload,
|
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
TicketExtensionRequestsResponse,
|
TicketExtensionRequestsResponse,
|
||||||
TicketOverviewDetail,
|
TicketOverviewDetail,
|
||||||
@@ -90,41 +88,6 @@ export const ticketService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
submitRepair: async (
|
|
||||||
ticketId: string,
|
|
||||||
payload: SubmitRepairPayload,
|
|
||||||
): Promise<TicketDetail> => {
|
|
||||||
const response = await axiosClient.post<TicketDetail>(
|
|
||||||
API_ROUTES.TICKETS.SUBMIT_REPAIR(ticketId),
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
submitRepairUpload: async (
|
|
||||||
ticketId: string,
|
|
||||||
payload: SubmitRepairUploadPayload,
|
|
||||||
): Promise<TicketDetail> => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('defect_class', payload.defect_class);
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
|
|
||||||
requestTicketExtension: async (
|
requestTicketExtension: async (
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
assignmentId: number,
|
assignmentId: number,
|
||||||
|
|||||||
@@ -29,18 +29,6 @@ export interface AssignTicketPayload {
|
|||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmitRepairPayload {
|
|
||||||
notes: string;
|
|
||||||
proof_path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubmitRepairUploadPayload {
|
|
||||||
defect_class: string;
|
|
||||||
notes: string;
|
|
||||||
video: File;
|
|
||||||
images: File[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RequestTicketExtensionPayload {
|
export interface RequestTicketExtensionPayload {
|
||||||
requested_due_at: string;
|
requested_due_at: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user