'use client'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Separator } from '@/components/ui/separator'; import { Calendar, Check, X, Copy, CheckCircle2, Code2, LayoutTemplate, ExternalLink, Phone, Globe, Loader2, User, Building2, MessageSquare, Clock } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { StatusBadge } from '@/components/ui/status-badge'; import { priorityBadge, ticketStatusBadge, sourceBadge, classificationBadge, companyTypeBadge, publishBadge, activeBadge, yesNoBadge, billableBadge, approvedBadge, toneClass, paletteClass, } from '@/lib/status-registry'; import { useState, useEffect } from 'react'; // ── Live lookup types (fetched from DB) ─────────────────────────────────────── interface Lookups { statuses: Record; resources: Record; companies: Record; issueTypes: Record; subIssueTypes: Record; queues: Record; configItems: Record; } // ── Field metadata for formatted view ───────────────────────────────────────── type FieldType = 'date' | 'bool' | 'status' | 'priority' | 'source' | 'queue' | 'company_type' | 'classification' | 'url' | 'phone' | 'hours' | 'id' | 'resource' | 'company' | 'issue_type' | 'sub_issue_type' | 'config_item'; type FieldGroup = { label: string; fields: Array<{ key: string; label: string; type?: FieldType }>; paired?: string; }; const TICKET_GROUPS: FieldGroup[] = [ { label: 'Parties', fields: [ { key: 'company_id', label: 'Company', type: 'company' }, { key: 'contact_id', label: 'Contact ID', type: 'id' }, { key: 'assigned_resource_id', label: 'Assigned Resource', type: 'resource' }, { key: 'configuration_item_id', label: 'Configuration Item', type: 'config_item' }, ], }, { label: 'Details', fields: [ { key: 'priority', label: 'Priority', type: 'priority' }, { key: 'source', label: 'Source', type: 'source' }, { key: 'queue_id', label: 'Queue', type: 'queue' }, { key: 'issue_type', label: 'Issue Type', type: 'issue_type' }, { key: 'sub_issue_type', label: 'Sub-Issue Type', type: 'sub_issue_type' }, ], }, { label: 'Dates & Time', paired: 'System', fields: [ { key: 'create_date', label: 'Created', type: 'date' }, { key: 'due_date_time', label: 'Due', type: 'date' }, { key: 'last_activity_date', label: 'Last Activity', type: 'date' }, { key: 'completed_date', label: 'Completed', type: 'date' }, { key: 'estimated_hours', label: 'Estimated Hours', type: 'hours' }, ], }, { label: 'System', paired: 'Dates & Time', fields: [ { key: 'id', label: 'Record ID', type: 'id' }, { key: 'synced_at', label: 'Synced At', type: 'date' }, { key: 'is_deleted', label: 'Deleted', type: 'bool' }, ], }, ]; const COMPANY_GROUPS: FieldGroup[] = [ { label: 'Identity', fields: [ { key: 'company_name', label: 'Company Name' }, { key: 'company_number', label: 'Company #' }, { key: 'company_type', label: 'Type', type: 'company_type' }, { key: 'classification', label: 'Classification', type: 'classification' }, { key: 'is_active', label: 'Active', type: 'bool' }, ], }, { label: 'Contact', fields: [ { key: 'phone', label: 'Phone', type: 'phone' }, { key: 'alternate_phone1', label: 'Alt Phone 1', type: 'phone' }, { key: 'alternate_phone2', label: 'Alt Phone 2', type: 'phone' }, { key: 'fax', label: 'Fax', type: 'phone' }, { key: 'web_site_url', label: 'Website', type: 'url' }, ], }, { label: 'Address', fields: [ { key: 'address1', label: 'Address 1' }, { key: 'address2', label: 'Address 2' }, { key: 'city', label: 'City' }, { key: 'state', label: 'State' }, { key: 'postal_code', label: 'Postal Code' }, { key: 'country', label: 'Country' }, ], }, { label: 'System', paired: 'Address', fields: [ { key: 'id', label: 'Record ID', type: 'id' }, { key: 'synced_at', label: 'Synced At', type: 'date' }, { key: 'is_deleted', label: 'Deleted', type: 'bool' }, ], }, ]; // ── Helpers ──────────────────────────────────────────────────────────────────── function resolveLabel(key: string, value: any, type: FieldType | undefined, lookups: Lookups): { display: React.ReactNode; isEmpty: boolean } { if (value === null || value === undefined || value === '') { return { display: , isEmpty: true }; } switch (type) { case 'bool': { const badge = yesNoBadge(Boolean(value)); return { display: ( {value ? : } {badge.label} ), isEmpty: false, }; } case 'date': { try { const d = new Date(value); return { display: ( {d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })} ), isEmpty: false, }; } catch { break; } } case 'status': { const label = lookups.statuses[Number(value)] ?? `Status ${value}`; const badge = ticketStatusBadge(label); return { display: , isEmpty: false }; } case 'priority': { return { display: , isEmpty: false }; } case 'source': { return { display: , isEmpty: false }; } case 'queue': { const label = lookups.queues[Number(value)] ?? `Queue ${value}`; return { display: {label}, isEmpty: false }; } case 'company_type': { return { display: , isEmpty: false }; } case 'classification': { return { display: , isEmpty: false }; } case 'resource': { const name = lookups.resources[Number(value)]; return { display: name ? {name} : {value}, isEmpty: false, }; } case 'company': { const name = lookups.companies[Number(value)]; return { display: name ? {name} : {value}, isEmpty: false, }; } case 'issue_type': { const label = lookups.issueTypes[Number(value)] ?? `Issue ${value}`; return { display: {label}, isEmpty: false }; } case 'sub_issue_type': { const label = lookups.subIssueTypes[Number(value)] ?? `Sub-Issue ${value}`; return { display: {label}, isEmpty: false }; } case 'config_item': { const name = lookups.configItems[Number(value)]; return { display: name ? {name} : {value}, isEmpty: false, }; } case 'url': return { display: ( {value} ), isEmpty: false, }; case 'phone': return { display: ( {value} ), isEmpty: false, }; case 'id': return { display: {value}, isEmpty: false }; case 'hours': return { display: {Number(value).toFixed(1)} hrs, isEmpty: false }; } if (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/)) { return resolveLabel(key, value, 'date', lookups); } return { display: {String(value)}, isEmpty: false }; } function detectGroups(data: Record): FieldGroup[] { if ('ticket_number' in data) return TICKET_GROUPS; if ('company_name' in data) return COMPANY_GROUPS; return [{ label: 'Fields', fields: Object.keys(data).map(k => ({ key: k, label: k })) }]; } const EMPTY_LOOKUPS: Lookups = { statuses: {}, resources: {}, companies: {}, issueTypes: {}, subIssueTypes: {}, queues: {}, configItems: {} }; // ── Component ────────────────────────────────────────────────────────────────── interface DetailModalProps { open: boolean; onOpenChange: (open: boolean) => void; title: string; data: Record | null; fields?: Array<{ key: string; label: string; render?: (value: any) => React.ReactNode }>; } export default function DetailModal({ open, onOpenChange, title, data, fields }: DetailModalProps) { const [copiedField, setCopiedField] = useState(null); const [lookups, setLookups] = useState(EMPTY_LOOKUPS); const [lookupsLoading, setLookupsLoading] = useState(false); const [notes, setNotes] = useState([]); const [notesLoading, setNotesLoading] = useState(false); const [timeEntries, setTimeEntries] = useState([]); const [timeEntriesLoading, setTimeEntriesLoading] = useState(false); useEffect(() => { if (!open) return; setLookupsLoading(true); fetch('/api/data/lookups') .then(r => r.json()) .then(d => { setLookups({ statuses: Object.fromEntries((d.statuses ?? []).map((r: any) => [r.value, r.label])), resources: Object.fromEntries((d.resources ?? []).map((r: any) => [r.id, r.name])), companies: Object.fromEntries((d.companies ?? []).map((r: any) => [r.id, r.name])), issueTypes: Object.fromEntries((d.issueTypes ?? []).map((r: any) => [r.value, r.label])), subIssueTypes:Object.fromEntries((d.subIssueTypes?? []).map((r: any) => [r.value, r.label])), queues: Object.fromEntries((d.queues ?? []).map((r: any) => [r.value, r.label])), configItems: Object.fromEntries((d.configItems ?? []).map((r: any) => [r.id, r.name])), }); }) .catch(() => {}) .finally(() => setLookupsLoading(false)); if (data && 'ticket_number' in data && data.id) { setNotesLoading(true); fetch(`/api/data/ticket-notes?ticket_id=${data.id}&sort_by=create_date_time&sort_order=asc&limit=200`) .then(r => r.json()) .then(d => setNotes(d.ticketNotes ?? [])) .catch(() => setNotes([])) .finally(() => setNotesLoading(false)); setTimeEntriesLoading(true); fetch(`/api/data/time-entries?ticket_id=${data.id}&sort_by=entry_date&sort_order=asc&limit=200`) .then(r => r.json()) .then(d => setTimeEntries(d.timeEntries ?? [])) .catch(() => setTimeEntries([])) .finally(() => setTimeEntriesLoading(false)); } else { setNotes([]); setTimeEntries([]); } }, [open]); if (!data) return null; const copyToClipboard = (text: string, fieldKey: string) => { navigator.clipboard.writeText(text); setCopiedField(fieldKey); setTimeout(() => setCopiedField(null), 2000); }; const groups = detectGroups(data); // Raw tab: all fields const rawFields = fields || Object.keys(data).map(k => ({ key: k, label: k, render: undefined })); const renderRaw = (value: any): React.ReactNode => { if (value === null || value === undefined) return null; if (typeof value === 'boolean') return {value ? 'true' : 'false'}; if (typeof value === 'object') return
{JSON.stringify(value, null, 2)}
; return {String(value)}; }; return ( {/* Header */}
{'ticket_number' in data ? (
{data.ticket_number} {data.title}
{(() => { const label = lookups.statuses[Number(data.status)] ?? `Status ${data.status}`; return ; })()}
) : (
{title} Record ID: {data.id}
{'is_active' in data && ( )}
)}
{/* Tabs */}
Formatted {'ticket_number' in data && ( Time {timeEntries.length > 0 && ( {timeEntries.reduce((s, e) => s + (parseFloat(e.hours_worked) || 0), 0).toFixed(1)}h )} )} {'ticket_number' in data && ( Notes {notes.length > 0 && ( {notes.length} )} )} Raw
{/* ── Formatted Tab ── */}
{(() => { const rendered = new Set(); return groups.map((group) => { if (rendered.has(group.label)) return null; const visibleFields = group.fields.filter(f => f.key in data); if (visibleFields.length === 0) return null; // Inline badge row for Details group if (group.label === 'Details') { return (

Details

{visibleFields.map((field) => { const value = data[field.key]; const { display, isEmpty } = resolveLabel(field.key, value, field.type, lookups); if (isEmpty) return null; return (
{field.label}: {display}
); })}
); } const pairedGroup = group.paired ? groups.find(g => g.label === group.paired) : null; const pairedVisible = pairedGroup ? pairedGroup.fields.filter(f => f.key in data) : []; const isPaired = !!pairedGroup && pairedVisible.length > 0; if (isPaired) { rendered.add(group.label); rendered.add(pairedGroup!.label); } const renderGroupTable = (g: FieldGroup, fields: typeof visibleFields) => (

{g.label}

{fields.map((field, idx) => { const value = data[field.key]; const { display, isEmpty } = resolveLabel(field.key, value, field.type, lookups); const stringValue = value !== null && value !== undefined ? String(value) : ''; return (
{idx > 0 && }
{field.label}
{display}
{stringValue && !isEmpty && ( )}
); })}
); if (isPaired) { return (
{renderGroupTable(group, visibleFields)} {renderGroupTable(pairedGroup!, pairedVisible)}
); } return (

{group.label}

{visibleFields.map((field, idx) => { const value = data[field.key]; const { display, isEmpty } = resolveLabel(field.key, value, field.type, lookups); const stringValue = value !== null && value !== undefined ? String(value) : ''; return (
{idx > 0 && }
{field.label}
{display}
{stringValue && !isEmpty && ( )}
); })}
); }); })()} {/* Description block for tickets */} {'description' in data && data.description && (

Description

{data.description}
)}
{/* ── Time Entries Tab ── */} {timeEntriesLoading ? (
) : timeEntries.length === 0 ? (

No time entries on this ticket

) : (
{/* Summary bar */}
{timeEntries.reduce((s, e) => s + (parseFloat(e.hours_worked) || 0), 0).toFixed(2)} total hours
{timeEntries.length} {timeEntries.length === 1 ? 'entry' : 'entries'}
{timeEntries.filter(e => e.billable).length} billable
{/* Entry rows */}
{timeEntries.map((entry, idx) => (
{idx > 0 && }
{entry.resource_name && ( {entry.resource_name} )} {entry.billable && ( )} {entry.approved && ( )}
{entry.notes && (

{entry.notes}

)}
{parseFloat(entry.hours_worked).toFixed(2)}h {entry.entry_date && ( {new Date(entry.entry_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })} )}
))}
)}
{/* ── Notes Tab ── */} {notesLoading ? (
) : notes.length === 0 ? (

No notes on this ticket

) : (
{notes.map((note) => { return (
{note.creator_name && ( {note.creator_name} )} {note.publish != null && ( )} {note.title && ( {note.title} )}
{note.create_date_time && ( {new Date(note.create_date_time).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })} {' '} {new Date(note.create_date_time).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} )}
{note.description && (
{note.description}
)}
); })}
)}
{/* ── Raw Tab ── */}
{rawFields.map((field, idx) => { const value = data[field.key]; const stringValue = value !== null && value !== undefined ? String(value) : ''; return (
{idx > 0 && }
{field.key}
{field.render ? field.render(value) : renderRaw(value)}
{stringValue && ( )}
); })}
); }