feat: RMM Overshell, IT Glue audit/write-back, LogLift, link-aware bundles, dashboard overhaul
- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift) - LogLift evidence pipeline (migration 078): upload webhook, B2 storage client, receiver/matcher, EventLogCollector PowerShell script - IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket xrefs, applications/configurations browse pages + apply/revert/audit endpoints - Link-aware analyzer bundles (migration 073) + provider toggle (migration 074): link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion panels, analyze-bundle endpoint - Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts admin page, reconciler service, resolve endpoints - Dashboard overhaul: integration-health service + alerts, overview/health endpoints - Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
378e68ad8a
commit
1112a06afe
132 changed files with 21352 additions and 743 deletions
|
|
@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
|
|||
import { toast } from 'sonner';
|
||||
import { Sparkles, Loader2 } from 'lucide-react';
|
||||
import type { JobStatus } from '@/lib/types/analyzer';
|
||||
import type { AnalyzerProvider } from './provider-toggle';
|
||||
|
||||
interface AnalyzeButtonProps {
|
||||
ticketNumber: string;
|
||||
|
|
@ -13,6 +14,8 @@ interface AnalyzeButtonProps {
|
|||
force?: boolean;
|
||||
variant?: 'default' | 'outline' | 'secondary';
|
||||
label?: string;
|
||||
/** LLM provider (anthropic = Claude default; openrouter = DeepSeek). */
|
||||
provider?: AnalyzerProvider;
|
||||
}
|
||||
|
||||
const STAGE_LABEL: Record<JobStatus, string> = {
|
||||
|
|
@ -31,13 +34,16 @@ export function AnalyzeButton({
|
|||
force = false,
|
||||
variant = 'default',
|
||||
label = 'Analyze',
|
||||
provider = 'anthropic',
|
||||
}: AnalyzeButtonProps) {
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<JobStatus | 'idle'>('idle');
|
||||
|
||||
async function pollJob(jobId: string) {
|
||||
const start = Date.now();
|
||||
const TIMEOUT_MS = 5 * 60 * 1000;
|
||||
// DeepSeek runs (especially V4 Pro deep analysis) take 4-6× longer than
|
||||
// Claude — observed ~5min on a typical ticket. 12min keeps headroom.
|
||||
const TIMEOUT_MS = 12 * 60 * 1000;
|
||||
while (Date.now() - start < TIMEOUT_MS) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const res = await fetch(`/api/analyzer/jobs/${jobId}`);
|
||||
|
|
@ -64,7 +70,7 @@ export function AnalyzeButton({
|
|||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ force }),
|
||||
body: JSON.stringify({ force, provider }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
|
|
|
|||
493
components/analyzer/itglue-suggestions-panel.tsx
Normal file
493
components/analyzer/itglue-suggestions-panel.tsx
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } 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 {
|
||||
ProviderToggle,
|
||||
type AnalyzerProvider,
|
||||
} from '@/components/analyzer/provider-toggle';
|
||||
import {
|
||||
Sparkles,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
CheckCircle2,
|
||||
Server,
|
||||
Layers,
|
||||
Database,
|
||||
} from 'lucide-react';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
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 AuditRow {
|
||||
id: string;
|
||||
generated_at: string;
|
||||
provider: 'anthropic' | 'openrouter';
|
||||
ticket_count: number;
|
||||
field_gaps: FieldGap[];
|
||||
notes_promotions: NotePromotion[];
|
||||
contradictions: { description: string; evidence: string }[];
|
||||
overall_score: number | null;
|
||||
estimated_cost_usd: number | null;
|
||||
}
|
||||
|
||||
interface MatchedAsset {
|
||||
id: string;
|
||||
name: string | null;
|
||||
hostname?: string | null;
|
||||
type_name: string | null;
|
||||
score: number;
|
||||
matched_term: string;
|
||||
latestAudit: AuditRow | null;
|
||||
}
|
||||
|
||||
interface SuggestionsResponse {
|
||||
ticketNumber: string;
|
||||
organizationId: string | null;
|
||||
organizationName: string | null;
|
||||
flexibleAssets: MatchedAsset[];
|
||||
configurations: MatchedAsset[];
|
||||
}
|
||||
|
||||
const CONFIDENCE_TONE: Record<FieldGap['confidence'], string> = {
|
||||
high: 'border-red-500 bg-red-500/10',
|
||||
medium: 'border-amber-500 bg-amber-500/10',
|
||||
low: 'border-blue-500 bg-blue-500/10',
|
||||
};
|
||||
|
||||
interface ItglueSuggestionsPanelProps {
|
||||
analysisId: string;
|
||||
}
|
||||
|
||||
export function ItglueSuggestionsPanel({ analysisId }: ItglueSuggestionsPanelProps) {
|
||||
const { data: session } = useSession();
|
||||
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
|
||||
const canWrite = role === 'admin' || role === 'super-admin';
|
||||
|
||||
const [data, setData] = useState<SuggestionsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [provider, setProvider] = useState<AnalyzerProvider>('anthropic');
|
||||
const [auditing, setAuditing] = useState<string | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
|
||||
async function loadSuggestions(): Promise<void> {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/analyzer/analyses/${analysisId}/itglue-suggestions`
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(err.error ?? `Request failed: ${res.status}`);
|
||||
}
|
||||
const d = (await res.json()) as SuggestionsResponse;
|
||||
setData(d);
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runAudit(
|
||||
assetType: 'flexible_asset' | 'configuration',
|
||||
assetId: string
|
||||
): Promise<void> {
|
||||
const key = `${assetType}:${assetId}`;
|
||||
setAuditing(key);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/analyzer/analyses/${analysisId}/itglue-suggestions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ assetType, assetId, provider }),
|
||||
}
|
||||
);
|
||||
const d = await res.json();
|
||||
if (!res.ok) throw new Error(d.message || d.error || 'Audit failed');
|
||||
toast.success('Audit complete');
|
||||
// Refresh the suggestions to pick up the new audit row.
|
||||
await loadSuggestions();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Audit failed');
|
||||
} finally {
|
||||
setAuditing(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyGap(
|
||||
assetType: 'flexible_asset' | 'configuration',
|
||||
assetId: string,
|
||||
auditId: string,
|
||||
gap: FieldGap | NotePromotion,
|
||||
kind: 'field_gap' | 'note_promotion'
|
||||
): Promise<void> {
|
||||
if (!canWrite) 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 = `${assetType}:${assetId}:${kind}:${fieldName}`;
|
||||
setBusyKey(key);
|
||||
try {
|
||||
const path =
|
||||
assetType === 'flexible_asset'
|
||||
? `/api/analyzer/itglue/applications/${assetId}/apply`
|
||||
: `/api/analyzer/itglue/configurations/${assetId}/apply`;
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
auditId,
|
||||
fieldName,
|
||||
suggestedValue: suggested,
|
||||
sourceEvidence: evidence,
|
||||
}),
|
||||
});
|
||||
const d = await res.json();
|
||||
if (!res.ok) throw new Error(d.message || d.error || 'Apply failed');
|
||||
toast.success(`Applied: ${fieldName}`);
|
||||
await loadSuggestions();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Apply failed');
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
const totalMatches = useMemo(() => {
|
||||
if (!data) return 0;
|
||||
return data.flexibleAssets.length + data.configurations.length;
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-5 h-5" />
|
||||
<CardTitle className="text-base">IT Glue documentation</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ProviderToggle value={provider} onChange={setProvider} size="sm" />
|
||||
<Button onClick={loadSuggestions} disabled={loading} size="sm">
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" />
|
||||
Checking…
|
||||
</>
|
||||
) : data ? (
|
||||
<>
|
||||
<Sparkles className="w-3.5 h-3.5 mr-1.5" />
|
||||
Re-check
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-3.5 h-3.5 mr-1.5" />
|
||||
Check IT Glue documentation
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loadError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Couldn’t load suggestions</AlertTitle>
|
||||
<AlertDescription>{loadError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!data && !loading && !loadError && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click <strong>Check IT Glue documentation</strong> to find IT Glue
|
||||
records this ticket touched and surface what should be documented.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Matched <strong>{totalMatches}</strong> IT Glue record
|
||||
{totalMatches === 1 ? '' : 's'} for{' '}
|
||||
<strong>{data.organizationName ?? 'this client'}</strong>.
|
||||
{totalMatches === 0 &&
|
||||
' (No matches — ticket fingerprint did not mention any IT Glue assets we could find.)'}
|
||||
</p>
|
||||
|
||||
{data.flexibleAssets.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center gap-1">
|
||||
<Layers className="w-3.5 h-3.5" /> Applications ({data.flexibleAssets.length})
|
||||
</h3>
|
||||
{data.flexibleAssets.map((m) =>
|
||||
renderAssetMatch(
|
||||
m,
|
||||
'flexible_asset',
|
||||
auditing === `flexible_asset:${m.id}`,
|
||||
busyKey,
|
||||
canWrite,
|
||||
() => runAudit('flexible_asset', m.id),
|
||||
(g, k, auditId) => applyGap('flexible_asset', m.id, auditId, g, k)
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data.configurations.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center gap-1">
|
||||
<Server className="w-3.5 h-3.5" /> Configurations ({data.configurations.length})
|
||||
</h3>
|
||||
{data.configurations.map((m) =>
|
||||
renderAssetMatch(
|
||||
m,
|
||||
'configuration',
|
||||
auditing === `configuration:${m.id}`,
|
||||
busyKey,
|
||||
canWrite,
|
||||
() => runAudit('configuration', m.id),
|
||||
(g, k, auditId) => applyGap('configuration', m.id, auditId, g, k)
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAssetMatch(
|
||||
m: MatchedAsset,
|
||||
assetType: 'flexible_asset' | 'configuration',
|
||||
auditing: boolean,
|
||||
busyKey: string | null,
|
||||
canWrite: boolean,
|
||||
onRunAudit: () => void,
|
||||
onApply: (
|
||||
gap: FieldGap | NotePromotion,
|
||||
kind: 'field_gap' | 'note_promotion',
|
||||
auditId: string
|
||||
) => void
|
||||
) {
|
||||
const detailHref =
|
||||
assetType === 'flexible_asset'
|
||||
? `/analyzer/itglue/applications/${m.id}`
|
||||
: `/analyzer/itglue/configurations/${m.id}`;
|
||||
const audit = m.latestAudit;
|
||||
return (
|
||||
<div key={`${assetType}:${m.id}`} className="rounded-md border p-3 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link href={detailHref} className="font-medium hover:underline">
|
||||
{m.name ?? m.id}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{m.type_name ?? '—'}
|
||||
{m.hostname && ` · ${m.hostname}`}
|
||||
{' · '}match: <span className="font-mono">{m.matched_term}</span>
|
||||
{audit?.overall_score !== null && audit?.overall_score !== undefined && (
|
||||
<>
|
||||
{' · '}score{' '}
|
||||
<Badge
|
||||
variant={
|
||||
(audit.overall_score ?? 0) > 0.8
|
||||
? 'default'
|
||||
: (audit.overall_score ?? 0) > 0.5
|
||||
? 'secondary'
|
||||
: 'destructive'
|
||||
}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{Math.round((audit.overall_score ?? 0) * 100)}%
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={detailHref}>
|
||||
Open
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button onClick={onRunAudit} disabled={auditing} size="sm">
|
||||
{auditing ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" />
|
||||
Auditing…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-3.5 h-3.5 mr-1.5" />
|
||||
{audit ? 'Re-audit for this ticket' : 'Audit for this ticket'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{audit && (
|
||||
<div className="space-y-2">
|
||||
{audit.field_gaps.length === 0 && audit.notes_promotions.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No new gaps surfaced from this ticket. Existing record looks
|
||||
sufficient for what was learned.
|
||||
</p>
|
||||
)}
|
||||
{audit.field_gaps.map((g) => {
|
||||
const k = `${assetType}:${m.id}:field_gap:${g.field_name}`;
|
||||
const busy = busyKey === k;
|
||||
return (
|
||||
<div
|
||||
key={k}
|
||||
className={`border-l-4 rounded p-2 ${CONFIDENCE_TONE[g.confidence]}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{g.field_name}</p>
|
||||
<p className="text-xs mt-0.5">{g.why_missing_matters}</p>
|
||||
{g.suggested_value !== null && (
|
||||
<p className="text-xs mt-1">
|
||||
<span className="font-medium">Suggested: </span>
|
||||
<span className="font-mono break-all">
|
||||
{g.suggested_value}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 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={() => onApply(g, 'field_gap', audit.id)}
|
||||
title={
|
||||
!canWrite
|
||||
? 'Requires admin'
|
||||
: g.suggested_value === null
|
||||
? 'No concrete suggestion'
|
||||
: 'Apply to IT Glue'
|
||||
}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{audit.notes_promotions.map((p, i) => {
|
||||
const k = `${assetType}:${m.id}:note_promotion:${p.target_field}:${i}`;
|
||||
const busy = busyKey === `${assetType}:${m.id}:note_promotion:${p.target_field}`;
|
||||
return (
|
||||
<div
|
||||
key={k}
|
||||
className="border-l-4 border-primary/40 bg-primary/5 rounded p-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-mono italic text-muted-foreground break-words">
|
||||
“{p.quoted_note_text}”
|
||||
</p>
|
||||
<p className="text-xs mt-1">
|
||||
→ <span className="font-medium">{p.target_field}</span>:{' '}
|
||||
<span className="font-mono break-all">{p.suggested_value}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Badge variant="outline" className="text-[10px] uppercase">
|
||||
{p.confidence}
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canWrite || busy}
|
||||
onClick={() => onApply(p, 'note_promotion', audit.id)}
|
||||
title={!canWrite ? 'Requires admin' : 'Apply to IT Glue'}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{audit.contradictions.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
{audit.contradictions.map((c, i) => (
|
||||
<p key={i}>
|
||||
⚠ {c.description}
|
||||
<span className="ml-1">— {c.evidence}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MatchedAssetExt extends MatchedAsset {
|
||||
hostname?: string | null;
|
||||
}
|
||||
void ({} as MatchedAssetExt);
|
||||
75
components/analyzer/provider-toggle.tsx
Normal file
75
components/analyzer/provider-toggle.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
'use client';
|
||||
|
||||
import { Sparkles, Zap } from 'lucide-react';
|
||||
|
||||
export type AnalyzerProvider = 'anthropic' | 'openrouter';
|
||||
|
||||
interface ProviderToggleProps {
|
||||
value: AnalyzerProvider;
|
||||
onChange: (next: AnalyzerProvider) => void;
|
||||
disabled?: boolean;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const OPTIONS: Array<{
|
||||
value: AnalyzerProvider;
|
||||
label: string;
|
||||
hint: string;
|
||||
icon: typeof Sparkles;
|
||||
}> = [
|
||||
{
|
||||
value: 'anthropic',
|
||||
label: 'Claude',
|
||||
hint: 'Haiku → Sonnet → Opus',
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
value: 'openrouter',
|
||||
label: 'DeepSeek',
|
||||
hint: 'V4 Flash → V4 Pro → R1',
|
||||
icon: Zap,
|
||||
},
|
||||
];
|
||||
|
||||
export function ProviderToggle({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
size = 'md',
|
||||
}: ProviderToggleProps) {
|
||||
const padding = size === 'sm' ? 'px-2 py-1 text-xs' : 'px-3 py-1.5 text-sm';
|
||||
return (
|
||||
<div
|
||||
className="inline-flex rounded-md border bg-muted/40 p-0.5"
|
||||
role="radiogroup"
|
||||
aria-label="LLM provider"
|
||||
>
|
||||
{OPTIONS.map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
const active = opt.value === value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(opt.value)}
|
||||
title={opt.hint}
|
||||
className={[
|
||||
padding,
|
||||
'rounded-sm flex items-center gap-1.5 transition-colors',
|
||||
active
|
||||
? 'bg-background shadow-sm font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon className={size === 'sm' ? 'w-3 h-3' : 'w-3.5 h-3.5'} />
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
368
components/analyzer/related-tickets-panel.tsx
Normal file
368
components/analyzer/related-tickets-panel.tsx
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { toast } from 'sonner';
|
||||
import { Sparkles, Loader2, Network } from 'lucide-react';
|
||||
import type {
|
||||
AggregateReportStatus,
|
||||
DiscoveredLinks,
|
||||
TicketRef,
|
||||
} from '@/lib/types/analyzer';
|
||||
|
||||
interface RelatedTicketsPanelProps {
|
||||
ticketNumber: string;
|
||||
/** LLM provider for the bundle run. Defaults to 'anthropic'. */
|
||||
provider?: 'anthropic' | 'openrouter';
|
||||
}
|
||||
|
||||
type Phase =
|
||||
| 'idle'
|
||||
| 'starting'
|
||||
| 'pending_analyses'
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'complete'
|
||||
| 'failed';
|
||||
|
||||
const REPORT_POLL_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
export function RelatedTicketsPanel({
|
||||
ticketNumber,
|
||||
provider = 'anthropic',
|
||||
}: RelatedTicketsPanelProps) {
|
||||
const router = useRouter();
|
||||
const [links, setLinks] = useState<DiscoveredLinks | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [includeSuggested, setIncludeSuggested] = useState(false);
|
||||
const [suggestionsLoading, setSuggestionsLoading] = useState(false);
|
||||
const [phase, setPhase] = useState<Phase>('idle');
|
||||
const [statusLabel, setStatusLabel] = useState<string>('');
|
||||
|
||||
// Initial cheap fetch.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/links`
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `Request failed: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as DiscoveredLinks;
|
||||
if (cancelled) return;
|
||||
setLinks(data);
|
||||
// Pre-check all explicit refs.
|
||||
setSelected(new Set(data.explicit.map((r) => r.ticket_number)));
|
||||
} catch (err) {
|
||||
if (!cancelled)
|
||||
setLoadError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [ticketNumber]);
|
||||
|
||||
async function loadSuggestions(): Promise<void> {
|
||||
if (!links) return;
|
||||
setSuggestionsLoading(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/links`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ includeSuggested: true }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `Request failed: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as DiscoveredLinks;
|
||||
setLinks(data);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? `Suggestion failed: ${err.message}`
|
||||
: 'Suggestion failed'
|
||||
);
|
||||
setIncludeSuggested(false);
|
||||
} finally {
|
||||
setSuggestionsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRef(ref: TicketRef): void {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(ref.ticket_number)) next.delete(ref.ticket_number);
|
||||
else next.add(ref.ticket_number);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function pollReport(reportId: string): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < REPORT_POLL_TIMEOUT_MS) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
const res = await fetch(`/api/analyzer/aggregate-reports/${reportId}`);
|
||||
if (!res.ok) throw new Error(`Report poll failed: ${res.status}`);
|
||||
const data = (await res.json()) as {
|
||||
report: { status: AggregateReportStatus; errorMessage: string | null };
|
||||
};
|
||||
const status = data.report.status;
|
||||
setPhase(status as Phase);
|
||||
setStatusLabel(
|
||||
status === 'pending_analyses'
|
||||
? 'Analyzing linked tickets…'
|
||||
: status === 'pending' || status === 'running'
|
||||
? 'Building bundle report…'
|
||||
: status === 'complete'
|
||||
? 'Done'
|
||||
: status === 'failed'
|
||||
? 'Failed'
|
||||
: ''
|
||||
);
|
||||
if (status === 'complete') {
|
||||
router.push(`/analyzer/reports/${reportId}`);
|
||||
return;
|
||||
}
|
||||
if (status === 'failed') {
|
||||
throw new Error(data.report.errorMessage ?? 'Bundle report failed');
|
||||
}
|
||||
}
|
||||
throw new Error('Bundle report timed out after 10 minutes');
|
||||
}
|
||||
|
||||
async function submit(opts: { confirmedCost?: boolean } = {}): Promise<void> {
|
||||
if (selected.size === 0) {
|
||||
toast.error('Select at least one linked ticket');
|
||||
return;
|
||||
}
|
||||
setPhase('starting');
|
||||
setStatusLabel('Queueing analyses…');
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/analyze-bundle`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
linkedTicketNumbers: Array.from(selected),
|
||||
includeItglueContext: true,
|
||||
confirmedCost: opts.confirmedCost ?? false,
|
||||
provider,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (res.status === 400) {
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
requiresConfirmation?: boolean;
|
||||
message?: string;
|
||||
estimatedCost?: number;
|
||||
};
|
||||
if (data.requiresConfirmation) {
|
||||
const ok = window.confirm(
|
||||
`${data.message ?? 'Confirmation required'}.\n\nEstimated cost: $${data.estimatedCost?.toFixed(2) ?? '?'}\n\nProceed?`
|
||||
);
|
||||
if (ok) {
|
||||
await submit({ confirmedCost: true });
|
||||
return;
|
||||
}
|
||||
setPhase('idle');
|
||||
setStatusLabel('');
|
||||
return;
|
||||
}
|
||||
throw new Error(data.message ?? 'Bundle request rejected');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `Request failed: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
aggregateReportId: string;
|
||||
status: AggregateReportStatus;
|
||||
};
|
||||
setPhase(data.status as Phase);
|
||||
setStatusLabel(
|
||||
data.status === 'pending_analyses'
|
||||
? 'Analyzing linked tickets…'
|
||||
: 'Building bundle report…'
|
||||
);
|
||||
await pollReport(data.aggregateReportId);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Bundle failed');
|
||||
setPhase('idle');
|
||||
setStatusLabel('');
|
||||
}
|
||||
}
|
||||
|
||||
const allRefs = useMemo<TicketRef[]>(
|
||||
() => (links ? [...links.explicit, ...links.suggested] : []),
|
||||
[links]
|
||||
);
|
||||
const isRunning = phase !== 'idle' && phase !== 'failed' && phase !== 'complete';
|
||||
const selectedCount = selected.size;
|
||||
const hasContent = links && (links.explicit.length > 0 || links.isProblemTicket);
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Couldn’t check for related tickets</AlertTitle>
|
||||
<AlertDescription>{loadError}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (links === null) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasContent) {
|
||||
// Nothing to show — render nothing, the regular AnalyzeButton on the
|
||||
// parent page is sufficient.
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={links.isProblemTicket ? 'border-primary' : ''}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="w-5 h-5" />
|
||||
<CardTitle className="text-base">
|
||||
Related tickets detected ({allRefs.length})
|
||||
</CardTitle>
|
||||
{links.isProblemTicket && (
|
||||
<Badge variant="secondary">Problem ticket</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="include-suggested"
|
||||
checked={includeSuggested}
|
||||
disabled={isRunning || suggestionsLoading}
|
||||
onCheckedChange={(v) => {
|
||||
const next = Boolean(v);
|
||||
setIncludeSuggested(next);
|
||||
if (next && links.suggested.length === 0) {
|
||||
void loadSuggestions();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="include-suggested" className="text-xs">
|
||||
{suggestionsLoading ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Asking AI…
|
||||
</span>
|
||||
) : (
|
||||
'AI-suggest more'
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{links.isProblemTicket
|
||||
? 'This looks like a problem/master ticket. Bundling will analyze every linked ticket and produce a cross-ticket report.'
|
||||
: 'This ticket references other tickets. Bundle them to get a cross-ticket analysis.'}
|
||||
</p>
|
||||
|
||||
<ul className="divide-y">
|
||||
{allRefs.map((ref) => (
|
||||
<li
|
||||
key={ref.ticket_number + ':' + ref.source}
|
||||
className="py-2 flex items-start gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
id={'rt-' + ref.ticket_number}
|
||||
checked={selected.has(ref.ticket_number)}
|
||||
onCheckedChange={() => toggleRef(ref)}
|
||||
disabled={isRunning}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono text-sm">{ref.ticket_number}</span>
|
||||
{ref.confidence === 'high' && (
|
||||
<Badge variant="default" className="text-[10px] py-0">
|
||||
explicit
|
||||
</Badge>
|
||||
)}
|
||||
{ref.source === 'llm_suggested' && (
|
||||
<Badge variant="outline" className="text-[10px] py-0">
|
||||
AI-suggested
|
||||
</Badge>
|
||||
)}
|
||||
{ref.status_label && (
|
||||
<Badge variant="secondary" className="text-[10px] py-0">
|
||||
{ref.status_label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{ref.title && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{ref.title}
|
||||
</p>
|
||||
)}
|
||||
{ref.reason && (
|
||||
<p className="text-xs text-muted-foreground italic mt-0.5">
|
||||
{ref.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap pt-2">
|
||||
<Button
|
||||
onClick={() => void submit()}
|
||||
disabled={isRunning || selectedCount === 0}
|
||||
variant={links.isProblemTicket ? 'default' : 'secondary'}
|
||||
>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{statusLabel || 'Working…'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Analyze with {selectedCount} linked ticket
|
||||
{selectedCount === 1 ? '' : 's'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({selectedCount + 1} total — master + linked)
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -14,20 +14,104 @@ import { Button } from '@/components/ui/button';
|
|||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Share2 } from 'lucide-react';
|
||||
import { Share2, Clock, Users, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ShareModalProps {
|
||||
analysisId: string;
|
||||
}
|
||||
|
||||
interface DirectoryEntry {
|
||||
email: string;
|
||||
displayName: string | null;
|
||||
jobTitle: string | null;
|
||||
department: string | null;
|
||||
}
|
||||
|
||||
interface RecentEntry {
|
||||
email: string;
|
||||
lastSharedAt: string;
|
||||
}
|
||||
|
||||
interface RecipientsResponse {
|
||||
recent: RecentEntry[];
|
||||
directory: DirectoryEntry[];
|
||||
allowedDomains: string[];
|
||||
}
|
||||
|
||||
const MAX_DIRECTORY_VISIBLE = 12;
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
const minute = 60_000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
if (ms < hour) return `${Math.max(1, Math.round(ms / minute))}m ago`;
|
||||
if (ms < day) return `${Math.round(ms / hour)}h ago`;
|
||||
return `${Math.round(ms / day)}d ago`;
|
||||
}
|
||||
|
||||
export function ShareModal({ analysisId }: ShareModalProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [recipientEmail, setRecipientEmail] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
const [recipients, setRecipients] = useState<RecipientsResponse | null>(null);
|
||||
const [recipientsError, setRecipientsError] = useState<string | null>(null);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Fetch recipients lazily on first dialog open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (recipients !== null) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/analyzer/share/recipients');
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `Request failed: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as RecipientsResponse;
|
||||
if (!cancelled) setRecipients(data);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setRecipientsError(
|
||||
err instanceof Error ? err.message : 'Unknown error'
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, recipients]);
|
||||
|
||||
const filteredDirectory = useMemo(() => {
|
||||
if (!recipients) return [];
|
||||
const q = recipientEmail.trim().toLowerCase();
|
||||
if (!q) return recipients.directory.slice(0, MAX_DIRECTORY_VISIBLE);
|
||||
return recipients.directory
|
||||
.filter(
|
||||
(d) =>
|
||||
d.email.toLowerCase().includes(q) ||
|
||||
(d.displayName ?? '').toLowerCase().includes(q)
|
||||
)
|
||||
.slice(0, MAX_DIRECTORY_VISIBLE);
|
||||
}, [recipients, recipientEmail]);
|
||||
|
||||
const hasRecent = (recipients?.recent.length ?? 0) > 0;
|
||||
|
||||
function pick(email: string): void {
|
||||
setRecipientEmail(email);
|
||||
setShowSuggestions(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
|
|
@ -66,8 +150,16 @@ export function ShareModal({ analysisId }: ShareModalProps) {
|
|||
}
|
||||
}
|
||||
|
||||
// Reset transient state when the dialog closes.
|
||||
function onOpenChange(next: boolean): void {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Share2 className="w-4 h-4 mr-2" />
|
||||
|
|
@ -78,22 +170,107 @@ export function ShareModal({ analysisId }: ShareModalProps) {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Share this analysis</DialogTitle>
|
||||
<DialogDescription>
|
||||
Recipient must be on an allowed domain (set via
|
||||
ALLOWED_SHARE_DOMAINS).
|
||||
{recipients?.allowedDomains.length
|
||||
? `Allowed domains: ${recipients.allowedDomains.join(', ')}`
|
||||
: 'Recipient must be on an allowed domain.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="recipient">Recipient email</Label>
|
||||
<Input
|
||||
id="recipient"
|
||||
type="email"
|
||||
required
|
||||
value={recipientEmail}
|
||||
onChange={(e) => setRecipientEmail(e.target.value)}
|
||||
placeholder="colleague@wulfconsulting.com"
|
||||
/>
|
||||
<Label htmlFor="recipient">Recipient</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="recipient"
|
||||
ref={inputRef}
|
||||
type="email"
|
||||
required
|
||||
autoComplete="off"
|
||||
value={recipientEmail}
|
||||
onChange={(e) => {
|
||||
setRecipientEmail(e.target.value);
|
||||
setShowSuggestions(true);
|
||||
}}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onBlur={() => {
|
||||
// Delay so a click on a suggestion lands before we hide.
|
||||
setTimeout(() => setShowSuggestions(false), 150);
|
||||
}}
|
||||
placeholder="Search teammates or type an email…"
|
||||
/>
|
||||
|
||||
{showSuggestions && recipients !== null && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-md max-h-72 overflow-auto">
|
||||
{hasRecent && recipientEmail.trim().length === 0 && (
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" /> Recent
|
||||
</div>
|
||||
)}
|
||||
{hasRecent &&
|
||||
recipientEmail.trim().length === 0 &&
|
||||
recipients.recent.slice(0, 3).map((r) => (
|
||||
<button
|
||||
key={'recent-' + r.email}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center justify-between gap-2"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
pick(r.email);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{r.email}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{timeAgo(r.lastSharedAt)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground flex items-center gap-1 border-t">
|
||||
<Users className="w-3 h-3" /> Directory
|
||||
</div>
|
||||
{filteredDirectory.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
No matching directory users.
|
||||
</div>
|
||||
) : (
|
||||
filteredDirectory.map((d) => (
|
||||
<button
|
||||
key={'dir-' + d.email}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-start justify-between gap-2"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
pick(d.email);
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">
|
||||
{d.displayName ?? d.email}
|
||||
</div>
|
||||
{d.displayName && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{d.email}
|
||||
{d.jobTitle ? ` · ${d.jobTitle}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{recipientEmail === d.email && (
|
||||
<Check className="w-4 h-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{recipientsError && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Couldn’t load directory ({recipientsError}). Type any
|
||||
allowed-domain email to share.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="note">Note (optional)</Label>
|
||||
<Textarea
|
||||
|
|
@ -105,6 +282,7 @@ export function ShareModal({ analysisId }: ShareModalProps) {
|
|||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue