@decido/plugin-chameleon 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +13 -0
- package/package.json +47 -0
- package/src/App.tsx +116 -0
- package/src/components/DatawayDashboard.tsx +152 -0
- package/src/components/DatawayFilterModal.tsx +132 -0
- package/src/components/DatawayMathModal.tsx +120 -0
- package/src/components/DatawayOrchestratorCanvas.tsx +345 -0
- package/src/components/DatawayPipelineManager.tsx +227 -0
- package/src/components/DatawaySchemaMapper.tsx +291 -0
- package/src/components/FormulaBar.tsx +242 -0
- package/src/hooks/useSocketSync.ts +104 -0
- package/src/index.css +18 -0
- package/src/index.ts +121 -0
- package/src/logic/rules.ts +39 -0
- package/src/logic/workflowGuard.ts +45 -0
- package/src/main.tsx +10 -0
- package/src/services/gemini.ts +110 -0
- package/src/store/datawayStore.ts +26 -0
- package/src/stores/authStore.ts +26 -0
- package/src/stores/orderStore.ts +263 -0
- package/src/stores/uiStore.ts +19 -0
- package/src/types.ts +39 -0
- package/src/useAgent.ts +89 -0
- package/src/utils/sounds.ts +52 -0
- package/src/views/DatabaseAdminView.tsx +707 -0
- package/src/views/DatawayStudioView.tsx +959 -0
- package/src/views/DiscoveryAdminView.tsx +59 -0
- package/src/views/KuspideDashboardView.tsx +144 -0
- package/src/views/LoginView.tsx +122 -0
- package/src/views/MadefrontChatView.tsx +174 -0
- package/src/views/MadefrontExcelView.tsx +95 -0
- package/src/views/MadefrontKanbanView.tsx +292 -0
- package/src/views/roles/CajaView.tsx +76 -0
- package/src/views/roles/DespachoView.tsx +100 -0
- package/src/views/roles/PlantaView.tsx +83 -0
- package/src/views/roles/VentasView.tsx +101 -0
- package/src/views/roles/WorkflowAdminView.tsx +62 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { toast } from 'sonner';
|
|
4
|
+
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
5
|
+
import {
|
|
6
|
+
Database, TableProperties, RefreshCw, X,
|
|
7
|
+
Search, ServerCrash, FunctionSquare, LayoutGrid,
|
|
8
|
+
ChevronRight, Loader2, ArrowUpDown, Server, Download,
|
|
9
|
+
Layers, Plus, Trash2, ArrowRight, BarChart3, Table2,
|
|
10
|
+
Save, Link, ChevronDown, Filter, Edit2, Network, Clock
|
|
11
|
+
} from 'lucide-react';
|
|
12
|
+
import { FormulaBar } from '../components/FormulaBar';
|
|
13
|
+
import { DatawayDashboard } from '../components/DatawayDashboard';
|
|
14
|
+
import { DatawayFilterModal } from '../components/DatawayFilterModal';
|
|
15
|
+
import { DatawayMathModal } from '../components/DatawayMathModal';
|
|
16
|
+
import { DatawayPipelineManager } from '../components/DatawayPipelineManager';
|
|
17
|
+
import { DatawaySchemaMapper } from '../components/DatawaySchemaMapper';
|
|
18
|
+
import { DatawayOrchestratorCanvas } from '../components/DatawayOrchestratorCanvas';
|
|
19
|
+
|
|
20
|
+
interface SortConfig {
|
|
21
|
+
key: string;
|
|
22
|
+
direction: 'asc' | 'desc';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface PipelineStep {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
script: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const baseUrl = (import.meta as any).env?.VITE_API_BASE_URL || 'http://localhost:3001';
|
|
32
|
+
|
|
33
|
+
export const DatawayStudioView = () => {
|
|
34
|
+
// Dynamic Schema & Tables State
|
|
35
|
+
interface SecondaryResource {
|
|
36
|
+
connectorId: string;
|
|
37
|
+
resource: string;
|
|
38
|
+
boundName: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const [connectors] = useState<{id: string, name: string}[]>([
|
|
42
|
+
{ id: 'postgres', name: 'PostgreSQL Core' },
|
|
43
|
+
{ id: 'google_sheets', name: 'Google Sheets MCP' }
|
|
44
|
+
]);
|
|
45
|
+
const [activeConnector, setActiveConnector] = useState('google_sheets');
|
|
46
|
+
const [tables, setTables] = useState<string[]>([]);
|
|
47
|
+
const [columnsCache, setColumnsCache] = useState<Record<string, string[]>>({});
|
|
48
|
+
|
|
49
|
+
// --- PIPELINE LOGIC ---
|
|
50
|
+
const [pipelineSteps, setPipelineSteps] = useState<PipelineStep[]>([]);
|
|
51
|
+
const [activeStepId, setActiveStepId] = useState<string | null>(null);
|
|
52
|
+
const [activeTable, setActiveTable] = useState<string | null>(null);
|
|
53
|
+
|
|
54
|
+
// UI states
|
|
55
|
+
const [viewMode, setViewMode] = useState<string>('grid');
|
|
56
|
+
const [isTablesLoading, setIsTablesLoading] = useState(true);
|
|
57
|
+
const [isDataLoading, setIsDataLoading] = useState(false);
|
|
58
|
+
const [isSyncing, setIsSyncing] = useState(false);
|
|
59
|
+
const [data, setData] = useState<any[]>([]);
|
|
60
|
+
const [secondaryResources, setSecondaryResources] = useState<SecondaryResource[]>([]);
|
|
61
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
62
|
+
const [sortConfig, setSortConfig] = useState<SortConfig | null>(null);
|
|
63
|
+
const [connectionError, setConnectionError] = useState<string | null>(null);
|
|
64
|
+
const [error, setError] = useState<string | null>(null);
|
|
65
|
+
const [activeColumnMenu, setActiveColumnMenu] = useState<string | null>(null);
|
|
66
|
+
const [filterModalConfig, setFilterModalConfig] = useState<{isOpen: boolean, col: string, type: 'text'|'number'|'date'} | null>(null);
|
|
67
|
+
const [mathModalOpen, setMathModalOpen] = useState(false);
|
|
68
|
+
const [pipelineManagerOpen, setPipelineManagerOpen] = useState(false);
|
|
69
|
+
const [isSchemaMapperOpen, setIsSchemaMapperOpen] = useState(false);
|
|
70
|
+
const [isOrchestratorOpen, setIsOrchestratorOpen] = useState(false);
|
|
71
|
+
const [leftSidebarOpen, setLeftSidebarOpen] = useState(false);
|
|
72
|
+
const [stepsSidebarOpen, setStepsSidebarOpen] = useState(false);
|
|
73
|
+
|
|
74
|
+
// Script State
|
|
75
|
+
const [activeScript, setActiveScript] = useState('');
|
|
76
|
+
|
|
77
|
+
const fetchSchema = async () => {
|
|
78
|
+
setIsTablesLoading(true);
|
|
79
|
+
setConnectionError(null);
|
|
80
|
+
try {
|
|
81
|
+
const res = await fetch(`${baseUrl}/api/dataway/resources?connectorId=${activeConnector}`, {
|
|
82
|
+
headers: { 'x-api-key': 'dev_ak_chameleon_studio_99x' }
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok) throw new Error('API Endpoint no disponible');
|
|
85
|
+
|
|
86
|
+
const result = await res.json();
|
|
87
|
+
const tableList = Array.isArray(result.resources) ? result.resources : [];
|
|
88
|
+
setTables(tableList);
|
|
89
|
+
|
|
90
|
+
if (tableList.length > 0) setActiveTable(tableList[0]);
|
|
91
|
+
else setActiveTable(null);
|
|
92
|
+
} catch (error: any) {
|
|
93
|
+
setConnectionError(error.message);
|
|
94
|
+
} finally {
|
|
95
|
+
setIsTablesLoading(false);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
fetchSchema();
|
|
101
|
+
localStorage.setItem('dataway_active_connector', activeConnector);
|
|
102
|
+
}, [activeConnector]);
|
|
103
|
+
|
|
104
|
+
const compilePipelineScript = (steps: PipelineStep[], upToId?: string): string => {
|
|
105
|
+
let compiled = '';
|
|
106
|
+
for (const step of steps) {
|
|
107
|
+
if (!compiled) {
|
|
108
|
+
compiled = step.script;
|
|
109
|
+
} else {
|
|
110
|
+
// Safely replace the exact word "Source" or "SOURCE" with the entire accumulated nested function
|
|
111
|
+
// M-Script naturally composes as nested AST layers
|
|
112
|
+
compiled = step.script.replace(/\bSource\b/g, compiled);
|
|
113
|
+
}
|
|
114
|
+
if (upToId && step.id === upToId) break;
|
|
115
|
+
}
|
|
116
|
+
return compiled;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const handleLoadPipeline = (loadedScript: string) => {
|
|
120
|
+
const newStep = {
|
|
121
|
+
id: Date.now().toString(),
|
|
122
|
+
name: "Plantilla ETL",
|
|
123
|
+
script: loadedScript,
|
|
124
|
+
isValidData: true,
|
|
125
|
+
isComputed: false
|
|
126
|
+
};
|
|
127
|
+
setPipelineSteps([newStep]);
|
|
128
|
+
setActiveStepId(newStep.id);
|
|
129
|
+
setActiveScript(loadedScript);
|
|
130
|
+
if (activeTable) fetchTableData(activeTable, loadedScript);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const fetchTableData = async (tableName: string, scriptToRun?: string) => {
|
|
134
|
+
setIsDataLoading(true);
|
|
135
|
+
setSearchTerm('');
|
|
136
|
+
setSortConfig(null);
|
|
137
|
+
setError(null);
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch(`${baseUrl}/api/dataway/query`, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: {
|
|
142
|
+
'x-api-key': 'dev_ak_chameleon_studio_99x',
|
|
143
|
+
'Content-Type': 'application/json'
|
|
144
|
+
},
|
|
145
|
+
body: JSON.stringify({
|
|
146
|
+
connectorId: activeConnector,
|
|
147
|
+
resource: tableName,
|
|
148
|
+
type: activeConnector === 'postgres' ? 'table' : 'range',
|
|
149
|
+
m_script: scriptToRun || undefined,
|
|
150
|
+
secondaryResources: secondaryResources.length > 0 ? secondaryResources : undefined
|
|
151
|
+
})
|
|
152
|
+
});
|
|
153
|
+
if (!res.ok) {
|
|
154
|
+
const errBody = await res.json().catch(() => ({}));
|
|
155
|
+
throw new Error(errBody.error || `El backend Dataway rechazó la solicitud (HTTP ${res.status})`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const result = await res.json();
|
|
159
|
+
setData(Array.isArray(result.data) ? result.data : []);
|
|
160
|
+
// Update the active script state exclusively if passed down
|
|
161
|
+
if (scriptToRun !== undefined) {
|
|
162
|
+
setActiveScript(scriptToRun);
|
|
163
|
+
}
|
|
164
|
+
} catch (err: any) {
|
|
165
|
+
setError(err.message);
|
|
166
|
+
toast.error(`Error en la consulta`, { description: err.message });
|
|
167
|
+
setData([]);
|
|
168
|
+
} finally {
|
|
169
|
+
setIsDataLoading(false);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (activeTable) {
|
|
175
|
+
localStorage.setItem('dataway_active_resource', activeTable);
|
|
176
|
+
const fullCompiledScript = compilePipelineScript(pipelineSteps, activeStepId || undefined);
|
|
177
|
+
fetchTableData(activeTable, fullCompiledScript);
|
|
178
|
+
} else {
|
|
179
|
+
localStorage.removeItem('dataway_active_resource');
|
|
180
|
+
}
|
|
181
|
+
}, [activeTable]); // Only re-fetch when activeTable changes initially
|
|
182
|
+
|
|
183
|
+
const handleExecutePipelineStep = (script: string, overrideTable?: string) => {
|
|
184
|
+
if (!script.trim()) return;
|
|
185
|
+
|
|
186
|
+
let newSteps = [...pipelineSteps];
|
|
187
|
+
const isChangingTable = overrideTable && overrideTable !== activeTable;
|
|
188
|
+
|
|
189
|
+
// If the AI changes the target table, we MUST clear the pipeline to avoid polluting it with incompatible columns
|
|
190
|
+
if (isChangingTable) {
|
|
191
|
+
newSteps = [];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const activeIndex = isChangingTable ? -1 : newSteps.findIndex(s => s.id === activeStepId);
|
|
195
|
+
|
|
196
|
+
// Append a new step if we are at the end, if there is no active step, or if the active step is invalid
|
|
197
|
+
if (activeIndex === newSteps.length - 1 || activeStepId === null || activeIndex === -1) {
|
|
198
|
+
const newStep = {
|
|
199
|
+
id: Math.random().toString(36).substr(2, 9),
|
|
200
|
+
name: `Paso ${newSteps.length + 1}`,
|
|
201
|
+
script
|
|
202
|
+
};
|
|
203
|
+
newSteps.push(newStep);
|
|
204
|
+
setPipelineSteps(newSteps);
|
|
205
|
+
setActiveStepId(newStep.id);
|
|
206
|
+
} else {
|
|
207
|
+
// Edit existing step
|
|
208
|
+
newSteps[activeIndex].script = script;
|
|
209
|
+
setPipelineSteps(newSteps);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const stepToCompile = isChangingTable ? newSteps[0].id : (activeStepId || undefined);
|
|
213
|
+
const fullCompiledScript = compilePipelineScript(newSteps, stepToCompile);
|
|
214
|
+
|
|
215
|
+
const targetTable = overrideTable || activeTable;
|
|
216
|
+
if (targetTable) {
|
|
217
|
+
fetchTableData(targetTable, fullCompiledScript);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// Escuchar scripts generados desde el Chat Principal (DatawayChatShell)
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
const handleRemoteScript = (e: any) => {
|
|
224
|
+
const script = e.detail?.script;
|
|
225
|
+
const targetResource = e.detail?.resource;
|
|
226
|
+
|
|
227
|
+
if (script && typeof script === 'string') {
|
|
228
|
+
if (targetResource && targetResource !== activeTable) {
|
|
229
|
+
setActiveTable(targetResource);
|
|
230
|
+
handleExecutePipelineStep(script, targetResource);
|
|
231
|
+
toast.success(`Cambiando de forma autónoma a la tabla ${targetResource}`);
|
|
232
|
+
} else {
|
|
233
|
+
handleExecutePipelineStep(script);
|
|
234
|
+
toast.success('Recibido desde Agente AI');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
window.addEventListener('intent:dataway-generate-script', handleRemoteScript);
|
|
239
|
+
return () => window.removeEventListener('intent:dataway-generate-script', handleRemoteScript);
|
|
240
|
+
}, [pipelineSteps, activeStepId, activeTable]);
|
|
241
|
+
|
|
242
|
+
const handleSelectStep = (stepId: string) => {
|
|
243
|
+
setActiveStepId(stepId);
|
|
244
|
+
const fullCompiledScript = compilePipelineScript(pipelineSteps, stepId);
|
|
245
|
+
if (activeTable) {
|
|
246
|
+
fetchTableData(activeTable, fullCompiledScript);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const handleDeleteStep = (e: React.MouseEvent, stepId: string) => {
|
|
251
|
+
e.stopPropagation();
|
|
252
|
+
const filtered = pipelineSteps.filter(s => s.id !== stepId);
|
|
253
|
+
setPipelineSteps(filtered);
|
|
254
|
+
|
|
255
|
+
const newActiveId = filtered.length > 0 ? filtered[filtered.length - 1].id : null;
|
|
256
|
+
setActiveStepId(newActiveId);
|
|
257
|
+
|
|
258
|
+
const fullCompiledScript = compilePipelineScript(filtered, newActiveId || undefined);
|
|
259
|
+
if (activeTable) {
|
|
260
|
+
fetchTableData(activeTable, fullCompiledScript);
|
|
261
|
+
} else {
|
|
262
|
+
setData([]); // fallback clear
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const handleClearPipeline = () => {
|
|
267
|
+
setPipelineSteps([]);
|
|
268
|
+
setActiveStepId(null);
|
|
269
|
+
setActiveScript('');
|
|
270
|
+
if (activeTable) {
|
|
271
|
+
fetchTableData(activeTable, '');
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const handleSort = (key: string) => {
|
|
276
|
+
let direction: 'asc' | 'desc' = 'asc';
|
|
277
|
+
if (sortConfig && sortConfig.key === key && sortConfig.direction === 'asc') direction = 'desc';
|
|
278
|
+
setSortConfig({ key, direction });
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const executeVisualAction = (action: string, col: string) => {
|
|
282
|
+
setActiveColumnMenu(null);
|
|
283
|
+
let code = '';
|
|
284
|
+
if (action === 'rename') {
|
|
285
|
+
const newName = prompt(`Renombrar columna '${col}':`, col);
|
|
286
|
+
if (!newName || newName === col) return;
|
|
287
|
+
code = `Table.RenameColumns(Source, {{"${col}", "${newName}"}})`;
|
|
288
|
+
} else if (action === 'remove') {
|
|
289
|
+
if (!confirm(`¿Estás seguro de eliminar la columna '${col}'?`)) return;
|
|
290
|
+
code = `Table.RemoveColumns(Source, "${col}")`;
|
|
291
|
+
} else if (action === 'sort') {
|
|
292
|
+
code = `Table.Sort(Source, "${col}")`;
|
|
293
|
+
} else if (action === 'filter') {
|
|
294
|
+
const val = processedData.find(r => r[col] !== null && r[col] !== undefined)?.[col];
|
|
295
|
+
let type: 'text'|'number'|'date' = 'text';
|
|
296
|
+
if (val !== undefined && val !== null) {
|
|
297
|
+
if (!isNaN(Number(val)) && String(val).trim() !== '') type = 'number';
|
|
298
|
+
else if (isNaN(Number(val)) && !isNaN(Date.parse(String(val)))) type = 'date';
|
|
299
|
+
}
|
|
300
|
+
setFilterModalConfig({ isOpen: true, col, type });
|
|
301
|
+
return; // Modal handles injection
|
|
302
|
+
} else if (action === 'group_sum') {
|
|
303
|
+
const sumCol = prompt(`Vas a agrupar por '${col}'. ¿Qué columna deseas SUMAR para totalizar?`, "");
|
|
304
|
+
if (!sumCol) return;
|
|
305
|
+
code = `Table.GroupSum(Source, "${col}", "${sumCol}", "Total_${sumCol}")`;
|
|
306
|
+
} else if (action === 'drop_nulls') {
|
|
307
|
+
code = `Table.DropNulls(Source, "${col}")`;
|
|
308
|
+
} else if (action === 'fill_nulls') {
|
|
309
|
+
const val = prompt(`¿Con qué valor (ej. 0 o "N/A") deseas rellenar los datos vacíos en '${col}'?`, "0");
|
|
310
|
+
if (val === null || val === '') return;
|
|
311
|
+
const isNum = !isNaN(Number(val)) && val.trim() !== '';
|
|
312
|
+
const mVal = isNum ? val : `"${val}"`;
|
|
313
|
+
code = `Table.FillNulls(Source, "${col}", ${mVal})`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (code) {
|
|
317
|
+
handleExecutePipelineStep(code);
|
|
318
|
+
toast.success("Paso generado exitosamente");
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const handleApplyFilter = (col: string, op: string, val: string) => {
|
|
323
|
+
setFilterModalConfig(null);
|
|
324
|
+
let mVal = `"${val}"`;
|
|
325
|
+
const isNum = !isNaN(Number(val)) && val.trim() !== '';
|
|
326
|
+
// Si el usuario eligió Number type o Date type, enviamos raw types al parser o strings.
|
|
327
|
+
// M-polyglot lee Number(val) si no tiene comillas.
|
|
328
|
+
if (isNum) mVal = val;
|
|
329
|
+
|
|
330
|
+
const code = `Table.Filter(Source, "${col}", "${op}", ${mVal})`;
|
|
331
|
+
handleExecutePipelineStep(code);
|
|
332
|
+
toast.success("Filtro Inteligente aplicado");
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const handleApplyMath = (newColName: string, leftCol: string, op: string, rightOperand: string) => {
|
|
336
|
+
setMathModalOpen(false);
|
|
337
|
+
const rightQuoted = `"${rightOperand}"`;
|
|
338
|
+
const code = `Table.MathColumn(Source, "${newColName}", "${leftCol}", "${op}", ${rightQuoted})`;
|
|
339
|
+
handleExecutePipelineStep(code);
|
|
340
|
+
toast.success("Columna Calculada agregada");
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const processedData = useMemo(() => {
|
|
344
|
+
let sortableItems = [...data];
|
|
345
|
+
if (searchTerm) {
|
|
346
|
+
const lowercasedFilter = searchTerm.toLowerCase();
|
|
347
|
+
sortableItems = sortableItems.filter(item =>
|
|
348
|
+
Object.keys(item).some(key => String(item[key]).toLowerCase().includes(lowercasedFilter))
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
if (sortConfig !== null) {
|
|
352
|
+
sortableItems.sort((a, b) => {
|
|
353
|
+
const aVal = a[sortConfig.key];
|
|
354
|
+
const bVal = b[sortConfig.key];
|
|
355
|
+
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
|
|
356
|
+
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
|
|
357
|
+
return 0;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
return sortableItems;
|
|
361
|
+
}, [data, searchTerm, sortConfig]);
|
|
362
|
+
|
|
363
|
+
const columns = data.length > 0 ? Object.keys(data[0]) : [];
|
|
364
|
+
|
|
365
|
+
// --- DATA PROFILER (QUALITY METRICS) ---
|
|
366
|
+
const columnStats = useMemo(() => {
|
|
367
|
+
if (processedData.length === 0) return {};
|
|
368
|
+
const stats: Record<string, { valid: number, empty: number, total: number }> = {};
|
|
369
|
+
|
|
370
|
+
columns.forEach(col => {
|
|
371
|
+
let valid = 0;
|
|
372
|
+
let empty = 0;
|
|
373
|
+
const total = processedData.length;
|
|
374
|
+
|
|
375
|
+
for (let i = 0; i < total; i++) {
|
|
376
|
+
const val = processedData[i][col];
|
|
377
|
+
if (val === null || val === undefined || val === '') {
|
|
378
|
+
empty++;
|
|
379
|
+
} else {
|
|
380
|
+
valid++;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
stats[col] = { valid, empty, total };
|
|
384
|
+
});
|
|
385
|
+
return stats;
|
|
386
|
+
}, [processedData, columns]);
|
|
387
|
+
|
|
388
|
+
const handleExportCSV = () => {
|
|
389
|
+
if (processedData.length === 0) return;
|
|
390
|
+
const csvRows = [];
|
|
391
|
+
csvRows.push(columns.join(','));
|
|
392
|
+
for (const row of processedData) {
|
|
393
|
+
const values = columns.map(header => {
|
|
394
|
+
const val = row[header];
|
|
395
|
+
if (val === null || val === undefined) return '';
|
|
396
|
+
const str = String(val).replace(/"/g, '""');
|
|
397
|
+
return `"${str}"`;
|
|
398
|
+
});
|
|
399
|
+
csvRows.push(values.join(','));
|
|
400
|
+
}
|
|
401
|
+
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' });
|
|
402
|
+
const url = URL.createObjectURL(blob);
|
|
403
|
+
const link = document.createElement('a');
|
|
404
|
+
link.setAttribute('href', url);
|
|
405
|
+
link.setAttribute('download', `dataway_${activeTable}_${new Date().getTime()}.csv`);
|
|
406
|
+
document.body.appendChild(link);
|
|
407
|
+
link.click();
|
|
408
|
+
document.body.removeChild(link);
|
|
409
|
+
toast.success(`Exportados ${processedData.length} registros exitosamente`);
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const handleSyncDestination = async () => {
|
|
413
|
+
if (processedData.length === 0) return;
|
|
414
|
+
const targetTable = prompt("Ingrese el nombre de la tabla de destino en PostgreSQL:");
|
|
415
|
+
if (!targetTable) return;
|
|
416
|
+
|
|
417
|
+
setIsSyncing(true);
|
|
418
|
+
toast.loading(`Sincronizando ${processedData.length} registros hacia ${targetTable}...`, { id: 'sync-db' });
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
const payload = {
|
|
422
|
+
connectorId: 'postgres',
|
|
423
|
+
destinationTable: targetTable,
|
|
424
|
+
data: processedData
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const response = await fetch(`${baseUrl}/api/dataway/sync`, {
|
|
428
|
+
method: 'POST',
|
|
429
|
+
headers: {
|
|
430
|
+
'Content-Type': 'application/json',
|
|
431
|
+
'x-api-key': 'dev_ak_chameleon_studio_99x'
|
|
432
|
+
},
|
|
433
|
+
body: JSON.stringify(payload)
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
if (!response.ok) {
|
|
437
|
+
const err = await response.json();
|
|
438
|
+
throw new Error(err.error || 'Error en sincronización');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const result = await response.json();
|
|
442
|
+
toast.success(`Sincronización Exitosa: ${result.rowsInserted} filas insertadas en ${result.destination}`, { id: 'sync-db' });
|
|
443
|
+
} catch (error: any) {
|
|
444
|
+
toast.error(`Error de Sincronización`, { description: error.message, id: 'sync-db' });
|
|
445
|
+
} finally {
|
|
446
|
+
setIsSyncing(false);
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
// --- VIRTUALIZATION HOOK ---
|
|
451
|
+
const parentRef = useRef<HTMLDivElement>(null);
|
|
452
|
+
const rowVirtualizer = useVirtualizer({
|
|
453
|
+
count: processedData.length,
|
|
454
|
+
getScrollElement: () => parentRef.current,
|
|
455
|
+
estimateSize: () => 45, // default row height approximation
|
|
456
|
+
overscan: 10,
|
|
457
|
+
});
|
|
458
|
+
const virtualItems = rowVirtualizer.getVirtualItems();
|
|
459
|
+
|
|
460
|
+
return (
|
|
461
|
+
<div className="w-full h-full flex flex-row overflow-hidden" style={{
|
|
462
|
+
'--bg-primary': 'var(--ds-surface-primary, #ffffff)',
|
|
463
|
+
'--bg-elevated': 'var(--ds-surface-secondary, #f8fafc)',
|
|
464
|
+
'--bg-surface': 'var(--ds-surface-tertiary, #f1f5f9)',
|
|
465
|
+
'--border-color': 'var(--ds-border-default, #e2e8f0)',
|
|
466
|
+
'--text-primary': 'var(--ds-text-primary, #0f172a)',
|
|
467
|
+
'--text-secondary': 'var(--ds-text-secondary, #64748b)',
|
|
468
|
+
'--brand-primary': 'var(--ds-accent-blue, #6366f1)',
|
|
469
|
+
'--brand-primary-hover': 'var(--ds-accent-blue-hover, #4f46e5)',
|
|
470
|
+
} as React.CSSProperties}>
|
|
471
|
+
|
|
472
|
+
<AnimatePresence>
|
|
473
|
+
{leftSidebarOpen && (
|
|
474
|
+
<motion.div
|
|
475
|
+
initial={{ width: 0, opacity: 0 }}
|
|
476
|
+
animate={{ width: 288, opacity: 1 }}
|
|
477
|
+
exit={{ width: 0, opacity: 0 }}
|
|
478
|
+
transition={{ duration: 0.2 }}
|
|
479
|
+
className="border-r border-[var(--border-color)] bg-[var(--bg-elevated)] flex flex-col h-full z-10 shrink-0 overflow-hidden"
|
|
480
|
+
>
|
|
481
|
+
<div className="w-72 flex flex-col h-full">
|
|
482
|
+
<div className="p-6 border-b border-[var(--border-color)] flex flex-col gap-4 bg-[var(--bg-primary)]">
|
|
483
|
+
<div className="flex items-center gap-3">
|
|
484
|
+
<div className="p-2 bg-[var(--brand-primary)]/10 rounded-xl">
|
|
485
|
+
<FunctionSquare className="w-6 h-6 text-[var(--brand-primary)]" />
|
|
486
|
+
</div>
|
|
487
|
+
<div>
|
|
488
|
+
<h2 className="font-extrabold text-[var(--text-primary)] tracking-tight text-lg leading-none">Dataway Studio</h2>
|
|
489
|
+
<p className="text-[11px] font-medium text-[var(--text-secondary)] uppercase tracking-wider mt-1">M-Script Engine</p>
|
|
490
|
+
</div>
|
|
491
|
+
</div>
|
|
492
|
+
|
|
493
|
+
<select
|
|
494
|
+
value={activeConnector}
|
|
495
|
+
onChange={(e) => setActiveConnector(e.target.value)}
|
|
496
|
+
className="w-full bg-[var(--bg-surface)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm font-bold rounded-lg px-3 py-2 outline-none cursor-pointer"
|
|
497
|
+
>
|
|
498
|
+
{connectors.map(c => (
|
|
499
|
+
<option key={c.id} value={c.id}>{c.name}</option>
|
|
500
|
+
))}
|
|
501
|
+
</select>
|
|
502
|
+
</div>
|
|
503
|
+
|
|
504
|
+
<div className="p-4 flex-1 overflow-y-auto w-full space-y-1.5 custom-scrollbar">
|
|
505
|
+
<h3 className="text-xs font-bold text-[var(--text-secondary)] tracking-wider uppercase mb-3 px-2 flex items-center justify-between">
|
|
506
|
+
Recursos
|
|
507
|
+
<button onClick={fetchSchema} className="hover:text-[var(--text-primary)] transition-colors p-1 rounded-md">
|
|
508
|
+
<RefreshCw className="w-3 h-3" />
|
|
509
|
+
</button>
|
|
510
|
+
</h3>
|
|
511
|
+
|
|
512
|
+
{isTablesLoading ? (
|
|
513
|
+
<div className="space-y-2 px-2">
|
|
514
|
+
{[1,2,3].map(skeleton => (
|
|
515
|
+
<div key={skeleton} className="h-10 bg-[var(--bg-surface)] rounded-lg animate-pulse w-full"></div>
|
|
516
|
+
))}
|
|
517
|
+
</div>
|
|
518
|
+
) : (
|
|
519
|
+
tables.map((table, idx) => (
|
|
520
|
+
<button
|
|
521
|
+
key={`${table}-${idx}`}
|
|
522
|
+
onClick={() => setActiveTable(table)}
|
|
523
|
+
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm font-semibold transition-all group ${
|
|
524
|
+
activeTable === table
|
|
525
|
+
? 'bg-[var(--brand-primary)] text-white shadow-md'
|
|
526
|
+
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-surface)] hover:text-[var(--text-primary)]'
|
|
527
|
+
}`}
|
|
528
|
+
>
|
|
529
|
+
<div className="flex items-center gap-3">
|
|
530
|
+
<TableProperties className={`w-4 h-4 ${activeTable === table ? 'text-[var(--bg-surface)]' : ''}`} />
|
|
531
|
+
<span className="capitalize">{table}</span>
|
|
532
|
+
</div>
|
|
533
|
+
<div className="flex items-center gap-2">
|
|
534
|
+
<div
|
|
535
|
+
onClick={(e) => {
|
|
536
|
+
e.stopPropagation();
|
|
537
|
+
const suggestedName = `${activeConnector}_${table}`.replace(/[^a-zA-Z0-9_]/g, '');
|
|
538
|
+
const boundName = prompt("Asignar nombre de variable local (ej. Clientes) para el JOIN:", suggestedName);
|
|
539
|
+
if (boundName) {
|
|
540
|
+
if (secondaryResources.some(r => r.boundName === boundName)) {
|
|
541
|
+
toast.error("El nombre de la variable ya existe.");
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
setSecondaryResources(prev => [...prev, { connectorId: activeConnector, resource: table, boundName }]);
|
|
545
|
+
toast.success(`Referencia: ${boundName} agregada al contexto.`);
|
|
546
|
+
}
|
|
547
|
+
}}
|
|
548
|
+
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-black/10 rounded transition-colors"
|
|
549
|
+
title="Inyectar en Contexto Secundario (Federated Join)"
|
|
550
|
+
>
|
|
551
|
+
<Plus className="w-3.5 h-3.5" />
|
|
552
|
+
</div>
|
|
553
|
+
{activeTable === table && <ChevronRight className="w-4 h-4 opacity-70" />}
|
|
554
|
+
</div>
|
|
555
|
+
</button>
|
|
556
|
+
))
|
|
557
|
+
)}
|
|
558
|
+
</div>
|
|
559
|
+
</div>
|
|
560
|
+
</motion.div>
|
|
561
|
+
)}
|
|
562
|
+
</AnimatePresence>
|
|
563
|
+
|
|
564
|
+
<div className="flex-1 bg-[var(--bg-surface)] flex flex-col h-full min-w-0">
|
|
565
|
+
<FormulaBar
|
|
566
|
+
activeResource={activeTable}
|
|
567
|
+
activeConnector={activeConnector}
|
|
568
|
+
isLoading={isDataLoading}
|
|
569
|
+
schemaColumns={columns}
|
|
570
|
+
onExecute={handleExecutePipelineStep}
|
|
571
|
+
onClear={handleClearPipeline}
|
|
572
|
+
/>
|
|
573
|
+
|
|
574
|
+
<div className="bg-[var(--bg-primary)] border-b border-[var(--border-color)] px-4 py-2 flex items-center justify-between z-10 overflow-x-auto custom-scrollbar">
|
|
575
|
+
<div className="flex items-center gap-2">
|
|
576
|
+
<button
|
|
577
|
+
onClick={() => setLeftSidebarOpen(!leftSidebarOpen)}
|
|
578
|
+
className={`p-1.5 rounded-md border transition-colors flex items-center justify-center ${leftSidebarOpen ? 'bg-slate-800 text-white border-slate-700 dark:bg-indigo-500/20 dark:text-indigo-300' : 'bg-[var(--bg-surface)] text-[var(--text-secondary)] border-[var(--border-color)] hover:bg-[var(--bg-elevated)]'}`}
|
|
579
|
+
title="Alternar panel de Recursos"
|
|
580
|
+
>
|
|
581
|
+
<Database className="w-4 h-4" />
|
|
582
|
+
</button>
|
|
583
|
+
<button
|
|
584
|
+
onClick={() => setStepsSidebarOpen(!stepsSidebarOpen)}
|
|
585
|
+
className={`p-1.5 rounded-md border transition-colors flex items-center justify-center ${stepsSidebarOpen ? 'bg-slate-800 text-white border-slate-700 dark:bg-indigo-500/20 dark:text-indigo-300' : 'bg-[var(--bg-surface)] text-[var(--text-secondary)] border-[var(--border-color)] hover:bg-[var(--bg-elevated)]'}`}
|
|
586
|
+
title="Alternar panel de Pasos Aplicados"
|
|
587
|
+
>
|
|
588
|
+
<Layers className="w-4 h-4" />
|
|
589
|
+
</button>
|
|
590
|
+
|
|
591
|
+
<div className="w-px h-4 bg-[var(--border-color)] mx-1" />
|
|
592
|
+
|
|
593
|
+
<button
|
|
594
|
+
onClick={() => setMathModalOpen(true)}
|
|
595
|
+
disabled={columns.length === 0}
|
|
596
|
+
className="text-xs font-semibold px-3 py-1.5 rounded-md bg-purple-50 text-purple-700 border border-purple-200 hover:bg-purple-100 transition-colors flex items-center gap-2 disabled:opacity-50"
|
|
597
|
+
>
|
|
598
|
+
<Plus className="w-3.5 h-3.5" />
|
|
599
|
+
Columna Calculada
|
|
600
|
+
</button>
|
|
601
|
+
</div>
|
|
602
|
+
<div>
|
|
603
|
+
<button
|
|
604
|
+
onClick={() => setPipelineManagerOpen(true)}
|
|
605
|
+
className="text-xs font-semibold px-3 py-1.5 rounded-md bg-[var(--bg-surface)] border border-[var(--border-color)] hover:bg-[var(--bg-elevated)] transition-colors flex items-center gap-2 text-[var(--text-primary)]"
|
|
606
|
+
>
|
|
607
|
+
<Database className="w-3.5 h-3.5" />
|
|
608
|
+
Gestor de Plantillas
|
|
609
|
+
</button>
|
|
610
|
+
</div>
|
|
611
|
+
<div>
|
|
612
|
+
<button
|
|
613
|
+
onClick={() => setIsOrchestratorOpen(true)}
|
|
614
|
+
className="text-xs font-semibold px-3 py-1.5 rounded-md bg-amber-50 text-amber-700 border border-amber-200 hover:bg-amber-100 transition-colors flex items-center gap-2"
|
|
615
|
+
>
|
|
616
|
+
<Clock className="w-3.5 h-3.5" />
|
|
617
|
+
Dataway Cron (Orchestrator)
|
|
618
|
+
</button>
|
|
619
|
+
</div>
|
|
620
|
+
<div>
|
|
621
|
+
<button
|
|
622
|
+
onClick={() => setIsSchemaMapperOpen(true)}
|
|
623
|
+
disabled={!activeTable}
|
|
624
|
+
className="text-xs font-semibold px-3 py-1.5 rounded-md bg-indigo-50 text-indigo-700 border border-indigo-200 hover:bg-indigo-100 transition-colors flex items-center gap-2 disabled:opacity-50"
|
|
625
|
+
>
|
|
626
|
+
<Network className="w-3.5 h-3.5" />
|
|
627
|
+
Mapa Relacional (ERD)
|
|
628
|
+
</button>
|
|
629
|
+
</div>
|
|
630
|
+
</div>
|
|
631
|
+
|
|
632
|
+
<div className="flex-1 flex min-w-0 min-h-0 bg-[var(--bg-primary)]">
|
|
633
|
+
{/* APPLIED STEPS PIPELINE SIDEBAR */}
|
|
634
|
+
<AnimatePresence>
|
|
635
|
+
{stepsSidebarOpen && (
|
|
636
|
+
<motion.div
|
|
637
|
+
initial={{ width: 0, opacity: 0 }}
|
|
638
|
+
animate={{ width: 256, opacity: 1 }}
|
|
639
|
+
exit={{ width: 0, opacity: 0 }}
|
|
640
|
+
transition={{ duration: 0.2 }}
|
|
641
|
+
className="border-r border-[var(--border-color)] bg-[var(--bg-elevated)] flex flex-col min-h-0 shrink-0 overflow-hidden"
|
|
642
|
+
>
|
|
643
|
+
<div className="w-64 flex flex-col h-full">
|
|
644
|
+
<div className="p-4 border-b border-[var(--border-color)] flex items-center justify-between">
|
|
645
|
+
<h3 className="text-xs font-bold text-[var(--text-primary)] uppercase tracking-wider flex items-center gap-2">
|
|
646
|
+
<Layers className="w-4 h-4 text-[var(--brand-primary)]" />
|
|
647
|
+
Pasos Aplicados
|
|
648
|
+
</h3>
|
|
649
|
+
{pipelineSteps.length > 0 && (
|
|
650
|
+
<button onClick={handleClearPipeline} className="text-[var(--text-secondary)] hover:text-red-500 transition-colors" title="Limpiar Pipeline">
|
|
651
|
+
<Trash2 className="w-4 h-4" />
|
|
652
|
+
</button>
|
|
653
|
+
)}
|
|
654
|
+
</div>
|
|
655
|
+
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
|
656
|
+
<div
|
|
657
|
+
className={`p-3 rounded-lg border cursor-pointer transition-all text-sm mb-2 ${
|
|
658
|
+
activeStepId === null
|
|
659
|
+
? 'border-[var(--brand-primary)] bg-[var(--brand-primary)]/10 text-[var(--brand-primary)]'
|
|
660
|
+
: 'border-[var(--border-color)] hover:border-[var(--brand-primary)]/50 text-[var(--text-secondary)]'
|
|
661
|
+
}`}
|
|
662
|
+
onClick={() => handleSelectStep('source')}
|
|
663
|
+
>
|
|
664
|
+
<div className="font-semibold flex items-center gap-2">
|
|
665
|
+
<Database className="w-4 h-4" />
|
|
666
|
+
Origen de Datos
|
|
667
|
+
</div>
|
|
668
|
+
<div className="text-[10px] opacity-70 mt-1 truncate">
|
|
669
|
+
{activeTable || 'Seleccione una tabla'}
|
|
670
|
+
</div>
|
|
671
|
+
</div>
|
|
672
|
+
|
|
673
|
+
{pipelineSteps.map((step, index) => (
|
|
674
|
+
<div className="flex flex-col relative" key={step.id}>
|
|
675
|
+
<div className="absolute -top-3 left-4 h-3 w-px bg-[var(--border-color)]"></div>
|
|
676
|
+
<div
|
|
677
|
+
className={`flex items-start justify-between p-3 rounded-lg border group cursor-pointer transition-all text-sm ${
|
|
678
|
+
activeStepId === step.id
|
|
679
|
+
? 'border-purple-500 bg-purple-50/50 text-purple-900 shadow-sm'
|
|
680
|
+
: 'border-[var(--border-color)] hover:border-purple-300 text-[var(--text-secondary)] bg-[var(--bg-primary)]'
|
|
681
|
+
}`}
|
|
682
|
+
onClick={() => handleSelectStep(step.id)}
|
|
683
|
+
>
|
|
684
|
+
<div className="min-w-0 pr-2">
|
|
685
|
+
<div className="font-semibold">{step.name}</div>
|
|
686
|
+
<div className="text-[10px] opacity-70 mt-1 truncate font-mono">
|
|
687
|
+
{step.script.length > 30 ? step.script.substring(0, 30) + '...' : step.script}
|
|
688
|
+
</div>
|
|
689
|
+
</div>
|
|
690
|
+
<button
|
|
691
|
+
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 transition-opacity p-1"
|
|
692
|
+
onClick={(e) => handleDeleteStep(e, step.id)}
|
|
693
|
+
title="Eliminar Paso"
|
|
694
|
+
>
|
|
695
|
+
<X className="w-3.5 h-3.5" />
|
|
696
|
+
</button>
|
|
697
|
+
</div>
|
|
698
|
+
</div>
|
|
699
|
+
))}
|
|
700
|
+
|
|
701
|
+
<div className="text-center pt-2">
|
|
702
|
+
<span className="text-[10px] text-[var(--text-secondary)] font-medium">Ejecuta código para añadir pasos</span>
|
|
703
|
+
</div>
|
|
704
|
+
|
|
705
|
+
{/* BOUND SECONDARY RESOURCES */}
|
|
706
|
+
{secondaryResources.length > 0 && (
|
|
707
|
+
<div className="mt-6 border-t border-[var(--border-color)] pt-4">
|
|
708
|
+
<h4 className="text-[10px] font-bold text-[var(--text-secondary)] uppercase tracking-wider mb-2 flex items-center justify-between">
|
|
709
|
+
<span>Recursos Vinculados</span>
|
|
710
|
+
<span className="bg-[var(--brand-primary)]/20 text-[var(--brand-primary)] px-1.5 py-0.5 rounded text-[9px]">{secondaryResources.length}</span>
|
|
711
|
+
</h4>
|
|
712
|
+
<div className="space-y-1.5">
|
|
713
|
+
{secondaryResources.map((res, idx) => (
|
|
714
|
+
<div key={idx} className="flex flex-col p-2 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-color)] group">
|
|
715
|
+
<div className="flex items-center justify-between">
|
|
716
|
+
<span className="text-xs font-bold text-[var(--text-primary)]">{res.boundName}</span>
|
|
717
|
+
<button
|
|
718
|
+
onClick={() => setSecondaryResources(prev => prev.filter(r => r.boundName !== res.boundName))}
|
|
719
|
+
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 transition-opacity p-0.5"
|
|
720
|
+
title="Remover Binding"
|
|
721
|
+
>
|
|
722
|
+
<X className="w-3 h-3" />
|
|
723
|
+
</button>
|
|
724
|
+
</div>
|
|
725
|
+
<div className="text-[9px] text-[var(--text-secondary)] truncate flex items-center gap-1 mt-0.5">
|
|
726
|
+
<Database className="w-2.5 h-2.5" />
|
|
727
|
+
{res.connectorId} : {res.resource}
|
|
728
|
+
</div>
|
|
729
|
+
</div>
|
|
730
|
+
))}
|
|
731
|
+
</div>
|
|
732
|
+
<div className="text-[9px] text-[var(--text-secondary)] mt-2 text-center opacity-70">
|
|
733
|
+
Utilizalos en Table.NestedJoin(...)
|
|
734
|
+
</div>
|
|
735
|
+
</div>
|
|
736
|
+
)}
|
|
737
|
+
</div>
|
|
738
|
+
</div>
|
|
739
|
+
</motion.div>
|
|
740
|
+
)}
|
|
741
|
+
</AnimatePresence>
|
|
742
|
+
|
|
743
|
+
<div className="flex-1 min-w-0 overflow-hidden p-6 flex flex-col relative">
|
|
744
|
+
{!activeTable ? (
|
|
745
|
+
<div className="m-auto flex flex-col items-center justify-center opacity-40">
|
|
746
|
+
<LayoutGrid className="w-16 h-16 mb-4" />
|
|
747
|
+
<h2 className="text-xl font-bold">Sin Datos Activos</h2>
|
|
748
|
+
<p className="text-sm">Selecciona o crea un Dataway.</p>
|
|
749
|
+
</div>
|
|
750
|
+
) : viewMode === 'dashboard' ? (
|
|
751
|
+
<DatawayDashboard data={processedData} columns={columns} />
|
|
752
|
+
) : (
|
|
753
|
+
<div className="flex-1 min-w-0 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-2xl shadow-sm overflow-hidden flex flex-col relative">
|
|
754
|
+
<div ref={parentRef} className="overflow-auto flex-1 custom-scrollbar relative">
|
|
755
|
+
<table className="w-full text-left text-sm whitespace-nowrap">
|
|
756
|
+
<thead className="bg-[var(--bg-elevated)] sticky top-0 shadow-[0_1px_2px_rgba(0,0,0,0.05)]" style={{ zIndex: 10 }}>
|
|
757
|
+
<tr>
|
|
758
|
+
{columns.map(col => {
|
|
759
|
+
const sampleVal = processedData.length > 0 ? processedData.find(r => r[col] !== null && r[col] !== undefined)?.[col] : null;
|
|
760
|
+
const isNumericCol = sampleVal !== null && sampleVal !== undefined && !isNaN(Number(sampleVal)) && String(sampleVal).trim() !== '';
|
|
761
|
+
const stats = columnStats[col];
|
|
762
|
+
|
|
763
|
+
return (
|
|
764
|
+
<th key={col} className="px-6 py-4 hover:bg-[var(--bg-surface)] transition-colors select-none relative group">
|
|
765
|
+
<div className={`flex flex-col gap-1 w-full`}>
|
|
766
|
+
<div className={`flex items-center gap-2 text-xs font-bold text-[var(--text-secondary)] uppercase tracking-wider cursor-pointer ${isNumericCol ? 'justify-end' : ''}`} onClick={() => setActiveColumnMenu(activeColumnMenu === col ? null : col)}>
|
|
767
|
+
{col}
|
|
768
|
+
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${activeColumnMenu === col ? 'rotate-180 text-[var(--brand-primary)]' : 'opacity-0 group-hover:opacity-100'}`} />
|
|
769
|
+
</div>
|
|
770
|
+
{stats && (
|
|
771
|
+
<div className="h-[3px] w-full bg-red-400 mt-0.5 flex overflow-hidden rounded-full opacity-70" title={`Válidos: ${stats.valid} | Vacíos: ${stats.empty}`}>
|
|
772
|
+
<div className="bg-green-500 h-full" style={{ width: `${(stats.valid / stats.total) * 100}%` }} />
|
|
773
|
+
<div className="bg-gray-300 h-full" style={{ width: `${(stats.empty / stats.total) * 100}%` }} />
|
|
774
|
+
</div>
|
|
775
|
+
)}
|
|
776
|
+
</div>
|
|
777
|
+
|
|
778
|
+
{activeColumnMenu === col && (
|
|
779
|
+
<>
|
|
780
|
+
{/* Invisible overlay to close menu */}
|
|
781
|
+
<div className="fixed inset-0 z-40" onClick={() => setActiveColumnMenu(null)}></div>
|
|
782
|
+
<div className="absolute top-full left-4 mt-1 w-48 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl shadow-xl z-50 overflow-hidden flex flex-col py-1 animate-in fade-in zoom-in-95 duration-150">
|
|
783
|
+
<button onClick={() => executeVisualAction('sort', col)} className="px-3 py-2 text-xs font-medium text-left hover:bg-[var(--bg-surface)] flex items-center gap-2 text-[var(--text-primary)]">
|
|
784
|
+
<ArrowUpDown className="w-3.5 h-3.5 opacity-70" /> Ordenar Ascendente
|
|
785
|
+
</button>
|
|
786
|
+
<button onClick={() => executeVisualAction('filter', col)} className="px-3 py-2 text-xs font-medium text-left hover:bg-[var(--bg-surface)] flex items-center gap-2 text-[var(--text-primary)]">
|
|
787
|
+
<Filter className="w-3.5 h-3.5 opacity-70" /> Filtrar Valor Exacto
|
|
788
|
+
</button>
|
|
789
|
+
<div className="h-px bg-[var(--border-color)] my-1 w-full" />
|
|
790
|
+
<button onClick={() => executeVisualAction('group_sum', col)} className="px-3 py-2 text-xs font-medium text-left hover:bg-[var(--bg-surface)] flex items-center gap-2 text-purple-600">
|
|
791
|
+
<Layers className="w-3.5 h-3.5 opacity-70" /> Agrupar y Sumar
|
|
792
|
+
</button>
|
|
793
|
+
<div className="h-px bg-[var(--border-color)] my-1 w-full" />
|
|
794
|
+
<button onClick={() => executeVisualAction('rename', col)} className="px-3 py-2 text-xs font-medium text-left hover:bg-[var(--bg-surface)] flex items-center gap-2 text-[var(--text-primary)]">
|
|
795
|
+
<Edit2 className="w-3.5 h-3.5 opacity-70" /> Renombrar Columna
|
|
796
|
+
</button>
|
|
797
|
+
<button onClick={() => executeVisualAction('remove', col)} className="px-3 py-2 text-xs font-medium text-left hover:bg-red-50 text-red-600 flex items-center gap-2">
|
|
798
|
+
<Trash2 className="w-3.5 h-3.5 opacity-70" /> Remover Columna
|
|
799
|
+
</button>
|
|
800
|
+
<div className="h-px bg-[var(--border-color)] my-1 w-full" />
|
|
801
|
+
<button onClick={() => executeVisualAction('drop_nulls', col)} className="px-3 py-2 text-xs font-medium text-left hover:bg-orange-50 text-orange-600 flex items-center gap-2">
|
|
802
|
+
<Trash2 className="w-3.5 h-3.5 opacity-70" /> Eliminar filas vacías
|
|
803
|
+
</button>
|
|
804
|
+
<button onClick={() => executeVisualAction('fill_nulls', col)} className="px-3 py-2 text-xs font-medium text-left hover:bg-orange-50 text-orange-600 flex items-center gap-2">
|
|
805
|
+
<Edit2 className="w-3.5 h-3.5 opacity-70" /> Rellenar vacíos
|
|
806
|
+
</button>
|
|
807
|
+
</div>
|
|
808
|
+
</>
|
|
809
|
+
)}
|
|
810
|
+
</th>
|
|
811
|
+
)})}
|
|
812
|
+
</tr>
|
|
813
|
+
</thead>
|
|
814
|
+
<tbody className="divide-y divide-[var(--border-color)] text-[var(--text-primary)] font-medium">
|
|
815
|
+
{processedData.length === 0 ? (
|
|
816
|
+
<tr>
|
|
817
|
+
<td colSpan={columns.length} className="h-48 text-center text-[var(--text-secondary)]">
|
|
818
|
+
{isDataLoading ? (
|
|
819
|
+
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-2 text-[var(--brand-primary)]" />
|
|
820
|
+
) : (
|
|
821
|
+
<span>Genera una consulta o ajusta tu filtro ({activeScript || 'Vacío'}).</span>
|
|
822
|
+
)}
|
|
823
|
+
</td>
|
|
824
|
+
</tr>
|
|
825
|
+
) : (
|
|
826
|
+
<>
|
|
827
|
+
{virtualItems.length > 0 && virtualItems[0].start > 0 && (
|
|
828
|
+
<tr>
|
|
829
|
+
<td style={{ height: `${virtualItems[0].start}px` }}></td>
|
|
830
|
+
</tr>
|
|
831
|
+
)}
|
|
832
|
+
{virtualItems.map((virtualRow) => {
|
|
833
|
+
const row = processedData[virtualRow.index];
|
|
834
|
+
return (
|
|
835
|
+
<tr
|
|
836
|
+
key={virtualRow.key}
|
|
837
|
+
data-index={virtualRow.index}
|
|
838
|
+
ref={rowVirtualizer.measureElement}
|
|
839
|
+
className="hover:bg-[var(--bg-surface)]/70 transition-colors"
|
|
840
|
+
>
|
|
841
|
+
{columns.map(col => {
|
|
842
|
+
const val = row[col];
|
|
843
|
+
const isNumber = val !== null && val !== undefined && !isNaN(Number(val)) && String(val).trim() !== '';
|
|
844
|
+
return (
|
|
845
|
+
<td key={col} className={`px-6 py-3 max-w-[250px] truncate text-ellipsis ${isNumber ? 'text-right font-mono text-purple-700 font-semibold bg-purple-50/20' : ''}`}>
|
|
846
|
+
<span title={String(val)}>{val !== null && val !== undefined ? String(val) : <span className="text-gray-400 italic">null</span>}</span>
|
|
847
|
+
</td>
|
|
848
|
+
);
|
|
849
|
+
})}
|
|
850
|
+
</tr>
|
|
851
|
+
);
|
|
852
|
+
})}
|
|
853
|
+
{virtualItems.length > 0 && virtualItems[virtualItems.length - 1].end < rowVirtualizer.getTotalSize() && (
|
|
854
|
+
<tr>
|
|
855
|
+
<td style={{ height: `${rowVirtualizer.getTotalSize() - virtualItems[virtualItems.length - 1].end}px` }}></td>
|
|
856
|
+
</tr>
|
|
857
|
+
)}
|
|
858
|
+
</>
|
|
859
|
+
)}
|
|
860
|
+
</tbody>
|
|
861
|
+
</table>
|
|
862
|
+
</div>
|
|
863
|
+
|
|
864
|
+
<div className="px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-elevated)] text-xs font-semibold text-[var(--text-secondary)] flex justify-between items-center z-10">
|
|
865
|
+
<div className="flex items-center gap-4">
|
|
866
|
+
<span>Total Records: {processedData.length}</span>
|
|
867
|
+
<div className="h-4 w-px bg-[var(--border-color)]"></div>
|
|
868
|
+
<div className="flex bg-[var(--bg-surface)] p-0.5 rounded-md border border-[var(--border-color)]">
|
|
869
|
+
<button
|
|
870
|
+
onClick={() => setViewMode('grid')}
|
|
871
|
+
className={`px-3 py-1 flex items-center gap-1.5 rounded-md transition-colors ${viewMode === 'grid' ? 'bg-white shadow-sm text-[var(--brand-primary)]' : 'hover:text-[var(--text-primary)]'}`}
|
|
872
|
+
>
|
|
873
|
+
<Table2 className="w-3.5 h-3.5" /> DataGrid
|
|
874
|
+
</button>
|
|
875
|
+
<button
|
|
876
|
+
onClick={() => setViewMode('dashboard')}
|
|
877
|
+
className={`px-3 py-1 flex items-center gap-1.5 rounded-md transition-colors ${viewMode === 'dashboard' ? 'bg-white shadow-sm text-[var(--brand-primary)]' : 'hover:text-[var(--text-primary)]'}`}
|
|
878
|
+
disabled={processedData.length === 0}
|
|
879
|
+
>
|
|
880
|
+
<BarChart3 className="w-3.5 h-3.5" /> Graficar
|
|
881
|
+
</button>
|
|
882
|
+
</div>
|
|
883
|
+
</div>
|
|
884
|
+
<div className="flex items-center gap-4">
|
|
885
|
+
<span className="opacity-70">Powered by Polars & Rust</span>
|
|
886
|
+
<button
|
|
887
|
+
onClick={handleExportCSV}
|
|
888
|
+
disabled={processedData.length === 0}
|
|
889
|
+
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md hover:bg-[var(--border-color)] transition-colors text-[var(--text-primary)] disabled:opacity-50"
|
|
890
|
+
title="Descargar datos en pantalla como CSV"
|
|
891
|
+
>
|
|
892
|
+
<Download className="w-3.5 h-3.5" />
|
|
893
|
+
<span>Exportar CSV</span>
|
|
894
|
+
</button>
|
|
895
|
+
<button
|
|
896
|
+
onClick={handleSyncDestination}
|
|
897
|
+
disabled={processedData.length === 0 || isSyncing}
|
|
898
|
+
className="flex items-center gap-1.5 px-4 py-1.5 rounded-lg bg-[var(--brand-primary)] hover:bg-[var(--brand-primary-hover)] transition-colors text-white font-bold disabled:opacity-50"
|
|
899
|
+
title="Empujar datos transformados a una tabla en Postgres"
|
|
900
|
+
>
|
|
901
|
+
{isSyncing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Server className="w-4 h-4" />}
|
|
902
|
+
<span>Sync (Push)</span>
|
|
903
|
+
</button>
|
|
904
|
+
</div>
|
|
905
|
+
</div>
|
|
906
|
+
|
|
907
|
+
{filterModalConfig && (
|
|
908
|
+
<DatawayFilterModal
|
|
909
|
+
isOpen={filterModalConfig.isOpen}
|
|
910
|
+
column={filterModalConfig.col}
|
|
911
|
+
colType={filterModalConfig.type}
|
|
912
|
+
onClose={() => setFilterModalConfig(null)}
|
|
913
|
+
onApply={handleApplyFilter}
|
|
914
|
+
/>
|
|
915
|
+
)}
|
|
916
|
+
|
|
917
|
+
<DatawayMathModal
|
|
918
|
+
isOpen={mathModalOpen}
|
|
919
|
+
columns={columns}
|
|
920
|
+
onClose={() => setMathModalOpen(false)}
|
|
921
|
+
onApply={handleApplyMath}
|
|
922
|
+
/>
|
|
923
|
+
|
|
924
|
+
<DatawayPipelineManager
|
|
925
|
+
isOpen={pipelineManagerOpen}
|
|
926
|
+
onClose={() => setPipelineManagerOpen(false)}
|
|
927
|
+
currentScript={compilePipelineScript(pipelineSteps)}
|
|
928
|
+
activeConnector={activeConnector}
|
|
929
|
+
activeResource={activeTable}
|
|
930
|
+
onLoadPipeline={handleLoadPipeline}
|
|
931
|
+
/>
|
|
932
|
+
|
|
933
|
+
{isSchemaMapperOpen && activeTable && (
|
|
934
|
+
<DatawaySchemaMapper
|
|
935
|
+
sourceConnector={activeConnector}
|
|
936
|
+
sourceResource={activeTable}
|
|
937
|
+
targetConnector={"postgres"}
|
|
938
|
+
targetResource={"ventas"}
|
|
939
|
+
onClose={() => setIsSchemaMapperOpen(false)}
|
|
940
|
+
/>
|
|
941
|
+
)}
|
|
942
|
+
|
|
943
|
+
{isOrchestratorOpen && (
|
|
944
|
+
<DatawayOrchestratorCanvas onClose={() => setIsOrchestratorOpen(false)} />
|
|
945
|
+
)}
|
|
946
|
+
</div>
|
|
947
|
+
)}
|
|
948
|
+
</div>
|
|
949
|
+
</div>
|
|
950
|
+
</div>
|
|
951
|
+
<style>{`
|
|
952
|
+
.custom-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
953
|
+
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
|
954
|
+
.custom-scrollbar::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 10px; }
|
|
955
|
+
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }
|
|
956
|
+
`}</style>
|
|
957
|
+
</div>
|
|
958
|
+
);
|
|
959
|
+
};
|