246 lines
7.0 KiB
TypeScript
246 lines
7.0 KiB
TypeScript
'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 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,
|
|
assignmentId,
|
|
dueAt,
|
|
}: {
|
|
ticket: TicketDetail;
|
|
assignmentId: number;
|
|
dueAt: string | null;
|
|
}) {
|
|
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
|
ticket.id,
|
|
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) {
|
|
return (
|
|
<RequestExtensionForm
|
|
ticket={ticket}
|
|
assignmentId={assignmentId}
|
|
currentDueAt={dueAt}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return <ExtensionRequestDetails request={latestRequest} />;
|
|
}
|