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,
|
||||
};
|
||||
Reference in New Issue
Block a user