feat: Autotask webhook integration, TicketNotes, Datto RMM, workflow engine, Veeam agents/alarms, AI triage, misc improvements
This commit is contained in:
parent
347cf4e298
commit
d7c3dc7168
74 changed files with 37844 additions and 322 deletions
|
|
@ -4,81 +4,106 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
|||
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, MapPin, Mail } from 'lucide-react';
|
||||
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 { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// ── Autotask label maps ────────────────────────────────────────────────────────
|
||||
// ── Static picklist maps (Autotask standard values from DB) ──────────────────
|
||||
|
||||
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 PRIORITY_MAP: Record<number, { label: string; cls: string }> = {
|
||||
2: { label: 'Critical', cls: 'bg-red-500/15 text-red-600 border border-red-500/30' },
|
||||
3: { label: 'High', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
|
||||
4: { label: 'Medium', cls: 'bg-yellow-500/15 text-yellow-700 border border-yellow-500/30' },
|
||||
6: { label: 'Low', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' },
|
||||
7: { label: 'Very Low', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
|
||||
8: { label: 'Critical', cls: 'bg-red-500/15 text-red-600 border border-red-500/30' },
|
||||
9: { label: 'High', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
|
||||
10: { label: 'Medium', cls: 'bg-yellow-500/15 text-yellow-700 border border-yellow-500/30' },
|
||||
11: { label: 'Low', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' },
|
||||
};
|
||||
|
||||
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 STATUS_COLOR: Record<string, string> = {
|
||||
'New': 'bg-blue-500/15 text-blue-600 border border-blue-500/30',
|
||||
'In Progress': 'bg-indigo-500/15 text-indigo-600 border border-indigo-500/30',
|
||||
'Complete': 'bg-green-500/15 text-green-600 border border-green-500/30',
|
||||
'Waiting Customer': 'bg-amber-500/15 text-amber-700 border border-amber-500/30',
|
||||
'Waiting Materials': 'bg-orange-500/15 text-orange-600 border border-orange-500/30',
|
||||
'Waiting Vendor': 'bg-orange-500/15 text-orange-600 border border-orange-500/30',
|
||||
'Waiting Approval': 'bg-purple-500/15 text-purple-600 border border-purple-500/30',
|
||||
'On Hold': 'bg-slate-500/15 text-slate-500 border border-slate-500/30',
|
||||
'Escalate': 'bg-red-500/15 text-red-600 border border-red-500/30',
|
||||
'Escalate to Wulf': 'bg-red-500/15 text-red-600 border border-red-500/30',
|
||||
'Escalate to MC': 'bg-red-500/15 text-red-600 border border-red-500/30',
|
||||
'Resource Assigned': 'bg-cyan-500/15 text-cyan-600 border border-cyan-500/30',
|
||||
'Service Call Scheduled': 'bg-teal-500/15 text-teal-600 border border-teal-500/30',
|
||||
'Dispatched': 'bg-teal-500/15 text-teal-600 border border-teal-500/30',
|
||||
'Resolved <CSAT Survey>': 'bg-green-500/15 text-green-600 border border-green-500/30',
|
||||
};
|
||||
|
||||
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 SOURCE_MAP: Record<number, string> = {
|
||||
[-2]: 'System', [-1]: 'Internal',
|
||||
1: 'Phone', 2: 'Email', 4: 'Web Portal', 6: 'Monitoring Alert',
|
||||
8: 'RMM Alert', 17: 'Chat', 21: 'API', 22: 'Automation',
|
||||
27: 'In Person', 29: 'Client Portal', 30: 'Microsoft Teams',
|
||||
31: 'Webhook', 33: 'Datto RMM', 34: 'Rewst', 35: 'TimeZest',
|
||||
36: 'DeskDirector', 38: 'Huntress', 39: 'Blumira', 40: 'SentinelOne',
|
||||
};
|
||||
|
||||
const QUEUE: Record<number, string> = {
|
||||
29482833: 'Client Services', 29482834: 'Network Operations',
|
||||
29482835: 'Help Desk', 29482836: 'Projects',
|
||||
const COMPANY_TYPE_MAP: Record<number, { label: string; cls: string }> = {
|
||||
1: { label: 'Customer', cls: 'bg-green-500/15 text-green-600 border border-green-500/30' },
|
||||
2: { label: 'Lead', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' },
|
||||
3: { label: 'Prospect', cls: 'bg-purple-500/15 text-purple-600 border border-purple-500/30' },
|
||||
4: { label: 'Dead', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
|
||||
6: { label: 'Cancelation', cls: 'bg-red-500/15 text-red-600 border border-red-500/30' },
|
||||
7: { label: 'Vendor', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
|
||||
8: { label: 'Partner', cls: 'bg-cyan-500/15 text-cyan-600 border border-cyan-500/30' },
|
||||
};
|
||||
|
||||
const COMPANY_TYPE: Record<number, string> = {
|
||||
1: 'Customer', 2: 'Lead', 3: 'Prospect', 4: 'Dead', 6: 'Cancelation',
|
||||
7: 'Vendor', 8: 'Partner',
|
||||
};
|
||||
// ── Live lookup types (fetched from DB) ───────────────────────────────────────
|
||||
|
||||
interface Lookups {
|
||||
statuses: Record<number, string>;
|
||||
resources: Record<number, string>;
|
||||
companies: Record<number, string>;
|
||||
issueTypes: Record<number, string>;
|
||||
subIssueTypes: Record<number, string>;
|
||||
queues: Record<number, string>;
|
||||
configItems: Record<number, string>;
|
||||
}
|
||||
|
||||
// ── Field metadata for formatted view ─────────────────────────────────────────
|
||||
|
||||
type FieldType = 'date' | 'bool' | 'status' | 'priority' | 'source' | 'queue' | 'company_type' | 'url' | 'phone' | 'hours' | 'id' | 'resource' | 'company' | 'issue_type' | 'sub_issue_type' | 'config_item';
|
||||
|
||||
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';
|
||||
}>;
|
||||
fields: Array<{ key: string; label: string; type?: FieldType }>;
|
||||
paired?: string;
|
||||
};
|
||||
|
||||
const TICKET_GROUPS: FieldGroup[] = [
|
||||
{
|
||||
label: 'Overview',
|
||||
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: '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' },
|
||||
{ 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' },
|
||||
|
|
@ -89,6 +114,7 @@ const TICKET_GROUPS: FieldGroup[] = [
|
|||
},
|
||||
{
|
||||
label: 'System',
|
||||
paired: 'Dates & Time',
|
||||
fields: [
|
||||
{ key: 'id', label: 'Record ID', type: 'id' },
|
||||
{ key: 'synced_at', label: 'Synced At', type: 'date' },
|
||||
|
|
@ -130,6 +156,7 @@ const COMPANY_GROUPS: FieldGroup[] = [
|
|||
},
|
||||
{
|
||||
label: 'System',
|
||||
paired: 'Address',
|
||||
fields: [
|
||||
{ key: 'id', label: 'Record ID', type: 'id' },
|
||||
{ key: 'synced_at', label: 'Synced At', type: 'date' },
|
||||
|
|
@ -140,23 +167,24 @@ const COMPANY_GROUPS: FieldGroup[] = [
|
|||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function resolveLabel(key: string, value: any, type?: string): { display: React.ReactNode; isEmpty: boolean } {
|
||||
function ColorBadge({ cls, children }: { cls: string; children: React.ReactNode }) {
|
||||
return <span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${cls}`}>{children}</span>;
|
||||
}
|
||||
|
||||
function resolveLabel(key: string, value: any, type: FieldType | undefined, lookups: Lookups): { 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 };
|
||||
return { display: <span className="text-muted-foreground/40 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>
|
||||
),
|
||||
display: value
|
||||
? <ColorBadge cls="bg-green-500/15 text-green-600 border border-green-500/30"><Check className="w-3 h-3 mr-1" />Yes</ColorBadge>
|
||||
: <ColorBadge cls="bg-slate-500/15 text-slate-500 border border-slate-500/30"><X className="w-3 h-3 mr-1" />No</ColorBadge>,
|
||||
isEmpty: false,
|
||||
};
|
||||
case 'date':
|
||||
case 'date': {
|
||||
try {
|
||||
const d = new Date(value);
|
||||
return {
|
||||
|
|
@ -164,39 +192,74 @@ function resolveLabel(key: string, value: any, type?: string): { display: React.
|
|||
<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 };
|
||||
const label = lookups.statuses[Number(value)] ?? `Status ${value}`;
|
||||
const cls = STATUS_COLOR[label] ?? 'bg-muted text-muted-foreground border border-border';
|
||||
return { display: <ColorBadge cls={cls}>{label}</ColorBadge>, isEmpty: false };
|
||||
}
|
||||
case 'priority': {
|
||||
const p = TICKET_PRIORITY[Number(value)];
|
||||
return { display: <Badge variant={p?.variant ?? 'secondary'}>{p?.label ?? `Priority ${value}`}</Badge>, isEmpty: false };
|
||||
const p = PRIORITY_MAP[Number(value)];
|
||||
return { display: <ColorBadge cls={p?.cls ?? 'bg-muted text-muted-foreground border border-border'}>{p?.label ?? `Priority ${value}`}</ColorBadge>, isEmpty: false };
|
||||
}
|
||||
case 'source': {
|
||||
const label = TICKET_SOURCE[Number(value)] ?? `Source ${value}`;
|
||||
return { display: <Badge variant="secondary">{label}</Badge>, isEmpty: false };
|
||||
const label = SOURCE_MAP[Number(value)] ?? `Source ${value}`;
|
||||
return { display: <ColorBadge cls="bg-violet-500/15 text-violet-600 border border-violet-500/30">{label}</ColorBadge>, isEmpty: false };
|
||||
}
|
||||
case 'queue': {
|
||||
const label = QUEUE[Number(value)] ?? `Queue ${value}`;
|
||||
return { display: <span className="text-sm font-medium">{label}</span>, isEmpty: false };
|
||||
const qLabel = lookups.queues[Number(value)] ?? `Queue ${value}`;
|
||||
return { display: <ColorBadge cls="bg-indigo-500/15 text-indigo-600 border border-indigo-500/30">{qLabel}</ColorBadge>, isEmpty: false };
|
||||
}
|
||||
case 'company_type': {
|
||||
const label = COMPANY_TYPE[Number(value)] ?? `Type ${value}`;
|
||||
return { display: <Badge variant="outline">{label}</Badge>, isEmpty: false };
|
||||
const ct = COMPANY_TYPE_MAP[Number(value)];
|
||||
return { display: <ColorBadge cls={ct?.cls ?? 'bg-muted text-muted-foreground border border-border'}>{ct?.label ?? `Type ${value}`}</ColorBadge>, isEmpty: false };
|
||||
}
|
||||
case 'resource': {
|
||||
const name = lookups.resources[Number(value)];
|
||||
return {
|
||||
display: name
|
||||
? <span className="inline-flex items-center gap-1.5 text-sm"><User className="w-3.5 h-3.5 text-muted-foreground" />{name}</span>
|
||||
: <span className="font-mono text-xs bg-muted px-2 py-0.5 rounded">{value}</span>,
|
||||
isEmpty: false,
|
||||
};
|
||||
}
|
||||
case 'company': {
|
||||
const name = lookups.companies[Number(value)];
|
||||
return {
|
||||
display: name
|
||||
? <span className="inline-flex items-center gap-1.5 text-sm"><Building2 className="w-3.5 h-3.5 text-muted-foreground" />{name}</span>
|
||||
: <span className="font-mono text-xs bg-muted px-2 py-0.5 rounded">{value}</span>,
|
||||
isEmpty: false,
|
||||
};
|
||||
}
|
||||
case 'issue_type': {
|
||||
const label = lookups.issueTypes[Number(value)] ?? `Issue ${value}`;
|
||||
return { display: <ColorBadge cls="bg-sky-500/15 text-sky-600 border border-sky-500/30">{label}</ColorBadge>, isEmpty: false };
|
||||
}
|
||||
case 'sub_issue_type': {
|
||||
const label = lookups.subIssueTypes[Number(value)] ?? `Sub-Issue ${value}`;
|
||||
return { display: <ColorBadge cls="bg-sky-500/10 text-sky-500 border border-sky-500/20">{label}</ColorBadge>, isEmpty: false };
|
||||
}
|
||||
case 'config_item': {
|
||||
const name = lookups.configItems[Number(value)];
|
||||
return {
|
||||
display: name
|
||||
? <span className="text-sm">{name}</span>
|
||||
: <span className="font-mono text-xs bg-muted px-2 py-0.5 rounded">{value}</span>,
|
||||
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" />
|
||||
<Globe className="w-3.5 h-3.5" />{value}<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
),
|
||||
isEmpty: false,
|
||||
|
|
@ -217,7 +280,7 @@ function resolveLabel(key: string, value: any, type?: string): { display: React.
|
|||
}
|
||||
|
||||
if (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/)) {
|
||||
return resolveLabel(key, value, 'date');
|
||||
return resolveLabel(key, value, 'date', lookups);
|
||||
}
|
||||
|
||||
return { display: <span className="text-sm">{String(value)}</span>, isEmpty: false };
|
||||
|
|
@ -229,6 +292,8 @@ function detectGroups(data: Record<string, any>): FieldGroup[] {
|
|||
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 {
|
||||
|
|
@ -241,6 +306,51 @@ interface DetailModalProps {
|
|||
|
||||
export default function DetailModal({ open, onOpenChange, title, data, fields }: DetailModalProps) {
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
const [lookups, setLookups] = useState<Lookups>(EMPTY_LOOKUPS);
|
||||
const [lookupsLoading, setLookupsLoading] = useState(false);
|
||||
const [notes, setNotes] = useState<any[]>([]);
|
||||
const [notesLoading, setNotesLoading] = useState(false);
|
||||
const [timeEntries, setTimeEntries] = useState<any[]>([]);
|
||||
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;
|
||||
|
||||
|
|
@ -264,27 +374,40 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-7xl max-h-[90vh] overflow-hidden flex flex-col gap-0 p-0">
|
||||
<DialogContent className="max-w-7xl max-h-[90vh] overflow-hidden flex flex-col gap-0 p-0 border-2 border-blue-500/70 shadow-[0_0_0_1px_rgba(59,130,246,0.15),0_20px_60px_-10px_rgba(59,130,246,0.25)]">
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-6 pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
Record ID: <span className="font-mono">{data.id}</span>
|
||||
</DialogDescription>
|
||||
<div className="px-6 pt-5 pb-4 border-b">
|
||||
{'ticket_number' in data ? (
|
||||
<div className="flex items-start justify-between gap-6 pr-8">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<span className="font-mono text-sm font-semibold text-muted-foreground shrink-0">{data.ticket_number}</span>
|
||||
<DialogTitle className="text-xl font-bold leading-tight" style={{overflowWrap:'anywhere', wordBreak:'break-word'}}>{data.title}</DialogTitle>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex flex-col items-end gap-1">
|
||||
{(() => {
|
||||
const label = lookups.statuses[Number(data.status)] ?? `Status ${data.status}`;
|
||||
const cls = STATUS_COLOR[label] ?? 'bg-muted text-muted-foreground border border-border';
|
||||
return <ColorBadge cls={cls}>{label}</ColorBadge>;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
{'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 className="flex items-start justify-between gap-4 pr-8">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
Record ID: <span className="font-mono">{data.id}</span>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{'is_active' in data && (
|
||||
<ColorBadge cls={data.is_active ? 'bg-green-500/15 text-green-600 border border-green-500/30' : 'bg-slate-500/15 text-slate-500 border border-slate-500/30'}>
|
||||
{data.is_active ? 'Active' : 'Inactive'}
|
||||
</ColorBadge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
|
|
@ -295,6 +418,26 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
<LayoutTemplate className="w-3.5 h-3.5" />
|
||||
Formatted
|
||||
</TabsTrigger>
|
||||
{'ticket_number' in data && (
|
||||
<TabsTrigger value="time" className="gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
Time
|
||||
{timeEntries.length > 0 && (
|
||||
<span className="ml-1 rounded-full bg-blue-500/20 text-blue-600 text-xs px-1.5 py-0.5 font-medium">
|
||||
{timeEntries.reduce((s, e) => s + (parseFloat(e.hours_worked) || 0), 0).toFixed(1)}h
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{'ticket_number' in data && (
|
||||
<TabsTrigger value="notes" className="gap-1.5">
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
Notes
|
||||
{notes.length > 0 && (
|
||||
<span className="ml-1 rounded-full bg-blue-500/20 text-blue-600 text-xs px-1.5 py-0.5 font-medium">{notes.length}</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="raw" className="gap-1.5">
|
||||
<Code2 className="w-3.5 h-3.5" />
|
||||
Raw
|
||||
|
|
@ -305,55 +448,138 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
{/* ── 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' : ''}`}>
|
||||
{(() => {
|
||||
const rendered = new Set<string>();
|
||||
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 (
|
||||
<div key="Details">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">Details</h3>
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-3 px-4 py-3">
|
||||
{visibleFields.map((field) => {
|
||||
const value = data[field.key];
|
||||
const { display, isEmpty } = resolveLabel(field.key, value, field.type, lookups);
|
||||
if (isEmpty) return null;
|
||||
return (
|
||||
<div key={field.key} className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{field.label}:</span>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
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) => (
|
||||
<div key={g.label} className="flex-1 min-w-0">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">{g.label}</h3>
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
{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 (
|
||||
<div key={field.key}>
|
||||
{idx > 0 && <Separator />}
|
||||
<div className="group grid grid-cols-[140px_1fr] items-start">
|
||||
<div className="px-3 py-2.5 bg-muted/40 text-xs font-medium text-muted-foreground border-r truncate">
|
||||
{field.label}
|
||||
</div>
|
||||
<div className="px-3 py-2.5 flex items-start justify-between gap-2 min-w-0 overflow-hidden">
|
||||
<div className={`flex-1 min-w-0 ${isEmpty ? 'opacity-40' : ''}`} style={{overflowWrap:'anywhere', wordBreak:'break-word'}}>
|
||||
{display}
|
||||
</div>
|
||||
{stringValue && !isEmpty && (
|
||||
<Button variant="ghost" size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={() => copyToClipboard(stringValue, `${g.label}-${field.key}`)}
|
||||
>
|
||||
{copiedField === `${g.label}-${field.key}`
|
||||
? <CheckCircle2 className="w-3 h-3 text-green-500" />
|
||||
: <Copy className="w-3 h-3" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
|
||||
if (isPaired) {
|
||||
return (
|
||||
<div key={group.label} className="grid grid-cols-2 gap-4">
|
||||
{renderGroupTable(group, visibleFields)}
|
||||
{renderGroupTable(pairedGroup!, pairedVisible)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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, lookups);
|
||||
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 overflow-hidden">
|
||||
<div className={`flex-1 min-w-0 ${isEmpty ? 'opacity-40' : ''}`} style={{overflowWrap:'anywhere', wordBreak:'break-word'}}>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{/* 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">
|
||||
<div className="rounded-lg border p-4 text-sm whitespace-pre-wrap leading-relaxed text-muted-foreground break-words overflow-hidden" style={{overflowWrap:'anywhere', wordBreak:'break-word'}}>
|
||||
{data.description}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -361,6 +587,137 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Time Entries Tab ── */}
|
||||
<TabsContent value="time" className="flex-1 overflow-y-auto px-6 py-4 mt-0">
|
||||
{timeEntriesLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : timeEntries.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground gap-2">
|
||||
<Clock className="w-8 h-8 opacity-30" />
|
||||
<p className="text-sm">No time entries on this ticket</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* Summary bar */}
|
||||
<div className="rounded-lg border px-4 py-3 flex items-center gap-6 bg-muted/30 mb-4">
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="font-semibold">{timeEntries.reduce((s, e) => s + (parseFloat(e.hours_worked) || 0), 0).toFixed(2)}</span>
|
||||
<span className="text-muted-foreground">total hours</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{timeEntries.length} {timeEntries.length === 1 ? 'entry' : 'entries'}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{timeEntries.filter(e => e.billable).length} billable
|
||||
</div>
|
||||
</div>
|
||||
{/* Entry rows */}
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
{timeEntries.map((entry, idx) => (
|
||||
<div key={entry.id}>
|
||||
{idx > 0 && <Separator />}
|
||||
<div className="px-4 py-3 grid grid-cols-[1fr_auto] gap-4 items-start">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{entry.resource_name && (
|
||||
<span className="inline-flex items-center gap-1 text-sm font-medium">
|
||||
<User className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{entry.resource_name}
|
||||
</span>
|
||||
)}
|
||||
{entry.billable && (
|
||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-green-500/15 text-green-600 border border-green-500/30">Billable</span>
|
||||
)}
|
||||
{entry.approved && (
|
||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-blue-500/15 text-blue-600 border border-blue-500/30">Approved</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.notes && (
|
||||
<p className="text-sm text-muted-foreground" style={{overflowWrap:'anywhere', wordBreak:'break-word'}}>{entry.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 flex flex-col items-end gap-1">
|
||||
<span className="text-sm font-semibold tabular-nums">
|
||||
{parseFloat(entry.hours_worked).toFixed(2)}h
|
||||
</span>
|
||||
{entry.entry_date && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(entry.entry_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Notes Tab ── */}
|
||||
<TabsContent value="notes" className="flex-1 overflow-y-auto px-6 py-4 mt-0">
|
||||
{notesLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground gap-2">
|
||||
<MessageSquare className="w-8 h-8 opacity-30" />
|
||||
<p className="text-sm">No notes on this ticket</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{notes.map((note) => {
|
||||
const publishCls: Record<number, string> = {
|
||||
1: 'bg-green-500/15 text-green-600 border border-green-500/30',
|
||||
2: 'bg-amber-500/15 text-amber-700 border border-amber-500/30',
|
||||
4: 'bg-slate-500/15 text-slate-500 border border-slate-500/30',
|
||||
};
|
||||
const publishLabel: Record<number, string> = {
|
||||
1: 'All Users', 2: 'Internal', 4: 'Internal Only',
|
||||
};
|
||||
return (
|
||||
<div key={note.id} className="rounded-lg border p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{note.creator_name && (
|
||||
<span className="inline-flex items-center gap-1 text-sm font-medium">
|
||||
<User className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{note.creator_name}
|
||||
</span>
|
||||
)}
|
||||
{note.publish != null && (
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${publishCls[note.publish] ?? 'bg-muted text-muted-foreground border border-border'}`}>
|
||||
{publishLabel[note.publish] ?? `Publish ${note.publish}`}
|
||||
</span>
|
||||
)}
|
||||
{note.title && (
|
||||
<span className="text-sm font-semibold text-foreground">{note.title}</span>
|
||||
)}
|
||||
</div>
|
||||
{note.create_date_time && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{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' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{note.description && (
|
||||
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed border-t pt-2" style={{overflowWrap:'anywhere', wordBreak:'break-word'}}>
|
||||
{note.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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">
|
||||
|
|
|
|||
290
components/admin/IntegrationStatusTabs.tsx
Normal file
290
components/admin/IntegrationStatusTabs.tsx
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import {
|
||||
Shield, Monitor, Network, Apple,
|
||||
CheckCircle2, XCircle, AlertTriangle, RefreshCw, Loader2,
|
||||
Server, HardDrive, Cpu, Clock,
|
||||
} from 'lucide-react';
|
||||
|
||||
function StatusDot({ ok, warn }: { ok: boolean; warn?: boolean }) {
|
||||
if (!ok) return <span className="inline-block w-2 h-2 rounded-full bg-red-500" />;
|
||||
if (warn) return <span className="inline-block w-2 h-2 rounded-full bg-yellow-500" />;
|
||||
return <span className="inline-block w-2 h-2 rounded-full bg-green-500" />;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub, icon: Icon, cls }: {
|
||||
label: string; value: string | number; sub?: string;
|
||||
icon?: React.ElementType; cls?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-lg border p-4 flex flex-col gap-1 ${cls ?? ''}`}>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{Icon && <Icon className="w-3.5 h-3.5" />}
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-2xl font-bold tabular-nums">{value}</div>
|
||||
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtDate(d: string | null) {
|
||||
if (!d) return 'Never';
|
||||
return new Date(d).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function VeeamTab({ data, onSync, syncing }: { data: any; onSync: () => void; syncing: boolean }) {
|
||||
if (!data) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
|
||||
const aj = data.agentJobs ?? {};
|
||||
const bj = data.backupJobs ?? {};
|
||||
const totalFailed = (aj.failed ?? 0) + (bj.failed ?? 0);
|
||||
const totalWarning = (aj.warning ?? 0) + (bj.warning ?? 0);
|
||||
const totalRunning = aj.running ?? 0;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot ok={data.configured} warn={totalFailed > 0 || totalWarning > 0} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{data.configured ? 'Connected to VSPC' : 'Not configured'}</p>
|
||||
<p className="text-xs text-muted-foreground">Last sync: {fmtDate(data.lastSync)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={onSync} disabled={syncing || !data.configured}>
|
||||
{syncing ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
|
||||
Sync Now
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatCard label="Organizations" value={data.organizations ?? 0} icon={Server} />
|
||||
<StatCard label="Protected Workloads" value={data.protectedWorkloads ?? 0} icon={HardDrive} />
|
||||
<StatCard label="Agent Jobs" value={aj.total ?? 0} sub={`${aj.success ?? 0} success`} icon={Shield} />
|
||||
<StatCard label="Backup Jobs" value={bj.total ?? 0} sub={`${bj.success ?? 0} success`} icon={Shield} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatCard label="Running" value={totalRunning} icon={Clock}
|
||||
cls={totalRunning > 0 ? 'border-blue-500/30 bg-blue-500/5' : ''} />
|
||||
<StatCard label="Failed" value={totalFailed} icon={XCircle}
|
||||
cls={totalFailed > 0 ? 'border-red-500/30 bg-red-500/5' : ''} />
|
||||
<StatCard label="Warning" value={totalWarning} icon={AlertTriangle}
|
||||
cls={totalWarning > 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} />
|
||||
<StatCard label="Success" value={(aj.success ?? 0) + (bj.success ?? 0)} icon={CheckCircle2}
|
||||
cls="border-green-500/30 bg-green-500/5" />
|
||||
</div>
|
||||
{(totalFailed > 0 || totalWarning > 0 || totalRunning > 0) && (
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Attention Required</p>
|
||||
{totalFailed > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-600">
|
||||
<XCircle className="w-4 h-4" />
|
||||
{totalFailed} job{totalFailed !== 1 ? 's' : ''} failed
|
||||
</div>
|
||||
)}
|
||||
{totalWarning > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm text-yellow-700">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
{totalWarning} job{totalWarning !== 1 ? 's' : ''} completed with warnings
|
||||
</div>
|
||||
)}
|
||||
{totalRunning > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm text-blue-600">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{totalRunning} job{totalRunning !== 1 ? 's' : ''} running (stalled jobs appear here)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DattoRmmTab({ data, onSync, syncing }: { data: any; onSync: () => void; syncing: boolean }) {
|
||||
if (!data) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
|
||||
const unlinked = Math.max(0, (data.totalConfigItems ?? 0) - (data.rmmLinkedDevices ?? 0));
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot ok={data.configured} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{data.configured ? 'Connected' : 'Not configured'}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.apiUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<a href="https://concord.rmm.datto.com" target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" size="sm">Open Portal</Button>
|
||||
</a>
|
||||
<Button size="sm" onClick={onSync} disabled={syncing || !data.configured}>
|
||||
{syncing ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
|
||||
Sync CIs
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<StatCard label="Active Config Items" value={data.totalConfigItems ?? 0} icon={Cpu} />
|
||||
<StatCard label="RMM-Linked Devices" value={data.rmmLinkedDevices ?? 0} icon={Monitor}
|
||||
sub="with rmm_device_uid" />
|
||||
<StatCard label="Unlinked Devices" value={unlinked} icon={Monitor}
|
||||
cls={unlinked > 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} />
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-muted/30 text-sm text-muted-foreground">
|
||||
Datto RMM device data is queried live via the RMM API when investigating alerts.
|
||||
Device records link to Autotask Configuration Items via <code className="text-xs bg-muted px-1 rounded">rmm_device_uid</code>.
|
||||
Run an Autotask Configuration Items sync to refresh CI data.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuvikTab({ data }: { data: any }) {
|
||||
if (!data) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot ok={data.configured} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{data.configured ? 'Connected' : 'Not configured'}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.apiUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="https://auvikapi.us5.my.auvik.com" target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" size="sm">Open Portal</Button>
|
||||
</a>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-muted/30 text-sm text-muted-foreground">
|
||||
Auvik provides network topology and device data. The API is configured and accessible.
|
||||
Full sync and dashboard integration is planned — data is currently available via the Auvik API endpoints
|
||||
at <code className="text-xs bg-muted px-1 rounded">/api/auvik/devices</code> and <code className="text-xs bg-muted px-1 rounded">/api/auvik/tenant-mappings</code>.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddigyTab({ data }: { data: any }) {
|
||||
if (!data) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot ok={data.configured} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{data.configured ? 'Connected' : 'Not configured'}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.apiUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="https://app.addigy.com" target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" size="sm">Open Portal</Button>
|
||||
</a>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-muted/30 text-sm text-muted-foreground">
|
||||
Addigy manages Apple (macOS/iOS) devices. The API is configured and accessible via token auth.
|
||||
Device and policy data is available via <code className="text-xs bg-muted px-1 rounded">/api/addigy-devices</code> and <code className="text-xs bg-muted px-1 rounded">/api/addigy-policies</code>.
|
||||
Full sync integration is planned.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IntegrationStatusTabs() {
|
||||
const [status, setStatus] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [veeamSyncing, setVeeamSyncing] = useState(false);
|
||||
const [rmmSyncing, setRmmSyncing] = useState(false);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/integrations/status');
|
||||
if (res.ok) setStatus(await res.json());
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch integration status:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchStatus(); }, []);
|
||||
|
||||
const handleVeeamSync = async () => {
|
||||
setVeeamSyncing(true);
|
||||
try {
|
||||
await fetch('/api/veeam/sync', { method: 'POST', body: JSON.stringify({ syncType: 'full' }), headers: { 'Content-Type': 'application/json' } });
|
||||
// Poll until done
|
||||
const poll = setInterval(async () => {
|
||||
const r = await fetch('/api/veeam/sync');
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
if (!d.isSyncing) {
|
||||
clearInterval(poll);
|
||||
setVeeamSyncing(false);
|
||||
fetchStatus();
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
} catch {
|
||||
setVeeamSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRmmSync = async () => {
|
||||
setRmmSyncing(true);
|
||||
try {
|
||||
await fetch('/api/sync/entity', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ entities: ['configuration_items'] }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
setTimeout(() => { setRmmSyncing(false); fetchStatus(); }, 5000);
|
||||
} catch {
|
||||
setRmmSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="veeam" className="w-full">
|
||||
<TabsList className="grid w-full max-w-lg grid-cols-4">
|
||||
<TabsTrigger value="veeam" className="gap-1.5">
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
Veeam
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="datto" className="gap-1.5">
|
||||
<Monitor className="w-3.5 h-3.5" />
|
||||
Datto RMM
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="auvik" className="gap-1.5">
|
||||
<Network className="w-3.5 h-3.5" />
|
||||
Auvik
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="addigy" className="gap-1.5">
|
||||
<Apple className="w-3.5 h-3.5" />
|
||||
Addigy
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="veeam" className="mt-6">
|
||||
<VeeamTab data={status?.veeam} onSync={handleVeeamSync} syncing={veeamSyncing} />
|
||||
</TabsContent>
|
||||
<TabsContent value="datto" className="mt-6">
|
||||
<DattoRmmTab data={status?.dattoRmm} onSync={handleRmmSync} syncing={rmmSyncing} />
|
||||
</TabsContent>
|
||||
<TabsContent value="auvik" className="mt-6">
|
||||
<AuvikTab data={status?.auvik} />
|
||||
</TabsContent>
|
||||
<TabsContent value="addigy" className="mt-6">
|
||||
<AddigyTab data={status?.addigy} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue