/** * CardDetailModal — Full CARD ownership detail view * * Opens from the CARD tooltip "Actions" button on the reporting page. * Shows the full ownership record and allows confirm/decline/redirect * directly against the CARD API (no queue item required). */ import React, { useState, useEffect, useCallback } from 'react'; import { X, Loader, AlertCircle, CheckCircle, XCircle, ArrowRightLeft, ExternalLink } from 'lucide-react'; // ⚠️ CONVENTION: Prefer using REACT_APP_API_BASE without an absolute URL fallback — other components use relative paths via the env var (e.g. '' default) rather than hardcoding http://localhost:3001/api const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const OVERLAY = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)', zIndex: 10200, display: 'flex', alignItems: 'center', justifyContent: 'center', }; const MODAL = { background: 'linear-gradient(135deg, #1E293B, #0F172A)', borderRadius: '1rem', border: '1px solid rgba(124, 58, 237, 0.25)', width: '90vw', maxWidth: '580px', maxHeight: '85vh', overflow: 'auto', padding: '1.5rem', position: 'relative', }; const SECTION = { background: 'rgba(15, 23, 42, 0.6)', border: '1px solid rgba(51, 65, 85, 0.5)', borderRadius: '0.5rem', padding: '0.75rem', marginBottom: '0.75rem', }; const LABEL = { fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.2rem' }; const VALUE = { fontSize: '0.75rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" }; const TEAM_BADGE = (color) => ({ display: 'inline-block', padding: '0.15rem 0.5rem', borderRadius: '0.25rem', fontSize: '0.7rem', fontWeight: '600', fontFamily: 'monospace', background: `${color}15`, border: `1px solid ${color}40`, color, }); const INPUT = { width: '100%', boxSizing: 'border-box', background: 'rgba(15, 23, 42, 0.8)', border: '1px solid rgba(51, 65, 85, 0.6)', borderRadius: '0.375rem', color: '#E2E8F0', padding: '0.5rem 0.75rem', fontSize: '0.75rem', fontFamily: "'JetBrains Mono', monospace", outline: 'none', }; const BTN = { padding: '0.5rem 1.25rem', borderRadius: '0.375rem', border: 'none', fontSize: '0.75rem', fontWeight: '600', cursor: 'pointer', transition: 'all 0.12s', }; export default function CardDetailModal({ isOpen, onClose, ip, ownerData: initialOwnerData, cardTeams }) { const [ownerData, setOwnerData] = useState(initialOwnerData || null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [action, setAction] = useState('confirm'); const [teamName, setTeamName] = useState(''); const [fromTeam, setFromTeam] = useState(''); const [toTeam, setToTeam] = useState(''); const [comment, setComment] = useState(''); const [executing, setExecuting] = useState(false); const [execError, setExecError] = useState(null); const [execSuccess, setExecSuccess] = useState(null); // Fetch owner data if not provided or refresh on open useEffect(() => { if (!isOpen || !ip) return; // If we already have data from the tooltip cache, use it if (initialOwnerData && !initialOwnerData.notFound && !initialOwnerData.error) { setOwnerData(initialOwnerData); // Pre-fill team fields if (initialOwnerData.confirmed) { setTeamName(initialOwnerData.confirmed.name || ''); setFromTeam(initialOwnerData.confirmed.name || ''); } else if (initialOwnerData.unconfirmed) { setTeamName(initialOwnerData.unconfirmed.name || ''); setFromTeam(initialOwnerData.unconfirmed.name || ''); } return; } // Fetch fresh setLoading(true); setError(null); fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(ip)}`, { credentials: 'include' }) .then(r => { if (!r.ok) return r.json().then(d => { throw new Error(d.error || `HTTP ${r.status}`); }); return r.json(); }) .then(data => { setOwnerData(data); if (data.confirmed) { setTeamName(data.confirmed.name || ''); setFromTeam(data.confirmed.name || ''); } else if (data.unconfirmed) { setTeamName(data.unconfirmed.name || ''); setFromTeam(data.unconfirmed.name || ''); } setLoading(false); }) .catch(err => { setError(err.message); setLoading(false); }); }, [isOpen, ip, initialOwnerData]); // Reset state on close useEffect(() => { if (!isOpen) { setExecError(null); setExecSuccess(null); setComment(''); } }, [isOpen]); const handleExecute = useCallback(async () => { if (!ownerData?.asset_id) return; setExecuting(true); setExecError(null); setExecSuccess(null); try { let url, body; const assetId = ownerData.asset_id; if (action === 'confirm') { url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/confirm`; body = { teamName: teamName.trim(), comment: comment.trim() }; } else if (action === 'decline') { url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/decline`; body = { teamName: teamName.trim(), comment: comment.trim() }; } else if (action === 'redirect') { url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/redirect`; body = { fromTeam: fromTeam.trim(), toTeam: toTeam.trim() }; } const res = await fetch(url, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) { setExecError(data.error || data.message || `${action} failed.`); } else { setExecSuccess(`${action.charAt(0).toUpperCase() + action.slice(1)} successful.`); } } catch (err) { setExecError(err.message || 'Network error.'); } finally { setExecuting(false); } }, [ownerData, action, teamName, fromTeam, toTeam, comment]); if (!isOpen) return null; const canExecute = () => { if (action === 'confirm' || action === 'decline') return teamName.trim().length > 0; if (action === 'redirect') return fromTeam.trim().length > 0 && toTeam.trim().length > 0; return false; }; return (