Files
road-monitoring-ui/src/app/(modules)/ticket/[ticketId]/components/actions/TicketExtensionRequestPanel.tsx

305 lines
8.7 KiB
TypeScript

'use client';
import { useState } from 'react';
import {
AlertCircle,
CalendarDays,
CheckCircle2,
Clock3,
Loader2,
Plus,
RotateCcw,
XCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type {
TicketExtensionRequest,
TicketExtensionRequestsResponse,
} from '@/types';
import { formatDate } from '@/utils/date';
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
import { ExtensionRequestHistoryDialog } from './ExtensionRequestHistoryDialog';
import { RequestExtensionForm } from './RequestExtensionForm';
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: 'This 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: 'This extension request was 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: 'This extension request was 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;
}
>;
export 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)));
}
export function ExtensionRequestDetails({
request,
requests,
onRequestAnother,
}: {
request: TicketExtensionRequest;
requests: TicketExtensionRequest[];
onRequestAnother?: () => void;
}) {
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 By"
value={request.requested_by?.name ?? 'Assigned worker'}
/>
<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}
{request.decision_note ? (
<DetailRow
label="Manager Comments"
value={request.decision_note}
/>
) : 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>
<div className="space-y-2">
<ExtensionRequestHistoryDialog requests={requests} />
{request.status !== 'pending' && onRequestAnother ? (
<Button type="button" className="w-full" onClick={onRequestAnother}>
<Plus />
Request Another Extension
</Button>
) : null}
</div>
</div>
);
}
export function TicketExtensionRequestPanel({
ticketId,
assignmentId,
dueAt,
canRequest,
}: {
ticketId: string;
assignmentId: number;
dueAt: string | null;
canRequest: boolean;
}) {
const [isRequestFormOpen, setIsRequestFormOpen] = useState(false);
const extensionRequestQuery = useTicketExtensionRequestQuery(
ticketId,
assignmentId,
);
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) {
if (!canRequest) {
return (
<p className="text-sm text-muted-foreground">
No extension request history is available for this ticket lot.
</p>
);
}
return (
<RequestExtensionForm
ticketId={ticketId}
assignmentId={assignmentId}
currentDueAt={dueAt}
/>
);
}
if (isRequestFormOpen && latestRequest.status !== 'pending') {
return (
<div className="space-y-4">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsRequestFormOpen(false)}
>
Back to latest request
</Button>
<RequestExtensionForm
ticketId={ticketId}
assignmentId={assignmentId}
currentDueAt={dueAt}
/>
</div>
);
}
return (
<ExtensionRequestDetails
request={latestRequest}
requests={extensionRequestQuery.data?.items ?? []}
onRequestAnother={
canRequest ? () => setIsRequestFormOpen(true) : undefined
}
/>
);
}