@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,707 @@
|
|
|
1
|
+
import React, { useState, useEffect, useMemo } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { toast } from 'sonner';
|
|
4
|
+
import {
|
|
5
|
+
Database, TableProperties, RefreshCw, Plus, Edit2, Trash2, X,
|
|
6
|
+
Check, Search, Filter, ServerCrash, DatabaseZap, LayoutGrid,
|
|
7
|
+
ChevronRight, AlertCircle, Loader2, ArrowUpDown, Server
|
|
8
|
+
} from 'lucide-react';
|
|
9
|
+
|
|
10
|
+
const baseUrl = (import.meta as any).env?.VITE_API_BASE_URL || 'http://localhost:3001';
|
|
11
|
+
|
|
12
|
+
type SortConfig = { key: string; direction: 'asc' | 'desc' } | null;
|
|
13
|
+
|
|
14
|
+
export const DatabaseAdminView = () => {
|
|
15
|
+
// Dynamic Schema & Tables State
|
|
16
|
+
const [connectors] = useState<{id: string, name: string}[]>([
|
|
17
|
+
{ id: 'postgres', name: 'PostgreSQL Core' },
|
|
18
|
+
{ id: 'google_sheets', name: 'Google Sheets MCP' }
|
|
19
|
+
]);
|
|
20
|
+
const [activeConnector, setActiveConnector] = useState('postgres');
|
|
21
|
+
const [tables, setTables] = useState<string[]>([]);
|
|
22
|
+
const [activeTable, setActiveTable] = useState<string | null>(null);
|
|
23
|
+
|
|
24
|
+
// UI states
|
|
25
|
+
const [isTablesLoading, setIsTablesLoading] = useState(true);
|
|
26
|
+
const [isDataLoading, setIsDataLoading] = useState(false);
|
|
27
|
+
const [data, setData] = useState<any[]>([]);
|
|
28
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
29
|
+
const [sortConfig, setSortConfig] = useState<SortConfig>(null);
|
|
30
|
+
const [connectionError, setConnectionError] = useState<string | null>(null);
|
|
31
|
+
|
|
32
|
+
// Schema View States
|
|
33
|
+
const [viewMode, setViewMode] = useState<'data' | 'schema'>('data');
|
|
34
|
+
const [schemaData, setSchemaData] = useState<any[]>([]);
|
|
35
|
+
const [isSchemaLoading, setIsSchemaLoading] = useState(false);
|
|
36
|
+
|
|
37
|
+
// Form & Modal States
|
|
38
|
+
const [isFormOpen, setIsFormOpen] = useState(false);
|
|
39
|
+
const [editRecord, setEditRecord] = useState<any>(null);
|
|
40
|
+
const [isCreating, setIsCreating] = useState(false);
|
|
41
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
42
|
+
|
|
43
|
+
// 1. Fetch available tables dynamically from the backend
|
|
44
|
+
const fetchSchema = async () => {
|
|
45
|
+
setIsTablesLoading(true);
|
|
46
|
+
setConnectionError(null);
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(`${baseUrl}/api/dataway/resources?connectorId=${activeConnector}`, {
|
|
49
|
+
headers: { 'x-api-key': 'dev_ak_chameleon_studio_99x' }
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) throw new Error('API Endpoint /api/dataway/resources no disponible');
|
|
52
|
+
|
|
53
|
+
const result = await res.json();
|
|
54
|
+
const tableList = Array.isArray(result.resources) ? result.resources : [];
|
|
55
|
+
setTables(tableList);
|
|
56
|
+
|
|
57
|
+
if (tableList.length > 0) setActiveTable(tableList[0]);
|
|
58
|
+
else setActiveTable(null);
|
|
59
|
+
} catch (error: any) {
|
|
60
|
+
console.error('[DB Admin] Failed to load schema:', error);
|
|
61
|
+
setConnectionError(error.message);
|
|
62
|
+
} finally {
|
|
63
|
+
setIsTablesLoading(false);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
fetchSchema();
|
|
69
|
+
}, [activeConnector]);
|
|
70
|
+
|
|
71
|
+
// 2. Fetch specific table data using Universal Dataway
|
|
72
|
+
const fetchTableData = async (tableName: string, silent = false) => {
|
|
73
|
+
if (!silent) setIsDataLoading(true);
|
|
74
|
+
setSearchTerm(''); // Reset search on table change
|
|
75
|
+
setSortConfig(null);
|
|
76
|
+
try {
|
|
77
|
+
// Using generic Dataway routing instead of direct DB endpoints
|
|
78
|
+
const res = await fetch(`${baseUrl}/api/dataway/query`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: {
|
|
81
|
+
'x-api-key': 'dev_ak_chameleon_studio_99x',
|
|
82
|
+
'Content-Type': 'application/json'
|
|
83
|
+
},
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
connectorId: activeConnector,
|
|
86
|
+
resource: tableName,
|
|
87
|
+
type: activeConnector === 'postgres' ? 'table' : 'range'
|
|
88
|
+
})
|
|
89
|
+
});
|
|
90
|
+
if (!res.ok) throw new Error(`El backend Dataway rechazó la lectura de la tabla ${tableName}`);
|
|
91
|
+
|
|
92
|
+
const result = await res.json();
|
|
93
|
+
setData(Array.isArray(result.data) ? result.data : []);
|
|
94
|
+
} catch (error: any) {
|
|
95
|
+
toast.error(`Acceso Denegado a ${tableName}`, { description: error.message });
|
|
96
|
+
setData([]); // NO MOCKS.
|
|
97
|
+
} finally {
|
|
98
|
+
setIsDataLoading(false);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const fetchTableSchema = async (tableName: string) => {
|
|
103
|
+
setIsSchemaLoading(true);
|
|
104
|
+
try {
|
|
105
|
+
const res = await fetch(`${baseUrl}/api/dataway/schema?connectorId=${activeConnector}&resource=${tableName}`, {
|
|
106
|
+
headers: { 'x-api-key': 'dev_ak_chameleon_studio_99x' }
|
|
107
|
+
});
|
|
108
|
+
if (!res.ok) throw new Error(`El Dataway backend rechazó el esquema de ${tableName}`);
|
|
109
|
+
|
|
110
|
+
const result = await res.json();
|
|
111
|
+
setSchemaData(result.schema || []);
|
|
112
|
+
} catch (error: any) {
|
|
113
|
+
toast.error(`Error de Esquema Dataway`, { description: error.message });
|
|
114
|
+
setSchemaData([]);
|
|
115
|
+
} finally {
|
|
116
|
+
setIsSchemaLoading(false);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (activeTable) {
|
|
122
|
+
fetchTableData(activeTable);
|
|
123
|
+
fetchTableSchema(activeTable);
|
|
124
|
+
setViewMode('data'); // Reset to data view on table change
|
|
125
|
+
}
|
|
126
|
+
}, [activeTable]);
|
|
127
|
+
|
|
128
|
+
// 3. Handle Sort
|
|
129
|
+
const handleSort = (key: string) => {
|
|
130
|
+
let direction: 'asc' | 'desc' = 'asc';
|
|
131
|
+
if (sortConfig && sortConfig.key === key && sortConfig.direction === 'asc') {
|
|
132
|
+
direction = 'desc';
|
|
133
|
+
}
|
|
134
|
+
setSortConfig({ key, direction });
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Derived processed data
|
|
138
|
+
const processedData = useMemo(() => {
|
|
139
|
+
let sortableItems = [...data];
|
|
140
|
+
|
|
141
|
+
// Search Filter
|
|
142
|
+
if (searchTerm) {
|
|
143
|
+
const lowercasedFilter = searchTerm.toLowerCase();
|
|
144
|
+
sortableItems = sortableItems.filter(item => {
|
|
145
|
+
return Object.keys(item).some(key =>
|
|
146
|
+
String(item[key]).toLowerCase().includes(lowercasedFilter)
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Sorting
|
|
152
|
+
if (sortConfig !== null) {
|
|
153
|
+
sortableItems.sort((a, b) => {
|
|
154
|
+
const aVal = a[sortConfig.key];
|
|
155
|
+
const bVal = b[sortConfig.key];
|
|
156
|
+
|
|
157
|
+
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
|
|
158
|
+
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
|
|
159
|
+
return 0;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return sortableItems;
|
|
164
|
+
}, [data, searchTerm, sortConfig]);
|
|
165
|
+
|
|
166
|
+
// Ocultar ID, created_at u otros metadatos complejos en columnas automáticas si lo deseamos,
|
|
167
|
+
// pero por defecto, en un DDBB Admin pro, queremos ver todo (o truncado)
|
|
168
|
+
const columns = data.length > 0 ? Object.keys(data[0]) : [];
|
|
169
|
+
|
|
170
|
+
// 4. CRUD Actions
|
|
171
|
+
const handleSave = async (record: any) => {
|
|
172
|
+
if (!activeTable) return;
|
|
173
|
+
setIsSaving(true);
|
|
174
|
+
const isNew = isCreating;
|
|
175
|
+
const method = isNew ? 'POST' : 'PUT';
|
|
176
|
+
const url = isNew
|
|
177
|
+
? `${baseUrl}/api/db/${activeTable}`
|
|
178
|
+
: `${baseUrl}/api/db/${activeTable}/${record.id}`;
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const payload = { ...record };
|
|
182
|
+
if (isNew) delete payload.id; // Evitar enviar ID en POST si es autoincremental/UUID
|
|
183
|
+
|
|
184
|
+
const res = await fetch(url, {
|
|
185
|
+
method,
|
|
186
|
+
headers: {
|
|
187
|
+
'Content-Type': 'application/json',
|
|
188
|
+
'x-api-key': 'dev_ak_chameleon_studio_99x'
|
|
189
|
+
},
|
|
190
|
+
body: JSON.stringify(payload)
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (!res.ok) {
|
|
194
|
+
const err = await res.json().catch(() => ({}));
|
|
195
|
+
throw new Error(err.message || err.error || 'Error desconocido al guardar en base de datos');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
toast.success(`Registro ${isNew ? 'creado' : 'actualizado'} en la tabla ${activeTable}`);
|
|
199
|
+
setIsFormOpen(false);
|
|
200
|
+
|
|
201
|
+
// Reload from server source of truth
|
|
202
|
+
fetchTableData(activeTable, true);
|
|
203
|
+
} catch (error: any) {
|
|
204
|
+
toast.error('Error de Integridad', { description: error.message });
|
|
205
|
+
} finally {
|
|
206
|
+
setIsSaving(false);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const handleDelete = async (id: string | number) => {
|
|
211
|
+
if (!activeTable) return;
|
|
212
|
+
if (!confirm('¿Atención: Esta acción es destructiva y permanente. ¿Confirmar eliminación?')) return;
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const res = await fetch(`${baseUrl}/api/db/${activeTable}/${id}`, {
|
|
216
|
+
method: 'DELETE',
|
|
217
|
+
headers: { 'x-api-key': 'dev_ak_chameleon_studio_99x' }
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (!res.ok) throw new Error('No se pudo eliminar el registro (violación de Foreign Key o ruta no implementada)');
|
|
221
|
+
|
|
222
|
+
toast.success('Registro eliminado exitosamente');
|
|
223
|
+
// Optimistic update
|
|
224
|
+
setData(prev => prev.filter(r => r.id !== id));
|
|
225
|
+
} catch (error: any) {
|
|
226
|
+
toast.error('Fallo al Eliminar', { description: error.message });
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const openEditModal = (record: any) => {
|
|
231
|
+
setEditRecord({ ...record });
|
|
232
|
+
setIsCreating(false);
|
|
233
|
+
setIsFormOpen(true);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const openCreateModal = () => {
|
|
237
|
+
const structuralRecord: any = {};
|
|
238
|
+
if (columns.length > 0) {
|
|
239
|
+
columns.forEach(col => {
|
|
240
|
+
if (col !== 'id' && col !== 'created_at' && col !== 'updated_at') {
|
|
241
|
+
structuralRecord[col] = '';
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
setEditRecord(structuralRecord);
|
|
246
|
+
setIsCreating(true);
|
|
247
|
+
setIsFormOpen(true);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const inferInputType = (val: any) => {
|
|
251
|
+
if (typeof val === 'boolean') return 'checkbox';
|
|
252
|
+
if (typeof val === 'number') return 'number';
|
|
253
|
+
return 'text';
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<div className="w-full h-full flex flex-row overflow-hidden" style={{
|
|
258
|
+
'--bg-primary': 'var(--ds-surface-primary, #ffffff)',
|
|
259
|
+
'--bg-elevated': 'var(--ds-surface-secondary, #f8fafc)',
|
|
260
|
+
'--bg-surface': 'var(--ds-surface-tertiary, #f1f5f9)',
|
|
261
|
+
'--border-color': 'var(--ds-border-default, #e2e8f0)',
|
|
262
|
+
'--text-primary': 'var(--ds-text-primary, #0f172a)',
|
|
263
|
+
'--text-secondary': 'var(--ds-text-secondary, #64748b)',
|
|
264
|
+
'--brand-primary': 'var(--ds-accent-blue, #3b82f6)',
|
|
265
|
+
'--brand-primary-hover': 'var(--ds-accent-blue-hover, #2563eb)',
|
|
266
|
+
} as React.CSSProperties}>
|
|
267
|
+
|
|
268
|
+
{/* Sidebar de Estructura */}
|
|
269
|
+
<div className="w-72 border-r border-[var(--border-color)] bg-[var(--bg-elevated)] flex flex-col h-full z-10 shadow-[2px_0_10px_rgba(0,0,0,0.02)]">
|
|
270
|
+
<div className="p-6 border-b border-[var(--border-color)] flex flex-col gap-4 bg-[var(--bg-primary)]">
|
|
271
|
+
<div className="flex items-center gap-3">
|
|
272
|
+
<div className="p-2 bg-[var(--brand-primary)]/10 rounded-xl">
|
|
273
|
+
<DatabaseZap className="w-6 h-6 text-[var(--brand-primary)]" />
|
|
274
|
+
</div>
|
|
275
|
+
<div>
|
|
276
|
+
<h2 className="font-extrabold text-[var(--text-primary)] tracking-tight text-lg leading-none">Data Studio</h2>
|
|
277
|
+
<p className="text-[11px] font-medium text-[var(--text-secondary)] uppercase tracking-wider mt-1">Dataway Core</p>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
<select
|
|
282
|
+
value={activeConnector}
|
|
283
|
+
onChange={(e) => setActiveConnector(e.target.value)}
|
|
284
|
+
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 hover:border-[var(--brand-primary)]/50 focus:border-[var(--brand-primary)] focus:ring-2 focus:ring-[var(--brand-primary)]/20 transition-all shadow-sm"
|
|
285
|
+
>
|
|
286
|
+
{connectors.map(c => (
|
|
287
|
+
<option key={c.id} value={c.id}>{c.name}</option>
|
|
288
|
+
))}
|
|
289
|
+
</select>
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
<div className="p-4 flex-1 overflow-y-auto w-full space-y-1.5 custom-scrollbar">
|
|
293
|
+
<h3 className="text-xs font-bold text-[var(--text-secondary)] tracking-wider uppercase mb-3 px-2 flex items-center justify-between">
|
|
294
|
+
Esquema Púbico
|
|
295
|
+
<button onClick={fetchSchema} className="hover:text-[var(--text-primary)] transition-colors p-1 rounded-md hover:bg-[var(--bg-surface)]">
|
|
296
|
+
<RefreshCw className="w-3 h-3" />
|
|
297
|
+
</button>
|
|
298
|
+
</h3>
|
|
299
|
+
|
|
300
|
+
{isTablesLoading ? (
|
|
301
|
+
<div className="space-y-2 px-2">
|
|
302
|
+
{[1,2,3,4].map(skeleton => (
|
|
303
|
+
<div key={skeleton} className="h-10 bg-[var(--bg-surface)] rounded-lg animate-pulse w-full"></div>
|
|
304
|
+
))}
|
|
305
|
+
</div>
|
|
306
|
+
) : connectionError ? (
|
|
307
|
+
<div className="p-4 mx-2 bg-red-50 text-red-600 rounded-xl border border-red-100 flex flex-col items-center text-center gap-2">
|
|
308
|
+
<ServerCrash className="w-6 h-6" />
|
|
309
|
+
<p className="text-sm font-semibold">Backend Offline</p>
|
|
310
|
+
<p className="text-xs opacity-80">{connectionError}</p>
|
|
311
|
+
</div>
|
|
312
|
+
) : tables.length === 0 ? (
|
|
313
|
+
<div className="p-4 text-center text-[var(--text-secondary)] text-sm">
|
|
314
|
+
No se descubrieron tablas vía /api/db/tables.
|
|
315
|
+
</div>
|
|
316
|
+
) : (
|
|
317
|
+
tables.map(table => (
|
|
318
|
+
<button
|
|
319
|
+
key={table}
|
|
320
|
+
onClick={() => setActiveTable(table)}
|
|
321
|
+
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm font-semibold transition-all duration-200 group ${
|
|
322
|
+
activeTable === table
|
|
323
|
+
? 'bg-gradient-to-r from-[var(--brand-primary)] to-[var(--brand-primary-hover)] text-white shadow-md shadow-blue-500/20'
|
|
324
|
+
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-surface)] hover:text-[var(--text-primary)]'
|
|
325
|
+
}`}
|
|
326
|
+
>
|
|
327
|
+
<div className="flex items-center gap-3">
|
|
328
|
+
<TableProperties className={`w-4 h-4 ${activeTable === table ? 'text-blue-100' : 'text-slate-400 group-hover:text-slate-600'}`} />
|
|
329
|
+
<span className="capitalize">{table}</span>
|
|
330
|
+
</div>
|
|
331
|
+
{activeTable === table && <ChevronRight className="w-4 h-4 opacity-70" />}
|
|
332
|
+
</button>
|
|
333
|
+
))
|
|
334
|
+
)}
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
{/* Panel Principal */}
|
|
339
|
+
<div className="flex-1 bg-[var(--bg-surface)] flex flex-col h-full min-w-0 relative">
|
|
340
|
+
{/* Header Superior */}
|
|
341
|
+
<header className="px-8 py-6 bg-[var(--bg-primary)] border-b border-[var(--border-color)] flex items-center justify-between sticky top-0 z-20 shadow-sm">
|
|
342
|
+
<div className="flex flex-col">
|
|
343
|
+
<div className="flex items-center gap-2 text-[var(--text-secondary)] text-xs font-bold uppercase tracking-wider mb-1">
|
|
344
|
+
<Server className="w-3.5 h-3.5" />
|
|
345
|
+
<span>{connectors.find(c => c.id === activeConnector)?.name} / {activeTable || '...'}</span>
|
|
346
|
+
</div>
|
|
347
|
+
<h1 className="text-3xl font-extrabold text-[var(--text-primary)] capitalize tracking-tight flex items-center gap-3">
|
|
348
|
+
{activeTable || 'Selecciona un Recurso'}
|
|
349
|
+
{isDataLoading && <Loader2 className="w-5 h-5 animate-spin text-[var(--brand-primary)]" />}
|
|
350
|
+
</h1>
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
{activeTable && (
|
|
354
|
+
<div className="flex gap-4 items-center">
|
|
355
|
+
{/* Toggle Mode Segmented Control */}
|
|
356
|
+
<div className="flex bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded-lg p-1 shadow-sm h-[42px] items-center mr-2">
|
|
357
|
+
<button onClick={() => setViewMode('data')}
|
|
358
|
+
className={`px-4 py-1.5 text-xs font-bold rounded-md transition-all duration-200 ${
|
|
359
|
+
viewMode === 'data'
|
|
360
|
+
? 'bg-[var(--bg-primary)] text-[var(--text-primary)] shadow-sm border border-[var(--border-color)]'
|
|
361
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-transparent'
|
|
362
|
+
}`}>
|
|
363
|
+
Registros
|
|
364
|
+
</button>
|
|
365
|
+
<button onClick={() => setViewMode('schema')}
|
|
366
|
+
className={`px-4 py-1.5 text-xs font-bold rounded-md transition-all duration-200 ${
|
|
367
|
+
viewMode === 'schema'
|
|
368
|
+
? 'bg-[var(--bg-primary)] text-[var(--brand-primary)] shadow-sm border border-[var(--brand-primary)]/30'
|
|
369
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-transparent'
|
|
370
|
+
}`}>
|
|
371
|
+
Estructura
|
|
372
|
+
</button>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
<div className="relative group">
|
|
376
|
+
<Search className="w-4 h-4 text-[var(--text-secondary)] absolute left-3.5 top-1/2 -translate-y-1/2 group-focus-within:text-[var(--brand-primary)] transition-colors" />
|
|
377
|
+
<input
|
|
378
|
+
type="text"
|
|
379
|
+
placeholder={`Buscar en ${activeTable}...`}
|
|
380
|
+
value={searchTerm}
|
|
381
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
382
|
+
className="pl-10 pr-4 py-2.5 rounded-xl border border-[var(--border-color)] bg-[var(--bg-elevated)] text-[var(--text-primary)] text-sm font-medium outline-none focus:ring-2 focus:ring-[var(--brand-primary)]/20 focus:border-[var(--brand-primary)] transition-all w-72 shadow-inner"
|
|
383
|
+
/>
|
|
384
|
+
</div>
|
|
385
|
+
<div className="h-8 w-px bg-[var(--border-color)] mx-2"></div>
|
|
386
|
+
<button
|
|
387
|
+
onClick={() => fetchTableData(activeTable)}
|
|
388
|
+
disabled={isDataLoading}
|
|
389
|
+
className="p-2.5 text-[var(--text-secondary)] hover:bg-[var(--bg-surface)] hover:text-[var(--text-primary)] rounded-xl transition-all disabled:opacity-50 border border-transparent hover:border-[var(--border-color)]"
|
|
390
|
+
title="Recargar Data"
|
|
391
|
+
>
|
|
392
|
+
<RefreshCw className={`w-[18px] h-[18px] ${isDataLoading ? 'animate-spin' : ''}`} />
|
|
393
|
+
</button>
|
|
394
|
+
<button
|
|
395
|
+
onClick={openCreateModal}
|
|
396
|
+
disabled={tables.length === 0}
|
|
397
|
+
className="flex items-center gap-2 bg-[var(--brand-primary)] hover:bg-[var(--brand-primary-hover)] text-white px-5 py-2.5 rounded-xl font-bold text-sm shadow-lg shadow-[var(--brand-primary)]/25 transition-all active:scale-95 disabled:opacity-50 disabled:grayscale"
|
|
398
|
+
>
|
|
399
|
+
<Plus className="w-[18px] h-[18px]" />
|
|
400
|
+
Insertar Fila
|
|
401
|
+
</button>
|
|
402
|
+
</div>
|
|
403
|
+
)}
|
|
404
|
+
</header>
|
|
405
|
+
|
|
406
|
+
{/* Área de Grilla de Datos */}
|
|
407
|
+
<div className="flex-1 overflow-hidden p-6 relative flex flex-col">
|
|
408
|
+
{!activeTable ? (
|
|
409
|
+
<div className="m-auto flex flex-col items-center justify-center opacity-40">
|
|
410
|
+
<LayoutGrid className="w-16 h-16 mb-4" />
|
|
411
|
+
<h2 className="text-xl font-bold">Sin Datos Activos</h2>
|
|
412
|
+
<p className="text-sm">Selecciona una tabla en el panel lateral.</p>
|
|
413
|
+
</div>
|
|
414
|
+
) : (
|
|
415
|
+
<div className="flex-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-2xl shadow-sm overflow-hidden flex flex-col relative">
|
|
416
|
+
{/* Pro Data Grid */}
|
|
417
|
+
<div className="overflow-auto flex-1 custom-scrollbar">
|
|
418
|
+
{viewMode === 'schema' ? (
|
|
419
|
+
<table className="w-full text-left text-sm whitespace-nowrap">
|
|
420
|
+
<thead className="bg-[var(--bg-elevated)] sticky top-0 z-10 shadow-[0_1px_2px_rgba(0,0,0,0.05)]">
|
|
421
|
+
<tr>
|
|
422
|
+
<th className="px-6 py-4 text-xs font-bold text-[var(--text-secondary)] uppercase tracking-wider">Columna</th>
|
|
423
|
+
<th className="px-6 py-4 text-xs font-bold text-[var(--text-secondary)] uppercase tracking-wider">Tipo de Dato</th>
|
|
424
|
+
<th className="px-6 py-4 text-xs font-bold text-[var(--text-secondary)] uppercase tracking-wider">Nulabilidad</th>
|
|
425
|
+
<th className="px-6 py-4 text-xs font-bold text-[var(--text-secondary)] uppercase tracking-wider">Valor por Defecto</th>
|
|
426
|
+
</tr>
|
|
427
|
+
</thead>
|
|
428
|
+
<tbody className="divide-y divide-[var(--border-color)] text-[var(--text-primary)] font-medium">
|
|
429
|
+
{isSchemaLoading ? (
|
|
430
|
+
<tr>
|
|
431
|
+
<td colSpan={4} className="h-48 text-center text-[var(--text-secondary)]">
|
|
432
|
+
<div className="flex flex-col items-center gap-3">
|
|
433
|
+
<Loader2 className="w-8 h-8 animate-spin text-[var(--brand-primary)]" />
|
|
434
|
+
<span>Cargando esquema estructural...</span>
|
|
435
|
+
</div>
|
|
436
|
+
</td>
|
|
437
|
+
</tr>
|
|
438
|
+
) : (
|
|
439
|
+
schemaData.map((col: any) => (
|
|
440
|
+
<motion.tr
|
|
441
|
+
initial={{ opacity: 0 }}
|
|
442
|
+
animate={{ opacity: 1 }}
|
|
443
|
+
key={col.name}
|
|
444
|
+
className="hover:bg-[var(--bg-surface)]/70 transition-colors"
|
|
445
|
+
>
|
|
446
|
+
<td className="px-6 py-3">
|
|
447
|
+
<span className="font-bold text-[var(--text-primary)]">{col.name}</span>
|
|
448
|
+
</td>
|
|
449
|
+
<td className="px-6 py-3">
|
|
450
|
+
<span className="font-mono text-[11px] text-blue-600 bg-blue-50 px-2 py-1 rounded-md">
|
|
451
|
+
{col.type}
|
|
452
|
+
</span>
|
|
453
|
+
</td>
|
|
454
|
+
<td className="px-6 py-3">
|
|
455
|
+
{col.is_nullable === 'YES'
|
|
456
|
+
? <span className="opacity-60 text-[11px] font-bold uppercase">Nullable</span>
|
|
457
|
+
: <span className="text-red-600 bg-red-50 px-2 py-0.5 rounded text-[10px] font-bold uppercase">Not Null</span>
|
|
458
|
+
}
|
|
459
|
+
</td>
|
|
460
|
+
<td className="px-6 py-3">
|
|
461
|
+
{col.default_value ? (
|
|
462
|
+
<span className="font-mono text-[11px] text-purple-600 bg-purple-50 px-2 py-1 rounded-md">
|
|
463
|
+
{col.default_value}
|
|
464
|
+
</span>
|
|
465
|
+
) : <span className="text-gray-400 italic">None</span>}
|
|
466
|
+
</td>
|
|
467
|
+
</motion.tr>
|
|
468
|
+
))
|
|
469
|
+
)}
|
|
470
|
+
</tbody>
|
|
471
|
+
</table>
|
|
472
|
+
) : (
|
|
473
|
+
<table className="w-full text-left text-sm whitespace-nowrap">
|
|
474
|
+
<thead className="bg-[var(--bg-elevated)] sticky top-0 z-10 shadow-[0_1px_2px_rgba(0,0,0,0.05)]">
|
|
475
|
+
<tr>
|
|
476
|
+
{columns.map(col => (
|
|
477
|
+
<th
|
|
478
|
+
key={col}
|
|
479
|
+
onClick={() => handleSort(col)}
|
|
480
|
+
className="px-6 py-4 cursor-pointer hover:bg-[var(--bg-surface)] transition-colors group select-none"
|
|
481
|
+
>
|
|
482
|
+
<div className="flex items-center gap-2 text-xs font-bold text-[var(--text-secondary)] uppercase tracking-wider">
|
|
483
|
+
{col}
|
|
484
|
+
<ArrowUpDown className={`w-3.5 h-3.5 ${sortConfig?.key === col ? 'text-[var(--brand-primary)]' : 'opacity-0 group-hover:opacity-50 transition-opacity'}`} />
|
|
485
|
+
</div>
|
|
486
|
+
</th>
|
|
487
|
+
))}
|
|
488
|
+
<th className="px-6 py-4 w-24 sticky right-0 bg-[var(--bg-elevated)] backdrop-blur-md shadow-[-4px_0_10px_rgba(0,0,0,0.02)]">
|
|
489
|
+
<span className="sr-only">Actions</span>
|
|
490
|
+
</th>
|
|
491
|
+
</tr>
|
|
492
|
+
</thead>
|
|
493
|
+
<tbody className="divide-y divide-[var(--border-color)] text-[var(--text-primary)] font-medium">
|
|
494
|
+
{processedData.length === 0 ? (
|
|
495
|
+
<tr>
|
|
496
|
+
<td colSpan={columns.length + 1} className="h-48 text-center text-[var(--text-secondary)]">
|
|
497
|
+
{isDataLoading ? (
|
|
498
|
+
<div className="flex flex-col items-center gap-3">
|
|
499
|
+
<Loader2 className="w-8 h-8 animate-spin text-[var(--brand-primary)]" />
|
|
500
|
+
<span>Consultando {activeTable}...</span>
|
|
501
|
+
</div>
|
|
502
|
+
) : (
|
|
503
|
+
<div className="flex flex-col items-center gap-3 opacity-60">
|
|
504
|
+
<Database className="w-10 h-10" />
|
|
505
|
+
<span>La consulta retornó 0 filas.</span>
|
|
506
|
+
</div>
|
|
507
|
+
)}
|
|
508
|
+
</td>
|
|
509
|
+
</tr>
|
|
510
|
+
) : (
|
|
511
|
+
processedData.map((row, i) => (
|
|
512
|
+
<motion.tr
|
|
513
|
+
initial={{ opacity: 0 }}
|
|
514
|
+
animate={{ opacity: 1 }}
|
|
515
|
+
transition={{ duration: 0.2, delay: Math.min(i * 0.02, 0.5) }}
|
|
516
|
+
key={row.id || i}
|
|
517
|
+
className="hover:bg-[var(--bg-surface)]/70 transition-colors group"
|
|
518
|
+
>
|
|
519
|
+
{columns.map(col => {
|
|
520
|
+
const val = row[col];
|
|
521
|
+
const isObject = typeof val === 'object' && val !== null;
|
|
522
|
+
const isBoolean = typeof val === 'boolean';
|
|
523
|
+
|
|
524
|
+
return (
|
|
525
|
+
<td key={col} className="px-6 py-3 max-w-[250px] truncate text-ellipsis">
|
|
526
|
+
{isBoolean ? (
|
|
527
|
+
<span className={`px-2 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider ${val ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
|
528
|
+
{val ? 'TRUE' : 'FALSE'}
|
|
529
|
+
</span>
|
|
530
|
+
) : isObject ? (
|
|
531
|
+
<span className="font-mono text-[11px] text-emerald-600 bg-emerald-50 px-2 py-1 rounded-md">
|
|
532
|
+
{JSON.stringify(val)}
|
|
533
|
+
</span>
|
|
534
|
+
) : (
|
|
535
|
+
<span title={String(val)}>{val !== null && val !== undefined ? String(val) : <span className="text-gray-400 italic">null</span>}</span>
|
|
536
|
+
)}
|
|
537
|
+
</td>
|
|
538
|
+
);
|
|
539
|
+
})}
|
|
540
|
+
<td className="px-6 py-3 text-right sticky right-0 bg-[var(--bg-primary)] group-hover:bg-[#f8fafc] transition-colors shadow-[-4px_0_10px_rgba(0,0,0,0.02)] border-l border-transparent group-hover:border-[var(--border-color)]">
|
|
541
|
+
<div className="flex items-center justify-end gap-1 opacity-10 md:opacity-0 group-hover:opacity-100 transition-opacity">
|
|
542
|
+
<button
|
|
543
|
+
onClick={() => openEditModal(row)}
|
|
544
|
+
className="p-1.5 hover:bg-blue-100 text-blue-600 rounded-md transition-colors tooltip-trigger"
|
|
545
|
+
title="Editar Registro"
|
|
546
|
+
>
|
|
547
|
+
<Edit2 className="w-4 h-4" />
|
|
548
|
+
</button>
|
|
549
|
+
<button
|
|
550
|
+
onClick={() => handleDelete(row.id)}
|
|
551
|
+
className="p-1.5 hover:bg-red-100 text-red-600 rounded-md transition-colors tooltip-trigger"
|
|
552
|
+
title="Eliminar Permanente"
|
|
553
|
+
>
|
|
554
|
+
<Trash2 className="w-4 h-4" />
|
|
555
|
+
</button>
|
|
556
|
+
</div>
|
|
557
|
+
</td>
|
|
558
|
+
</motion.tr>
|
|
559
|
+
))
|
|
560
|
+
)}
|
|
561
|
+
</tbody>
|
|
562
|
+
</table>
|
|
563
|
+
)}
|
|
564
|
+
</div>
|
|
565
|
+
|
|
566
|
+
{/* Footer del Grid */}
|
|
567
|
+
<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">
|
|
568
|
+
<span>Total Records: {processedData.length}</span>
|
|
569
|
+
<span className="uppercase tracking-wider">Macia Platform v2.0 - Core Gateway</span>
|
|
570
|
+
</div>
|
|
571
|
+
</div>
|
|
572
|
+
)}
|
|
573
|
+
</div>
|
|
574
|
+
</div>
|
|
575
|
+
|
|
576
|
+
{/* Modal Glassmórfico de Edición/Creación Pro */}
|
|
577
|
+
<AnimatePresence>
|
|
578
|
+
{isFormOpen && (
|
|
579
|
+
<motion.div
|
|
580
|
+
initial={{ opacity: 0 }}
|
|
581
|
+
animate={{ opacity: 1 }}
|
|
582
|
+
exit={{ opacity: 0 }}
|
|
583
|
+
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[var(--text-primary)]/40 backdrop-blur-md"
|
|
584
|
+
>
|
|
585
|
+
<motion.div
|
|
586
|
+
initial={{ scale: 0.95, opacity: 0, y: 20 }}
|
|
587
|
+
animate={{ scale: 1, opacity: 1, y: 0 }}
|
|
588
|
+
exit={{ scale: 0.95, opacity: 0, y: -20 }}
|
|
589
|
+
transition={{ type: "spring", bounce: 0, duration: 0.3 }}
|
|
590
|
+
className="bg-[var(--bg-primary)] border border-white/20 rounded-3xl shadow-[0_20px_60px_-10px_rgba(0,0,0,0.3)] w-full max-w-2xl overflow-hidden flex flex-col relative"
|
|
591
|
+
>
|
|
592
|
+
{/* Gradiente sutil en el header */}
|
|
593
|
+
<div className="absolute top-0 left-0 w-full h-32 bg-gradient-to-br from-blue-500/10 to-purple-500/5 pointer-events-none"></div>
|
|
594
|
+
|
|
595
|
+
<div className="px-8 py-5 border-b border-[var(--border-color)] flex justify-between items-center relative z-10 bg-[var(--bg-primary)]/80 backdrop-blur-lg">
|
|
596
|
+
<div>
|
|
597
|
+
<h3 className="font-extrabold text-xl text-[var(--text-primary)] tracking-tight">
|
|
598
|
+
{isCreating ? 'Insertar Documento' : 'Modificar Documento'}
|
|
599
|
+
</h3>
|
|
600
|
+
<p className="text-xs text-[var(--text-secondary)] font-medium mt-0.5">
|
|
601
|
+
Tabla destino: <span className="uppercase tracking-wider bg-[var(--bg-surface)] px-1.5 py-0.5 rounded text-[var(--brand-primary)]">{activeTable}</span>
|
|
602
|
+
</p>
|
|
603
|
+
</div>
|
|
604
|
+
<button onClick={() => setIsFormOpen(false)} className="p-2 bg-[var(--bg-surface)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--border-color)] rounded-xl transition-all">
|
|
605
|
+
<X className="w-5 h-5" />
|
|
606
|
+
</button>
|
|
607
|
+
</div>
|
|
608
|
+
|
|
609
|
+
<div className="p-8 space-y-5 max-h-[65vh] overflow-y-auto custom-scrollbar relative z-10">
|
|
610
|
+
{editRecord && Object.keys(editRecord).map((key) => {
|
|
611
|
+
// Protect immutable fields based on Decido OS Postgres patterns
|
|
612
|
+
const isImmutable = (!isCreating && key === 'id') || key === 'created_at' || key === 'updated_at';
|
|
613
|
+
const val = editRecord[key];
|
|
614
|
+
const inferredType = inferInputType(val);
|
|
615
|
+
|
|
616
|
+
return (
|
|
617
|
+
<div key={key} className="flex flex-col gap-1.5">
|
|
618
|
+
<label className="text-[11px] font-bold text-[var(--text-secondary)] uppercase tracking-wider pl-1 flex justify-between">
|
|
619
|
+
{key}
|
|
620
|
+
{isImmutable && <span className="opacity-50 font-mono lowercase">auto-generated</span>}
|
|
621
|
+
</label>
|
|
622
|
+
|
|
623
|
+
{isImmutable ? (
|
|
624
|
+
<input
|
|
625
|
+
disabled
|
|
626
|
+
value={val || 'Depende del Motor DB'}
|
|
627
|
+
className="w-full px-4 py-3 rounded-xl border border-[var(--border-color)] bg-[var(--bg-surface)] text-[var(--text-secondary)] font-mono text-xs cursor-not-allowed opacity-70"
|
|
628
|
+
/>
|
|
629
|
+
) : typeof val === 'boolean' ? (
|
|
630
|
+
<select
|
|
631
|
+
value={String(val)}
|
|
632
|
+
onChange={e => setEditRecord({ ...editRecord, [key]: e.target.value === 'true' })}
|
|
633
|
+
className="w-full px-4 py-3 rounded-xl border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] focus:ring-2 focus:ring-[var(--brand-primary)]/20 focus:border-[var(--brand-primary)] text-sm font-medium outline-none transition-all cursor-pointer shadow-sm"
|
|
634
|
+
>
|
|
635
|
+
<option value="true">Verdadero (TRUE)</option>
|
|
636
|
+
<option value="false">Falso (FALSE)</option>
|
|
637
|
+
</select>
|
|
638
|
+
) : typeof val === 'object' && val !== null ? (
|
|
639
|
+
<textarea
|
|
640
|
+
rows={4}
|
|
641
|
+
value={JSON.stringify(val, null, 2)}
|
|
642
|
+
onChange={e => {
|
|
643
|
+
try {
|
|
644
|
+
const parsed = JSON.parse(e.target.value);
|
|
645
|
+
setEditRecord({ ...editRecord, [key]: parsed });
|
|
646
|
+
} catch (err) {
|
|
647
|
+
// Provide a mechanism to handle invalid JSON edits later if needed,
|
|
648
|
+
// but for now, just allow arbitrary text entry representing the JSON blob.
|
|
649
|
+
setEditRecord({ ...editRecord, [key]: e.target.value });
|
|
650
|
+
}
|
|
651
|
+
}}
|
|
652
|
+
className="w-full px-4 py-3 rounded-xl border border-[var(--border-color)] bg-[var(--bg-primary)] font-mono text-xs focus:ring-2 focus:ring-[var(--brand-primary)]/20 focus:border-[var(--brand-primary)] outline-none transition-all shadow-sm"
|
|
653
|
+
/>
|
|
654
|
+
) : (
|
|
655
|
+
<input
|
|
656
|
+
type={inferredType}
|
|
657
|
+
value={val !== null ? val : ''}
|
|
658
|
+
onChange={e => setEditRecord({ ...editRecord, [key]: inferredType === 'number' ? Number(e.target.value) : e.target.value })}
|
|
659
|
+
className="w-full px-4 py-3 rounded-xl border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] focus:ring-2 focus:ring-[var(--brand-primary)]/20 focus:border-[var(--brand-primary)] text-sm font-medium outline-none transition-all shadow-sm"
|
|
660
|
+
placeholder={`Ingresar ${key}...`}
|
|
661
|
+
/>
|
|
662
|
+
)}
|
|
663
|
+
</div>
|
|
664
|
+
)
|
|
665
|
+
})}
|
|
666
|
+
</div>
|
|
667
|
+
|
|
668
|
+
<div className="px-8 py-5 border-t border-[var(--border-color)] bg-[var(--bg-surface)] flex justify-end gap-3 rounded-b-3xl relative z-10">
|
|
669
|
+
<button
|
|
670
|
+
onClick={() => setIsFormOpen(false)}
|
|
671
|
+
className="px-5 py-2.5 rounded-xl text-sm font-bold text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--border-color)] transition-all"
|
|
672
|
+
>
|
|
673
|
+
Descartar
|
|
674
|
+
</button>
|
|
675
|
+
<button
|
|
676
|
+
onClick={() => handleSave(editRecord)}
|
|
677
|
+
disabled={isSaving}
|
|
678
|
+
className="flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-extrabold text-white bg-gradient-to-r from-[var(--brand-primary)] to-[var(--brand-primary-hover)] shadow-lg shadow-[var(--brand-primary)]/30 transition-all active:scale-95 disabled:opacity-70"
|
|
679
|
+
>
|
|
680
|
+
{isSaving ? <Loader2 className="w-[18px] h-[18px] animate-spin" /> : <Check className="w-[18px] h-[18px]" />}
|
|
681
|
+
{isSaving ? 'Sincronizando...' : 'Confirmar Cambios'}
|
|
682
|
+
</button>
|
|
683
|
+
</div>
|
|
684
|
+
</motion.div>
|
|
685
|
+
</motion.div>
|
|
686
|
+
)}
|
|
687
|
+
</AnimatePresence>
|
|
688
|
+
|
|
689
|
+
<style>{`
|
|
690
|
+
.custom-scrollbar::-webkit-scrollbar {
|
|
691
|
+
width: 6px;
|
|
692
|
+
height: 6px;
|
|
693
|
+
}
|
|
694
|
+
.custom-scrollbar::-webkit-scrollbar-track {
|
|
695
|
+
background: transparent;
|
|
696
|
+
}
|
|
697
|
+
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
698
|
+
background: var(--border-color);
|
|
699
|
+
border-radius: 10px;
|
|
700
|
+
}
|
|
701
|
+
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
702
|
+
background: var(--text-secondary);
|
|
703
|
+
}
|
|
704
|
+
`}</style>
|
|
705
|
+
</div>
|
|
706
|
+
);
|
|
707
|
+
};
|