2 Commits

16 changed files with 1180 additions and 168 deletions

1
.gitignore vendored
View File

@@ -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
View File

@@ -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
View File

@@ -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"
}
} }
} }
} }

View File

@@ -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(', ') || '—';

View File

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

View File

@@ -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>

View File

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

View File

@@ -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} />;
}

View File

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

View File

@@ -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} />;
}

View File

@@ -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();

View File

@@ -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,
}; };

View File

@@ -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) =>

View File

@@ -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,

View File

@@ -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';

View File

@@ -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;