feat(ticket): add shared extension request history for workers and managers

This commit is contained in:
2026-08-03 16:04:15 +05:30
parent 0a4d9512cb
commit 6169bbdc28
5 changed files with 284 additions and 15 deletions

View File

@@ -43,19 +43,32 @@ function getTicketAction(
ticketId: string, ticketId: string,
assignment?: TicketAssignmentSummary, assignment?: TicketAssignmentSummary,
) { ) {
if (assignment && !assignment.is_complete) { if (assignment) {
return ( return (
<PermissionGuard <PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN} permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={ fallback={
<PermissionGuard <PermissionGuard
permissions={PERMISSIONS.TICKET.WORK} permissions={PERMISSIONS.TICKET.WORK}
fallback={
<PermissionGuard
permissions={PERMISSIONS.TICKET.READ}
fallback={<NoTicketAction />} fallback={<NoTicketAction />}
> >
<RequestExtensionAction <RequestExtensionAction
ticketId={ticketId} ticketId={ticketId}
assignmentId={assignment.id} assignmentId={assignment.id}
dueAt={assignment.due_at} dueAt={assignment.due_at}
canRequest={false}
/>
</PermissionGuard>
}
>
<RequestExtensionAction
ticketId={ticketId}
assignmentId={assignment.id}
dueAt={assignment.due_at}
canRequest={!assignment.is_complete}
/> />
</PermissionGuard> </PermissionGuard>
} }
@@ -63,6 +76,7 @@ function getTicketAction(
<ReviewExtensionRequestAction <ReviewExtensionRequestAction
ticketId={ticketId} ticketId={ticketId}
assignmentId={assignment.id} assignmentId={assignment.id}
canReview={!assignment.is_complete}
/> />
</PermissionGuard> </PermissionGuard>
); );

View File

@@ -0,0 +1,162 @@
'use client';
import { ArrowRight, History } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import type {
TicketExtensionRequest,
TicketExtensionRequestStatus,
} from '@/types';
import { formatDate } from '@/utils/date';
const statusConfig: Record<
TicketExtensionRequestStatus,
{ label: string; className: string }
> = {
pending: {
label: 'Pending',
className:
'border-amber-200 bg-amber-100 text-amber-800 dark:border-amber-900 dark:bg-amber-950/50 dark:text-amber-300',
},
approved: {
label: 'Approved',
className:
'border-emerald-200 bg-emerald-100 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/50 dark:text-emerald-300',
},
rejected: {
label: 'Rejected',
className:
'border-red-200 bg-red-100 text-red-800 dark:border-red-900 dark:bg-red-950/50 dark:text-red-300',
},
cancelled: {
label: 'Cancelled',
className: 'border-border bg-muted text-muted-foreground',
},
};
function HistoryItem({ request }: { request: TicketExtensionRequest }) {
const status = statusConfig[request.status];
return (
<article className="space-y-3 rounded-lg border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<Badge variant="outline" className={cn('border', status.className)}>
{status.label}
</Badge>
<span className="text-xs text-muted-foreground">
Requested {formatDate(request.requested_at)}
</span>
</div>
<div>
<p className="text-xs text-muted-foreground">Requested By</p>
<p className="mt-0.5 text-sm font-medium">
{request.requested_by?.name ?? 'Assigned worker'}
</p>
</div>
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-3 rounded-lg bg-muted/40 p-3">
<div>
<p className="text-xs text-muted-foreground">Previous Due Date</p>
<p className="mt-1 text-sm font-medium">
{formatDate(request.current_due_at)}
</p>
</div>
<ArrowRight className="mt-5 size-4 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">Requested Due Date</p>
<p className="mt-1 text-sm font-medium">
{formatDate(request.requested_due_at)}
</p>
</div>
</div>
<div>
<p className="text-xs text-muted-foreground">Reason</p>
<p className="mt-1 text-sm leading-relaxed">{request.reason}</p>
</div>
{request.resolved_at || request.resolved_by || request.decision_note ? (
<div className="space-y-2 border-t pt-3">
<p className="text-xs font-semibold">Manager Response</p>
<div className="grid gap-2 text-sm sm:grid-cols-2">
{request.resolved_by ? (
<div>
<p className="text-xs text-muted-foreground">Resolved By</p>
<p className="mt-0.5 font-medium">
{request.resolved_by.name} · {request.resolved_by.role.name}
</p>
</div>
) : null}
{request.resolved_at ? (
<div>
<p className="text-xs text-muted-foreground">Resolved On</p>
<p className="mt-0.5 font-medium">
{formatDate(request.resolved_at)}
</p>
</div>
) : null}
</div>
{request.decision_note ? (
<div>
<p className="text-xs text-muted-foreground">Manager Comments</p>
<p className="mt-1 text-sm leading-relaxed">
{request.decision_note}
</p>
</div>
) : null}
</div>
) : null}
</article>
);
}
export function ExtensionRequestHistoryDialog({
requests,
}: {
requests: TicketExtensionRequest[];
}) {
const sortedRequests = [...requests].sort(
(left, right) =>
new Date(right.requested_at).getTime() -
new Date(left.requested_at).getTime(),
);
return (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="outline" className="w-full">
<History />
View Request History ({requests.length})
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Extension Request History</DialogTitle>
<DialogDescription>
Review every deadline extension request and manager decision for
this ticket lot.
</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-3">
{sortedRequests.map((request) => (
<HistoryItem key={request.id} request={request} />
))}
</DialogBody>
<DialogFooter showCloseButton />
</DialogContent>
</Dialog>
);
}

View File

@@ -7,21 +7,24 @@ export function RequestExtensionAction({
ticketId, ticketId,
assignmentId, assignmentId,
dueAt, dueAt,
canRequest,
}: { }: {
ticketId: string; ticketId: string;
assignmentId: number; assignmentId: number;
dueAt: string | null; dueAt: string | null;
canRequest: boolean;
}) { }) {
return ( return (
<TicketActionCard <TicketActionCard
icon={Clock3} icon={Clock3}
title="Request Extension" title={canRequest ? 'Request Extension' : 'Extension Request History'}
contextLabel={`Lot ${assignmentId}`} contextLabel={`Lot ${assignmentId}`}
> >
<TicketExtensionRequestPanel <TicketExtensionRequestPanel
ticketId={ticketId} ticketId={ticketId}
assignmentId={assignmentId} assignmentId={assignmentId}
dueAt={dueAt} dueAt={dueAt}
canRequest={canRequest}
/> />
</TicketActionCard> </TicketActionCard>
); );

View File

@@ -33,6 +33,11 @@ import {
useExtensionRequestDecision, useExtensionRequestDecision,
} from '../../../../extension-requests/hooks/useExtensionRequestDecision'; } from '../../../../extension-requests/hooks/useExtensionRequestDecision';
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries'; import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
import { ExtensionRequestHistoryDialog } from './ExtensionRequestHistoryDialog';
import {
ExtensionRequestDetails,
getLatestRequest,
} from './TicketExtensionRequestPanel';
import { TicketActionCard } from './TicketActionCard'; import { TicketActionCard } from './TicketActionCard';
function getPendingRequest(response: TicketExtensionRequestsResponse) { function getPendingRequest(response: TicketExtensionRequestsResponse) {
@@ -86,10 +91,12 @@ function ExtensionReviewForm({
ticketId, ticketId,
assignmentId, assignmentId,
request, request,
requests,
}: { }: {
ticketId: string; ticketId: string;
assignmentId: number; assignmentId: number;
request: TicketExtensionRequest; request: TicketExtensionRequest;
requests: TicketExtensionRequest[];
}) { }) {
const { const {
note, note,
@@ -210,6 +217,8 @@ function ExtensionReviewForm({
</Button> </Button>
</div> </div>
<ExtensionRequestHistoryDialog requests={requests} />
<AlertDialog <AlertDialog
open={isRejectConfirmationOpen} open={isRejectConfirmationOpen}
onOpenChange={setIsRejectConfirmationOpen} onOpenChange={setIsRejectConfirmationOpen}
@@ -242,9 +251,11 @@ function ExtensionReviewForm({
export function ReviewExtensionRequestAction({ export function ReviewExtensionRequestAction({
ticketId, ticketId,
assignmentId, assignmentId,
canReview,
}: { }: {
ticketId: string; ticketId: string;
assignmentId: number; assignmentId: number;
canReview: boolean;
}) { }) {
const extensionRequestQuery = useTicketExtensionRequestQuery( const extensionRequestQuery = useTicketExtensionRequestQuery(
ticketId, ticketId,
@@ -274,13 +285,32 @@ export function ReviewExtensionRequestAction({
? getPendingRequest(extensionRequestQuery.data) ? getPendingRequest(extensionRequestQuery.data)
: null; : null;
if (!pendingRequest) return 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 ( return (
<ExtensionReviewForm <ExtensionReviewForm
ticketId={ticketId} ticketId={ticketId}
assignmentId={assignmentId} assignmentId={assignmentId}
request={pendingRequest} request={pendingRequest}
requests={requests}
/> />
); );
} }

View File

@@ -1,12 +1,13 @@
'use client'; 'use client';
import { useState } from 'react';
import { import {
AlertCircle, AlertCircle,
CalendarDays, CalendarDays,
CheckCircle2, CheckCircle2,
Clock3, Clock3,
History,
Loader2, Loader2,
Plus,
RotateCcw, RotateCcw,
XCircle, XCircle,
} from 'lucide-react'; } from 'lucide-react';
@@ -20,6 +21,7 @@ import type {
import { formatDate } from '@/utils/date'; import { formatDate } from '@/utils/date';
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries'; import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
import { ExtensionRequestHistoryDialog } from './ExtensionRequestHistoryDialog';
import { RequestExtensionForm } from './RequestExtensionForm'; import { RequestExtensionForm } from './RequestExtensionForm';
interface DetailRowProps { interface DetailRowProps {
@@ -44,21 +46,21 @@ function DetailRow({ label, value, children, className }: DetailRowProps) {
const statusConfig = { const statusConfig = {
pending: { pending: {
title: 'Request Pending', title: 'Request Pending',
description: 'Your extension request is waiting for manager approval.', description: 'This extension request is waiting for manager approval.',
icon: Clock3, icon: Clock3,
className: className:
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300', 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300',
}, },
approved: { approved: {
title: 'Request Approved', title: 'Request Approved',
description: 'Your extension request has been approved by the manager.', description: 'This extension request was approved by the manager.',
icon: CheckCircle2, icon: CheckCircle2,
className: className:
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300', 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300',
}, },
rejected: { rejected: {
title: 'Request Rejected', title: 'Request Rejected',
description: 'Your extension request has been rejected by the manager.', description: 'This extension request was rejected by the manager.',
icon: AlertCircle, icon: AlertCircle,
className: className:
'border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300', 'border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300',
@@ -79,7 +81,7 @@ const statusConfig = {
} }
>; >;
function getLatestRequest(response: TicketExtensionRequestsResponse) { export function getLatestRequest(response: TicketExtensionRequestsResponse) {
return response.items.reduce<TicketExtensionRequest | null>( return response.items.reduce<TicketExtensionRequest | null>(
(latest, item) => { (latest, item) => {
if (!latest) return item; if (!latest) return item;
@@ -98,10 +100,14 @@ function getExtensionDays(request: TicketExtensionRequest) {
return Math.max(0, Math.ceil(difference / (24 * 60 * 60 * 1000))); return Math.max(0, Math.ceil(difference / (24 * 60 * 60 * 1000)));
} }
function ExtensionRequestDetails({ export function ExtensionRequestDetails({
request, request,
requests,
onRequestAnother,
}: { }: {
request: TicketExtensionRequest; request: TicketExtensionRequest;
requests: TicketExtensionRequest[];
onRequestAnother?: () => void;
}) { }) {
const config = statusConfig[request.status]; const config = statusConfig[request.status];
const StatusIcon = config.icon; const StatusIcon = config.icon;
@@ -124,6 +130,10 @@ function ExtensionRequestDetails({
label="Requested On" label="Requested On"
value={formatDate(request.requested_at)} value={formatDate(request.requested_at)}
/> />
<DetailRow
label="Requested By"
value={request.requested_by?.name ?? 'Assigned worker'}
/>
<DetailRow <DetailRow
label="Requested Due Date" label="Requested Due Date"
value={formatDate(request.requested_due_at)} value={formatDate(request.requested_due_at)}
@@ -160,6 +170,12 @@ function ExtensionRequestDetails({
value={formatDate(request.requested_due_at)} value={formatDate(request.requested_due_at)}
/> />
) : null} ) : null}
{request.decision_note ? (
<DetailRow
label="Manager Comments"
value={request.decision_note}
/>
) : null}
</div> </div>
</section> </section>
) : null} ) : null}
@@ -176,10 +192,15 @@ function ExtensionRequestDetails({
</div> </div>
</section> </section>
<Button type="button" variant="outline" className="w-full"> <div className="space-y-2">
<History /> <ExtensionRequestHistoryDialog requests={requests} />
View Approval History {request.status !== 'pending' && onRequestAnother ? (
<Button type="button" className="w-full" onClick={onRequestAnother}>
<Plus />
Request Another Extension
</Button> </Button>
) : null}
</div>
</div> </div>
); );
} }
@@ -188,11 +209,14 @@ export function TicketExtensionRequestPanel({
ticketId, ticketId,
assignmentId, assignmentId,
dueAt, dueAt,
canRequest,
}: { }: {
ticketId: string; ticketId: string;
assignmentId: number; assignmentId: number;
dueAt: string | null; dueAt: string | null;
canRequest: boolean;
}) { }) {
const [isRequestFormOpen, setIsRequestFormOpen] = useState(false);
const extensionRequestQuery = useTicketExtensionRequestQuery( const extensionRequestQuery = useTicketExtensionRequestQuery(
ticketId, ticketId,
assignmentId, assignmentId,
@@ -231,6 +255,14 @@ export function TicketExtensionRequestPanel({
: null; : null;
if (!latestRequest) { if (!latestRequest) {
if (!canRequest) {
return (
<p className="text-sm text-muted-foreground">
No extension request history is available for this ticket lot.
</p>
);
}
return ( return (
<RequestExtensionForm <RequestExtensionForm
ticketId={ticketId} ticketId={ticketId}
@@ -240,5 +272,33 @@ export function TicketExtensionRequestPanel({
); );
} }
return <ExtensionRequestDetails request={latestRequest} />; 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
}
/>
);
} }