fix: ticket modal — picklist labels for status/priority, compact layout

- Ticket [id] route: resolve statusLabel/priorityLabel via getTicketStatusPicklist()
  and getTicketPriorityPicklist() with module-level cache (fetched once per deploy)
- Modal: remove subtitle, move ticket# and title into header, compact single-row
  metadata (status badge + priority badge + assigned resource), dates in 2-col grid,
  smaller buttons
This commit is contained in:
lorentz 2026-03-23 10:14:12 -04:00
parent 115819bea0
commit 0a318fb9d6
2 changed files with 89 additions and 120 deletions

View file

@ -1,6 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAutotaskClient } from '@/lib/services/autotask-factory';
let _statusPicklist: Record<number, string> | null = null;
let _priorityPicklist: Record<number, string> | null = null;
async function getPicklists() {
const client = getAutotaskClient();
if (!_statusPicklist) {
try { _statusPicklist = await client.getTicketStatusPicklist(); } catch { _statusPicklist = {}; }
}
if (!_priorityPicklist) {
try { _priorityPicklist = await client.getTicketPriorityPicklist(); } catch { _priorityPicklist = {}; }
}
return { statusPicklist: _statusPicklist!, priorityPicklist: _priorityPicklist! };
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
@ -9,8 +23,10 @@ export async function GET(
const { id } = await params;
const client = getAutotaskClient();
// Get ticket by ID
const ticket = await client.getEntityById('Tickets', parseInt(id));
const [ticket, { statusPicklist, priorityPicklist }] = await Promise.all([
client.getEntityById('Tickets', parseInt(id)),
getPicklists(),
]);
if (!ticket) {
return NextResponse.json(
@ -19,9 +35,9 @@ export async function GET(
);
}
// Get assigned resource name if available
let assignedResourceName = null;
const ticketData = ticket as any;
let assignedResourceName = null;
if (ticketData.assignedResourceID) {
try {
const resource = await client.getEntityById('Resources', ticketData.assignedResourceID) as any;
@ -35,6 +51,8 @@ export async function GET(
ticket: {
...ticket,
assignedResourceName,
statusLabel: statusPicklist[ticketData.status] ?? null,
priorityLabel: priorityPicklist[ticketData.priority] ?? null,
},
});
} catch (error) {

View file

@ -4,7 +4,6 @@ import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
@ -13,7 +12,6 @@ import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import {
Ticket as TicketIcon,
Building2,
User,
Calendar,
Clock,
@ -77,25 +75,24 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe
onOpenChange(newOpen);
};
const getStatusBadge = (status: number) => {
const statusMap: Record<number, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
1: { label: "New", variant: "default" },
5: { label: "Complete", variant: "secondary" },
8: { label: "In Progress", variant: "default" },
};
const config = statusMap[status] || { label: `Status ${status}`, variant: "outline" };
return <Badge variant={config.variant}>{config.label}</Badge>;
const getStatusBadge = (status: number, label?: string | null) => {
const resolved = label || `Status ${status}`;
const lower = resolved.toLowerCase();
const variant =
lower.includes('complete') || lower.includes('closed') || lower.includes('resolved') ? 'secondary' :
lower.includes('progress') || lower.includes('waiting') ? 'default' :
lower.includes('new') ? 'default' : 'outline';
return <Badge variant={variant}>{resolved}</Badge>;
};
const getPriorityBadge = (priority: number) => {
const priorityMap: Record<number, { label: string; variant: "default" | "secondary" | "destructive" }> = {
1: { label: "Critical", variant: "destructive" },
2: { label: "High", variant: "destructive" },
3: { label: "Medium", variant: "default" },
4: { label: "Low", variant: "secondary" },
};
const config = priorityMap[priority] || { label: `Priority ${priority}`, variant: "outline" as any };
return <Badge variant={config.variant}>{config.label}</Badge>;
const getPriorityBadge = (priority: number, label?: string | null) => {
const resolved = label || `Priority ${priority}`;
const lower = resolved.toLowerCase();
const variant: "destructive" | "default" | "secondary" | "outline" =
lower.includes('critical') || lower.includes('high') ? 'destructive' :
lower.includes('medium') ? 'default' :
lower.includes('low') ? 'secondary' : 'outline';
return <Badge variant={variant}>{resolved}</Badge>;
};
const formatDate = (dateString?: string) => {
@ -111,102 +108,69 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TicketIcon className="h-5 w-5" />
Ticket Details
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader className="pb-0">
<DialogTitle className="flex items-center gap-2 text-base">
<TicketIcon className="h-4 w-4 shrink-0" />
<span className="font-mono text-blue-600 dark:text-blue-400">{ticketNumber}</span>
{ticket && <span className="text-muted-foreground font-normal truncate">{ticket.title}</span>}
</DialogTitle>
<DialogDescription>
Viewing ticket information from Autotask PSA
</DialogDescription>
</DialogHeader>
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<div className="flex items-center justify-center py-6">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{error && (
<div className="flex items-center gap-2 p-4 bg-destructive/10 text-destructive rounded-lg">
<AlertCircle className="h-5 w-5" />
<div className="flex items-center gap-2 p-3 bg-destructive/10 text-destructive rounded-lg text-sm">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{ticket && !loading && (
<div className="space-y-4">
{/* Header Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-muted rounded-lg">
<div>
<p className="text-sm text-muted-foreground">Ticket Number</p>
<p className="font-mono font-semibold text-blue-600 dark:text-blue-400">
{ticket.ticketNumber}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Status</p>
<div className="mt-1">{getStatusBadge(ticket.status)}</div>
</div>
<div className="space-y-3">
{/* Metadata row */}
<div className="flex flex-wrap items-center gap-2 text-sm">
{getStatusBadge(ticket.status, ticket.statusLabel)}
{getPriorityBadge(ticket.priority, ticket.priorityLabel)}
{ticket.assignedResourceName && (
<span className="flex items-center gap-1 text-muted-foreground">
<User className="h-3 w-3" />
{ticket.assignedResourceName}
</span>
)}
</div>
{/* Title */}
<div>
<h3 className="text-lg font-semibold">{ticket.title}</h3>
</div>
{/* Key Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-3">
<div>
<p className="text-sm text-muted-foreground flex items-center gap-2">
<User className="h-4 w-4" />
Assigned To
</p>
<p className="font-medium">{ticket.assignedResourceName || 'Unassigned'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Priority</p>
<div className="mt-1">{getPriorityBadge(ticket.priority)}</div>
</div>
<div>
<p className="text-sm text-muted-foreground flex items-center gap-2">
<Calendar className="h-4 w-4" />
Created Date
</p>
<p className="font-medium">{formatDate(ticket.createDate)}</p>
</div>
{/* Dates row */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Calendar className="h-3 w-3 shrink-0" />
<span>Created</span>
<span className="text-foreground font-medium">{formatDate(ticket.createDate)}</span>
</div>
<div className="space-y-3">
<div>
<p className="text-sm text-muted-foreground flex items-center gap-2">
<Clock className="h-4 w-4" />
Due Date
</p>
<p className="font-medium">{formatDate(ticket.dueDateTime)}</p>
{ticket.dueDateTime && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<Clock className="h-3 w-3 shrink-0" />
<span>Due</span>
<span className="text-foreground font-medium">{formatDate(ticket.dueDateTime)}</span>
</div>
{ticket.resolvedDateTime && (
<div>
<p className="text-sm text-muted-foreground flex items-center gap-2">
<CheckCircle className="h-4 w-4" />
Resolved Date
</p>
<p className="font-medium">{formatDate(ticket.resolvedDateTime)}</p>
</div>
)}
{ticket.purchaseOrderNumber && (
<div>
<p className="text-sm text-muted-foreground">PO Number</p>
<p className="font-medium">{ticket.purchaseOrderNumber}</p>
</div>
)}
</div>
)}
{ticket.resolvedDateTime && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<CheckCircle className="h-3 w-3 shrink-0" />
<span>Resolved</span>
<span className="text-foreground font-medium">{formatDate(ticket.resolvedDateTime)}</span>
</div>
)}
{ticket.purchaseOrderNumber && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<span>PO</span>
<span className="text-foreground font-medium">{ticket.purchaseOrderNumber}</span>
</div>
)}
</div>
<Separator />
@ -214,7 +178,7 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe
{/* Description */}
{ticket.description && (
<div>
<p className="text-sm font-semibold mb-2">Description</p>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">Description</p>
<div className="p-3 bg-muted rounded-lg whitespace-pre-wrap text-sm">
{ticket.description}
</div>
@ -224,33 +188,20 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe
{/* Resolution */}
{ticket.resolution && (
<div>
<p className="text-sm font-semibold mb-2">Resolution</p>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">Resolution</p>
<div className="p-3 bg-muted rounded-lg whitespace-pre-wrap text-sm">
{ticket.resolution}
</div>
</div>
)}
{/* Quote Number */}
{ticket.userDefinedFields && (
<>
{ticket.userDefinedFields.find((f: any) => f.name === 'Quote Number' && f.value) && (
<div>
<p className="text-sm text-muted-foreground">Quote Number</p>
<p className="font-medium">
{ticket.userDefinedFields.find((f: any) => f.name === 'Quote Number')?.value}
</p>
</div>
)}
</>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
<div className="flex justify-end gap-2 pt-1">
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button
size="sm"
onClick={() => {
window.open(`https://wam.autotask.net/Mvc/ServiceDesk/TicketDetail.mvc?ticketId=${ticket.id}`, '_blank');
}}