vedanshmadan21 commited on
Commit
fdcac22
·
1 Parent(s): d061a75

fix: remove localhost fallback from analysis actions

Browse files
frontend/src/components/ResultsDashboard.jsx CHANGED
@@ -6,7 +6,8 @@ import ResearchTab from "./tabs/ResearchTab.jsx";
6
  import { Download, Eye, X } from "lucide-react";
7
  import { useState } from "react";
8
 
9
- const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:3001";
 
10
 
11
  const TABS = [
12
  { id: "overview", label: "Overview" },
@@ -16,28 +17,69 @@ const TABS = [
16
  { id: "research", label: "Research" },
17
  ];
18
 
19
- export default function ResultsDashboard({ result, activeTab, onTabChange, jobId, onScoreUpdate }) {
 
 
 
 
 
 
20
  const [previewUrl, setPreviewUrl] = useState(null);
21
 
22
  return (
23
  <div style={{ padding: "24px" }}>
24
  <div className="container">
25
  {/* Page header */}
26
- <div style={{ marginBottom: "24px", display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
 
 
 
 
 
 
 
27
  <div>
28
- <div className="eyebrow" style={{ marginBottom: "8px" }}>Analysis Complete</div>
29
- <h2 style={{ fontFamily: "var(--font-body)", fontWeight: 600, marginBottom: "4px" }}>
 
 
 
 
 
 
 
 
30
  {result.company_name}
31
  </h2>
32
- <p style={{ fontSize: "12px", color: "var(--text-muted)", fontFamily: "var(--font-mono)" }}>
 
 
 
 
 
 
33
  Job ID: {result.job_id}
34
  </p>
35
  </div>
36
  <div style={{ display: "flex", gap: "8px", marginTop: "8px" }}>
37
  <button
38
- onClick={() => setPreviewUrl(`${BASE_URL}/api/cam/${result.job_id}/download/pdf?preview=true`)}
 
 
 
 
39
  className="btn btn-sm"
40
- style={{ display: "flex", alignItems: "center", gap: "6px", fontSize: "12px", padding: "6px 14px", background: "var(--accent-subtle)", color: "var(--accent)", border: "1px solid rgba(59,130,246,0.3)", cursor: "pointer" }}
 
 
 
 
 
 
 
 
 
 
41
  >
42
  <Eye size={14} /> Preview PDF
43
  </button>
@@ -46,7 +88,14 @@ export default function ResultsDashboard({ result, activeTab, onTabChange, jobId
46
  target="_blank"
47
  rel="noopener noreferrer"
48
  className="btn btn-primary btn-sm"
49
- style={{ textDecoration: "none", display: "flex", alignItems: "center", gap: "6px", fontSize: "12px", padding: "6px 14px" }}
 
 
 
 
 
 
 
50
  >
51
  <Download size={14} /> CAM PDF
52
  </a>
@@ -55,7 +104,17 @@ export default function ResultsDashboard({ result, activeTab, onTabChange, jobId
55
  target="_blank"
56
  rel="noopener noreferrer"
57
  className="btn btn-sm"
58
- style={{ textDecoration: "none", display: "flex", alignItems: "center", gap: "6px", fontSize: "12px", padding: "6px 14px", background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
 
 
 
 
 
 
 
 
 
 
59
  >
60
  <Download size={14} /> DOCX
61
  </a>
@@ -78,7 +137,13 @@ export default function ResultsDashboard({ result, activeTab, onTabChange, jobId
78
  {/* Active tab content */}
79
  <div>
80
  {activeTab === "overview" && <OverviewTab result={result} />}
81
- {activeTab === "fraud" && <FraudTab fraudFeatures={result.fraud_features} result={result} onScoreUpdate={onScoreUpdate} />}
 
 
 
 
 
 
82
  {activeTab === "score" && (
83
  <ScoreTab
84
  scoreBreakdown={result.score_breakdown}
@@ -87,29 +152,71 @@ export default function ResultsDashboard({ result, activeTab, onTabChange, jobId
87
  />
88
  )}
89
  {activeTab === "graph" && (
90
- <EntityGraphTab nodes={result.entity_nodes} edges={result.entity_edges} jobId={result.job_id} />
 
 
 
 
 
 
 
91
  )}
92
- {activeTab === "research" && <ResearchTab findings={result.research_findings} />}
93
  </div>
94
  </div>
95
 
96
  {/* PDF Preview Modal */}
97
  {previewUrl && (
98
- <div style={{ position: "fixed", inset: 0, zIndex: 9999, background: "rgba(10, 10, 15, 0.9)", backdropFilter: "blur(4px)", display: "flex", flexDirection: "column", padding: "24px" }}>
99
- <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "16px", color: "var(--text-primary)" }}>
100
- <h3 style={{ fontFamily: "var(--font-body)", fontWeight: 600 }}>CAM Report Preview</h3>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  <button
102
  onClick={() => setPreviewUrl(null)}
103
  className="btn btn-sm"
104
- style={{ display: "flex", alignItems: "center", gap: "6px", background: "var(--bg-elevated)", color: "var(--text-primary)", border: "1px solid var(--border)", cursor: "pointer" }}
 
 
 
 
 
 
 
 
105
  >
106
  <X size={16} /> Close Preview
107
  </button>
108
  </div>
109
- <iframe
110
- src={previewUrl}
111
  title="PDF Preview"
112
- style={{ flex: 1, border: "1px solid var(--border)", borderRadius: "8px", background: "white", width: "100%", height: "100%" }}
 
 
 
 
 
 
 
113
  />
114
  </div>
115
  )}
 
6
  import { Download, Eye, X } from "lucide-react";
7
  import { useState } from "react";
8
 
9
+ const configuredBaseUrl = import.meta.env.VITE_API_URL?.trim();
10
+ const BASE_URL = configuredBaseUrl || "";
11
 
12
  const TABS = [
13
  { id: "overview", label: "Overview" },
 
17
  { id: "research", label: "Research" },
18
  ];
19
 
20
+ export default function ResultsDashboard({
21
+ result,
22
+ activeTab,
23
+ onTabChange,
24
+ jobId,
25
+ onScoreUpdate,
26
+ }) {
27
  const [previewUrl, setPreviewUrl] = useState(null);
28
 
29
  return (
30
  <div style={{ padding: "24px" }}>
31
  <div className="container">
32
  {/* Page header */}
33
+ <div
34
+ style={{
35
+ marginBottom: "24px",
36
+ display: "flex",
37
+ justifyContent: "space-between",
38
+ alignItems: "flex-start",
39
+ }}
40
+ >
41
  <div>
42
+ <div className="eyebrow" style={{ marginBottom: "8px" }}>
43
+ Analysis Complete
44
+ </div>
45
+ <h2
46
+ style={{
47
+ fontFamily: "var(--font-body)",
48
+ fontWeight: 600,
49
+ marginBottom: "4px",
50
+ }}
51
+ >
52
  {result.company_name}
53
  </h2>
54
+ <p
55
+ style={{
56
+ fontSize: "12px",
57
+ color: "var(--text-muted)",
58
+ fontFamily: "var(--font-mono)",
59
+ }}
60
+ >
61
  Job ID: {result.job_id}
62
  </p>
63
  </div>
64
  <div style={{ display: "flex", gap: "8px", marginTop: "8px" }}>
65
  <button
66
+ onClick={() =>
67
+ setPreviewUrl(
68
+ `${BASE_URL}/api/cam/${result.job_id}/download/pdf?preview=true`,
69
+ )
70
+ }
71
  className="btn btn-sm"
72
+ style={{
73
+ display: "flex",
74
+ alignItems: "center",
75
+ gap: "6px",
76
+ fontSize: "12px",
77
+ padding: "6px 14px",
78
+ background: "var(--accent-subtle)",
79
+ color: "var(--accent)",
80
+ border: "1px solid rgba(59,130,246,0.3)",
81
+ cursor: "pointer",
82
+ }}
83
  >
84
  <Eye size={14} /> Preview PDF
85
  </button>
 
88
  target="_blank"
89
  rel="noopener noreferrer"
90
  className="btn btn-primary btn-sm"
91
+ style={{
92
+ textDecoration: "none",
93
+ display: "flex",
94
+ alignItems: "center",
95
+ gap: "6px",
96
+ fontSize: "12px",
97
+ padding: "6px 14px",
98
+ }}
99
  >
100
  <Download size={14} /> CAM PDF
101
  </a>
 
104
  target="_blank"
105
  rel="noopener noreferrer"
106
  className="btn btn-sm"
107
+ style={{
108
+ textDecoration: "none",
109
+ display: "flex",
110
+ alignItems: "center",
111
+ gap: "6px",
112
+ fontSize: "12px",
113
+ padding: "6px 14px",
114
+ background: "var(--bg-elevated)",
115
+ border: "1px solid var(--border)",
116
+ color: "var(--text-secondary)",
117
+ }}
118
  >
119
  <Download size={14} /> DOCX
120
  </a>
 
137
  {/* Active tab content */}
138
  <div>
139
  {activeTab === "overview" && <OverviewTab result={result} />}
140
+ {activeTab === "fraud" && (
141
+ <FraudTab
142
+ fraudFeatures={result.fraud_features}
143
+ result={result}
144
+ onScoreUpdate={onScoreUpdate}
145
+ />
146
+ )}
147
  {activeTab === "score" && (
148
  <ScoreTab
149
  scoreBreakdown={result.score_breakdown}
 
152
  />
153
  )}
154
  {activeTab === "graph" && (
155
+ <EntityGraphTab
156
+ nodes={result.entity_nodes}
157
+ edges={result.entity_edges}
158
+ jobId={result.job_id}
159
+ />
160
+ )}
161
+ {activeTab === "research" && (
162
+ <ResearchTab findings={result.research_findings} />
163
  )}
 
164
  </div>
165
  </div>
166
 
167
  {/* PDF Preview Modal */}
168
  {previewUrl && (
169
+ <div
170
+ style={{
171
+ position: "fixed",
172
+ inset: 0,
173
+ zIndex: 9999,
174
+ background: "rgba(10, 10, 15, 0.9)",
175
+ backdropFilter: "blur(4px)",
176
+ display: "flex",
177
+ flexDirection: "column",
178
+ padding: "24px",
179
+ }}
180
+ >
181
+ <div
182
+ style={{
183
+ display: "flex",
184
+ justifyContent: "space-between",
185
+ alignItems: "center",
186
+ marginBottom: "16px",
187
+ color: "var(--text-primary)",
188
+ }}
189
+ >
190
+ <h3 style={{ fontFamily: "var(--font-body)", fontWeight: 600 }}>
191
+ CAM Report Preview
192
+ </h3>
193
  <button
194
  onClick={() => setPreviewUrl(null)}
195
  className="btn btn-sm"
196
+ style={{
197
+ display: "flex",
198
+ alignItems: "center",
199
+ gap: "6px",
200
+ background: "var(--bg-elevated)",
201
+ color: "var(--text-primary)",
202
+ border: "1px solid var(--border)",
203
+ cursor: "pointer",
204
+ }}
205
  >
206
  <X size={16} /> Close Preview
207
  </button>
208
  </div>
209
+ <iframe
210
+ src={previewUrl}
211
  title="PDF Preview"
212
+ style={{
213
+ flex: 1,
214
+ border: "1px solid var(--border)",
215
+ borderRadius: "8px",
216
+ background: "white",
217
+ width: "100%",
218
+ height: "100%",
219
+ }}
220
  />
221
  </div>
222
  )}
frontend/src/components/tabs/EntityGraphTab.jsx CHANGED
@@ -2,7 +2,8 @@ import { useState, useCallback, useEffect, useRef } from "react";
2
  import ForceGraph2D from "react-force-graph-2d";
3
  import { X } from "lucide-react";
4
 
5
- const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:3001";
 
6
 
7
  function getNodeColor(type, riskLevel) {
8
  if (riskLevel === "HIGH") return "#ef4444";
@@ -23,16 +24,40 @@ function LegendDot({ color, label, dashed }) {
23
  return (
24
  <div className="flex items-center gap-sm">
25
  {dashed ? (
26
- <svg width="24" height="8"><line x1="0" y1="4" x2="24" y2="4" stroke={color} strokeWidth="2" strokeDasharray="4 3" /></svg>
 
 
 
 
 
 
 
 
 
 
27
  ) : (
28
- <div style={{ width: "12px", height: "12px", borderRadius: "50%", background: color, flexShrink: 0 }} />
 
 
 
 
 
 
 
 
29
  )}
30
- <span style={{ color: "var(--text-muted)", fontSize: "12px" }}>{label}</span>
 
 
31
  </div>
32
  );
33
  }
34
 
35
- export default function EntityGraphTab({ nodes: propNodes, edges: propEdges, jobId }) {
 
 
 
 
36
  const [fetchedNodes, setFetchedNodes] = useState(null);
37
  const [fetchedEdges, setFetchedEdges] = useState(null);
38
  const [loading, setLoading] = useState(false);
@@ -49,7 +74,7 @@ export default function EntityGraphTab({ nodes: propNodes, edges: propEdges, job
49
  let cancelled = false;
50
  setLoading(true);
51
  fetch(`${BASE_URL}/api/analysis/${encodeURIComponent(jobId)}/entity-graph`)
52
- .then((res) => res.ok ? res.json() : null)
53
  .then((data) => {
54
  if (cancelled || !data) return;
55
  if (data.nodes && data.nodes.length > 0) {
@@ -58,34 +83,70 @@ export default function EntityGraphTab({ nodes: propNodes, edges: propEdges, job
58
  }
59
  })
60
  .catch(() => {})
61
- .finally(() => { if (!cancelled) setLoading(false); });
62
- return () => { cancelled = true; };
 
 
 
 
63
  }, [hasPropsData, jobId]);
64
 
65
  const hasProbableMatch = (edges || []).some((e) => e.is_probable_match);
66
  const hasHighRisk = (nodes || []).some((n) => n.risk_level === "HIGH");
67
- const historicalNodes = (nodes || []).filter((n) => n.historical_match != null && n.historical_match !== undefined && n.historical_match !== false);
 
 
 
 
 
68
 
69
  if (loading) {
70
  return (
71
- <div className="flex flex-col items-center justify-center" style={{ padding: "80px 24px", gap: "12px" }}>
72
- <p style={{ fontSize: "15px", fontWeight: 500 }}>Loading entity graph…</p>
 
 
 
 
 
73
  </div>
74
  );
75
  }
76
 
77
  if (!nodes || nodes.length === 0) {
78
  return (
79
- <div className="flex flex-col items-center justify-center" style={{ padding: "80px 24px", gap: "12px" }}>
80
- <p style={{ fontSize: "15px", fontWeight: 500 }}>No entity relationships detected in uploaded documents.</p>
81
- <p style={{ color: "var(--text-muted)", fontSize: "13px" }}>Entity graph is built from promoter names, related companies, and loan cross-references extracted via NER.</p>
 
 
 
 
 
 
 
 
82
  </div>
83
  );
84
  }
85
 
86
  const graphData = {
87
- nodes: nodes.map((n) => ({ id: n.id, name: n.name, type: n.type, risk_level: n.risk_level, historical_match: n.historical_match, color: getNodeColor(n.type, n.risk_level) })),
88
- links: edges.map((e) => ({ source: e.source, target: e.target, relationship: e.relationship, amount_crore: e.amount_crore, is_probable_match: e.is_probable_match, color: e.is_probable_match ? "#eab308" : "rgba(255,255,255,0.08)" })),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  };
90
 
91
  const handleNodeClick = useCallback((node) => {
@@ -102,26 +163,81 @@ export default function EntityGraphTab({ nodes: propNodes, edges: propEdges, job
102
  <div className="flex flex-col gap-md">
103
  {/* Alert banners */}
104
  {hasProbableMatch && (
105
- <div className="card" style={{ background: "var(--warning-subtle)", borderColor: "rgba(234,179,8,0.3)" }}>
106
- <p style={{ color: "var(--warning)", fontSize: "13px" }}>⚠ Fuzzy-matched entities detected. Manual verification recommended.</p>
 
 
 
 
 
 
 
 
107
  </div>
108
  )}
109
  {hasHighRisk && (
110
- <div className="card" style={{ background: "var(--danger-subtle)", borderColor: "rgba(239,68,68,0.3)" }}>
111
- <p style={{ color: "var(--danger)", fontSize: "13px" }}>🚩 Related-party anomaly detected. Possible shell company or fund siphoning risk.</p>
 
 
 
 
 
 
 
 
 
112
  </div>
113
  )}
114
  {historicalNodes.map((node) => (
115
- <div key={node.id} className="card" style={{ background: "var(--danger-subtle)", borderColor: "var(--danger)" }}>
116
- <p style={{ color: "var(--danger)", fontSize: "13px", fontWeight: 700, marginBottom: "4px" }}>⚠ HISTORICAL MATCH DETECTED</p>
117
- <p style={{ color: "var(--danger)", fontSize: "12px" }}>Director DIN {node.id} appeared in a previously rejected application. Escalate.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  </div>
119
  ))}
120
 
121
  {/* Graph + sidebar */}
122
  <div className="flex gap-md">
123
- <div className="card" style={{ flex: 1, overflow: "hidden", padding: 0, background: "linear-gradient(135deg, #1a1a1f 0%, #1e1e28 50%, #1a1a22 100%)", position: "relative" }}>
124
- <div style={{ position: "absolute", inset: 0, opacity: 0.04, background: "radial-gradient(circle at 50% 50%, #3b82f6 0%, transparent 60%)", pointerEvents: "none" }} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  <ForceGraph2D
126
  ref={graphRef}
127
  graphData={graphData}
@@ -138,8 +254,9 @@ export default function EntityGraphTab({ nodes: propNodes, edges: propEdges, job
138
  d3AlphaDecay={0.02}
139
  d3VelocityDecay={0.3}
140
  nodeCanvasObject={(node, ctx, globalScale) => {
141
- if (typeof node.x !== "number" || typeof node.y !== "number") return;
142
-
 
143
  const r = node.risk_level === "HIGH" ? 10 : 7;
144
  const isHover = hoverNode && hoverNode.id === node.id;
145
  const isSel = selectedNode && selectedNode.id === node.id;
@@ -148,7 +265,14 @@ export default function EntityGraphTab({ nodes: propNodes, edges: propEdges, job
148
  // Outer glow
149
  ctx.beginPath();
150
  ctx.arc(node.x, node.y, r + (isHover ? 8 : 4), 0, 2 * Math.PI);
151
- const grad = ctx.createRadialGradient(node.x, node.y, r, node.x, node.y, r + (isHover ? 14 : 6));
 
 
 
 
 
 
 
152
  grad.addColorStop(0, color + (isHover ? "55" : "25"));
153
  grad.addColorStop(1, color + "00");
154
  ctx.fillStyle = grad;
@@ -166,7 +290,14 @@ export default function EntityGraphTab({ nodes: propNodes, edges: propEdges, job
166
  // Main circle
167
  ctx.beginPath();
168
  ctx.arc(node.x, node.y, r, 0, 2 * Math.PI);
169
- const cGrad = ctx.createRadialGradient(node.x - r * 0.3, node.y - r * 0.3, r * 0.1, node.x, node.y, r);
 
 
 
 
 
 
 
170
  cGrad.addColorStop(0, color + "ff");
171
  cGrad.addColorStop(1, color + "aa");
172
  ctx.fillStyle = cGrad;
@@ -202,7 +333,15 @@ export default function EntityGraphTab({ nodes: propNodes, edges: propEdges, job
202
  linkCanvasObject={(link, ctx) => {
203
  const start = link.source;
204
  const end = link.target;
205
- if (!start || !end || typeof start.x !== "number" || typeof start.y !== "number" || typeof end.x !== "number" || typeof end.y !== "number") return;
 
 
 
 
 
 
 
 
206
 
207
  ctx.beginPath();
208
  ctx.moveTo(start.x, start.y);
@@ -214,7 +353,12 @@ export default function EntityGraphTab({ nodes: propNodes, edges: propEdges, job
214
  ctx.lineWidth = 1.5;
215
  } else {
216
  ctx.setLineDash([]);
217
- const grad = ctx.createLinearGradient(start.x, start.y, end.x, end.y);
 
 
 
 
 
218
  grad.addColorStop(0, (start.color || "#3b82f6") + "55");
219
  grad.addColorStop(0.5, "rgba(255,255,255,0.12)");
220
  grad.addColorStop(1, (end.color || "#3b82f6") + "55");
@@ -239,30 +383,68 @@ export default function EntityGraphTab({ nodes: propNodes, edges: propEdges, job
239
  </div>
240
 
241
  {selectedNode && (
242
- <div className="card shrink-0" style={{ width: "220px", display: "flex", flexDirection: "column", gap: "12px" }}>
 
 
 
 
 
 
 
 
243
  <div className="flex justify-between items-center">
244
  <span className="label">Node Detail</span>
245
- <button onClick={() => setSelectedNode(null)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)" }}>
 
 
 
 
 
 
 
 
246
  <X size={14} />
247
  </button>
248
  </div>
249
  <div className="flex flex-col gap-sm">
250
  <div>
251
- <p className="label" style={{ marginBottom: "2px" }}>Name</p>
252
- <p style={{ fontSize: "13px", fontWeight: 600, wordBreak: "break-word" }}>{selectedNode.name}</p>
 
 
 
 
 
 
 
 
 
 
253
  </div>
254
  <div>
255
- <p className="label" style={{ marginBottom: "2px" }}>Type</p>
256
- <p style={{ fontSize: "13px", textTransform: "capitalize" }}>{selectedNode.type}</p>
 
 
 
 
257
  </div>
258
  <div>
259
- <p className="label" style={{ marginBottom: "4px" }}>Risk Level</p>
260
- <span className={`badge ${riskBadge(selectedNode.risk_level)}`}>{selectedNode.risk_level}</span>
 
 
 
 
261
  </div>
262
  {selectedNode.historical_match && (
263
  <div>
264
- <p className="label" style={{ marginBottom: "2px" }}>Historical Match</p>
265
- <p style={{ color: "var(--danger)", fontSize: "12px" }}>Yes</p>
 
 
 
 
266
  </div>
267
  )}
268
  </div>
@@ -272,7 +454,12 @@ export default function EntityGraphTab({ nodes: propNodes, edges: propEdges, job
272
 
273
  {/* Legend */}
274
  <div className="card">
275
- <span className="label" style={{ display: "block", marginBottom: "12px" }}>Legend</span>
 
 
 
 
 
276
  <div className="flex flex-wrap gap-lg">
277
  <LegendDot color="#3b82f6" label="Person (Promoter / Director)" />
278
  <LegendDot color="#8b5cf6" label="Company" />
 
2
  import ForceGraph2D from "react-force-graph-2d";
3
  import { X } from "lucide-react";
4
 
5
+ const configuredBaseUrl = import.meta.env.VITE_API_URL?.trim();
6
+ const BASE_URL = configuredBaseUrl || "";
7
 
8
  function getNodeColor(type, riskLevel) {
9
  if (riskLevel === "HIGH") return "#ef4444";
 
24
  return (
25
  <div className="flex items-center gap-sm">
26
  {dashed ? (
27
+ <svg width="24" height="8">
28
+ <line
29
+ x1="0"
30
+ y1="4"
31
+ x2="24"
32
+ y2="4"
33
+ stroke={color}
34
+ strokeWidth="2"
35
+ strokeDasharray="4 3"
36
+ />
37
+ </svg>
38
  ) : (
39
+ <div
40
+ style={{
41
+ width: "12px",
42
+ height: "12px",
43
+ borderRadius: "50%",
44
+ background: color,
45
+ flexShrink: 0,
46
+ }}
47
+ />
48
  )}
49
+ <span style={{ color: "var(--text-muted)", fontSize: "12px" }}>
50
+ {label}
51
+ </span>
52
  </div>
53
  );
54
  }
55
 
56
+ export default function EntityGraphTab({
57
+ nodes: propNodes,
58
+ edges: propEdges,
59
+ jobId,
60
+ }) {
61
  const [fetchedNodes, setFetchedNodes] = useState(null);
62
  const [fetchedEdges, setFetchedEdges] = useState(null);
63
  const [loading, setLoading] = useState(false);
 
74
  let cancelled = false;
75
  setLoading(true);
76
  fetch(`${BASE_URL}/api/analysis/${encodeURIComponent(jobId)}/entity-graph`)
77
+ .then((res) => (res.ok ? res.json() : null))
78
  .then((data) => {
79
  if (cancelled || !data) return;
80
  if (data.nodes && data.nodes.length > 0) {
 
83
  }
84
  })
85
  .catch(() => {})
86
+ .finally(() => {
87
+ if (!cancelled) setLoading(false);
88
+ });
89
+ return () => {
90
+ cancelled = true;
91
+ };
92
  }, [hasPropsData, jobId]);
93
 
94
  const hasProbableMatch = (edges || []).some((e) => e.is_probable_match);
95
  const hasHighRisk = (nodes || []).some((n) => n.risk_level === "HIGH");
96
+ const historicalNodes = (nodes || []).filter(
97
+ (n) =>
98
+ n.historical_match != null &&
99
+ n.historical_match !== undefined &&
100
+ n.historical_match !== false,
101
+ );
102
 
103
  if (loading) {
104
  return (
105
+ <div
106
+ className="flex flex-col items-center justify-center"
107
+ style={{ padding: "80px 24px", gap: "12px" }}
108
+ >
109
+ <p style={{ fontSize: "15px", fontWeight: 500 }}>
110
+ Loading entity graph…
111
+ </p>
112
  </div>
113
  );
114
  }
115
 
116
  if (!nodes || nodes.length === 0) {
117
  return (
118
+ <div
119
+ className="flex flex-col items-center justify-center"
120
+ style={{ padding: "80px 24px", gap: "12px" }}
121
+ >
122
+ <p style={{ fontSize: "15px", fontWeight: 500 }}>
123
+ No entity relationships detected in uploaded documents.
124
+ </p>
125
+ <p style={{ color: "var(--text-muted)", fontSize: "13px" }}>
126
+ Entity graph is built from promoter names, related companies, and loan
127
+ cross-references extracted via NER.
128
+ </p>
129
  </div>
130
  );
131
  }
132
 
133
  const graphData = {
134
+ nodes: nodes.map((n) => ({
135
+ id: n.id,
136
+ name: n.name,
137
+ type: n.type,
138
+ risk_level: n.risk_level,
139
+ historical_match: n.historical_match,
140
+ color: getNodeColor(n.type, n.risk_level),
141
+ })),
142
+ links: edges.map((e) => ({
143
+ source: e.source,
144
+ target: e.target,
145
+ relationship: e.relationship,
146
+ amount_crore: e.amount_crore,
147
+ is_probable_match: e.is_probable_match,
148
+ color: e.is_probable_match ? "#eab308" : "rgba(255,255,255,0.08)",
149
+ })),
150
  };
151
 
152
  const handleNodeClick = useCallback((node) => {
 
163
  <div className="flex flex-col gap-md">
164
  {/* Alert banners */}
165
  {hasProbableMatch && (
166
+ <div
167
+ className="card"
168
+ style={{
169
+ background: "var(--warning-subtle)",
170
+ borderColor: "rgba(234,179,8,0.3)",
171
+ }}
172
+ >
173
+ <p style={{ color: "var(--warning)", fontSize: "13px" }}>
174
+ ⚠ Fuzzy-matched entities detected. Manual verification recommended.
175
+ </p>
176
  </div>
177
  )}
178
  {hasHighRisk && (
179
+ <div
180
+ className="card"
181
+ style={{
182
+ background: "var(--danger-subtle)",
183
+ borderColor: "rgba(239,68,68,0.3)",
184
+ }}
185
+ >
186
+ <p style={{ color: "var(--danger)", fontSize: "13px" }}>
187
+ 🚩 Related-party anomaly detected. Possible shell company or fund
188
+ siphoning risk.
189
+ </p>
190
  </div>
191
  )}
192
  {historicalNodes.map((node) => (
193
+ <div
194
+ key={node.id}
195
+ className="card"
196
+ style={{
197
+ background: "var(--danger-subtle)",
198
+ borderColor: "var(--danger)",
199
+ }}
200
+ >
201
+ <p
202
+ style={{
203
+ color: "var(--danger)",
204
+ fontSize: "13px",
205
+ fontWeight: 700,
206
+ marginBottom: "4px",
207
+ }}
208
+ >
209
+ ⚠ HISTORICAL MATCH DETECTED
210
+ </p>
211
+ <p style={{ color: "var(--danger)", fontSize: "12px" }}>
212
+ Director DIN {node.id} appeared in a previously rejected
213
+ application. Escalate.
214
+ </p>
215
  </div>
216
  ))}
217
 
218
  {/* Graph + sidebar */}
219
  <div className="flex gap-md">
220
+ <div
221
+ className="card"
222
+ style={{
223
+ flex: 1,
224
+ overflow: "hidden",
225
+ padding: 0,
226
+ background:
227
+ "linear-gradient(135deg, #1a1a1f 0%, #1e1e28 50%, #1a1a22 100%)",
228
+ position: "relative",
229
+ }}
230
+ >
231
+ <div
232
+ style={{
233
+ position: "absolute",
234
+ inset: 0,
235
+ opacity: 0.04,
236
+ background:
237
+ "radial-gradient(circle at 50% 50%, #3b82f6 0%, transparent 60%)",
238
+ pointerEvents: "none",
239
+ }}
240
+ />
241
  <ForceGraph2D
242
  ref={graphRef}
243
  graphData={graphData}
 
254
  d3AlphaDecay={0.02}
255
  d3VelocityDecay={0.3}
256
  nodeCanvasObject={(node, ctx, globalScale) => {
257
+ if (typeof node.x !== "number" || typeof node.y !== "number")
258
+ return;
259
+
260
  const r = node.risk_level === "HIGH" ? 10 : 7;
261
  const isHover = hoverNode && hoverNode.id === node.id;
262
  const isSel = selectedNode && selectedNode.id === node.id;
 
265
  // Outer glow
266
  ctx.beginPath();
267
  ctx.arc(node.x, node.y, r + (isHover ? 8 : 4), 0, 2 * Math.PI);
268
+ const grad = ctx.createRadialGradient(
269
+ node.x,
270
+ node.y,
271
+ r,
272
+ node.x,
273
+ node.y,
274
+ r + (isHover ? 14 : 6),
275
+ );
276
  grad.addColorStop(0, color + (isHover ? "55" : "25"));
277
  grad.addColorStop(1, color + "00");
278
  ctx.fillStyle = grad;
 
290
  // Main circle
291
  ctx.beginPath();
292
  ctx.arc(node.x, node.y, r, 0, 2 * Math.PI);
293
+ const cGrad = ctx.createRadialGradient(
294
+ node.x - r * 0.3,
295
+ node.y - r * 0.3,
296
+ r * 0.1,
297
+ node.x,
298
+ node.y,
299
+ r,
300
+ );
301
  cGrad.addColorStop(0, color + "ff");
302
  cGrad.addColorStop(1, color + "aa");
303
  ctx.fillStyle = cGrad;
 
333
  linkCanvasObject={(link, ctx) => {
334
  const start = link.source;
335
  const end = link.target;
336
+ if (
337
+ !start ||
338
+ !end ||
339
+ typeof start.x !== "number" ||
340
+ typeof start.y !== "number" ||
341
+ typeof end.x !== "number" ||
342
+ typeof end.y !== "number"
343
+ )
344
+ return;
345
 
346
  ctx.beginPath();
347
  ctx.moveTo(start.x, start.y);
 
353
  ctx.lineWidth = 1.5;
354
  } else {
355
  ctx.setLineDash([]);
356
+ const grad = ctx.createLinearGradient(
357
+ start.x,
358
+ start.y,
359
+ end.x,
360
+ end.y,
361
+ );
362
  grad.addColorStop(0, (start.color || "#3b82f6") + "55");
363
  grad.addColorStop(0.5, "rgba(255,255,255,0.12)");
364
  grad.addColorStop(1, (end.color || "#3b82f6") + "55");
 
383
  </div>
384
 
385
  {selectedNode && (
386
+ <div
387
+ className="card shrink-0"
388
+ style={{
389
+ width: "220px",
390
+ display: "flex",
391
+ flexDirection: "column",
392
+ gap: "12px",
393
+ }}
394
+ >
395
  <div className="flex justify-between items-center">
396
  <span className="label">Node Detail</span>
397
+ <button
398
+ onClick={() => setSelectedNode(null)}
399
+ style={{
400
+ background: "none",
401
+ border: "none",
402
+ cursor: "pointer",
403
+ color: "var(--text-muted)",
404
+ }}
405
+ >
406
  <X size={14} />
407
  </button>
408
  </div>
409
  <div className="flex flex-col gap-sm">
410
  <div>
411
+ <p className="label" style={{ marginBottom: "2px" }}>
412
+ Name
413
+ </p>
414
+ <p
415
+ style={{
416
+ fontSize: "13px",
417
+ fontWeight: 600,
418
+ wordBreak: "break-word",
419
+ }}
420
+ >
421
+ {selectedNode.name}
422
+ </p>
423
  </div>
424
  <div>
425
+ <p className="label" style={{ marginBottom: "2px" }}>
426
+ Type
427
+ </p>
428
+ <p style={{ fontSize: "13px", textTransform: "capitalize" }}>
429
+ {selectedNode.type}
430
+ </p>
431
  </div>
432
  <div>
433
+ <p className="label" style={{ marginBottom: "4px" }}>
434
+ Risk Level
435
+ </p>
436
+ <span className={`badge ${riskBadge(selectedNode.risk_level)}`}>
437
+ {selectedNode.risk_level}
438
+ </span>
439
  </div>
440
  {selectedNode.historical_match && (
441
  <div>
442
+ <p className="label" style={{ marginBottom: "2px" }}>
443
+ Historical Match
444
+ </p>
445
+ <p style={{ color: "var(--danger)", fontSize: "12px" }}>
446
+ Yes
447
+ </p>
448
  </div>
449
  )}
450
  </div>
 
454
 
455
  {/* Legend */}
456
  <div className="card">
457
+ <span
458
+ className="label"
459
+ style={{ display: "block", marginBottom: "12px" }}
460
+ >
461
+ Legend
462
+ </span>
463
  <div className="flex flex-wrap gap-lg">
464
  <LegendDot color="#3b82f6" label="Person (Promoter / Director)" />
465
  <LegendDot color="#8b5cf6" label="Company" />