Compare commits
2 Commits
26e15360f7
...
14220445ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 14220445ad | |||
| 79272c7abc |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,6 +44,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
# Test / coverage
|
# Test / coverage
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
6
next-env.d.ts
vendored
6
next-env.d.ts
vendored
@@ -1,6 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
import "./.next/dev/types/routes.d.ts";
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
|
||||||
15
package-lock.json
generated
15
package-lock.json
generated
@@ -9480,6 +9480,21 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
|
"version": "16.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.10.tgz",
|
||||||
|
"integrity": "sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,8 +149,8 @@ export function TicketOverviewCard({
|
|||||||
const seconds = Math.floor(ticket.video_metadata.duration_seconds % 60);
|
const seconds = Math.floor(ticket.video_metadata.duration_seconds % 60);
|
||||||
const duration = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
const duration = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
const chainageName =
|
const chainageName =
|
||||||
ticket.location.chainage.name || ticket.chainage_name || null;
|
ticket.location?.chainage?.name || ticket.chainage_name || null;
|
||||||
const packageName = ticket.location.package.name || null;
|
const packageName = ticket.location?.package?.name || null;
|
||||||
const locationLabel =
|
const locationLabel =
|
||||||
[chainageName, packageName].filter(Boolean).join(', ') || '—';
|
[chainageName, packageName].filter(Boolean).join(', ') || '—';
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { TicketDetail } from '@/types';
|
|||||||
import { AssignTicketAction } from './actions/AssignTicketAction';
|
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 { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
||||||
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
||||||
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
||||||
|
|
||||||
@@ -29,10 +30,17 @@ function getTicketAction(ticket: TicketDetail) {
|
|||||||
if (ticket.assignment_status === 'assigned') {
|
if (ticket.assignment_status === 'assigned') {
|
||||||
return (
|
return (
|
||||||
<PermissionGuard
|
<PermissionGuard
|
||||||
permissions={PERMISSIONS.TICKET.WORK}
|
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||||
fallback={<NoTicketAction />}
|
fallback={
|
||||||
|
<PermissionGuard
|
||||||
|
permissions={PERMISSIONS.TICKET.WORK}
|
||||||
|
fallback={<NoTicketAction />}
|
||||||
|
>
|
||||||
|
<SubmitRepairAction ticket={ticket} />
|
||||||
|
</PermissionGuard>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SubmitRepairAction ticket={ticket} />
|
<ReviewExtensionRequestAction ticket={ticket} />
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
import { ImageIcon, Loader2, Send, Video, X } from 'lucide-react';
|
import {
|
||||||
|
Camera,
|
||||||
|
ImageIcon,
|
||||||
|
Loader2,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Send,
|
||||||
|
Video,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -10,122 +19,183 @@ import { cn } from '@/lib/utils';
|
|||||||
|
|
||||||
import { useSubmitRepairUploadMutation } from '../../../hooks/useTicketQueries';
|
import { useSubmitRepairUploadMutation } from '../../../hooks/useTicketQueries';
|
||||||
|
|
||||||
const MAX_IMAGES = 5;
|
const MAX_IMAGES = 30;
|
||||||
|
const MAX_NOTES = 500;
|
||||||
|
|
||||||
function RequiredMark() {
|
function RequiredMark() {
|
||||||
return <span className="text-destructive">*</span>;
|
return <span className="text-destructive">*</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MediaDropzoneProps {
|
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;
|
icon: ReactNode;
|
||||||
label: string;
|
title: string;
|
||||||
accept: string;
|
addLabel: string;
|
||||||
file: File | null;
|
onAdd: () => void;
|
||||||
onFileChange: (file: File | null) => void;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MediaDropzone({
|
function MediaSectionHeader({
|
||||||
icon,
|
icon,
|
||||||
label,
|
title,
|
||||||
accept,
|
addLabel,
|
||||||
file,
|
onAdd,
|
||||||
onFileChange,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: MediaDropzoneProps) {
|
}: MediaSectionHeaderProps) {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
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="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onAdd}
|
||||||
|
className="h-8 shrink-0 border-primary/30 text-primary hover:bg-primary/5 hover:text-primary"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaHint({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p className="rounded-md 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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center cursor-pointer gap-3 rounded-lg border border-dashed px-4 py-3 text-left transition-colors',
|
'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/50 hover:bg-muted/30',
|
'hover:border-primary/40 hover:bg-primary/5',
|
||||||
disabled && 'pointer-events-none opacity-60',
|
disabled && 'cursor-not-allowed opacity-60',
|
||||||
file && 'border-primary/40 bg-muted/20',
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<span className="flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
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}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||||
<span className="block truncate text-sm font-medium">
|
<span className="max-w-xs text-xs text-muted-foreground">
|
||||||
{file ? file.name : label}
|
{description}
|
||||||
</span>
|
|
||||||
{file ? (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Click to replace
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImageSlotProps {
|
interface ImageThumbnailProps {
|
||||||
file: File | null;
|
file: File;
|
||||||
previewUrl: string | null;
|
previewUrl: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onAdd: () => void;
|
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageSlot({
|
function ImageThumbnail({
|
||||||
file,
|
file,
|
||||||
previewUrl,
|
previewUrl,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onAdd,
|
|
||||||
onRemove,
|
onRemove,
|
||||||
}: ImageSlotProps) {
|
}: ImageThumbnailProps) {
|
||||||
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 cursor-pointer items-center justify-center rounded-full bg-background/90 text-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
aria-label={`Remove ${file.name}`}
|
|
||||||
>
|
|
||||||
<X className="size-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="group relative size-20 shrink-0 overflow-hidden rounded-lg border bg-muted/30">
|
||||||
type="button"
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
disabled={disabled}
|
<img
|
||||||
onClick={onAdd}
|
src={previewUrl}
|
||||||
className={cn(
|
alt={file.name}
|
||||||
'flex aspect-square cursor-pointer items-center justify-center rounded-lg border border-dashed bg-muted/20 text-muted-foreground transition-colors',
|
className="size-full object-cover"
|
||||||
'hover:border-primary/50 hover:bg-muted/40 hover:text-primary',
|
/>
|
||||||
disabled && 'cursor-not-allowed opacity-60',
|
<button
|
||||||
)}
|
type="button"
|
||||||
aria-label="Add image"
|
disabled={disabled}
|
||||||
>
|
onClick={onRemove}
|
||||||
<ImageIcon className="size-4" />
|
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"
|
||||||
</button>
|
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 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,14 +209,18 @@ export function RepairEvidenceForm({
|
|||||||
defectClass,
|
defectClass,
|
||||||
}: RepairEvidenceFormProps) {
|
}: RepairEvidenceFormProps) {
|
||||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const videoInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
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 [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const submitRepairUploadMutation = useSubmitRepairUploadMutation(ticketId);
|
const submitRepairUploadMutation = useSubmitRepairUploadMutation(ticketId);
|
||||||
const isSubmitting = submitRepairUploadMutation.isPending;
|
const isSubmitting = submitRepairUploadMutation.isPending;
|
||||||
const canSubmit = Boolean(notes.trim() && videoFile);
|
const canSubmit = Boolean(notes.trim() && videoFile);
|
||||||
|
const canAddMoreImages = imageFiles.length < MAX_IMAGES;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urls = imageFiles.map((file) => URL.createObjectURL(file));
|
const urls = imageFiles.map((file) => URL.createObjectURL(file));
|
||||||
@@ -156,6 +230,32 @@ export function RepairEvidenceForm({
|
|||||||
};
|
};
|
||||||
}, [imageFiles]);
|
}, [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) => {
|
const addImages = (files: FileList | null) => {
|
||||||
if (!files?.length) return;
|
if (!files?.length) return;
|
||||||
|
|
||||||
@@ -177,6 +277,16 @@ export function RepairEvidenceForm({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectVideo = (files: FileList | null) => {
|
||||||
|
setVideoFile(files?.[0] ?? null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeVideo = () => {
|
||||||
|
setVideoFile(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const removeImage = (index: number) => {
|
const removeImage = (index: number) => {
|
||||||
setImageFiles((current) =>
|
setImageFiles((current) =>
|
||||||
current.filter((_, itemIndex) => itemIndex !== index),
|
current.filter((_, itemIndex) => itemIndex !== index),
|
||||||
@@ -186,7 +296,7 @@ export function RepairEvidenceForm({
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!notes.trim() || !videoFile) {
|
if (!notes.trim() || !videoFile) {
|
||||||
setError('Repair notes and video are required.');
|
setError('Repair notes and a video are required.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,81 +316,143 @@ export function RepairEvidenceForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const imageSlots = Array.from({ length: MAX_IMAGES }, (_, index) => ({
|
const openImagePicker = () => {
|
||||||
file: imageFiles[index] ?? null,
|
if (!isSubmitting && canAddMoreImages) {
|
||||||
previewUrl: imagePreviews[index] ?? null,
|
imageInputRef.current?.click();
|
||||||
}));
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openVideoPicker = () => {
|
||||||
|
if (!isSubmitting) {
|
||||||
|
videoInputRef.current?.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<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-[104px]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="repair-notes">
|
<Label htmlFor="repair-notes">
|
||||||
Repair Notes <RequiredMark />
|
Repair Notes <RequiredMark />
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<div className="relative">
|
||||||
id="repair-notes"
|
<Textarea
|
||||||
value={notes}
|
id="repair-notes"
|
||||||
onChange={(event) => {
|
value={notes}
|
||||||
setNotes(event.target.value);
|
onChange={(event) => {
|
||||||
setError(null);
|
setNotes(event.target.value.slice(0, MAX_NOTES));
|
||||||
}}
|
|
||||||
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);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
|
placeholder="Describe the repair work completed, materials used, and any additional observations."
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
className="min-h-28 resize-none pb-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
<span className="pointer-events-none absolute bottom-2 right-3 text-xs text-muted-foreground">
|
||||||
|
{notes.length} / {MAX_NOTES}
|
||||||
<div className="space-y-2">
|
</span>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Clock3, Loader2, Send } from 'lucide-react';
|
||||||
|
|
||||||
|
import { DatePicker } from '@/components/form/DatePicker';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
|
import { useRequestTicketExtensionMutation } from '../../../hooks/useTicketQueries';
|
||||||
|
|
||||||
|
const MAX_REASON_LENGTH = 300;
|
||||||
|
|
||||||
|
interface RequestExtensionFormProps {
|
||||||
|
ticket: TicketDetail;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RequestExtensionForm({
|
||||||
|
ticket,
|
||||||
|
onCancel,
|
||||||
|
}: RequestExtensionFormProps) {
|
||||||
|
const currentDueDate = ticket.worker?.due_at
|
||||||
|
? new Date(ticket.worker.due_at)
|
||||||
|
: undefined;
|
||||||
|
const [requestedDueDate, setRequestedDueDate] = useState<Date>();
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const assignmentId = ticket.worker?.assignment_id;
|
||||||
|
const requestExtensionMutation = useRequestTicketExtensionMutation(
|
||||||
|
ticket.id,
|
||||||
|
assignmentId,
|
||||||
|
ticket.defect_class,
|
||||||
|
);
|
||||||
|
const isPending = requestExtensionMutation.isPending;
|
||||||
|
const isRequestedDateValid = Boolean(
|
||||||
|
requestedDueDate &&
|
||||||
|
(!currentDueDate || requestedDueDate.getTime() > currentDueDate.getTime()),
|
||||||
|
);
|
||||||
|
const canSubmit =
|
||||||
|
Boolean(assignmentId) &&
|
||||||
|
isRequestedDateValid &&
|
||||||
|
Boolean(reason.trim()) &&
|
||||||
|
!isPending;
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!requestedDueDate || !canSubmit) return;
|
||||||
|
|
||||||
|
await requestExtensionMutation.mutateAsync({
|
||||||
|
requested_due_at: requestedDueDate.toISOString(),
|
||||||
|
reason: reason.trim(),
|
||||||
|
});
|
||||||
|
setRequestedDueDate(undefined);
|
||||||
|
setReason('');
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex gap-3 rounded-lg border border-primary/15 bg-primary/5 p-4">
|
||||||
|
<Clock3 className="mt-0.5 size-5 shrink-0 text-primary" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Request more time to complete the repair. Your manager will review the
|
||||||
|
request.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Current Due Date</Label>
|
||||||
|
<DatePicker
|
||||||
|
value={currentDueDate}
|
||||||
|
showTimeZone={false}
|
||||||
|
placeholder="No due date available"
|
||||||
|
disabled
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
Requested Due Date <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<DatePicker
|
||||||
|
value={requestedDueDate}
|
||||||
|
onChange={setRequestedDueDate}
|
||||||
|
showTimeZone={false}
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
{requestedDueDate && !isRequestedDateValid ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
Requested date must be after the current due date.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="extension-reason">
|
||||||
|
Reason for Extension <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Textarea
|
||||||
|
id="extension-reason"
|
||||||
|
value={reason}
|
||||||
|
onChange={(event) =>
|
||||||
|
setReason(event.target.value.slice(0, MAX_REASON_LENGTH))
|
||||||
|
}
|
||||||
|
placeholder="Explain why additional time is required to complete the repair."
|
||||||
|
disabled={isPending}
|
||||||
|
className="min-h-28 resize-none pb-8"
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground">
|
||||||
|
{reason.length} / {MAX_REASON_LENGTH}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
CalendarClock,
|
||||||
|
Check,
|
||||||
|
Clock3,
|
||||||
|
Loader2,
|
||||||
|
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,
|
||||||
|
TicketExtensionRequest,
|
||||||
|
TicketExtensionRequestsResponse,
|
||||||
|
} from '@/types';
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
|
import {
|
||||||
|
useReviewTicketExtensionMutation,
|
||||||
|
useTicketExtensionRequestQuery,
|
||||||
|
} from '../../../hooks/useTicketQueries';
|
||||||
|
import { NoTicketAction } from './NoTicketAction';
|
||||||
|
import { TicketActionCard } from './TicketActionCard';
|
||||||
|
|
||||||
|
const MAX_NOTE_LENGTH = 300;
|
||||||
|
|
||||||
|
function getPendingRequest(response: TicketExtensionRequestsResponse) {
|
||||||
|
return response.items
|
||||||
|
.filter((request) => request.status === 'pending')
|
||||||
|
.reduce<TicketExtensionRequest | null>((latest, request) => {
|
||||||
|
if (!latest) return request;
|
||||||
|
return new Date(request.requested_at) > new Date(latest.requested_at)
|
||||||
|
? request
|
||||||
|
: latest;
|
||||||
|
}, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExtensionDays(request: TicketExtensionRequest) {
|
||||||
|
const difference =
|
||||||
|
new Date(request.requested_due_at).getTime() -
|
||||||
|
new Date(request.current_due_at).getTime();
|
||||||
|
return Math.max(0, Math.ceil(difference / (24 * 60 * 60 * 1000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function Requester({ request }: { request: TicketExtensionRequest }) {
|
||||||
|
const name = request.requested_by?.name ?? 'Assigned worker';
|
||||||
|
const initials = name
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase())
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 rounded-lg bg-muted/40 p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
|
||||||
|
{initials || 'W'}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[11px] text-muted-foreground">Requested By</p>
|
||||||
|
<p className="truncate text-sm font-medium">{name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">Requested On</p>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatDate(request.requested_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExtensionReviewForm({
|
||||||
|
ticket,
|
||||||
|
request,
|
||||||
|
}: {
|
||||||
|
ticket: TicketDetail;
|
||||||
|
request: TicketExtensionRequest;
|
||||||
|
}) {
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const [selectedAction, setSelectedAction] = useState<
|
||||||
|
'approve' | 'reject' | null
|
||||||
|
>(null);
|
||||||
|
const assignmentId = ticket.worker?.assignment_id as number;
|
||||||
|
const reviewMutation = useReviewTicketExtensionMutation(
|
||||||
|
ticket.id,
|
||||||
|
assignmentId,
|
||||||
|
ticket.defect_class,
|
||||||
|
);
|
||||||
|
const extensionDays = getExtensionDays(request);
|
||||||
|
|
||||||
|
const submitDecision = async (action: 'approve' | 'reject') => {
|
||||||
|
setSelectedAction(action);
|
||||||
|
try {
|
||||||
|
await reviewMutation.mutateAsync({ action, note: note.trim() });
|
||||||
|
} finally {
|
||||||
|
setSelectedAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TicketActionCard icon={CalendarClock} title="Extension Request Details">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Requester request={request} />
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold">Request Summary</p>
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-3 rounded-lg border p-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Current Due Date
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-medium">
|
||||||
|
{formatDate(request.current_due_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="mt-6 size-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Requested Due Date
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-medium">
|
||||||
|
{formatDate(request.requested_due_at)}
|
||||||
|
</p>
|
||||||
|
{extensionDays > 0 ? (
|
||||||
|
<span className="mt-1 inline-flex rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300">
|
||||||
|
+{extensionDays} {extensionDays === 1 ? 'Day' : 'Days'}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<p className="text-[11px] text-muted-foreground">Reason</p>
|
||||||
|
<p className="mt-1 text-sm leading-relaxed">{request.reason}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="extension-manager-note">
|
||||||
|
Add Manager Comments{' '}
|
||||||
|
<span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Textarea
|
||||||
|
id="extension-manager-note"
|
||||||
|
value={note}
|
||||||
|
onChange={(event) =>
|
||||||
|
setNote(event.target.value.slice(0, MAX_NOTE_LENGTH))
|
||||||
|
}
|
||||||
|
placeholder="Add a note explaining your decision..."
|
||||||
|
disabled={reviewMutation.isPending}
|
||||||
|
className="min-h-24 resize-none pb-8"
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground">
|
||||||
|
{note.length} / {MAX_NOTE_LENGTH}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="border-destructive/40 text-destructive hover:bg-destructive/5 hover:text-destructive"
|
||||||
|
disabled={reviewMutation.isPending}
|
||||||
|
onClick={() => submitDecision('reject')}
|
||||||
|
>
|
||||||
|
{selectedAction === 'reject' ? (
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<X />
|
||||||
|
)}
|
||||||
|
Reject Request
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={reviewMutation.isPending}
|
||||||
|
onClick={() => submitDecision('approve')}
|
||||||
|
>
|
||||||
|
{selectedAction === 'approve' ? (
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check />
|
||||||
|
)}
|
||||||
|
Approve Extension
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TicketActionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewExtensionRequestAction({
|
||||||
|
ticket,
|
||||||
|
}: {
|
||||||
|
ticket: TicketDetail;
|
||||||
|
}) {
|
||||||
|
const assignmentId = ticket.worker?.assignment_id;
|
||||||
|
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
||||||
|
ticket.id,
|
||||||
|
assignmentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!assignmentId || extensionRequestQuery.isError) {
|
||||||
|
return <NoTicketAction />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extensionRequestQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<TicketActionCard icon={Clock3} title="Extension Request">
|
||||||
|
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
<span className="text-sm">Checking for requests...</span>
|
||||||
|
</div>
|
||||||
|
</TicketActionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingRequest = extensionRequestQuery.data
|
||||||
|
? getPendingRequest(extensionRequestQuery.data)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!pendingRequest) return <NoTicketAction />;
|
||||||
|
|
||||||
|
return <ExtensionReviewForm ticket={ticket} request={pendingRequest} />;
|
||||||
|
}
|
||||||
@@ -1,19 +1,48 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { Wrench } from 'lucide-react';
|
import { Wrench } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import type { TicketDetail } from '@/types';
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
import { RepairEvidenceForm } from './RepairEvidenceForm';
|
import { RepairEvidenceForm } from './RepairEvidenceForm';
|
||||||
|
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
|
||||||
import { TicketActionCard } from './TicketActionCard';
|
import { TicketActionCard } from './TicketActionCard';
|
||||||
|
|
||||||
|
type RepairActionTab = 'repair-proof' | 'request-extension';
|
||||||
|
|
||||||
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
|
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
const [activeTab, setActiveTab] = useState<RepairActionTab>('repair-proof');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TicketActionCard icon={Wrench} title="Submit Repair Evidence">
|
<TicketActionCard icon={Wrench} title="Repair Proof">
|
||||||
<RepairEvidenceForm
|
<Tabs
|
||||||
ticketId={ticket.id}
|
value={activeTab}
|
||||||
defectClass={ticket.defect_class}
|
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>
|
</TicketActionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CalendarDays,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock3,
|
||||||
|
History,
|
||||||
|
Loader2,
|
||||||
|
RotateCcw,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type {
|
||||||
|
TicketDetail,
|
||||||
|
TicketExtensionRequest,
|
||||||
|
TicketExtensionRequestsResponse,
|
||||||
|
} from '@/types';
|
||||||
|
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;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value, children, className }: DetailRowProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-1 px-3 py-2.5', className)}>
|
||||||
|
<p className="text-[11px] font-medium text-muted-foreground">{label}</p>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-medium">{value}</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
pending: {
|
||||||
|
title: 'Request Pending',
|
||||||
|
description: 'Your extension request is waiting for manager approval.',
|
||||||
|
icon: Clock3,
|
||||||
|
className:
|
||||||
|
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300',
|
||||||
|
},
|
||||||
|
approved: {
|
||||||
|
title: 'Request Approved',
|
||||||
|
description: 'Your extension request has been approved by the manager.',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
className:
|
||||||
|
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300',
|
||||||
|
},
|
||||||
|
rejected: {
|
||||||
|
title: 'Request Rejected',
|
||||||
|
description: 'Your extension request has been rejected by the manager.',
|
||||||
|
icon: AlertCircle,
|
||||||
|
className:
|
||||||
|
'border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300',
|
||||||
|
},
|
||||||
|
cancelled: {
|
||||||
|
title: 'Request Cancelled',
|
||||||
|
description: 'This extension request was cancelled.',
|
||||||
|
icon: XCircle,
|
||||||
|
className: 'border-border bg-muted/50 text-muted-foreground',
|
||||||
|
},
|
||||||
|
} satisfies Record<
|
||||||
|
TicketExtensionRequest['status'],
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: typeof Clock3;
|
||||||
|
className: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
function getLatestRequest(response: TicketExtensionRequestsResponse) {
|
||||||
|
return response.items.reduce<TicketExtensionRequest | null>(
|
||||||
|
(latest, item) => {
|
||||||
|
if (!latest) return item;
|
||||||
|
return new Date(item.requested_at) > new Date(latest.requested_at)
|
||||||
|
? item
|
||||||
|
: latest;
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExtensionDays(request: TicketExtensionRequest) {
|
||||||
|
const difference =
|
||||||
|
new Date(request.requested_due_at).getTime() -
|
||||||
|
new Date(request.current_due_at).getTime();
|
||||||
|
return Math.max(0, Math.ceil(difference / (24 * 60 * 60 * 1000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExtensionRequestDetails({
|
||||||
|
request,
|
||||||
|
}: {
|
||||||
|
request: TicketExtensionRequest;
|
||||||
|
}) {
|
||||||
|
const config = statusConfig[request.status];
|
||||||
|
const StatusIcon = config.icon;
|
||||||
|
const extensionDays = getExtensionDays(request);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className={cn('flex gap-3 rounded-lg border p-4', config.className)}>
|
||||||
|
<StatusIcon className="mt-0.5 size-5 shrink-0" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-semibold">{config.title}</p>
|
||||||
|
<p className="text-xs leading-relaxed">{config.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold">Request Details</p>
|
||||||
|
<div className="divide-y overflow-hidden rounded-lg border">
|
||||||
|
<DetailRow
|
||||||
|
label="Requested On"
|
||||||
|
value={formatDate(request.requested_at)}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Requested Due Date"
|
||||||
|
value={formatDate(request.requested_due_at)}
|
||||||
|
>
|
||||||
|
{extensionDays > 0 ? (
|
||||||
|
<span className="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold text-primary">
|
||||||
|
+{extensionDays} {extensionDays === 1 ? 'Day' : 'Days'}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Reason for Extension" value={request.reason} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{request.resolved_at || request.resolved_by ? (
|
||||||
|
<section className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold">Manager Response</p>
|
||||||
|
<div className="divide-y overflow-hidden rounded-lg border">
|
||||||
|
{request.resolved_at ? (
|
||||||
|
<DetailRow
|
||||||
|
label="Responded On"
|
||||||
|
value={formatDate(request.resolved_at)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{request.resolved_by ? (
|
||||||
|
<DetailRow
|
||||||
|
label="Resolved By"
|
||||||
|
value={`${request.resolved_by.name} · ${request.resolved_by.role.name}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{request.status === 'approved' ? (
|
||||||
|
<DetailRow
|
||||||
|
label="Approved Due Date"
|
||||||
|
value={formatDate(request.requested_due_at)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold">Original Due Date</p>
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<DetailRow
|
||||||
|
label="Original schedule"
|
||||||
|
value={formatDate(request.current_due_at)}
|
||||||
|
>
|
||||||
|
<CalendarDays className="size-4 text-muted-foreground" />
|
||||||
|
</DetailRow>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Button type="button" variant="outline" className="w-full">
|
||||||
|
<History />
|
||||||
|
View Approval History
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketExtensionRequestPanel({
|
||||||
|
ticket,
|
||||||
|
onCancelRequest,
|
||||||
|
}: TicketExtensionRequestPanelProps) {
|
||||||
|
const assignmentId = ticket.worker?.assignment_id;
|
||||||
|
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
||||||
|
ticket.id,
|
||||||
|
assignmentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!assignmentId) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||||
|
No active assignment is available for this ticket.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extensionRequestQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
|
||||||
|
<Loader2 className="size-5 animate-spin" />
|
||||||
|
<span className="ml-2 text-sm">Loading extension request...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extensionRequestQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
Extension request details could not be loaded.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => extensionRequestQuery.refetch()}
|
||||||
|
>
|
||||||
|
<RotateCcw />
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestRequest = extensionRequestQuery.data
|
||||||
|
? getLatestRequest(extensionRequestQuery.data)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!latestRequest) {
|
||||||
|
return <RequestExtensionForm ticket={ticket} onCancel={onCancelRequest} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ExtensionRequestDetails request={latestRequest} />;
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import { ticketService } from '@/services/api';
|
|||||||
import type {
|
import type {
|
||||||
AssignTicketPayload,
|
AssignTicketPayload,
|
||||||
CloseTicketPayload,
|
CloseTicketPayload,
|
||||||
|
RequestTicketExtensionPayload,
|
||||||
|
ReviewTicketExtensionPayload,
|
||||||
ReviewRepairPayload,
|
ReviewRepairPayload,
|
||||||
SubmitRepairPayload,
|
SubmitRepairPayload,
|
||||||
SubmitRepairUploadPayload,
|
SubmitRepairUploadPayload,
|
||||||
@@ -83,6 +85,21 @@ export function useTicketDefectClassesQuery(ticketId: string | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useTicketExtensionRequestQuery(
|
||||||
|
ticketId: string | undefined,
|
||||||
|
assignmentId: number | undefined,
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ticketKeys.extensionRequest(ticketId ?? '', assignmentId),
|
||||||
|
queryFn: () =>
|
||||||
|
ticketService.getTicketExtensionRequest(
|
||||||
|
ticketId as string,
|
||||||
|
assignmentId as number,
|
||||||
|
),
|
||||||
|
enabled: Boolean(ticketId && assignmentId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useAssignableTicketUsersQuery(enabled: boolean) {
|
export function useAssignableTicketUsersQuery(enabled: boolean) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ticketKeys.assignableUsers(),
|
queryKey: ticketKeys.assignableUsers(),
|
||||||
@@ -158,6 +175,64 @@ export function useSubmitRepairUploadMutation(ticketId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useRequestTicketExtensionMutation(
|
||||||
|
ticketId: string,
|
||||||
|
assignmentId: number | undefined,
|
||||||
|
defectClass: string,
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: RequestTicketExtensionPayload) => {
|
||||||
|
if (assignmentId === undefined) {
|
||||||
|
throw new Error('No active assignment is available for this ticket.');
|
||||||
|
}
|
||||||
|
return ticketService.requestTicketExtension(
|
||||||
|
ticketId,
|
||||||
|
assignmentId,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Extension request submitted');
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.classDetail(ticketId, defectClass),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to request an extension'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function useReviewTicketExtensionMutation(
|
||||||
|
ticketId: string,
|
||||||
|
assignmentId: number,
|
||||||
|
defectClass: string,
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: ReviewTicketExtensionPayload) =>
|
||||||
|
ticketService.reviewTicketExtension(ticketId, assignmentId, payload),
|
||||||
|
onSuccess: (_response, payload) => {
|
||||||
|
toast.success(
|
||||||
|
payload.action === 'approve'
|
||||||
|
? 'Extension request approved'
|
||||||
|
: 'Extension request rejected',
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.classDetail(ticketId, defectClass),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to review extension request'),
|
||||||
|
});
|
||||||
|
}
|
||||||
export function useReviewRepairMutation(ticketId: string) {
|
export function useReviewRepairMutation(ticketId: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@@ -11,5 +11,13 @@ export const ticketKeys = {
|
|||||||
[...ticketKeys.details(), ticketId, 'class', defectClass] as const,
|
[...ticketKeys.details(), ticketId, 'class', defectClass] as const,
|
||||||
defectClasses: (ticketId: string) =>
|
defectClasses: (ticketId: string) =>
|
||||||
[...ticketKeys.details(), ticketId, 'defect-classes'] as const,
|
[...ticketKeys.details(), ticketId, 'defect-classes'] as const,
|
||||||
|
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
|
||||||
|
[
|
||||||
|
...ticketKeys.details(),
|
||||||
|
ticketId,
|
||||||
|
'assignments',
|
||||||
|
assignmentId,
|
||||||
|
'extension-request',
|
||||||
|
] as const,
|
||||||
assignableUsers: () => [...ticketKeys.all, 'assignable-users'] as const,
|
assignableUsers: () => [...ticketKeys.all, 'assignable-users'] as const,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ export const API_ROUTES = {
|
|||||||
SUBMIT_REPAIR: (id: string) => `/biz/api/v1/tickets/${id}/submit-repair`,
|
SUBMIT_REPAIR: (id: string) => `/biz/api/v1/tickets/${id}/submit-repair`,
|
||||||
SUBMIT_REPAIR_UPLOAD: (id: string) =>
|
SUBMIT_REPAIR_UPLOAD: (id: string) =>
|
||||||
`/biz/api/v1/tickets/${id}/repair-proof`,
|
`/biz/api/v1/tickets/${id}/repair-proof`,
|
||||||
|
REQUEST_EXTENSION: (ticketId: string, assignmentId: number) =>
|
||||||
|
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-requests`,
|
||||||
|
EXTENSION_REQUESTS: (ticketId: string) =>
|
||||||
|
`/biz/api/v1/tickets/${ticketId}/extension-requests`,
|
||||||
|
REVIEW_EXTENSION_REQUEST: (ticketId: string, assignmentId: number) =>
|
||||||
|
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-request/decision`,
|
||||||
REVIEW: (id: string) => `/biz/api/v1/tickets/${id}/review`,
|
REVIEW: (id: string) => `/biz/api/v1/tickets/${id}/review`,
|
||||||
CLOSE: (id: string) => `/biz/api/v1/tickets/${id}/close`,
|
CLOSE: (id: string) => `/biz/api/v1/tickets/${id}/close`,
|
||||||
DETAIL_EVENTS_TOKEN: (id: string) =>
|
DETAIL_EVENTS_TOKEN: (id: string) =>
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import type {
|
|||||||
AssignableTicketUsersResponse,
|
AssignableTicketUsersResponse,
|
||||||
AssignableTicketUsersParams,
|
AssignableTicketUsersParams,
|
||||||
CloseTicketPayload,
|
CloseTicketPayload,
|
||||||
|
RequestTicketExtensionPayload,
|
||||||
|
ReviewTicketExtensionPayload,
|
||||||
ReviewRepairPayload,
|
ReviewRepairPayload,
|
||||||
SseTokenResponse,
|
SseTokenResponse,
|
||||||
SubmitRepairPayload,
|
SubmitRepairPayload,
|
||||||
SubmitRepairUploadPayload,
|
SubmitRepairUploadPayload,
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
|
TicketExtensionRequestsResponse,
|
||||||
TicketOverviewDetail,
|
TicketOverviewDetail,
|
||||||
TicketDefectClassesResponse,
|
TicketDefectClassesResponse,
|
||||||
TicketListParams,
|
TicketListParams,
|
||||||
@@ -123,6 +126,42 @@ export const ticketService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
requestTicketExtension: async (
|
||||||
|
ticketId: string,
|
||||||
|
assignmentId: number,
|
||||||
|
payload: RequestTicketExtensionPayload,
|
||||||
|
): Promise<unknown> => {
|
||||||
|
const response = await axiosClient.post<unknown>(
|
||||||
|
API_ROUTES.TICKETS.REQUEST_EXTENSION(ticketId, assignmentId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTicketExtensionRequest: async (
|
||||||
|
ticketId: string,
|
||||||
|
assignmentId: number,
|
||||||
|
): Promise<TicketExtensionRequestsResponse> => {
|
||||||
|
const response = await axiosClient.get<TicketExtensionRequestsResponse>(
|
||||||
|
API_ROUTES.TICKETS.EXTENSION_REQUESTS(ticketId),
|
||||||
|
{
|
||||||
|
params: { assignment_id: assignmentId },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
reviewTicketExtension: async (
|
||||||
|
ticketId: string,
|
||||||
|
assignmentId: number,
|
||||||
|
payload: ReviewTicketExtensionPayload,
|
||||||
|
): Promise<unknown> => {
|
||||||
|
const response = await axiosClient.post<unknown>(
|
||||||
|
API_ROUTES.TICKETS.REVIEW_EXTENSION_REQUEST(ticketId, assignmentId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
reviewRepair: async (
|
reviewRepair: async (
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
payload: ReviewRepairPayload,
|
payload: ReviewRepairPayload,
|
||||||
|
|||||||
@@ -41,6 +41,45 @@ export interface SubmitRepairUploadPayload {
|
|||||||
images: File[];
|
images: File[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RequestTicketExtensionPayload {
|
||||||
|
requested_due_at: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewTicketExtensionPayload {
|
||||||
|
action: 'approve' | 'reject';
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketExtensionRequestActor {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
role: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketExtensionRequest {
|
||||||
|
id: number;
|
||||||
|
ticket_id: string;
|
||||||
|
assignment_id: number;
|
||||||
|
defect_class: string;
|
||||||
|
current_due_at: string;
|
||||||
|
requested_due_at: string;
|
||||||
|
reason: string;
|
||||||
|
status: 'pending' | 'approved' | 'rejected' | 'cancelled';
|
||||||
|
requested_by: TicketExtensionRequestActor | null;
|
||||||
|
requested_at: string;
|
||||||
|
resolved_by: TicketExtensionRequestActor | null;
|
||||||
|
resolved_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketExtensionRequestsResponse {
|
||||||
|
items: TicketExtensionRequest[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReviewRepairPayload {
|
export interface ReviewRepairPayload {
|
||||||
defect_class: string;
|
defect_class: string;
|
||||||
action: 'approve' | 'reject';
|
action: 'approve' | 'reject';
|
||||||
|
|||||||
@@ -49,8 +49,15 @@ export interface TicketVideo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketWorker extends TicketActor {
|
export interface TicketWorker extends TicketActor {
|
||||||
|
assignment_id: number;
|
||||||
assigned_at: string | null;
|
assigned_at: string | null;
|
||||||
assigned_by: TicketActor | null;
|
due_at: string | null;
|
||||||
|
assigned_by_user_id: number | null;
|
||||||
|
assigned_by_name: string | null;
|
||||||
|
role: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketRepair {
|
export interface TicketRepair {
|
||||||
@@ -86,9 +93,9 @@ export interface TicketLocationReference {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketLocation {
|
export interface TicketLocation {
|
||||||
project: TicketLocationReference;
|
project: TicketLocationReference | null;
|
||||||
package: TicketLocationReference;
|
package: TicketLocationReference | null;
|
||||||
chainage: TicketLocationReference;
|
chainage: TicketLocationReference | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketVideoMetadata {
|
export interface TicketVideoMetadata {
|
||||||
@@ -113,7 +120,7 @@ export interface TicketOverviewDetail {
|
|||||||
video_id: string;
|
video_id: string;
|
||||||
chainage_id: string;
|
chainage_id: string;
|
||||||
chainage_name: string;
|
chainage_name: string;
|
||||||
location: TicketLocation;
|
location: TicketLocation | null;
|
||||||
video_metadata: TicketVideoMetadata;
|
video_metadata: TicketVideoMetadata;
|
||||||
uploader: {
|
uploader: {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user