2025-11-19 14:18:16 -05:00
|
|
|
'use client';
|
|
|
|
|
|
feat: dual-tab DetailModal with Formatted and Raw views
- Formatted tab: grouped sections (Overview, Parties, Classification, Dates, System)
- ID-to-label resolution for status, priority, source, queue, company type
- Elegant bordered table layout with muted label column
- Clickable phone/URL fields, monospace IDs, formatted dates
- Description rendered in its own block for tickets
- Raw tab: all fields with monospace keys, copy-on-hover
- Auto-detects ticket vs company data for appropriate grouping
- Shadcn Tabs, Separator, Badge used throughout
2026-02-19 21:00:33 -05:00
|
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
2025-11-19 14:18:16 -05:00
|
|
|
import { Badge } from '@/components/ui/badge';
|
feat: dual-tab DetailModal with Formatted and Raw views
- Formatted tab: grouped sections (Overview, Parties, Classification, Dates, System)
- ID-to-label resolution for status, priority, source, queue, company type
- Elegant bordered table layout with muted label column
- Clickable phone/URL fields, monospace IDs, formatted dates
- Description rendered in its own block for tickets
- Raw tab: all fields with monospace keys, copy-on-hover
- Auto-detects ticket vs company data for appropriate grouping
- Shadcn Tabs, Separator, Badge used throughout
2026-02-19 21:00:33 -05:00
|
|
|
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, MapPin, Mail } from 'lucide-react';
|
2025-11-19 14:18:16 -05:00
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { useState } from 'react';
|
|
|
|
|
|
feat: dual-tab DetailModal with Formatted and Raw views
- Formatted tab: grouped sections (Overview, Parties, Classification, Dates, System)
- ID-to-label resolution for status, priority, source, queue, company type
- Elegant bordered table layout with muted label column
- Clickable phone/URL fields, monospace IDs, formatted dates
- Description rendered in its own block for tickets
- Raw tab: all fields with monospace keys, copy-on-hover
- Auto-detects ticket vs company data for appropriate grouping
- Shadcn Tabs, Separator, Badge used throughout
2026-02-19 21:00:33 -05:00
|
|
|
// ── Autotask label maps ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const TICKET_STATUS: Record<number, string> = {
|
|
|
|
|
1: 'New', 5: 'Complete', 8: 'In Progress', 9: 'Waiting Customer',
|
|
|
|
|
10: 'Waiting Materials', 11: 'Waiting Vendor', 12: 'Waiting Parts',
|
|
|
|
|
13: 'Scheduled', 14: 'Escalated', 16: 'Waiting on 3rd Party',
|
|
|
|
|
29: 'Customer Responded', 30: 'Dispatched', 31: 'Resolved',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const TICKET_PRIORITY: Record<number, { label: string; variant: 'destructive' | 'default' | 'secondary' | 'outline' }> = {
|
|
|
|
|
1: { label: 'Critical', variant: 'destructive' },
|
|
|
|
|
2: { label: 'High', variant: 'default' },
|
|
|
|
|
3: { label: 'Medium', variant: 'secondary' },
|
|
|
|
|
4: { label: 'Low', variant: 'outline' },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const TICKET_SOURCE: Record<number, string> = {
|
|
|
|
|
1: 'Phone', 2: 'Email', 5: 'Web Portal', 6: 'Monitoring Alert',
|
|
|
|
|
8: 'RMM Alert', 9: 'Chat', 10: 'In Person', 14: 'API',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const QUEUE: Record<number, string> = {
|
|
|
|
|
29482833: 'Client Services', 29482834: 'Network Operations',
|
|
|
|
|
29482835: 'Help Desk', 29482836: 'Projects',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const COMPANY_TYPE: Record<number, string> = {
|
|
|
|
|
1: 'Customer', 2: 'Lead', 3: 'Prospect', 4: 'Dead', 6: 'Cancelation',
|
|
|
|
|
7: 'Vendor', 8: 'Partner',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── Field metadata for formatted view ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
type FieldGroup = {
|
|
|
|
|
label: string;
|
|
|
|
|
fields: Array<{
|
|
|
|
|
key: string;
|
|
|
|
|
label: string;
|
|
|
|
|
type?: 'date' | 'bool' | 'status' | 'priority' | 'source' | 'queue' | 'company_type' | 'url' | 'phone' | 'email' | 'text' | 'hours' | 'id';
|
|
|
|
|
}>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const TICKET_GROUPS: FieldGroup[] = [
|
|
|
|
|
{
|
|
|
|
|
label: 'Overview',
|
|
|
|
|
fields: [
|
|
|
|
|
{ key: 'ticket_number', label: 'Ticket #' },
|
|
|
|
|
{ key: 'title', label: 'Title' },
|
|
|
|
|
{ key: 'status', label: 'Status', type: 'status' },
|
|
|
|
|
{ key: 'priority', label: 'Priority', type: 'priority' },
|
|
|
|
|
{ key: 'source', label: 'Source', type: 'source' },
|
|
|
|
|
{ key: 'queue_id', label: 'Queue', type: 'queue' },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Parties',
|
|
|
|
|
fields: [
|
|
|
|
|
{ key: 'company_id', label: 'Company ID', type: 'id' },
|
|
|
|
|
{ key: 'contact_id', label: 'Contact ID', type: 'id' },
|
|
|
|
|
{ key: 'assigned_resource_id', label: 'Assigned Resource ID', type: 'id' },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Classification',
|
|
|
|
|
fields: [
|
|
|
|
|
{ key: 'issue_type', label: 'Issue Type' },
|
|
|
|
|
{ key: 'sub_issue_type', label: 'Sub-Issue Type' },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Dates & Time',
|
|
|
|
|
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',
|
|
|
|
|
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: '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',
|
|
|
|
|
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?: string): { display: React.ReactNode; isEmpty: boolean } {
|
|
|
|
|
if (value === null || value === undefined || value === '') {
|
|
|
|
|
return { display: <span className="text-muted-foreground/50 italic text-xs">—</span>, isEmpty: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
case 'bool':
|
|
|
|
|
return {
|
|
|
|
|
display: (
|
|
|
|
|
<Badge variant={value ? 'default' : 'secondary'} className="gap-1">
|
|
|
|
|
{value ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />}
|
|
|
|
|
{value ? 'Yes' : 'No'}
|
|
|
|
|
</Badge>
|
|
|
|
|
),
|
|
|
|
|
isEmpty: false,
|
|
|
|
|
};
|
|
|
|
|
case 'date':
|
|
|
|
|
try {
|
|
|
|
|
const d = new Date(value);
|
|
|
|
|
return {
|
|
|
|
|
display: (
|
|
|
|
|
<span className="inline-flex items-center gap-1.5 text-sm">
|
|
|
|
|
<Calendar className="w-3.5 h-3.5 text-muted-foreground" />
|
|
|
|
|
{d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}
|
|
|
|
|
<span className="text-muted-foreground text-xs">{d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span>
|
|
|
|
|
</span>
|
|
|
|
|
),
|
|
|
|
|
isEmpty: false,
|
|
|
|
|
};
|
|
|
|
|
} catch { break; }
|
|
|
|
|
case 'status': {
|
|
|
|
|
const label = TICKET_STATUS[Number(value)] ?? `Status ${value}`;
|
|
|
|
|
return { display: <Badge variant="outline">{label}</Badge>, isEmpty: false };
|
|
|
|
|
}
|
|
|
|
|
case 'priority': {
|
|
|
|
|
const p = TICKET_PRIORITY[Number(value)];
|
|
|
|
|
return { display: <Badge variant={p?.variant ?? 'secondary'}>{p?.label ?? `Priority ${value}`}</Badge>, isEmpty: false };
|
|
|
|
|
}
|
|
|
|
|
case 'source': {
|
|
|
|
|
const label = TICKET_SOURCE[Number(value)] ?? `Source ${value}`;
|
|
|
|
|
return { display: <Badge variant="secondary">{label}</Badge>, isEmpty: false };
|
|
|
|
|
}
|
|
|
|
|
case 'queue': {
|
|
|
|
|
const label = QUEUE[Number(value)] ?? `Queue ${value}`;
|
|
|
|
|
return { display: <span className="text-sm font-medium">{label}</span>, isEmpty: false };
|
|
|
|
|
}
|
|
|
|
|
case 'company_type': {
|
|
|
|
|
const label = COMPANY_TYPE[Number(value)] ?? `Type ${value}`;
|
|
|
|
|
return { display: <Badge variant="outline">{label}</Badge>, isEmpty: false };
|
|
|
|
|
}
|
|
|
|
|
case 'url':
|
|
|
|
|
return {
|
|
|
|
|
display: (
|
|
|
|
|
<a href={String(value).startsWith('http') ? value : `https://${value}`} target="_blank" rel="noopener noreferrer"
|
|
|
|
|
className="inline-flex items-center gap-1 text-sm text-blue-500 hover:underline">
|
|
|
|
|
<Globe className="w-3.5 h-3.5" />{value}
|
|
|
|
|
<ExternalLink className="w-3 h-3" />
|
|
|
|
|
</a>
|
|
|
|
|
),
|
|
|
|
|
isEmpty: false,
|
|
|
|
|
};
|
|
|
|
|
case 'phone':
|
|
|
|
|
return {
|
|
|
|
|
display: (
|
|
|
|
|
<a href={`tel:${value}`} className="inline-flex items-center gap-1 text-sm hover:underline">
|
|
|
|
|
<Phone className="w-3.5 h-3.5 text-muted-foreground" />{value}
|
|
|
|
|
</a>
|
|
|
|
|
),
|
|
|
|
|
isEmpty: false,
|
|
|
|
|
};
|
|
|
|
|
case 'id':
|
|
|
|
|
return { display: <span className="font-mono text-xs bg-muted px-2 py-0.5 rounded">{value}</span>, isEmpty: false };
|
|
|
|
|
case 'hours':
|
|
|
|
|
return { display: <span className="text-sm">{Number(value).toFixed(1)} hrs</span>, isEmpty: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/)) {
|
|
|
|
|
return resolveLabel(key, value, 'date');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { display: <span className="text-sm">{String(value)}</span>, isEmpty: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function detectGroups(data: Record<string, any>): 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 })) }];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Component ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2025-11-19 14:18:16 -05:00
|
|
|
interface DetailModalProps {
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
title: string;
|
|
|
|
|
data: Record<string, any> | null;
|
feat: dual-tab DetailModal with Formatted and Raw views
- Formatted tab: grouped sections (Overview, Parties, Classification, Dates, System)
- ID-to-label resolution for status, priority, source, queue, company type
- Elegant bordered table layout with muted label column
- Clickable phone/URL fields, monospace IDs, formatted dates
- Description rendered in its own block for tickets
- Raw tab: all fields with monospace keys, copy-on-hover
- Auto-detects ticket vs company data for appropriate grouping
- Shadcn Tabs, Separator, Badge used throughout
2026-02-19 21:00:33 -05:00
|
|
|
fields?: Array<{ key: string; label: string; render?: (value: any) => React.ReactNode }>;
|
2025-11-19 14:18:16 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function DetailModal({ open, onOpenChange, title, data, fields }: DetailModalProps) {
|
|
|
|
|
const [copiedField, setCopiedField] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
if (!data) return null;
|
|
|
|
|
|
|
|
|
|
const copyToClipboard = (text: string, fieldKey: string) => {
|
|
|
|
|
navigator.clipboard.writeText(text);
|
|
|
|
|
setCopiedField(fieldKey);
|
|
|
|
|
setTimeout(() => setCopiedField(null), 2000);
|
|
|
|
|
};
|
|
|
|
|
|
feat: dual-tab DetailModal with Formatted and Raw views
- Formatted tab: grouped sections (Overview, Parties, Classification, Dates, System)
- ID-to-label resolution for status, priority, source, queue, company type
- Elegant bordered table layout with muted label column
- Clickable phone/URL fields, monospace IDs, formatted dates
- Description rendered in its own block for tickets
- Raw tab: all fields with monospace keys, copy-on-hover
- Auto-detects ticket vs company data for appropriate grouping
- Shadcn Tabs, Separator, Badge used throughout
2026-02-19 21:00:33 -05:00
|
|
|
const groups = detectGroups(data);
|
|
|
|
|
|
|
|
|
|
// Raw tab: all fields
|
|
|
|
|
const rawFields = fields || Object.keys(data).map(k => ({ key: k, label: k, render: undefined }));
|
2025-11-19 14:18:16 -05:00
|
|
|
|
feat: dual-tab DetailModal with Formatted and Raw views
- Formatted tab: grouped sections (Overview, Parties, Classification, Dates, System)
- ID-to-label resolution for status, priority, source, queue, company type
- Elegant bordered table layout with muted label column
- Clickable phone/URL fields, monospace IDs, formatted dates
- Description rendered in its own block for tickets
- Raw tab: all fields with monospace keys, copy-on-hover
- Auto-detects ticket vs company data for appropriate grouping
- Shadcn Tabs, Separator, Badge used throughout
2026-02-19 21:00:33 -05:00
|
|
|
const renderRaw = (value: any): React.ReactNode => {
|
|
|
|
|
if (value === null || value === undefined) return <span className="text-muted-foreground/50 italic text-xs">null</span>;
|
|
|
|
|
if (typeof value === 'boolean') return <Badge variant={value ? 'default' : 'secondary'}>{value ? 'true' : 'false'}</Badge>;
|
|
|
|
|
if (typeof value === 'object') return <pre className="text-xs bg-muted p-2 rounded border overflow-x-auto">{JSON.stringify(value, null, 2)}</pre>;
|
|
|
|
|
return <span className="text-sm font-mono">{String(value)}</span>;
|
|
|
|
|
};
|
2025-11-19 14:18:16 -05:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
feat: dual-tab DetailModal with Formatted and Raw views
- Formatted tab: grouped sections (Overview, Parties, Classification, Dates, System)
- ID-to-label resolution for status, priority, source, queue, company type
- Elegant bordered table layout with muted label column
- Clickable phone/URL fields, monospace IDs, formatted dates
- Description rendered in its own block for tickets
- Raw tab: all fields with monospace keys, copy-on-hover
- Auto-detects ticket vs company data for appropriate grouping
- Shadcn Tabs, Separator, Badge used throughout
2026-02-19 21:00:33 -05:00
|
|
|
<DialogContent className="max-w-7xl max-h-[90vh] overflow-hidden flex flex-col gap-0 p-0">
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="px-6 pt-6 pb-4 border-b">
|
2025-11-19 14:18:16 -05:00
|
|
|
<div className="flex items-start justify-between gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
feat: dual-tab DetailModal with Formatted and Raw views
- Formatted tab: grouped sections (Overview, Parties, Classification, Dates, System)
- ID-to-label resolution for status, priority, source, queue, company type
- Elegant bordered table layout with muted label column
- Clickable phone/URL fields, monospace IDs, formatted dates
- Description rendered in its own block for tickets
- Raw tab: all fields with monospace keys, copy-on-hover
- Auto-detects ticket vs company data for appropriate grouping
- Shadcn Tabs, Separator, Badge used throughout
2026-02-19 21:00:33 -05:00
|
|
|
<DialogDescription className="mt-1">
|
|
|
|
|
Record ID: <span className="font-mono">{data.id}</span>
|
2025-11-19 14:18:16 -05:00
|
|
|
</DialogDescription>
|
|
|
|
|
</div>
|
feat: dual-tab DetailModal with Formatted and Raw views
- Formatted tab: grouped sections (Overview, Parties, Classification, Dates, System)
- ID-to-label resolution for status, priority, source, queue, company type
- Elegant bordered table layout with muted label column
- Clickable phone/URL fields, monospace IDs, formatted dates
- Description rendered in its own block for tickets
- Raw tab: all fields with monospace keys, copy-on-hover
- Auto-detects ticket vs company data for appropriate grouping
- Shadcn Tabs, Separator, Badge used throughout
2026-02-19 21:00:33 -05:00
|
|
|
{'is_active' in data && (
|
|
|
|
|
<Badge variant={data.is_active ? 'default' : 'secondary'} className="shrink-0 mt-1">
|
|
|
|
|
{data.is_active ? 'Active' : 'Inactive'}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
{'status' in data && (
|
|
|
|
|
<Badge variant="outline" className="shrink-0 mt-1">
|
|
|
|
|
{TICKET_STATUS[Number(data.status)] ?? `Status ${data.status}`}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Tabs */}
|
|
|
|
|
<Tabs defaultValue="formatted" className="flex flex-col flex-1 overflow-hidden">
|
|
|
|
|
<div className="px-6 pt-3 pb-0 border-b">
|
|
|
|
|
<TabsList className="h-9">
|
|
|
|
|
<TabsTrigger value="formatted" className="gap-1.5">
|
|
|
|
|
<LayoutTemplate className="w-3.5 h-3.5" />
|
|
|
|
|
Formatted
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
<TabsTrigger value="raw" className="gap-1.5">
|
|
|
|
|
<Code2 className="w-3.5 h-3.5" />
|
|
|
|
|
Raw
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
</TabsList>
|
2025-11-19 14:18:16 -05:00
|
|
|
</div>
|
feat: dual-tab DetailModal with Formatted and Raw views
- Formatted tab: grouped sections (Overview, Parties, Classification, Dates, System)
- ID-to-label resolution for status, priority, source, queue, company type
- Elegant bordered table layout with muted label column
- Clickable phone/URL fields, monospace IDs, formatted dates
- Description rendered in its own block for tickets
- Raw tab: all fields with monospace keys, copy-on-hover
- Auto-detects ticket vs company data for appropriate grouping
- Shadcn Tabs, Separator, Badge used throughout
2026-02-19 21:00:33 -05:00
|
|
|
|
|
|
|
|
{/* ── Formatted Tab ── */}
|
|
|
|
|
<TabsContent value="formatted" className="flex-1 overflow-y-auto px-6 py-4 mt-0">
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{groups.map((group) => {
|
|
|
|
|
const visibleFields = group.fields.filter(f => f.key in data);
|
|
|
|
|
if (visibleFields.length === 0) return null;
|
|
|
|
|
return (
|
|
|
|
|
<div key={group.label}>
|
|
|
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">{group.label}</h3>
|
|
|
|
|
<div className="rounded-lg border overflow-hidden">
|
|
|
|
|
{visibleFields.map((field, idx) => {
|
|
|
|
|
const value = data[field.key];
|
|
|
|
|
const { display, isEmpty } = resolveLabel(field.key, value, field.type);
|
|
|
|
|
const stringValue = value !== null && value !== undefined ? String(value) : '';
|
|
|
|
|
return (
|
|
|
|
|
<div key={field.key}>
|
|
|
|
|
{idx > 0 && <Separator />}
|
|
|
|
|
<div className="group grid grid-cols-[220px_1fr] items-start">
|
|
|
|
|
<div className="px-4 py-3 bg-muted/40 text-sm font-medium text-muted-foreground border-r">
|
|
|
|
|
{field.label}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="px-4 py-3 flex items-start justify-between gap-2 min-w-0">
|
|
|
|
|
<div className={`flex-1 min-w-0 break-words ${isEmpty ? 'opacity-40' : ''}`}>
|
|
|
|
|
{display}
|
|
|
|
|
</div>
|
|
|
|
|
{stringValue && !isEmpty && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
|
|
|
|
onClick={() => copyToClipboard(stringValue, field.key)}
|
|
|
|
|
>
|
|
|
|
|
{copiedField === field.key
|
|
|
|
|
? <CheckCircle2 className="w-3.5 h-3.5 text-green-500" />
|
|
|
|
|
: <Copy className="w-3.5 h-3.5" />}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2025-11-19 14:18:16 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
feat: dual-tab DetailModal with Formatted and Raw views
- Formatted tab: grouped sections (Overview, Parties, Classification, Dates, System)
- ID-to-label resolution for status, priority, source, queue, company type
- Elegant bordered table layout with muted label column
- Clickable phone/URL fields, monospace IDs, formatted dates
- Description rendered in its own block for tickets
- Raw tab: all fields with monospace keys, copy-on-hover
- Auto-detects ticket vs company data for appropriate grouping
- Shadcn Tabs, Separator, Badge used throughout
2026-02-19 21:00:33 -05:00
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
{/* Description block for tickets */}
|
|
|
|
|
{'description' in data && data.description && (
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">Description</h3>
|
|
|
|
|
<div className="rounded-lg border p-4 text-sm whitespace-pre-wrap leading-relaxed text-muted-foreground">
|
|
|
|
|
{data.description}
|
2025-11-19 14:18:16 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
feat: dual-tab DetailModal with Formatted and Raw views
- Formatted tab: grouped sections (Overview, Parties, Classification, Dates, System)
- ID-to-label resolution for status, priority, source, queue, company type
- Elegant bordered table layout with muted label column
- Clickable phone/URL fields, monospace IDs, formatted dates
- Description rendered in its own block for tickets
- Raw tab: all fields with monospace keys, copy-on-hover
- Auto-detects ticket vs company data for appropriate grouping
- Shadcn Tabs, Separator, Badge used throughout
2026-02-19 21:00:33 -05:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
{/* ── Raw Tab ── */}
|
|
|
|
|
<TabsContent value="raw" className="flex-1 overflow-y-auto px-6 py-4 mt-0">
|
|
|
|
|
<div className="rounded-lg border overflow-hidden">
|
|
|
|
|
{rawFields.map((field, idx) => {
|
|
|
|
|
const value = data[field.key];
|
|
|
|
|
const stringValue = value !== null && value !== undefined ? String(value) : '';
|
|
|
|
|
return (
|
|
|
|
|
<div key={field.key}>
|
|
|
|
|
{idx > 0 && <Separator />}
|
|
|
|
|
<div className="group grid grid-cols-[220px_1fr] items-start">
|
|
|
|
|
<div className="px-4 py-2.5 bg-muted/40 text-xs font-mono text-muted-foreground border-r">
|
|
|
|
|
{field.key}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="px-4 py-2.5 flex items-start justify-between gap-2 min-w-0">
|
|
|
|
|
<div className="flex-1 min-w-0 break-words">
|
|
|
|
|
{field.render ? field.render(value) : renderRaw(value)}
|
|
|
|
|
</div>
|
|
|
|
|
{stringValue && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
|
|
|
|
onClick={() => copyToClipboard(stringValue, `raw-${field.key}`)}
|
|
|
|
|
>
|
|
|
|
|
{copiedField === `raw-${field.key}`
|
|
|
|
|
? <CheckCircle2 className="w-3.5 h-3.5 text-green-500" />
|
|
|
|
|
: <Copy className="w-3.5 h-3.5" />}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</TabsContent>
|
|
|
|
|
</Tabs>
|
2025-11-19 14:18:16 -05:00
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|