317 lines
9.5 KiB
TypeScript
317 lines
9.5 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
ArrowRight,
|
|
CalendarClock,
|
|
Check,
|
|
Clock3,
|
|
Loader2,
|
|
X,
|
|
} from 'lucide-react';
|
|
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import type {
|
|
TicketExtensionRequest,
|
|
TicketExtensionRequestsResponse,
|
|
} from '@/types';
|
|
import { formatDate } from '@/utils/date';
|
|
|
|
import {
|
|
MAX_EXTENSION_DECISION_NOTE_LENGTH,
|
|
useExtensionRequestDecision,
|
|
} from '../../../../extension-requests/hooks/useExtensionRequestDecision';
|
|
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
|
|
import { ExtensionRequestHistoryDialog } from './ExtensionRequestHistoryDialog';
|
|
import {
|
|
ExtensionRequestDetails,
|
|
getLatestRequest,
|
|
} from './TicketExtensionRequestPanel';
|
|
import { TicketActionCard } from './TicketActionCard';
|
|
|
|
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({
|
|
ticketId,
|
|
assignmentId,
|
|
request,
|
|
requests,
|
|
}: {
|
|
ticketId: string;
|
|
assignmentId: number;
|
|
request: TicketExtensionRequest;
|
|
requests: TicketExtensionRequest[];
|
|
}) {
|
|
const {
|
|
note,
|
|
setNote,
|
|
noteError,
|
|
selectedAction,
|
|
isPending,
|
|
isRejectConfirmationOpen,
|
|
setIsRejectConfirmationOpen,
|
|
submitDecision,
|
|
requestRejection,
|
|
} = useExtensionRequestDecision({ ticketId, assignmentId });
|
|
const extensionDays = getExtensionDays(request);
|
|
|
|
return (
|
|
<TicketActionCard
|
|
icon={CalendarClock}
|
|
title="Extension Request Details"
|
|
contextLabel={`Lot ${assignmentId}`}
|
|
>
|
|
<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">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="extension-manager-note">Add Manager Comments</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Required for rejection and optional for approval.
|
|
</p>
|
|
</div>
|
|
<div className="relative">
|
|
<Textarea
|
|
id="extension-manager-note"
|
|
value={note}
|
|
onChange={(event) => {
|
|
setNote(event.target.value);
|
|
}}
|
|
placeholder="Add a note explaining your decision..."
|
|
disabled={isPending}
|
|
aria-invalid={Boolean(noteError)}
|
|
aria-describedby={
|
|
noteError ? 'extension-manager-note-error' : undefined
|
|
}
|
|
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_EXTENSION_DECISION_NOTE_LENGTH}
|
|
</span>
|
|
</div>
|
|
{noteError ? (
|
|
<p
|
|
id="extension-manager-note-error"
|
|
className="text-xs text-destructive"
|
|
>
|
|
{noteError}
|
|
</p>
|
|
) : null}
|
|
</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={isPending}
|
|
onClick={requestRejection}
|
|
>
|
|
{selectedAction === 'reject' ? (
|
|
<Loader2 className="animate-spin" />
|
|
) : (
|
|
<X />
|
|
)}
|
|
Reject Request
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
disabled={isPending}
|
|
onClick={() => void submitDecision('approve')}
|
|
>
|
|
{selectedAction === 'approve' ? (
|
|
<Loader2 className="animate-spin" />
|
|
) : (
|
|
<Check />
|
|
)}
|
|
Approve Extension
|
|
</Button>
|
|
</div>
|
|
|
|
<ExtensionRequestHistoryDialog requests={requests} />
|
|
|
|
<AlertDialog
|
|
open={isRejectConfirmationOpen}
|
|
onOpenChange={setIsRejectConfirmationOpen}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Reject extension request?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to reject this extension request? This
|
|
decision cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isPending}>No</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
variant="destructive"
|
|
disabled={isPending}
|
|
onClick={() => void submitDecision('reject')}
|
|
>
|
|
Yes
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
</TicketActionCard>
|
|
);
|
|
}
|
|
|
|
export function ReviewExtensionRequestAction({
|
|
ticketId,
|
|
assignmentId,
|
|
canReview,
|
|
}: {
|
|
ticketId: string;
|
|
assignmentId: number;
|
|
canReview: boolean;
|
|
}) {
|
|
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
|
ticketId,
|
|
assignmentId,
|
|
);
|
|
|
|
if (extensionRequestQuery.isError) {
|
|
return null;
|
|
}
|
|
|
|
if (extensionRequestQuery.isLoading) {
|
|
return (
|
|
<TicketActionCard
|
|
icon={Clock3}
|
|
title="Extension Request"
|
|
contextLabel={`Lot ${assignmentId}`}
|
|
>
|
|
<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;
|
|
|
|
const requests = extensionRequestQuery.data?.items ?? [];
|
|
|
|
if (!pendingRequest || !canReview) {
|
|
const latestRequest = extensionRequestQuery.data
|
|
? getLatestRequest(extensionRequestQuery.data)
|
|
: null;
|
|
|
|
if (!latestRequest) return null;
|
|
|
|
return (
|
|
<TicketActionCard
|
|
icon={CalendarClock}
|
|
title="Extension Request"
|
|
contextLabel={`Lot ${assignmentId}`}
|
|
>
|
|
<ExtensionRequestDetails request={latestRequest} requests={requests} />
|
|
</TicketActionCard>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ExtensionReviewForm
|
|
ticketId={ticketId}
|
|
assignmentId={assignmentId}
|
|
request={pendingRequest}
|
|
requests={requests}
|
|
/>
|
|
);
|
|
}
|