232 lines
7.2 KiB
TypeScript
232 lines
7.2 KiB
TypeScript
'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 { 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 null;
|
|
}
|
|
|
|
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 null;
|
|
|
|
return <ExtensionReviewForm ticket={ticket} request={pendingRequest} />;
|
|
}
|