wulf-pulse/app/analyzer/itglue/applications/[id]/page.tsx
lorentz 96edfb4444 feat(07.1-05): user-tz on analyzer pages
- itglue/applications, applications/[id], configurations,
  configurations/[id], sites/[companyId], queue, ticket/[ticketNumber],
  tickets, reports, reports/[id]: useUserTimezone() in default export;
  thread tz into every inline toLocale*String call.
- analyzer/tickets/page.tsx converts module-scope formatRelative(iso)
  helper to formatRelative(iso, tz); updates 1 callsite.

Migrates 16 of 81 audit leak callsites.
2026-05-07 08:34:58 -04:00

802 lines
29 KiB
TypeScript

'use client';
import { useEffect, useMemo, useState, use } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Separator } from '@/components/ui/separator';
import {
ProviderToggle,
type AnalyzerProvider,
} from '@/components/analyzer/provider-toggle';
import { RmmScriptPicker } from '@/components/rmm/rmm-script-picker';
import { toast } from 'sonner';
import {
Sparkles,
Loader2,
ExternalLink,
AlertTriangle,
ArrowLeftRight,
CheckCircle2,
Undo2,
} from 'lucide-react';
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
import { useSession } from '@/lib/auth-client';
interface FieldRow {
id: string;
name: string;
kind: string | null;
hint: string | null;
required: boolean;
}
interface AssetDetail {
asset: {
id: string;
name: string | null;
organizationId: string | null;
organizationName: string | null;
flexibleAssetTypeId: string;
flexibleAssetTypeName: string | null;
autotaskCompanyId: string | null;
traits: Record<string, unknown>;
createdAt: string | null;
updatedAt: string | null;
};
fields: FieldRow[];
}
interface FieldGap {
field_name: string;
why_missing_matters: string;
suggested_value: string | null;
evidence_ticket_numbers: string[];
confidence: 'high' | 'medium' | 'low';
}
interface NotePromotion {
quoted_note_text: string;
target_field: string;
suggested_value: string;
confidence: 'high' | 'medium' | 'low';
}
interface Contradiction {
description: string;
evidence: string;
}
interface AuditRow {
id: string;
generated_at: string;
provider: 'anthropic' | 'openrouter';
model_used: string | null;
ticket_count: number;
field_gaps: FieldGap[];
notes_promotions: NotePromotion[];
contradictions: Contradiction[];
overall_score: number | null;
estimated_cost_usd: number | null;
}
interface WriteRow {
id: string;
audit_id: string | null;
field_name: string;
before_value: unknown;
after_value: unknown;
performed_by_user_id: string | null;
performed_at: string;
status: 'pending' | 'committed' | 'failed' | 'reverted';
error_message: string | null;
}
interface XrefRow {
id: string;
ticketNumber: string;
analysisId: string | null;
relationship: 'referenced' | 'updated' | 'should_have_referenced';
source: string;
details: { write_id?: string; field_name?: string; relevance_reason?: string } | null;
createdAt: string;
}
const CONFIDENCE_TONE: Record<FieldGap['confidence'], string> = {
high: 'border-red-500 bg-red-500/10 text-red-700 dark:text-red-300',
medium: 'border-amber-500 bg-amber-500/10 text-amber-700 dark:text-amber-300',
low: 'border-blue-500 bg-blue-500/10 text-blue-700 dark:text-blue-300',
};
function fieldNameToTraitKey(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
function formatTraitValue(v: unknown): string {
if (v === null || v === undefined) return '';
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
if (Array.isArray(v)) return v.length === 0 ? '' : JSON.stringify(v);
if (typeof v === 'object') {
const obj = v as { values?: unknown[] };
if (Array.isArray(obj.values)) {
return obj.values
.map((it) => {
const o = it as { name?: string; 'first-name'?: string; 'last-name'?: string };
if (o.name) return o.name;
if (o['first-name'] || o['last-name'])
return [o['first-name'], o['last-name']].filter(Boolean).join(' ');
return JSON.stringify(it);
})
.join(', ');
}
return JSON.stringify(v).slice(0, 200);
}
return String(v);
}
function isPopulated(v: unknown): boolean {
if (v === null || v === undefined) return false;
if (typeof v === 'string') return v.trim().length > 0;
if (Array.isArray(v)) return v.length > 0;
if (typeof v === 'object') {
const obj = v as { values?: unknown[] };
if (Array.isArray(obj.values)) return obj.values.length > 0;
return Object.keys(v).length > 0;
}
return true;
}
export default function ApplicationAuditPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const tz = useUserTimezone();
const { id } = use(params);
const { data: session } = useSession();
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
const canWrite = role === 'admin' || role === 'super-admin';
const [detail, setDetail] = useState<AssetDetail | null>(null);
const [audit, setAudit] = useState<AuditRow | null>(null);
const [history, setHistory] = useState<AuditRow[]>([]);
const [writes, setWrites] = useState<WriteRow[]>([]);
const [xrefs, setXrefs] = useState<XrefRow[]>([]);
const [error, setError] = useState<string | null>(null);
const [provider, setProvider] = useState<AnalyzerProvider>('anthropic');
const [running, setRunning] = useState(false);
const [busyKey, setBusyKey] = useState<string | null>(null);
async function loadAll(): Promise<void> {
try {
const [d, a, w, x] = await Promise.all([
fetch(`/api/analyzer/itglue/applications/${id}`).then((r) => r.json()),
fetch(`/api/analyzer/itglue/applications/${id}/audit?history=1`).then(
(r) => r.json()
),
fetch(`/api/analyzer/itglue/applications/${id}/writes`).then((r) =>
r.json()
),
fetch(`/api/analyzer/itglue/applications/${id}/xrefs`).then((r) =>
r.json()
),
]);
if (d.error) throw new Error(d.error);
setDetail(d as AssetDetail);
setAudit(a.audit ?? null);
setHistory(a.history ?? []);
setWrites(w.writes ?? []);
setXrefs(x.xrefs ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
}
useEffect(() => {
void loadAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
async function runAudit(): Promise<void> {
setRunning(true);
try {
const res = await fetch(`/api/analyzer/itglue/applications/${id}/audit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || data.error || 'Audit failed');
setAudit(data.audit);
// Refresh history.
void loadAll();
toast.success('Audit complete');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Audit failed');
} finally {
setRunning(false);
}
}
async function applyGap(
gap: FieldGap | NotePromotion,
kind: 'field_gap' | 'note_promotion'
): Promise<void> {
if (!canWrite) return;
if (!audit) return;
const fieldName =
kind === 'field_gap' ? (gap as FieldGap).field_name : (gap as NotePromotion).target_field;
const suggested =
kind === 'field_gap'
? (gap as FieldGap).suggested_value
: (gap as NotePromotion).suggested_value;
if (suggested === null || suggested === undefined || suggested === '') {
toast.error('No suggested value to apply');
return;
}
const evidence =
kind === 'field_gap'
? {
ticket_numbers: (gap as FieldGap).evidence_ticket_numbers,
gap_description: (gap as FieldGap).why_missing_matters,
}
: {
ticket_numbers: [],
gap_description: `Promoted from Notes: "${(gap as NotePromotion).quoted_note_text}"`,
};
const key = `${kind}:${fieldName}`;
setBusyKey(key);
try {
const res = await fetch(`/api/analyzer/itglue/applications/${id}/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
auditId: audit.id,
fieldName,
suggestedValue: suggested,
sourceEvidence: evidence,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || data.error || 'Apply failed');
}
toast.success(`Applied: ${fieldName}`);
void loadAll();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Apply failed');
} finally {
setBusyKey(null);
}
}
async function revertWrite(writeId: string): Promise<void> {
if (!canWrite) return;
setBusyKey(`revert:${writeId}`);
try {
const res = await fetch(
`/api/analyzer/itglue/applications/${id}/revert/${writeId}`,
{ method: 'POST' }
);
const data = await res.json();
if (!res.ok)
throw new Error(data.message || data.error || 'Revert failed');
toast.success('Reverted');
void loadAll();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Revert failed');
} finally {
setBusyKey(null);
}
}
const orderedFields = useMemo(() => {
if (!detail) return [];
return detail.fields.map((f) => {
const traitKey = fieldNameToTraitKey(f.name);
const value = detail.asset.traits[traitKey];
return { ...f, traitKey, value, populated: isPopulated(value) };
});
}, [detail]);
if (error) {
return (
<div className="container mx-auto px-6 py-6 max-w-4xl">
<Alert variant="destructive">
<AlertTitle>Couldn&rsquo;t load this asset</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</div>
);
}
if (!detail) {
return (
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
const a = detail.asset;
const filledCount = orderedFields.filter((f) => f.populated).length;
const totalCount = orderedFields.length;
return (
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
{/* Header */}
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1 min-w-0">
<p className="text-sm text-muted-foreground">
<Link
href="/analyzer/itglue/applications"
className="hover:underline"
>
Applications
</Link>{' '}
· {a.organizationName ?? 'Unknown org'}
</p>
<CardTitle className="text-2xl truncate">{a.name ?? a.id}</CardTitle>
<p className="text-xs text-muted-foreground">
{filledCount}/{totalCount} fields populated
{audit?.overall_score !== null && audit?.overall_score !== undefined ? (
<>
{' · '}
<Badge
variant={
(audit.overall_score ?? 0) > 0.8
? 'default'
: (audit.overall_score ?? 0) > 0.5
? 'secondary'
: 'destructive'
}
>
Audit score {Math.round((audit.overall_score ?? 0) * 100)}%
</Badge>
</>
) : null}
</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
<ProviderToggle value={provider} onChange={setProvider} size="sm" />
<Button onClick={runAudit} disabled={running}>
{running ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Auditing
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
{audit ? 'Re-audit' : 'Run audit'}
</>
)}
</Button>
{a.autotaskCompanyId && (
<RmmScriptPicker
filter="site_anchor"
companyId={a.autotaskCompanyId}
onComplete={() => loadAll()}
/>
)}
<Button asChild variant="outline" size="sm">
<a
href={`https://wulf.itglue.com/${a.organizationId}/assets/${a.flexibleAssetTypeId}/records/${a.id}`}
target="_blank"
rel="noreferrer"
>
Open in IT Glue
<ExternalLink className="w-3.5 h-3.5 ml-1.5" />
</a>
</Button>
</div>
</div>
</CardHeader>
</Card>
{/* Audit findings */}
{audit && (
<Card>
<CardHeader>
<CardTitle className="text-base">
Audit findings
<span className="ml-2 text-xs text-muted-foreground font-normal">
{new Date(audit.generated_at).toLocaleString(undefined, { timeZone: tz })}
{' · '}
{audit.provider === 'openrouter' ? 'DeepSeek' : 'Claude'}
{audit.estimated_cost_usd !== null
? ` · $${audit.estimated_cost_usd.toFixed(4)}`
: ''}
{' · '}
{audit.ticket_count} ticket{audit.ticket_count === 1 ? '' : 's'}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Field gaps */}
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Field gaps ({audit.field_gaps.length})
</h3>
{audit.field_gaps.length === 0 ? (
<p className="text-sm text-muted-foreground">No field gaps detected.</p>
) : (
<ul className="space-y-3">
{audit.field_gaps.map((g) => {
const key = `field_gap:${g.field_name}`;
const busy = busyKey === key;
return (
<li
key={key}
className={`border-l-4 rounded p-3 ${CONFIDENCE_TONE[g.confidence]}`}
>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="min-w-0 flex-1">
<p className="font-medium text-foreground">{g.field_name}</p>
<p className="text-sm mt-1">{g.why_missing_matters}</p>
{g.suggested_value !== null && (
<p className="text-sm mt-2">
<span className="font-medium">Suggested: </span>
<span className="font-mono">{g.suggested_value}</span>
</p>
)}
{g.evidence_ticket_numbers.length > 0 && (
<p className="text-xs mt-2 text-muted-foreground">
Evidence:{' '}
{g.evidence_ticket_numbers.map((tn, i) => (
<span key={tn}>
{i > 0 && ', '}
<Link
href={`/analyzer/ticket/${tn}`}
className="font-mono hover:underline"
>
{tn}
</Link>
</span>
))}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className="text-[10px] uppercase">
{g.confidence}
</Badge>
<Button
size="sm"
disabled={
!canWrite ||
g.suggested_value === null ||
g.suggested_value === '' ||
busy
}
onClick={() => applyGap(g, 'field_gap')}
title={
!canWrite
? 'Requires admin'
: g.suggested_value === null
? 'No concrete suggestion'
: 'Apply to IT Glue'
}
>
{busy ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<CheckCircle2 className="w-3.5 h-3.5 mr-1" />
)}
Apply
</Button>
</div>
</div>
</li>
);
})}
</ul>
)}
</section>
{/* Notes promotions */}
{audit.notes_promotions.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Promote from Notes ({audit.notes_promotions.length})
</h3>
<ul className="space-y-3">
{audit.notes_promotions.map((p, i) => {
const key = `note_promotion:${p.target_field}:${i}`;
const busy = busyKey === `note_promotion:${p.target_field}`;
return (
<li
key={key}
className="border-l-4 border-primary/40 bg-primary/5 rounded p-3"
>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-sm font-mono italic text-muted-foreground">
&ldquo;{p.quoted_note_text}&rdquo;
</p>
<p className="text-sm mt-2">
Belongs in{' '}
<span className="font-medium">{p.target_field}</span>
:{' '}
<span className="font-mono">{p.suggested_value}</span>
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className="text-[10px] uppercase">
{p.confidence}
</Badge>
<Button
size="sm"
disabled={!canWrite || busy}
onClick={() => applyGap(p, 'note_promotion')}
title={!canWrite ? 'Requires admin' : 'Apply to IT Glue'}
>
{busy ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<CheckCircle2 className="w-3.5 h-3.5 mr-1" />
)}
Apply
</Button>
</div>
</div>
</li>
);
})}
</ul>
</section>
)}
{/* Contradictions */}
{audit.contradictions.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Contradictions ({audit.contradictions.length})
</h3>
<ul className="space-y-2">
{audit.contradictions.map((c, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm p-3 rounded bg-muted/40"
>
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-amber-600" />
<div>
<p>{c.description}</p>
<p className="text-xs text-muted-foreground mt-1">
{c.evidence}
</p>
</div>
</li>
))}
</ul>
</section>
)}
</CardContent>
</Card>
)}
{!audit && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No audit yet. Click <strong>Run audit</strong> to analyze this asset.
</CardContent>
</Card>
)}
{/* Current fields */}
<Card>
<CardHeader>
<CardTitle className="text-base">Current fields</CardTitle>
</CardHeader>
<CardContent>
<dl className="divide-y">
{orderedFields.map((f) => (
<div key={f.id} className="py-2 grid grid-cols-3 gap-3 text-sm">
<dt
className={`font-medium ${f.populated ? '' : 'text-muted-foreground'}`}
>
{f.name}
{f.required && <span className="text-red-500 ml-1">*</span>}
</dt>
<dd className="col-span-2 break-words">
{f.populated ? (
formatTraitValue(f.value)
) : (
<span className="text-muted-foreground italic">empty</span>
)}
{f.hint && !f.populated && (
<p className="text-xs text-muted-foreground mt-0.5">{f.hint}</p>
)}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
{/* Tickets that touched this asset */}
{(xrefs.filter((x) => x.relationship === 'referenced').length > 0 ||
xrefs.filter((x) => x.relationship === 'updated').length > 0) && (
<Card>
<CardHeader>
<CardTitle className="text-base">Tickets that touched this asset</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{xrefs.filter((x) => x.relationship === 'referenced').length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Referenced by ({xrefs.filter((x) => x.relationship === 'referenced').length})
</h3>
<ul className="space-y-1 text-sm">
{xrefs
.filter((x) => x.relationship === 'referenced')
.map((x) => (
<li key={x.id}>
<Link
href={`/analyzer/ticket/${x.ticketNumber}`}
className="font-mono hover:underline"
>
{x.ticketNumber}
</Link>
{x.details?.relevance_reason && (
<span className="text-xs text-muted-foreground ml-2">
{x.details.relevance_reason}
</span>
)}
</li>
))}
</ul>
</section>
)}
{xrefs.filter((x) => x.relationship === 'updated').length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Updated by ({xrefs.filter((x) => x.relationship === 'updated').length})
</h3>
<ul className="space-y-1 text-sm">
{xrefs
.filter((x) => x.relationship === 'updated')
.map((x) => (
<li key={x.id}>
<Link
href={`/analyzer/ticket/${x.ticketNumber}`}
className="font-mono hover:underline"
>
{x.ticketNumber}
</Link>
{x.details?.field_name && (
<span className="text-xs text-muted-foreground ml-2">
set <span className="font-mono">{x.details.field_name}</span>
</span>
)}
</li>
))}
</ul>
</section>
)}
</CardContent>
</Card>
)}
{/* Write history */}
{writes.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<ArrowLeftRight className="w-4 h-4" />
Write history ({writes.length})
</CardTitle>
</CardHeader>
<CardContent>
<ul className="divide-y">
{writes.map((w) => (
<li key={w.id} className="py-3 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-sm">
<span className="font-medium">{w.field_name}</span>
<Badge
variant={
w.status === 'committed'
? 'default'
: w.status === 'reverted'
? 'secondary'
: w.status === 'failed'
? 'destructive'
: 'outline'
}
className="ml-2 text-[10px]"
>
{w.status}
</Badge>
</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(w.performed_at).toLocaleString(undefined, { timeZone: tz })}
</p>
<p className="text-xs mt-1 break-words">
<span className="text-muted-foreground">Before: </span>
<span className="font-mono">
{w.before_value === null || w.before_value === undefined
? '(empty)'
: JSON.stringify(w.before_value).slice(0, 200)}
</span>
</p>
<p className="text-xs mt-0.5 break-words">
<span className="text-muted-foreground">After: </span>
<span className="font-mono">
{JSON.stringify(w.after_value).slice(0, 200)}
</span>
</p>
{w.error_message && (
<p className="text-xs mt-1 text-destructive">
Error: {w.error_message}
</p>
)}
</div>
{w.status === 'committed' && (
<Button
size="sm"
variant="outline"
disabled={!canWrite || busyKey === `revert:${w.id}`}
onClick={() => revertWrite(w.id)}
title={!canWrite ? 'Requires admin' : 'Revert this write'}
>
{busyKey === `revert:${w.id}` ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Undo2 className="w-3.5 h-3.5 mr-1" />
)}
Revert
</Button>
)}
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Audit history */}
{history.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Audit history</CardTitle>
</CardHeader>
<CardContent>
<ul className="divide-y">
{history.map((h) => (
<li
key={h.id}
className="py-2 flex items-center justify-between text-sm"
>
<span className="text-muted-foreground">
{new Date(h.generated_at).toLocaleString(undefined, { timeZone: tz })}
{' · '}
{h.provider === 'openrouter' ? 'DeepSeek' : 'Claude'}
</span>
<span>
Score{' '}
<Badge variant="outline">
{h.overall_score !== null
? Math.round(h.overall_score * 100) + '%'
: 'n/a'}
</Badge>
</span>
</li>
))}
</ul>
</CardContent>
<Separator />
</Card>
)}
</div>
);
}