294 lines
12 KiB
HTML
294 lines
12 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
<title>Dashboard</title>
|
|
<style>
|
|
:root { --radius: 10px; }
|
|
* { box-sizing: border-box; }
|
|
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji'; background:#f6f7fb; color:#111; }
|
|
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e8e8ef; padding:16px 20px; display:flex; gap:12px; align-items:center; z-index:1;}
|
|
header h1 { margin:0; font-size:18px; font-weight:600;}
|
|
main { padding: 20px; max-width: 1200px; margin: 0 auto; }
|
|
.card { background:#fff; border:1px solid #e8e8ef; border-radius: var(--radius); padding:16px; }
|
|
.row { display:flex; gap:16px; align-items: center; flex-wrap:wrap; }
|
|
select, input, button, textarea { font: inherit; padding:10px; border-radius:8px; border:1px solid #d7d7e0; background:#fff; }
|
|
select:focus, input:focus, textarea:focus { outline: none; border-color:#888; }
|
|
button { cursor:pointer; }
|
|
button.primary { background:#111; color:#fff; border-color:#111; }
|
|
table { width:100%; border-collapse: collapse; }
|
|
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
|
|
th, td { padding:10px 12px; border-bottom: 1px solid #eee; vertical-align: top; }
|
|
.muted { color:#666; }
|
|
.tabs { display:flex; gap:6px; margin-top:12px; }
|
|
.tab { padding:8px 10px; border:1px solid #e0e0ea; border-bottom:none; background:#fafafa; border-top-left-radius:8px; border-top-right-radius:8px; cursor:pointer; font-size:14px; }
|
|
.tab.active { background:#fff; border-color:#e0e0ea; }
|
|
.panel { border:1px solid #e0e0ea; border-radius: 0 8px 8px 8px; padding:16px; background:#fff; }
|
|
.grid { display:grid; grid-template-columns: repeat(auto-fill,minmax(220px,1fr)); gap:12px; }
|
|
.help { font-size:12px; color:#777; margin-top:6px; }
|
|
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
|
|
.toolbar { display:flex; gap:10px; align-items:center; }
|
|
.spacer { flex:1 }
|
|
.error { color:#b00020; }
|
|
.success { color:#0a7d28; }
|
|
.sr-only{ position:absolute; width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
|
|
details summary { cursor:pointer; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Dashboard</h1>
|
|
<div class="spacer"></div>
|
|
<span class="pill muted">/api/*</span>
|
|
</header>
|
|
|
|
<main class="card">
|
|
<div class="row" style="margin-bottom:12px;">
|
|
<label for="tableSelect" class="sr-only">Tabla</label>
|
|
<select id="tableSelect"></select>
|
|
<div class="spacer"></div>
|
|
<div class="toolbar">
|
|
<button id="refreshBtn">Recargar</button>
|
|
<span id="status" class="muted"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tabs">
|
|
<button class="tab active" data-tab="datos">Datos</button>
|
|
<button class="tab" data-tab="nuevo">Nuevo</button>
|
|
<button class="tab" data-tab="esquema">Esquema</button>
|
|
</div>
|
|
<section class="panel" id="panel-datos">
|
|
<div class="help">Mostrando hasta <span id="limitInfo">100</span> filas.</div>
|
|
<div id="tableContainer" style="overflow:auto;"></div>
|
|
</section>
|
|
<section class="panel" id="panel-nuevo" hidden>
|
|
<form id="insertForm" class="grid"></form>
|
|
<div class="row" style="margin-top:10px;">
|
|
<div class="spacer"></div>
|
|
<button id="insertBtn" class="primary">Insertar</button>
|
|
</div>
|
|
<div id="insertMsg" class="help"></div>
|
|
</section>
|
|
<section class="panel" id="panel-esquema" hidden>
|
|
<pre id="schemaPre" style="white-space:pre-wrap;"></pre>
|
|
</section>
|
|
|
|
<details style="margin-top:16px;">
|
|
<summary>Endpoints</summary>
|
|
<div class="help">GET /api/tables • GET /api/schema/:tabla • GET /api/table/:tabla?limit=100 • POST /api/table/:tabla</div>
|
|
</details>
|
|
</main>
|
|
|
|
<script>
|
|
const $ = (s, r=document) => r.querySelector(s);
|
|
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
|
|
const state = { tables: [], table: null, schema: null, limit: 100 };
|
|
|
|
// Tabs
|
|
$$('.tab').forEach(t => t.addEventListener('click', () => {
|
|
$$('.tab').forEach(x => x.classList.remove('active'));
|
|
t.classList.add('active');
|
|
const tab = t.dataset.tab;
|
|
$('#panel-datos').hidden = tab !== 'datos';
|
|
$('#panel-nuevo').hidden = tab !== 'nuevo';
|
|
$('#panel-esquema').hidden = tab !== 'esquema';
|
|
}));
|
|
|
|
$('#refreshBtn').addEventListener('click', () => {
|
|
if (state.table) {
|
|
loadSchema(state.table);
|
|
loadData(state.table);
|
|
}
|
|
});
|
|
|
|
async function init() {
|
|
setStatus('Cargando tablas…');
|
|
const res = await fetch('/api/tables');
|
|
const tables = await res.json();
|
|
state.tables = tables;
|
|
const sel = $('#tableSelect');
|
|
sel.innerHTML = '';
|
|
tables.forEach(name => {
|
|
const o = document.createElement('option');
|
|
o.value = name; o.textContent = name;
|
|
sel.appendChild(o);
|
|
});
|
|
sel.addEventListener('change', () => selectTable(sel.value));
|
|
if (tables.length) {
|
|
selectTable(tables[0]);
|
|
} else {
|
|
setStatus('No hay tablas disponibles.');
|
|
}
|
|
}
|
|
|
|
async function selectTable(tbl) {
|
|
state.table = tbl;
|
|
await loadSchema(tbl);
|
|
await loadData(tbl);
|
|
buildForm();
|
|
}
|
|
|
|
async function loadSchema(tbl) {
|
|
const res = await fetch(`/api/schema/${tbl}`);
|
|
state.schema = await res.json();
|
|
$('#schemaPre').textContent = JSON.stringify(state.schema, null, 2);
|
|
}
|
|
|
|
async function loadData(tbl) {
|
|
setStatus('Cargando datos…');
|
|
const res = await fetch(`/api/table/${tbl}?limit=${state.limit}`);
|
|
const data = await res.json();
|
|
$('#limitInfo').textContent = String(state.limit);
|
|
renderTable(data);
|
|
clearStatus();
|
|
}
|
|
|
|
function renderTable(rows) {
|
|
const c = $('#tableContainer');
|
|
c.innerHTML = '';
|
|
if (!rows.length) { c.innerHTML = '<div class="muted">Sin datos.</div>'; return; }
|
|
const headers = Object.keys(rows[0]);
|
|
const table = document.createElement('table');
|
|
table.innerHTML = `
|
|
<thead><tr>${headers.map(h => '<th>'+h+'</th>').join('')}</tr></thead>
|
|
<tbody></tbody>
|
|
`;
|
|
const tbody = table.querySelector('tbody');
|
|
for (const row of rows) {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = headers.map(h => '<td>'+formatCell(row[h])+'</td>').join('');
|
|
tbody.appendChild(tr);
|
|
}
|
|
c.appendChild(table);
|
|
}
|
|
|
|
function formatCell(v) {
|
|
if (v === null || v === undefined) return '<span class="muted">NULL</span>';
|
|
if (typeof v === 'boolean') return v ? '✓' : '—';
|
|
if (typeof v === 'string' && v.match(/^\\d{4}-\\d{2}-\\d{2}/)) return new Date(v).toLocaleString();
|
|
return String(v);
|
|
}
|
|
|
|
function buildForm() {
|
|
const form = $('#insertForm');
|
|
form.innerHTML = '';
|
|
if (!state.schema) return;
|
|
for (const col of state.schema.columns) {
|
|
// Omitir PK auto y columnas generadas
|
|
if (col.is_primary || col.is_identity || (col.column_default || '').startsWith('nextval(')) continue;
|
|
|
|
const wrap = document.createElement('div');
|
|
const id = 'f_'+col.column_name;
|
|
wrap.innerHTML = `
|
|
<label for="${id}" class="muted">${col.column_name} <span class="muted">${col.data_type}</span> ${col.is_nullable ? '' : '<span class="pill">requerido</span>'}</label>
|
|
<div data-input></div>
|
|
${col.column_default ? '<div class="help">DEFAULT: '+col.column_default+'</div>' : ''}
|
|
`;
|
|
const holder = wrap.querySelector('[data-input]');
|
|
|
|
if (col.foreign) {
|
|
const sel = document.createElement('select');
|
|
sel.id = id;
|
|
holder.appendChild(sel);
|
|
hydrateOptions(sel, state.schema.table, col.column_name);
|
|
} else if (col.data_type.includes('boolean')) {
|
|
const inp = document.createElement('input');
|
|
inp.id = id; inp.type = 'checkbox';
|
|
holder.appendChild(inp);
|
|
} else if (col.data_type.includes('timestamp')) {
|
|
const inp = document.createElement('input');
|
|
inp.id = id; inp.type = 'datetime-local'; inp.required = !col.is_nullable && !col.column_default;
|
|
holder.appendChild(inp);
|
|
} else if (col.data_type.includes('date')) {
|
|
const inp = document.createElement('input');
|
|
inp.id = id; inp.type = 'date'; inp.required = !col.is_nullable && !col.column_default;
|
|
holder.appendChild(inp);
|
|
} else if (col.data_type.match(/numeric|integer|real|double/)) {
|
|
const inp = document.createElement('input');
|
|
inp.id = id; inp.type = 'number'; inp.step = 'any'; inp.required = !col.is_nullable && !col.column_default;
|
|
holder.appendChild(inp);
|
|
} else if (col.data_type.includes('text') || col.data_type.includes('character')) {
|
|
const inp = document.createElement('input');
|
|
inp.id = id; inp.type = 'text'; inp.required = !col.is_nullable && !col.column_default;
|
|
holder.appendChild(inp);
|
|
} else {
|
|
const inp = document.createElement('input');
|
|
inp.id = id; inp.type = 'text'; inp.required = !col.is_nullable && !col.column_default;
|
|
holder.appendChild(inp);
|
|
}
|
|
form.appendChild(wrap);
|
|
}
|
|
}
|
|
|
|
async function hydrateOptions(selectEl, table, column) {
|
|
selectEl.innerHTML = '<option value="">Cargando…</option>';
|
|
const res = await fetch(`/api/options/${table}/${column}`);
|
|
const opts = await res.json();
|
|
selectEl.innerHTML = '<option value="">Seleccione…</option>' + opts.map(o => `<option value="${o.id}">${o.label}</option>`).join('');
|
|
}
|
|
|
|
$('#insertBtn').addEventListener('click', async (e) => {
|
|
e.preventDefault();
|
|
if (!state.table) return;
|
|
const payload = {};
|
|
for (const col of state.schema.columns) {
|
|
if (col.is_primary || col.is_identity || (col.column_default || '').startsWith('nextval(')) continue;
|
|
const id = 'f_'+col.column_name;
|
|
const el = document.getElementById(id);
|
|
if (!el) continue;
|
|
|
|
let val = null;
|
|
if (el.type === 'checkbox') {
|
|
val = el.checked;
|
|
} else if (el.type === 'datetime-local' && el.value) {
|
|
// Convertir a ISO
|
|
val = new Date(el.value).toISOString().slice(0,19).replace('T',' ');
|
|
} else if (el.tagName === 'SELECT') {
|
|
val = el.value ? (isNaN(el.value) ? el.value : Number(el.value)) : null;
|
|
} else if (el.type === 'number') {
|
|
val = el.value === '' ? null : Number(el.value);
|
|
} else {
|
|
val = el.value === '' ? null : el.value;
|
|
}
|
|
|
|
if (val === null && !col.is_nullable && !col.column_default) {
|
|
showInsertMsg('Completa: '+col.column_name, true);
|
|
return;
|
|
}
|
|
if (val !== null) payload[col.column_name] = val;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/table/${state.table}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type':'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Error');
|
|
showInsertMsg('Insertado correctamente (id: '+(data.inserted?.id || '?')+')', false);
|
|
// Reset form
|
|
$('#insertForm').reset?.();
|
|
await loadData(state.table);
|
|
} catch (e) {
|
|
showInsertMsg(e.message, true);
|
|
}
|
|
});
|
|
|
|
function showInsertMsg(msg, isError=false) {
|
|
const m = $('#insertMsg');
|
|
m.className = 'help ' + (isError ? 'error' : 'success');
|
|
m.textContent = msg;
|
|
}
|
|
|
|
function setStatus(text) { $('#status').textContent = text; }
|
|
function clearStatus() { setStatus(''); }
|
|
|
|
// Start
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|