feat(extension-requests): add manager queue with quick decisions
This commit is contained in:
@@ -0,0 +1,155 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
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 StatusBadge({ status }: { status: TicketExtensionRequestStatus }) {
|
||||||
|
const config = statusConfig[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={cn('border', config.className)}>
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonCell({
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
}: {
|
||||||
|
name: string | null | undefined;
|
||||||
|
role: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{name || '-'}</p>
|
||||||
|
{role ? (
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{role}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExtensionRequestColumns(): ColumnDef<TicketExtensionRequest>[] {
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'ticket_name',
|
||||||
|
header: 'Ticket / Lot',
|
||||||
|
size: 170,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{row.original.ticket_name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Lot #{row.original.assignment_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'requested_by',
|
||||||
|
header: 'Requested By',
|
||||||
|
size: 170,
|
||||||
|
meta: { disableTruncate: true },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<PersonCell
|
||||||
|
name={row.original.requested_by?.name}
|
||||||
|
role={row.original.requested_by?.role.name}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
size: 125,
|
||||||
|
meta: { disableTruncate: true },
|
||||||
|
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'current_due_at',
|
||||||
|
header: 'Current Due Date',
|
||||||
|
size: 175,
|
||||||
|
cell: ({ row }) => formatDate(row.original.current_due_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'requested_due_at',
|
||||||
|
header: 'Requested Due Date',
|
||||||
|
size: 175,
|
||||||
|
cell: ({ row }) => formatDate(row.original.requested_due_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'reason',
|
||||||
|
header: 'Request Reason',
|
||||||
|
size: 260,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'resolved_by',
|
||||||
|
header: 'Resolved By',
|
||||||
|
size: 170,
|
||||||
|
meta: { disableTruncate: true },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<PersonCell
|
||||||
|
name={row.original.resolved_by?.name}
|
||||||
|
role={row.original.resolved_by?.role.name}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'decision_note',
|
||||||
|
header: 'Decision Note',
|
||||||
|
size: 220,
|
||||||
|
cell: ({ row }) => row.original.decision_note || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'previous_requests',
|
||||||
|
header: 'Previous Requests',
|
||||||
|
size: 150,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{row.original.history_meta.previous_request_count}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'resolved_at',
|
||||||
|
header: 'Resolved On',
|
||||||
|
size: 175,
|
||||||
|
cell: ({ row }) => formatDate(row.original.resolved_at),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { ArrowRight, Check, 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 {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import type { TicketExtensionRequest } from '@/types';
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MAX_EXTENSION_DECISION_NOTE_LENGTH,
|
||||||
|
type ExtensionRequestDecisionAction,
|
||||||
|
useExtensionRequestDecision,
|
||||||
|
} from '../hooks/useExtensionRequestDecision';
|
||||||
|
|
||||||
|
interface ExtensionRequestDecisionDialogProps {
|
||||||
|
request: TicketExtensionRequest | null;
|
||||||
|
action: ExtensionRequestDecisionAction | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExtensionRequestDecisionDialog({
|
||||||
|
request,
|
||||||
|
action,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: ExtensionRequestDecisionDialogProps) {
|
||||||
|
const handleSuccess = useCallback(() => onOpenChange(false), [onOpenChange]);
|
||||||
|
const {
|
||||||
|
note,
|
||||||
|
setNote,
|
||||||
|
noteError,
|
||||||
|
selectedAction,
|
||||||
|
isPending,
|
||||||
|
isRejectConfirmationOpen,
|
||||||
|
setIsRejectConfirmationOpen,
|
||||||
|
submitDecision,
|
||||||
|
requestRejection,
|
||||||
|
resetDecision,
|
||||||
|
} = useExtensionRequestDecision({
|
||||||
|
ticketId: request?.ticket_id ?? '',
|
||||||
|
assignmentId: request?.assignment_id ?? 0,
|
||||||
|
onSuccess: handleSuccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetDecision();
|
||||||
|
}, [action, open, request?.id, resetDecision]);
|
||||||
|
|
||||||
|
const handleOpenChange = (nextOpen: boolean) => {
|
||||||
|
if (isPending) return;
|
||||||
|
onOpenChange(nextOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!request || !action) return null;
|
||||||
|
|
||||||
|
const isApproval = action === 'approve';
|
||||||
|
const noteErrorId = `extension-manager-note-error-${request.id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-xl" showCloseButton={!isPending}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{isApproval
|
||||||
|
? 'Approve extension request'
|
||||||
|
: 'Reject extension request'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Review {request.ticket_name}, Lot #{request.assignment_id} before
|
||||||
|
confirming this decision.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody className="space-y-4">
|
||||||
|
<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-xs text-muted-foreground">
|
||||||
|
Current Due Date
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 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 font-medium">
|
||||||
|
{formatDate(request.requested_due_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">Requested By</p>
|
||||||
|
<p className="mt-1 font-medium">
|
||||||
|
{request.requested_by?.name ?? 'Assigned worker'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground">Reason</p>
|
||||||
|
<p className="mt-1 leading-relaxed">{request.reason}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
{isApproval
|
||||||
|
? 'Optional for approval.'
|
||||||
|
: 'Required for rejection.'}
|
||||||
|
</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 ? noteErrorId : 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={noteErrorId} className="text-xs text-destructive">
|
||||||
|
{noteError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{isApproval ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => void submitDecision('approve')}
|
||||||
|
>
|
||||||
|
{selectedAction === 'approve' ? (
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check />
|
||||||
|
)}
|
||||||
|
Approve Extension
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={requestRejection}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
Reject Request
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={isRejectConfirmationOpen}
|
||||||
|
onOpenChange={setIsRejectConfirmationOpen}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Reject extension request?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to reject the extension request for{' '}
|
||||||
|
{request.ticket_name}, Lot #{request.assignment_id}? This decision
|
||||||
|
cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isPending}>No</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => void submitDecision('reject')}
|
||||||
|
>
|
||||||
|
{selectedAction === 'reject' ? (
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Yes, Reject
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FilterX } from 'lucide-react';
|
||||||
|
|
||||||
|
import { TableSearchInput } from '@/components/data-table/TableSearchInput';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
|
import type { ExtensionRequestStatusFilter } from '../hooks/useExtensionRequestFilters';
|
||||||
|
|
||||||
|
interface ExtensionRequestFiltersProps {
|
||||||
|
ticketName: string;
|
||||||
|
statusFilter: ExtensionRequestStatusFilter;
|
||||||
|
onTicketNameChange: (value: string) => void;
|
||||||
|
onStatusChange: (value: ExtensionRequestStatusFilter) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExtensionRequestFilters({
|
||||||
|
ticketName,
|
||||||
|
statusFilter,
|
||||||
|
onTicketNameChange,
|
||||||
|
onStatusChange,
|
||||||
|
onClear,
|
||||||
|
}: ExtensionRequestFiltersProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<TableSearchInput
|
||||||
|
value={ticketName}
|
||||||
|
onChange={onTicketNameChange}
|
||||||
|
placeholder="Search by ticket name"
|
||||||
|
className="w-full sm:max-w-sm"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onStatusChange(value as ExtensionRequestStatusFilter)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full sm:w-44">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
<SelectItem value="approved">Approved</SelectItem>
|
||||||
|
<SelectItem value="rejected">Rejected</SelectItem>
|
||||||
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={!ticketName && statusFilter === 'pending'}
|
||||||
|
onClick={onClear}
|
||||||
|
>
|
||||||
|
<FilterX />
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { DataTable, type DataTableAction } from '@/components/data-table';
|
||||||
|
import type { TicketExtensionRequest } from '@/types';
|
||||||
|
|
||||||
|
interface ExtensionRequestTableProps {
|
||||||
|
columns: ColumnDef<TicketExtensionRequest>[];
|
||||||
|
requests: TicketExtensionRequest[];
|
||||||
|
isLoading: boolean;
|
||||||
|
toolbar: ReactNode;
|
||||||
|
skip: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
onPageChange: (skip: number) => void;
|
||||||
|
onLimitChange: (limit: number) => void;
|
||||||
|
onView: (request: TicketExtensionRequest) => void;
|
||||||
|
actions: DataTableAction<TicketExtensionRequest>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExtensionRequestTable({
|
||||||
|
columns,
|
||||||
|
requests,
|
||||||
|
isLoading,
|
||||||
|
toolbar,
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
onView,
|
||||||
|
actions,
|
||||||
|
}: ExtensionRequestTableProps) {
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
title="Extension Requests"
|
||||||
|
columns={columns}
|
||||||
|
data={requests}
|
||||||
|
isLoading={isLoading}
|
||||||
|
toolbar={toolbar}
|
||||||
|
emptyTitle="No extension requests found."
|
||||||
|
emptyDescription="Try changing the ticket or status filters."
|
||||||
|
onRowClick={onView}
|
||||||
|
actions={actions}
|
||||||
|
actionsSticky
|
||||||
|
minColumnSize={130}
|
||||||
|
pagination={{
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
totalItems: total,
|
||||||
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { ticketService } from '@/services/api';
|
||||||
|
import type { ReviewTicketExtensionPayload } from '@/types';
|
||||||
|
|
||||||
|
import { ticketKeys } from '../../ticket/queries/ticketKeys';
|
||||||
|
import { extensionRequestKeys } from '../queries/extensionRequestKeys';
|
||||||
|
|
||||||
|
export const MAX_EXTENSION_DECISION_NOTE_LENGTH = 300;
|
||||||
|
|
||||||
|
export type ExtensionRequestDecisionAction = 'approve' | 'reject';
|
||||||
|
|
||||||
|
interface UseExtensionRequestDecisionOptions {
|
||||||
|
ticketId: string;
|
||||||
|
assignmentId: number;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExtensionRequestDecision({
|
||||||
|
ticketId,
|
||||||
|
assignmentId,
|
||||||
|
onSuccess,
|
||||||
|
}: UseExtensionRequestDecisionOptions) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [note, setNoteValue] = useState('');
|
||||||
|
const [noteError, setNoteError] = useState<string | null>(null);
|
||||||
|
const [selectedAction, setSelectedAction] =
|
||||||
|
useState<ExtensionRequestDecisionAction | null>(null);
|
||||||
|
const [isRejectConfirmationOpen, setIsRejectConfirmationOpen] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
const reviewMutation = useMutation({
|
||||||
|
mutationFn: (payload: ReviewTicketExtensionPayload) =>
|
||||||
|
ticketService.reviewTicketExtension(ticketId, assignmentId, payload),
|
||||||
|
onSuccess: (_response, payload) => {
|
||||||
|
toast.success(
|
||||||
|
payload.action === 'approve'
|
||||||
|
? 'Extension request approved'
|
||||||
|
: 'Extension request rejected',
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: extensionRequestKeys.lists(),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.assignments(ticketId),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.timeline(ticketId),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to review extension request'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const setNote = useCallback((value: string) => {
|
||||||
|
const nextNote = value.slice(0, MAX_EXTENSION_DECISION_NOTE_LENGTH);
|
||||||
|
setNoteValue(nextNote);
|
||||||
|
if (nextNote.trim()) setNoteError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitDecision = useCallback(
|
||||||
|
async (action: ExtensionRequestDecisionAction) => {
|
||||||
|
const trimmedNote = note.trim();
|
||||||
|
|
||||||
|
if (action === 'reject' && !trimmedNote) {
|
||||||
|
setNoteError('Manager comments are required to reject this request.');
|
||||||
|
setIsRejectConfirmationOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedAction(action);
|
||||||
|
try {
|
||||||
|
await reviewMutation.mutateAsync({
|
||||||
|
action,
|
||||||
|
...(trimmedNote ? { note: trimmedNote } : {}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// The mutation displays the error toast and leaves the form open.
|
||||||
|
} finally {
|
||||||
|
setSelectedAction(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[note, reviewMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestRejection = useCallback(() => {
|
||||||
|
if (!note.trim()) {
|
||||||
|
setNoteError('Manager comments are required to reject this request.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNoteError(null);
|
||||||
|
setIsRejectConfirmationOpen(true);
|
||||||
|
}, [note]);
|
||||||
|
|
||||||
|
const resetDecision = useCallback(() => {
|
||||||
|
setNoteValue('');
|
||||||
|
setNoteError(null);
|
||||||
|
setSelectedAction(null);
|
||||||
|
setIsRejectConfirmationOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
note,
|
||||||
|
setNote,
|
||||||
|
noteError,
|
||||||
|
selectedAction,
|
||||||
|
isPending: reviewMutation.isPending,
|
||||||
|
isRejectConfirmationOpen,
|
||||||
|
setIsRejectConfirmationOpen,
|
||||||
|
submitDecision,
|
||||||
|
requestRejection,
|
||||||
|
resetDecision,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
|
import type { TicketExtensionRequestStatus } from '@/types';
|
||||||
|
|
||||||
|
export type ExtensionRequestStatusFilter = 'all' | TicketExtensionRequestStatus;
|
||||||
|
|
||||||
|
export function useExtensionRequestFilters() {
|
||||||
|
const [skip, setSkip] = useState(0);
|
||||||
|
const [limit, setLimitValue] = useState(10);
|
||||||
|
const [ticketName, setTicketNameValue] = useState('');
|
||||||
|
const [statusFilter, setStatusFilterValue] =
|
||||||
|
useState<ExtensionRequestStatusFilter>('pending');
|
||||||
|
const debouncedTicketName = useDebounce(ticketName.trim(), 400);
|
||||||
|
|
||||||
|
const setLimit = useCallback((value: number) => {
|
||||||
|
setLimitValue(value);
|
||||||
|
setSkip(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setTicketName = useCallback((value: string) => {
|
||||||
|
setTicketNameValue(value);
|
||||||
|
setSkip(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setStatusFilter = useCallback((value: ExtensionRequestStatusFilter) => {
|
||||||
|
setStatusFilterValue(value);
|
||||||
|
setSkip(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearFilters = useCallback(() => {
|
||||||
|
setTicketNameValue('');
|
||||||
|
setStatusFilterValue('pending');
|
||||||
|
setSkip(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
skip,
|
||||||
|
setSkip,
|
||||||
|
limit,
|
||||||
|
setLimit,
|
||||||
|
ticketName,
|
||||||
|
debouncedTicketName,
|
||||||
|
setTicketName,
|
||||||
|
statusFilter,
|
||||||
|
setStatusFilter,
|
||||||
|
clearFilters,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { ticketService } from '@/services/api';
|
||||||
|
import type { TicketExtensionRequestsParams } from '@/types';
|
||||||
|
|
||||||
|
import type { ExtensionRequestStatusFilter } from './useExtensionRequestFilters';
|
||||||
|
import { extensionRequestKeys } from '../queries/extensionRequestKeys';
|
||||||
|
|
||||||
|
interface UseExtensionRequestsQueryParams {
|
||||||
|
skip: number;
|
||||||
|
limit: number;
|
||||||
|
ticketName: string;
|
||||||
|
statusFilter: ExtensionRequestStatusFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExtensionRequestParams({
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
ticketName,
|
||||||
|
statusFilter,
|
||||||
|
}: UseExtensionRequestsQueryParams): TicketExtensionRequestsParams {
|
||||||
|
return {
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
ticket_name: ticketName || undefined,
|
||||||
|
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExtensionRequestsQuery(
|
||||||
|
params: UseExtensionRequestsQueryParams,
|
||||||
|
) {
|
||||||
|
const { skip, limit, ticketName, statusFilter } = params;
|
||||||
|
const queryParams = useMemo(
|
||||||
|
() =>
|
||||||
|
buildExtensionRequestParams({ skip, limit, ticketName, statusFilter }),
|
||||||
|
[limit, skip, statusFilter, ticketName],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: extensionRequestKeys.list(queryParams),
|
||||||
|
queryFn: () => ticketService.getTicketExtensionRequests(queryParams),
|
||||||
|
});
|
||||||
|
}
|
||||||
16
src/app/(modules)/extension-requests/layout.tsx
Normal file
16
src/app/(modules)/extension-requests/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function ExtensionRequestsLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.TICKET.ASSIGN}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
src/app/(modules)/extension-requests/page.tsx
Normal file
153
src/app/(modules)/extension-requests/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { AlertCircle, CalendarClock, Check, RotateCcw, X } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import type { DataTableAction } from '@/components/data-table';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import type { TicketExtensionRequest } from '@/types';
|
||||||
|
import { ROUTES } from '@/utils/routes';
|
||||||
|
|
||||||
|
import { useExtensionRequestColumns } from './components/ExtensionRequestColumns';
|
||||||
|
import { ExtensionRequestDecisionDialog } from './components/ExtensionRequestDecisionDialog';
|
||||||
|
import { ExtensionRequestFilters } from './components/ExtensionRequestFilters';
|
||||||
|
import { ExtensionRequestTable } from './components/ExtensionRequestTable';
|
||||||
|
import type { ExtensionRequestDecisionAction } from './hooks/useExtensionRequestDecision';
|
||||||
|
import { useExtensionRequestFilters } from './hooks/useExtensionRequestFilters';
|
||||||
|
import { useExtensionRequestsQuery } from './hooks/useExtensionRequestQueries';
|
||||||
|
|
||||||
|
export default function ExtensionRequestsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [decision, setDecision] = useState<{
|
||||||
|
request: TicketExtensionRequest;
|
||||||
|
action: ExtensionRequestDecisionAction;
|
||||||
|
} | null>(null);
|
||||||
|
const {
|
||||||
|
skip,
|
||||||
|
setSkip,
|
||||||
|
limit,
|
||||||
|
setLimit,
|
||||||
|
ticketName,
|
||||||
|
debouncedTicketName,
|
||||||
|
setTicketName,
|
||||||
|
statusFilter,
|
||||||
|
setStatusFilter,
|
||||||
|
clearFilters,
|
||||||
|
} = useExtensionRequestFilters();
|
||||||
|
const extensionRequestsQuery = useExtensionRequestsQuery({
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
ticketName: debouncedTicketName,
|
||||||
|
statusFilter,
|
||||||
|
});
|
||||||
|
const columns = useExtensionRequestColumns();
|
||||||
|
const requests = extensionRequestsQuery.data?.items ?? [];
|
||||||
|
const total = extensionRequestsQuery.data?.total ?? 0;
|
||||||
|
|
||||||
|
const actions = useMemo<DataTableAction<TicketExtensionRequest>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: 'Approve extension',
|
||||||
|
icon: <Check className="text-emerald-600" />,
|
||||||
|
permission: PERMISSIONS.TICKET.ASSIGN,
|
||||||
|
hidden: (request) => request.status !== 'pending',
|
||||||
|
className:
|
||||||
|
'border-emerald-200 hover:bg-emerald-50 dark:border-emerald-900 dark:hover:bg-emerald-950/50',
|
||||||
|
onClick: (request) => setDecision({ request, action: 'approve' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Reject extension',
|
||||||
|
icon: <X className="text-destructive" />,
|
||||||
|
permission: PERMISSIONS.TICKET.ASSIGN,
|
||||||
|
hidden: (request) => request.status !== 'pending',
|
||||||
|
className:
|
||||||
|
'border-destructive/30 hover:bg-destructive/5 hover:text-destructive',
|
||||||
|
onClick: (request) => setDecision({ request, action: 'reject' }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openTicket = useCallback(
|
||||||
|
(request: TicketExtensionRequest) => {
|
||||||
|
router.push(ROUTES.TICKET_DETAIL(request.ticket_id));
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterToolbar = useMemo(
|
||||||
|
() => (
|
||||||
|
<ExtensionRequestFilters
|
||||||
|
ticketName={ticketName}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
onTicketNameChange={setTicketName}
|
||||||
|
onStatusChange={setStatusFilter}
|
||||||
|
onClear={clearFilters}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[clearFilters, setTicketName, setStatusFilter, statusFilter, ticketName],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative z-10 space-y-5">
|
||||||
|
<PageHeader
|
||||||
|
title="Extension Requests"
|
||||||
|
description="Track deadline extension requests across all ticket lots."
|
||||||
|
icon={CalendarClock}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{extensionRequestsQuery.isError ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex min-h-48 flex-col items-center justify-center rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 text-center"
|
||||||
|
>
|
||||||
|
<AlertCircle className="mb-2 size-6 text-destructive" />
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
Failed to load extension requests.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-3"
|
||||||
|
disabled={extensionRequestsQuery.isFetching}
|
||||||
|
onClick={() => extensionRequestsQuery.refetch()}
|
||||||
|
>
|
||||||
|
<RotateCcw
|
||||||
|
className={
|
||||||
|
extensionRequestsQuery.isFetching ? 'animate-spin' : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ExtensionRequestTable
|
||||||
|
columns={columns}
|
||||||
|
requests={requests}
|
||||||
|
isLoading={extensionRequestsQuery.isLoading}
|
||||||
|
toolbar={filterToolbar}
|
||||||
|
skip={skip}
|
||||||
|
limit={limit}
|
||||||
|
total={total}
|
||||||
|
onPageChange={setSkip}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
onView={openTicket}
|
||||||
|
actions={actions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ExtensionRequestDecisionDialog
|
||||||
|
request={decision?.request ?? null}
|
||||||
|
action={decision?.action ?? null}
|
||||||
|
open={Boolean(decision)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDecision(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { TicketExtensionRequestsParams } from '@/types';
|
||||||
|
|
||||||
|
export const extensionRequestKeys = {
|
||||||
|
all: ['extension-requests'] as const,
|
||||||
|
lists: () => [...extensionRequestKeys.all, 'list'] as const,
|
||||||
|
list: (params: TicketExtensionRequestsParams) =>
|
||||||
|
[...extensionRequestKeys.lists(), params] as const,
|
||||||
|
};
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
@@ -30,13 +29,12 @@ import type {
|
|||||||
import { formatDate } from '@/utils/date';
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useReviewTicketExtensionMutation,
|
MAX_EXTENSION_DECISION_NOTE_LENGTH,
|
||||||
useTicketExtensionRequestQuery,
|
useExtensionRequestDecision,
|
||||||
} from '../../../hooks/useTicketQueries';
|
} from '../../../../extension-requests/hooks/useExtensionRequestDecision';
|
||||||
|
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
|
||||||
import { TicketActionCard } from './TicketActionCard';
|
import { TicketActionCard } from './TicketActionCard';
|
||||||
|
|
||||||
const MAX_NOTE_LENGTH = 300;
|
|
||||||
|
|
||||||
function getPendingRequest(response: TicketExtensionRequestsResponse) {
|
function getPendingRequest(response: TicketExtensionRequestsResponse) {
|
||||||
return response.items
|
return response.items
|
||||||
.filter((request) => request.status === 'pending')
|
.filter((request) => request.status === 'pending')
|
||||||
@@ -93,49 +91,19 @@ function ExtensionReviewForm({
|
|||||||
assignmentId: number;
|
assignmentId: number;
|
||||||
request: TicketExtensionRequest;
|
request: TicketExtensionRequest;
|
||||||
}) {
|
}) {
|
||||||
const [note, setNote] = useState('');
|
const {
|
||||||
const [noteError, setNoteError] = useState<string | null>(null);
|
note,
|
||||||
const [selectedAction, setSelectedAction] = useState<
|
setNote,
|
||||||
'approve' | 'reject' | null
|
noteError,
|
||||||
>(null);
|
selectedAction,
|
||||||
const [isRejectConfirmationOpen, setIsRejectConfirmationOpen] =
|
isPending,
|
||||||
useState(false);
|
isRejectConfirmationOpen,
|
||||||
const reviewMutation = useReviewTicketExtensionMutation(
|
setIsRejectConfirmationOpen,
|
||||||
ticketId,
|
submitDecision,
|
||||||
assignmentId,
|
requestRejection,
|
||||||
);
|
} = useExtensionRequestDecision({ ticketId, assignmentId });
|
||||||
const extensionDays = getExtensionDays(request);
|
const extensionDays = getExtensionDays(request);
|
||||||
|
|
||||||
const submitDecision = async (action: 'approve' | 'reject') => {
|
|
||||||
const trimmedNote = note.trim();
|
|
||||||
|
|
||||||
if (action === 'reject' && !trimmedNote) {
|
|
||||||
setNoteError('Manager comments are required to reject this request.');
|
|
||||||
setIsRejectConfirmationOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedAction(action);
|
|
||||||
try {
|
|
||||||
await reviewMutation.mutateAsync({
|
|
||||||
action,
|
|
||||||
...(trimmedNote ? { note: trimmedNote } : {}),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSelectedAction(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRejectRequest = () => {
|
|
||||||
if (!note.trim()) {
|
|
||||||
setNoteError('Manager comments are required to reject this request.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setNoteError(null);
|
|
||||||
setIsRejectConfirmationOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TicketActionCard icon={CalendarClock} title="Extension Request Details">
|
<TicketActionCard icon={CalendarClock} title="Extension Request Details">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -185,12 +153,10 @@ function ExtensionReviewForm({
|
|||||||
id="extension-manager-note"
|
id="extension-manager-note"
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextNote = event.target.value.slice(0, MAX_NOTE_LENGTH);
|
setNote(event.target.value);
|
||||||
setNote(nextNote);
|
|
||||||
if (nextNote.trim()) setNoteError(null);
|
|
||||||
}}
|
}}
|
||||||
placeholder="Add a note explaining your decision..."
|
placeholder="Add a note explaining your decision..."
|
||||||
disabled={reviewMutation.isPending}
|
disabled={isPending}
|
||||||
aria-invalid={Boolean(noteError)}
|
aria-invalid={Boolean(noteError)}
|
||||||
aria-describedby={
|
aria-describedby={
|
||||||
noteError ? 'extension-manager-note-error' : undefined
|
noteError ? 'extension-manager-note-error' : undefined
|
||||||
@@ -198,7 +164,7 @@ function ExtensionReviewForm({
|
|||||||
className="min-h-24 resize-none pb-8"
|
className="min-h-24 resize-none pb-8"
|
||||||
/>
|
/>
|
||||||
<span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground">
|
<span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground">
|
||||||
{note.length} / {MAX_NOTE_LENGTH}
|
{note.length} / {MAX_EXTENSION_DECISION_NOTE_LENGTH}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{noteError ? (
|
{noteError ? (
|
||||||
@@ -216,8 +182,8 @@ function ExtensionReviewForm({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-destructive/40 text-destructive hover:bg-destructive/5 hover:text-destructive"
|
className="border-destructive/40 text-destructive hover:bg-destructive/5 hover:text-destructive"
|
||||||
disabled={reviewMutation.isPending}
|
disabled={isPending}
|
||||||
onClick={handleRejectRequest}
|
onClick={requestRejection}
|
||||||
>
|
>
|
||||||
{selectedAction === 'reject' ? (
|
{selectedAction === 'reject' ? (
|
||||||
<Loader2 className="animate-spin" />
|
<Loader2 className="animate-spin" />
|
||||||
@@ -228,8 +194,8 @@ function ExtensionReviewForm({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={reviewMutation.isPending}
|
disabled={isPending}
|
||||||
onClick={() => submitDecision('approve')}
|
onClick={() => void submitDecision('approve')}
|
||||||
>
|
>
|
||||||
{selectedAction === 'approve' ? (
|
{selectedAction === 'approve' ? (
|
||||||
<Loader2 className="animate-spin" />
|
<Loader2 className="animate-spin" />
|
||||||
@@ -253,13 +219,11 @@ function ExtensionReviewForm({
|
|||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={reviewMutation.isPending}>
|
<AlertDialogCancel disabled={isPending}>No</AlertDialogCancel>
|
||||||
No
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={reviewMutation.isPending}
|
disabled={isPending}
|
||||||
onClick={() => submitDecision('reject')}
|
onClick={() => void submitDecision('reject')}
|
||||||
>
|
>
|
||||||
Yes
|
Yes
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import type {
|
|||||||
DiscardDetectionPayload,
|
DiscardDetectionPayload,
|
||||||
RequestTicketExtensionPayload,
|
RequestTicketExtensionPayload,
|
||||||
ReviewDetectionRepairProofPayload,
|
ReviewDetectionRepairProofPayload,
|
||||||
ReviewTicketExtensionPayload,
|
|
||||||
TicketAssignmentSummary,
|
TicketAssignmentSummary,
|
||||||
TicketListParams,
|
TicketListParams,
|
||||||
DetectionProofStatus,
|
DetectionProofStatus,
|
||||||
@@ -25,6 +24,7 @@ import type {
|
|||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
import { ticketKeys } from '../queries/ticketKeys';
|
import { ticketKeys } from '../queries/ticketKeys';
|
||||||
|
import { extensionRequestKeys } from '../../extension-requests/queries/extensionRequestKeys';
|
||||||
|
|
||||||
const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000;
|
const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
@@ -367,6 +367,9 @@ export function useRequestTicketExtensionMutation(
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
|
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: extensionRequestKeys.lists(),
|
||||||
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ticketKeys.assignments(ticketId),
|
queryKey: ticketKeys.assignments(ticketId),
|
||||||
});
|
});
|
||||||
@@ -378,36 +381,6 @@ export function useRequestTicketExtensionMutation(
|
|||||||
onError: () => toast.error('Failed to request an extension'),
|
onError: () => toast.error('Failed to request an extension'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
export function useReviewTicketExtensionMutation(
|
|
||||||
ticketId: string,
|
|
||||||
assignmentId: number,
|
|
||||||
) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (payload: ReviewTicketExtensionPayload) =>
|
|
||||||
ticketService.reviewTicketExtension(ticketId, assignmentId, payload),
|
|
||||||
onSuccess: (_response, payload) => {
|
|
||||||
toast.success(
|
|
||||||
payload.action === 'approve'
|
|
||||||
? 'Extension request approved'
|
|
||||||
: 'Extension request rejected',
|
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ticketKeys.assignments(ticketId),
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ticketKeys.timeline(ticketId),
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
|
||||||
},
|
|
||||||
onError: () => toast.error('Failed to review extension request'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCloseTicketMutation(ticketId: string) {
|
export function useCloseTicketMutation(ticketId: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ export function TableFooter<TData>({
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
onPageSizeChange?: (pageSize: number) => void;
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const currentPageSize = pageSize ?? table.getState().pagination.pageSize;
|
const paginationState = table.getState().pagination;
|
||||||
|
const currentPageSize =
|
||||||
|
pageSize ?? paginationState?.pageSize ?? PAGE_SIZE_OPTIONS[0];
|
||||||
const selectedRowCount = Object.keys(table.getState().rowSelection).length;
|
const selectedRowCount = Object.keys(table.getState().rowSelection).length;
|
||||||
const pageIndex = table.getState().pagination.pageIndex;
|
const pageIndex = paginationState?.pageIndex ?? 0;
|
||||||
const rawPageCount = table.getPageCount();
|
const rawPageCount = table.getPageCount();
|
||||||
const pageCount = Math.max(
|
const pageCount = Math.max(
|
||||||
1,
|
1,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export interface DataTableAction<TData> {
|
|||||||
onClick: (item: TData) => void;
|
onClick: (item: TData) => void;
|
||||||
permission?: PermissionInput;
|
permission?: PermissionInput;
|
||||||
disabled?: (item: TData) => boolean;
|
disabled?: (item: TData) => boolean;
|
||||||
|
hidden?: (item: TData) => boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +157,8 @@ function DataTableContent<TData, TValue>({
|
|||||||
return (
|
return (
|
||||||
<div className="flex justify-start gap-2">
|
<div className="flex justify-start gap-2">
|
||||||
{actions.map((action) => {
|
{actions.map((action) => {
|
||||||
|
if (action.hidden?.(item)) return null;
|
||||||
|
|
||||||
const label =
|
const label =
|
||||||
typeof action.label === 'function'
|
typeof action.label === 'function'
|
||||||
? action.label(item)
|
? action.label(item)
|
||||||
@@ -212,26 +215,30 @@ function DataTableContent<TData, TValue>({
|
|||||||
manualSorting: isManualSorting,
|
manualSorting: isManualSorting,
|
||||||
pageCount: pagination
|
pageCount: pagination
|
||||||
? Math.ceil((pagination.totalItems ?? data.length) / pagination.limit)
|
? Math.ceil((pagination.totalItems ?? data.length) / pagination.limit)
|
||||||
: -1,
|
: undefined,
|
||||||
state: {
|
state: {
|
||||||
rowSelection,
|
rowSelection,
|
||||||
columnVisibility,
|
columnVisibility,
|
||||||
columnPinning,
|
columnPinning,
|
||||||
columnSizing,
|
columnSizing,
|
||||||
sorting,
|
sorting,
|
||||||
pagination: pagination
|
...(pagination
|
||||||
? {
|
? {
|
||||||
|
pagination: {
|
||||||
pageIndex: Math.floor(pagination.skip / pagination.limit),
|
pageIndex: Math.floor(pagination.skip / pagination.limit),
|
||||||
pageSize: pagination.limit,
|
pageSize: pagination.limit,
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
onPaginationChange: (updater) => {
|
}
|
||||||
if (typeof updater === 'function' && pagination) {
|
: {}),
|
||||||
const newState = updater({
|
},
|
||||||
|
onPaginationChange: pagination
|
||||||
|
? (updater) => {
|
||||||
|
const currentState = {
|
||||||
pageIndex: Math.floor(pagination.skip / pagination.limit),
|
pageIndex: Math.floor(pagination.skip / pagination.limit),
|
||||||
pageSize: pagination.limit,
|
pageSize: pagination.limit,
|
||||||
});
|
};
|
||||||
|
const newState =
|
||||||
|
typeof updater === 'function' ? updater(currentState) : updater;
|
||||||
const pageSizeChanged = newState.pageSize !== pagination.limit;
|
const pageSizeChanged = newState.pageSize !== pagination.limit;
|
||||||
|
|
||||||
if (pageSizeChanged) {
|
if (pageSizeChanged) {
|
||||||
@@ -242,7 +249,7 @@ function DataTableContent<TData, TValue>({
|
|||||||
|
|
||||||
pagination.onPageChange(newState.pageIndex * newState.pageSize);
|
pagination.onPageChange(newState.pageIndex * newState.pageSize);
|
||||||
}
|
}
|
||||||
},
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Building2,
|
Building2,
|
||||||
BadgeIndianRupee,
|
BadgeIndianRupee,
|
||||||
|
CalendarClock,
|
||||||
Globe,
|
Globe,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { ComponentType, SVGProps } from 'react';
|
import type { ComponentType, SVGProps } from 'react';
|
||||||
@@ -37,6 +38,12 @@ export const menuItems: MenuItem[] = [
|
|||||||
icon: Ticket,
|
icon: Ticket,
|
||||||
permission: PERMISSIONS.TICKET.READ,
|
permission: PERMISSIONS.TICKET.READ,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Extension Requests',
|
||||||
|
path: ROUTES.EXTENSION_REQUESTS,
|
||||||
|
icon: CalendarClock,
|
||||||
|
permission: PERMISSIONS.TICKET.ASSIGN,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Project',
|
title: 'Project',
|
||||||
path: ROUTES.PROJECT,
|
path: ROUTES.PROJECT,
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ export const API_ROUTES = {
|
|||||||
ASSIGNMENTS: (id: string) => `/biz/api/v1/tickets/${id}/assignments`,
|
ASSIGNMENTS: (id: string) => `/biz/api/v1/tickets/${id}/assignments`,
|
||||||
REQUEST_EXTENSION: (ticketId: string, assignmentId: number) =>
|
REQUEST_EXTENSION: (ticketId: string, assignmentId: number) =>
|
||||||
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-requests`,
|
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-requests`,
|
||||||
EXTENSION_REQUESTS: (ticketId: string) =>
|
EXTENSION_REQUESTS: '/biz/api/v1/tickets/extension-requests',
|
||||||
|
TICKET_EXTENSION_REQUESTS: (ticketId: string) =>
|
||||||
`/biz/api/v1/tickets/${ticketId}/extension-requests`,
|
`/biz/api/v1/tickets/${ticketId}/extension-requests`,
|
||||||
REVIEW_EXTENSION_REQUEST: (ticketId: string, assignmentId: number) =>
|
REVIEW_EXTENSION_REQUEST: (ticketId: string, assignmentId: number) =>
|
||||||
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-request/decision`,
|
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/extension-request/decision`,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
TicketAssignmentSummary,
|
TicketAssignmentSummary,
|
||||||
TicketAssignmentsResponse,
|
TicketAssignmentsResponse,
|
||||||
TicketExtensionRequestsResponse,
|
TicketExtensionRequestsResponse,
|
||||||
|
TicketExtensionRequestsParams,
|
||||||
TicketOverviewDetail,
|
TicketOverviewDetail,
|
||||||
TicketDefectClassesResponse,
|
TicketDefectClassesResponse,
|
||||||
TicketListParams,
|
TicketListParams,
|
||||||
@@ -136,7 +137,7 @@ export const ticketService = {
|
|||||||
assignmentId: number,
|
assignmentId: number,
|
||||||
): Promise<TicketExtensionRequestsResponse> => {
|
): Promise<TicketExtensionRequestsResponse> => {
|
||||||
const response = await axiosClient.get<TicketExtensionRequestsResponse>(
|
const response = await axiosClient.get<TicketExtensionRequestsResponse>(
|
||||||
API_ROUTES.TICKETS.EXTENSION_REQUESTS(ticketId),
|
API_ROUTES.TICKETS.TICKET_EXTENSION_REQUESTS(ticketId),
|
||||||
{
|
{
|
||||||
params: { assignment_id: assignmentId },
|
params: { assignment_id: assignmentId },
|
||||||
},
|
},
|
||||||
@@ -144,6 +145,16 @@ export const ticketService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getTicketExtensionRequests: async (
|
||||||
|
params: TicketExtensionRequestsParams,
|
||||||
|
): Promise<TicketExtensionRequestsResponse> => {
|
||||||
|
const response = await axiosClient.get<TicketExtensionRequestsResponse>(
|
||||||
|
API_ROUTES.TICKETS.EXTENSION_REQUESTS,
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
reviewTicketExtension: async (
|
reviewTicketExtension: async (
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
assignmentId: number,
|
assignmentId: number,
|
||||||
|
|||||||
@@ -58,19 +58,33 @@ export interface TicketExtensionRequestActor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TicketExtensionRequestStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'approved'
|
||||||
|
| 'rejected'
|
||||||
|
| 'cancelled';
|
||||||
|
|
||||||
export interface TicketExtensionRequest {
|
export interface TicketExtensionRequest {
|
||||||
id: number;
|
id: number;
|
||||||
ticket_id: string;
|
ticket_id: string;
|
||||||
|
ticket_name: string;
|
||||||
assignment_id: number;
|
assignment_id: number;
|
||||||
defect_class: string;
|
defect_class?: string | null;
|
||||||
current_due_at: string;
|
current_due_at: string;
|
||||||
requested_due_at: string;
|
requested_due_at: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
status: 'pending' | 'approved' | 'rejected' | 'cancelled';
|
status: TicketExtensionRequestStatus;
|
||||||
requested_by: TicketExtensionRequestActor | null;
|
requested_by: TicketExtensionRequestActor | null;
|
||||||
requested_at: string;
|
requested_at: string;
|
||||||
resolved_by: TicketExtensionRequestActor | null;
|
resolved_by: TicketExtensionRequestActor | null;
|
||||||
resolved_at: string | null;
|
resolved_at: string | null;
|
||||||
|
decision_note: string | null;
|
||||||
|
history_meta: {
|
||||||
|
previous_request_count: number;
|
||||||
|
previous_approved_count: number;
|
||||||
|
previous_rejected_count: number;
|
||||||
|
previous_cancelled_count: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketExtensionRequestsResponse {
|
export interface TicketExtensionRequestsResponse {
|
||||||
@@ -78,6 +92,11 @@ export interface TicketExtensionRequestsResponse {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TicketExtensionRequestsParams extends PaginationParams {
|
||||||
|
ticket_name?: string;
|
||||||
|
status?: TicketExtensionRequestStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CloseTicketPayload {
|
export interface CloseTicketPayload {
|
||||||
defect_class: string;
|
defect_class: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const ROUTES = {
|
|||||||
ACCOUNT: '/account',
|
ACCOUNT: '/account',
|
||||||
UPLOAD: '/upload',
|
UPLOAD: '/upload',
|
||||||
TICKET: '/ticket',
|
TICKET: '/ticket',
|
||||||
|
EXTENSION_REQUESTS: '/extension-requests',
|
||||||
TICKET_DETAIL: (ticketId: string) => `/ticket/${ticketId}`,
|
TICKET_DETAIL: (ticketId: string) => `/ticket/${ticketId}`,
|
||||||
TICKET_ASSIGN: (ticketId: string) => `/ticket/${ticketId}/assign`,
|
TICKET_ASSIGN: (ticketId: string) => `/ticket/${ticketId}/assign`,
|
||||||
TICKET_REVIEW: (ticketId: string) => `/ticket/${ticketId}/review`,
|
TICKET_REVIEW: (ticketId: string) => `/ticket/${ticketId}/review`,
|
||||||
|
|||||||
Reference in New Issue
Block a user