first commit
This commit is contained in:
549
apps/web/pages/admin.vue
Normal file
549
apps/web/pages/admin.vue
Normal file
@@ -0,0 +1,549 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Header />
|
||||
<main class="container">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Admin — Control de ingesta</h1>
|
||||
<p>Gestiona el scheduler y encola la ingesta manualmente.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>Email (admin)</label>
|
||||
<input v-model="email" placeholder="admin@example.com" />
|
||||
<label>Password</label>
|
||||
<input type="password" v-model="password" placeholder="password" />
|
||||
<div class="row">
|
||||
<button class="btn" @click="login">Login</button>
|
||||
<button class="btn ghost" @click="logout">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>API Key (fallback)</label>
|
||||
<input v-model="key" placeholder="Introduce API key" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="row">
|
||||
<button class="btn" @click="pause">Pause scheduler</button>
|
||||
<button class="btn ghost" @click="resume">Resume scheduler</button>
|
||||
<button class="btn" @click="queue">Enqueue ingest</button>
|
||||
<button class="btn ghost" @click="run">Run now (sync)</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header class="section-head">
|
||||
<h2>Monitorización</h2>
|
||||
<button class="btn ghost" @click="loadStatus">Refresh</button>
|
||||
</header>
|
||||
<div v-if="status" class="status-grid">
|
||||
<div class="status-card">
|
||||
<strong>Catálogo</strong>
|
||||
<span>Items: {{ status.counts.catalogItems }}</span>
|
||||
<span>Versiones: {{ status.counts.catalogVersions }}</span>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<strong>Alertas</strong>
|
||||
<span>Perfiles: {{ status.counts.alertProfiles }}</span>
|
||||
<span>Entregas: {{ status.counts.alertDeliveries }}</span>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<strong>Usuarios</strong>
|
||||
<span>Total: {{ status.counts.users }}</span>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<strong>Última ingesta</strong>
|
||||
<span>{{ status.lastIngest?.status || '—' }}</span>
|
||||
<span>{{ formatDate(status.lastIngest?.startedAt) }}</span>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<strong>Últimas alertas</strong>
|
||||
<span>{{ status.lastAlerts?.status || '—' }}</span>
|
||||
<span>{{ formatDate(status.lastAlerts?.startedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="muted">Sin datos de monitorización.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header class="section-head">
|
||||
<h2>Backups</h2>
|
||||
<div class="row">
|
||||
<button class="btn" @click="runBackup">Run backup</button>
|
||||
<button class="btn ghost" @click="loadBackups">Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
<ul v-if="backups.length" class="backup-list">
|
||||
<li v-for="file in backups" :key="file">{{ file }}</li>
|
||||
</ul>
|
||||
<div v-else class="muted">No hay backups aún.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header class="section-head">
|
||||
<h2>Ingest runs</h2>
|
||||
<button class="btn ghost" @click="loadRuns">Refresh</button>
|
||||
</header>
|
||||
<div v-if="runs.length" class="runs">
|
||||
<div v-for="run in runs" :key="run.id" class="run-row">
|
||||
<div>
|
||||
<strong>{{ run.status }}</strong>
|
||||
<div class="muted">Inicio: {{ formatDate(run.startedAt) }}</div>
|
||||
<div class="muted" v-if="run.finishedAt">Fin: {{ formatDate(run.finishedAt) }}</div>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<span>Importados: {{ run.imported }}</span>
|
||||
<span>Actualizados: {{ run.updated }}</span>
|
||||
<span>Errores: {{ run.errorCount }}</span>
|
||||
<span v-if="run.durationMs">Duración: {{ Math.round(run.durationMs / 1000) }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="muted">Sin ejecuciones recientes.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header class="section-head">
|
||||
<h2>Planes</h2>
|
||||
<button class="btn ghost" @click="loadPlans">Refresh</button>
|
||||
</header>
|
||||
<div v-if="plans.length" class="plans">
|
||||
<div v-for="plan in plans" :key="plan.id" class="plan-row">
|
||||
<div>
|
||||
<strong>{{ plan.name }}</strong>
|
||||
<div class="muted">{{ plan.code }} · {{ plan.priceMonthly }} {{ plan.currency }}/mes</div>
|
||||
</div>
|
||||
<div class="muted">{{ plan.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="muted">Sin planes.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header class="section-head">
|
||||
<h2>Usuarios</h2>
|
||||
<button class="btn ghost" @click="loadUsers">Refresh</button>
|
||||
</header>
|
||||
<div v-if="users.length" class="users">
|
||||
<div v-for="user in users" :key="user.id" class="user-row">
|
||||
<div>
|
||||
<strong>{{ user.email }}</strong>
|
||||
<div class="muted">{{ user.role }} · {{ formatDate(user.createdAt) }}</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<select v-model="user.planId" @change="updateUserPlan(user)">
|
||||
<option value="">Sin plan</option>
|
||||
<option v-for="plan in plans" :key="plan.id" :value="plan.id">
|
||||
{{ plan.name }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="user.role" @change="updateUserRole(user)">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="muted">Sin usuarios.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header class="section-head">
|
||||
<h2>Alert runs</h2>
|
||||
<button class="btn ghost" @click="loadAlertRuns">Refresh</button>
|
||||
</header>
|
||||
<div v-if="alertRuns.length" class="runs">
|
||||
<div v-for="run in alertRuns" :key="run.id" class="run-row">
|
||||
<div>
|
||||
<strong>{{ run.status }}</strong>
|
||||
<div class="muted">Inicio: {{ formatDate(run.startedAt) }}</div>
|
||||
<div class="muted" v-if="run.finishedAt">Fin: {{ formatDate(run.finishedAt) }}</div>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<span>Perfiles: {{ run.profiles }}</span>
|
||||
<span>Enviadas: {{ run.sent }}</span>
|
||||
<span>Fallidas: {{ run.failed }}</span>
|
||||
<span v-if="run.durationMs">Duración: {{ Math.round(run.durationMs / 1000) }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="muted">Sin ejecuciones recientes.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" v-if="msg">
|
||||
<pre>{{ msg }}</pre>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from '~/components/Header.vue'
|
||||
import { ref } from 'vue'
|
||||
const config = useRuntimeConfig()
|
||||
const key = ref(localStorage.getItem('admin_key') || '')
|
||||
const token = ref(localStorage.getItem('admin_token') || '')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const msg = ref('')
|
||||
const runs = ref([])
|
||||
const alertRuns = ref([])
|
||||
const plans = ref([])
|
||||
const users = ref([])
|
||||
const status = ref(null)
|
||||
const backups = ref([])
|
||||
|
||||
function saveKey() {
|
||||
localStorage.setItem('admin_key', key.value)
|
||||
}
|
||||
|
||||
function saveToken(t) {
|
||||
token.value = t
|
||||
if (t) localStorage.setItem('admin_token', t)
|
||||
else localStorage.removeItem('admin_token')
|
||||
}
|
||||
|
||||
async function login() {
|
||||
try {
|
||||
const res = await $fetch(`${config.public.apiBase}/auth/login`, { method: 'POST', body: { email: email.value, password: password.value } })
|
||||
saveToken(res.access_token)
|
||||
msg.value = 'Logged in'
|
||||
await loadStatus()
|
||||
await loadBackups()
|
||||
await loadRuns()
|
||||
await loadAlertRuns()
|
||||
await loadPlans()
|
||||
await loadUsers()
|
||||
} catch (e) { msg.value = 'Login failed: ' + String(e) }
|
||||
}
|
||||
|
||||
function logout() {
|
||||
saveToken('')
|
||||
msg.value = 'Logged out'
|
||||
}
|
||||
|
||||
async function pause() {
|
||||
saveKey()
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
const res = await $fetch(`${config.public.apiBase}/admin/ingest/pause`, { method: 'POST', headers })
|
||||
msg.value = JSON.stringify(res, null, 2)
|
||||
await loadRuns()
|
||||
} catch (e) { msg.value = String(e) }
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
saveKey()
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
const res = await $fetch(`${config.public.apiBase}/admin/ingest/resume`, { method: 'POST', headers })
|
||||
msg.value = JSON.stringify(res, null, 2)
|
||||
await loadRuns()
|
||||
} catch (e) { msg.value = String(e) }
|
||||
}
|
||||
|
||||
async function queue() {
|
||||
saveKey()
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
const res = await $fetch(`${config.public.apiBase}/ingest/queue`, { method: 'POST', headers })
|
||||
msg.value = JSON.stringify(res, null, 2)
|
||||
await loadRuns()
|
||||
} catch (e) { msg.value = String(e) }
|
||||
}
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const res = await $fetch(`${config.public.apiBase}/ingest/run`)
|
||||
msg.value = JSON.stringify(res, null, 2)
|
||||
await loadRuns()
|
||||
} catch (e) { msg.value = String(e) }
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
else return
|
||||
const res = await $fetch(`${config.public.apiBase}/admin/monitor/status`, { headers })
|
||||
status.value = res
|
||||
} catch (e) {
|
||||
msg.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBackups() {
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
else return
|
||||
const res = await $fetch(`${config.public.apiBase}/admin/backup/list`, { headers })
|
||||
backups.value = res || []
|
||||
} catch (e) {
|
||||
msg.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function runBackup() {
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
else return
|
||||
const res = await $fetch(`${config.public.apiBase}/admin/backup/run`, { method: 'POST', headers })
|
||||
msg.value = JSON.stringify(res, null, 2)
|
||||
await loadBackups()
|
||||
} catch (e) {
|
||||
msg.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRuns() {
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
else return
|
||||
const res = await $fetch(`${config.public.apiBase}/admin/ingest/runs`, { headers })
|
||||
runs.value = res || []
|
||||
} catch (e) {
|
||||
msg.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlans() {
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
else return
|
||||
const res = await $fetch(`${config.public.apiBase}/admin/plans`, { headers })
|
||||
plans.value = res || []
|
||||
} catch (e) {
|
||||
msg.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
else return
|
||||
const res = await $fetch(`${config.public.apiBase}/admin/users`, { headers })
|
||||
users.value = res || []
|
||||
} catch (e) {
|
||||
msg.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUserPlan(user) {
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
else return
|
||||
await $fetch(`${config.public.apiBase}/admin/users/${user.id}/plan`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: { planId: user.planId }
|
||||
})
|
||||
messageToast('Plan actualizado')
|
||||
} catch (e) {
|
||||
msg.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUserRole(user) {
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
else return
|
||||
await $fetch(`${config.public.apiBase}/admin/users/${user.id}/role`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: { role: user.role }
|
||||
})
|
||||
messageToast('Rol actualizado')
|
||||
} catch (e) {
|
||||
msg.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAlertRuns() {
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
else return
|
||||
const res = await $fetch(`${config.public.apiBase}/admin/alerts/runs`, { headers })
|
||||
alertRuns.value = res || []
|
||||
} catch (e) {
|
||||
msg.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—'
|
||||
return new Date(value).toLocaleString('es-ES')
|
||||
}
|
||||
|
||||
function messageToast(text) {
|
||||
msg.value = text
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStatus()
|
||||
loadBackups()
|
||||
loadRuns()
|
||||
loadAlertRuns()
|
||||
loadPlans()
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f8fafc;
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.runs {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.run-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.8rem;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.metrics {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
text-align: right;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.plans,
|
||||
.users {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.status-card {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
padding: 0.8rem;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.backup-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.plan-row,
|
||||
.user-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.8rem;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
align-items: center;
|
||||
}
|
||||
.user-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
select {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #cbd5f5;
|
||||
background: #fff;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
label {
|
||||
font-weight: 600;
|
||||
margin-top: 0.6rem;
|
||||
display: block;
|
||||
}
|
||||
input {
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #cbd5f5;
|
||||
width: 100%;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid #0f172a;
|
||||
color: #0f172a;
|
||||
}
|
||||
</style>
|
||||
290
apps/web/pages/alerts.vue
Normal file
290
apps/web/pages/alerts.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Header />
|
||||
<main class="container">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Perfiles de alerta</h1>
|
||||
<p>Define reglas para recibir novedades por email o Telegram.</p>
|
||||
</div>
|
||||
<button class="btn" @click="openCreate">Nuevo perfil</button>
|
||||
</header>
|
||||
|
||||
<section class="profiles">
|
||||
<article v-for="profile in profiles" :key="profile.id" class="profile-card">
|
||||
<div>
|
||||
<h3>{{ profile.name }}</h3>
|
||||
<p class="muted">{{ profile.channel }} · {{ profile.frequency }}</p>
|
||||
<p v-if="profile.queryText" class="rule">Texto: {{ profile.queryText }}</p>
|
||||
<p v-if="profile.rules?.publishers?.length" class="rule">Publicadores: {{ profile.rules.publishers.join(', ') }}</p>
|
||||
<p v-if="profile.rules?.formats?.length" class="rule">Formatos: {{ profile.rules.formats.join(', ') }}</p>
|
||||
<p v-if="profile.rules?.topics?.length" class="rule">Temas: {{ profile.rules.topics.join(', ') }}</p>
|
||||
<p v-if="profile.rules?.territories?.length" class="rule">Territorios: {{ profile.rules.territories.join(', ') }}</p>
|
||||
<p v-if="profile.rules?.updatedWithinDays" class="rule">Actualizados en {{ profile.rules.updatedWithinDays }} días</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn ghost" @click="runProfile(profile.id)">Probar</button>
|
||||
<button class="btn" @click="editProfile(profile)">Editar</button>
|
||||
<button class="btn danger" @click="removeProfile(profile.id)">Eliminar</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<dialog ref="dialog" class="dialog">
|
||||
<form method="dialog" @submit.prevent="save">
|
||||
<h2>{{ editing ? 'Editar perfil' : 'Nuevo perfil' }}</h2>
|
||||
<label>Nombre</label>
|
||||
<input v-model="form.name" required />
|
||||
<label>Canal</label>
|
||||
<select v-model="form.channel">
|
||||
<option value="email">Email</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
</select>
|
||||
<label>Destino (email o chat id)</label>
|
||||
<input v-model="form.channelTarget" />
|
||||
<label>Frecuencia</label>
|
||||
<select v-model="form.frequency">
|
||||
<option value="instant">Instant</option>
|
||||
<option value="daily">Daily</option>
|
||||
</select>
|
||||
<label>Texto libre</label>
|
||||
<input v-model="form.queryText" placeholder="subvenciones, licitaciones..." />
|
||||
<label>Publicadores (coma)</label>
|
||||
<input v-model="publishers" />
|
||||
<label>Formatos (coma)</label>
|
||||
<input v-model="formats" />
|
||||
<label>Temas (coma)</label>
|
||||
<input v-model="topics" />
|
||||
<label>Territorios (coma)</label>
|
||||
<input v-model="territories" />
|
||||
<label>Actualizados en los últimos días</label>
|
||||
<input v-model.number="updatedWithinDays" type="number" min="1" />
|
||||
<div class="dialog-actions">
|
||||
<button class="btn ghost" @click="closeDialog">Cancelar</button>
|
||||
<button class="btn" type="submit">Guardar</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div v-if="message" class="toast">{{ message }}</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from '~/components/Header.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const dialog = ref(null)
|
||||
const message = ref('')
|
||||
const editing = ref(false)
|
||||
const profiles = ref([])
|
||||
|
||||
const form = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
channel: 'email',
|
||||
channelTarget: '',
|
||||
frequency: 'daily',
|
||||
queryText: '',
|
||||
rules: { publishers: [], formats: [], topics: [], territories: [], updatedWithinDays: undefined }
|
||||
})
|
||||
const publishers = ref('')
|
||||
const formats = ref('')
|
||||
const topics = ref('')
|
||||
const territories = ref('')
|
||||
const updatedWithinDays = ref(null)
|
||||
|
||||
async function load() {
|
||||
const res = await $fetch(`${config.public.apiBase}/alerts/profiles`)
|
||||
profiles.value = res || []
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = false
|
||||
resetForm()
|
||||
dialog.value?.showModal()
|
||||
}
|
||||
|
||||
function editProfile(profile) {
|
||||
editing.value = true
|
||||
form.id = profile.id
|
||||
form.name = profile.name
|
||||
form.channel = profile.channel
|
||||
form.channelTarget = profile.channelTarget
|
||||
form.frequency = profile.frequency
|
||||
form.queryText = profile.queryText
|
||||
form.rules = profile.rules || { publishers: [], formats: [], topics: [], territories: [], updatedWithinDays: undefined }
|
||||
publishers.value = (form.rules.publishers || []).join(', ')
|
||||
formats.value = (form.rules.formats || []).join(', ')
|
||||
topics.value = (form.rules.topics || []).join(', ')
|
||||
territories.value = (form.rules.territories || []).join(', ')
|
||||
updatedWithinDays.value = form.rules.updatedWithinDays ?? null
|
||||
dialog.value?.showModal()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.id = ''
|
||||
form.name = ''
|
||||
form.channel = 'email'
|
||||
form.channelTarget = ''
|
||||
form.frequency = 'daily'
|
||||
form.queryText = ''
|
||||
form.rules = { publishers: [], formats: [], topics: [], territories: [], updatedWithinDays: undefined }
|
||||
publishers.value = ''
|
||||
formats.value = ''
|
||||
topics.value = ''
|
||||
territories.value = ''
|
||||
updatedWithinDays.value = null
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
dialog.value?.close()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const days = Number(updatedWithinDays.value)
|
||||
form.rules = {
|
||||
publishers: splitList(publishers.value),
|
||||
formats: splitList(formats.value),
|
||||
topics: splitList(topics.value),
|
||||
territories: splitList(territories.value),
|
||||
updatedWithinDays: Number.isFinite(days) && days > 0 ? days : undefined
|
||||
}
|
||||
|
||||
if (editing.value) {
|
||||
await $fetch(`${config.public.apiBase}/alerts/profiles/${form.id}`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
} else {
|
||||
await $fetch(`${config.public.apiBase}/alerts/profiles`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
}
|
||||
message.value = 'Perfil guardado'
|
||||
dialog.value?.close()
|
||||
await load()
|
||||
}
|
||||
|
||||
async function runProfile(id) {
|
||||
const res = await $fetch(`${config.public.apiBase}/alerts/run/${id}`, { method: 'POST' })
|
||||
message.value = `Alertas enviadas: ${res.sent || 0}`
|
||||
}
|
||||
|
||||
async function removeProfile(id) {
|
||||
await $fetch(`${config.public.apiBase}/alerts/profiles/${id}`, { method: 'DELETE' })
|
||||
message.value = 'Perfil eliminado'
|
||||
await load()
|
||||
}
|
||||
|
||||
function splitList(value) {
|
||||
return value
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f1f5f9;
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid #0f172a;
|
||||
color: #0f172a;
|
||||
}
|
||||
.btn.danger {
|
||||
background: #dc2626;
|
||||
}
|
||||
.profiles {
|
||||
display: grid;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
.profile-card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.rule {
|
||||
color: #475569;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
.dialog {
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
width: min(420px, 90vw);
|
||||
}
|
||||
.dialog form {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.dialog input,
|
||||
.dialog select {
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #cbd5f5;
|
||||
}
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.toast {
|
||||
margin-top: 1.5rem;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
padding: 0.8rem 1.4rem;
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.profile-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
.actions {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
129
apps/web/pages/catalog.vue
Normal file
129
apps/web/pages/catalog.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Header />
|
||||
<main class="container">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Catálogo</h1>
|
||||
<p>Datasets ingestados desde datos.gob.es con clasificación básica.</p>
|
||||
</div>
|
||||
<button class="btn" @click="refresh">Actualizar</button>
|
||||
</header>
|
||||
|
||||
<div v-if="pending" class="state">Cargando...</div>
|
||||
<div v-else-if="error" class="state error">Error: {{ error.message }}</div>
|
||||
<section v-else class="grid">
|
||||
<article v-for="item in items" :key="item.id" class="card">
|
||||
<div class="card-header">
|
||||
<h3>{{ item.title }}</h3>
|
||||
<span class="pill">{{ item.format || 'Sin formato' }}</span>
|
||||
</div>
|
||||
<p class="publisher">{{ item.publisher || 'Sin publicador' }}</p>
|
||||
<p class="desc" v-if="item.description">{{ item.description }}</p>
|
||||
<div class="card-footer">
|
||||
<small>Actualizado: {{ formatDate(item.updatedAt) }}</small>
|
||||
<a v-if="item.sourceUrl" :href="item.sourceUrl" target="_blank">Fuente</a>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from '~/components/Header.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const { data, pending, error, refresh } = await useFetch(`${config.public.apiBase}/catalog`)
|
||||
const items = computed(() => data.value ?? [])
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—'
|
||||
return new Date(value).toLocaleDateString('es-ES')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #0f172a;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.state {
|
||||
padding: 1.5rem;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.state.error {
|
||||
border: 1px solid #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.pill {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
background: #e2e8f0;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.publisher {
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
.desc {
|
||||
color: #475569;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.card-footer a {
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
150
apps/web/pages/discover.vue
Normal file
150
apps/web/pages/discover.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Header />
|
||||
<main class="container">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Descubrimiento</h1>
|
||||
<p>Últimos cambios detectados en el catálogo.</p>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<label>Días</label>
|
||||
<select v-model.number="days" @change="refresh">
|
||||
<option :value="3">3</option>
|
||||
<option :value="7">7</option>
|
||||
<option :value="14">14</option>
|
||||
<option :value="30">30</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="pending" class="panel">Cargando...</div>
|
||||
<div v-else-if="error" class="panel error">Error: {{ error.message }}</div>
|
||||
<section v-else class="grid">
|
||||
<article v-for="change in changes" :key="change.id" class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h3>{{ change.snapshot?.title || 'Sin título' }}</h3>
|
||||
<p class="muted">{{ change.snapshot?.publisher || 'Sin publicador' }}</p>
|
||||
</div>
|
||||
<span class="pill">{{ change.eventType }}</span>
|
||||
</div>
|
||||
<p class="desc" v-if="change.snapshot?.description">{{ change.snapshot.description }}</p>
|
||||
<div class="tags">
|
||||
<span v-for="tag in change.tags?.topics || []" :key="tag" class="tag">{{ tag }}</span>
|
||||
<span v-for="tag in change.tags?.territories || []" :key="tag" class="tag">{{ tag }}</span>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<small>{{ formatDate(change.createdAt) }}</small>
|
||||
<a v-if="change.snapshot?.sourceUrl" :href="change.snapshot.sourceUrl" target="_blank">Fuente</a>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from '~/components/Header.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const days = ref(7)
|
||||
const { data, pending, error, refresh } = await useFetch(() => `${config.public.apiBase}/discover/changes?days=${days.value}&limit=30`)
|
||||
const changes = computed(() => data.value ?? [])
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return ''
|
||||
return new Date(value).toLocaleString('es-ES')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #eef2f9;
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
select {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #cbd5f5;
|
||||
background: #fff;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
.pill {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
border-radius: 999px;
|
||||
height: fit-content;
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.tag {
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.panel.error {
|
||||
border: 1px solid #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
.desc {
|
||||
color: #475569;
|
||||
}
|
||||
</style>
|
||||
271
apps/web/pages/index.vue
Normal file
271
apps/web/pages/index.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Header />
|
||||
<main class="container">
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="kicker">Radar automático de datos públicos</p>
|
||||
<h1>Detecta cambios en datos.gob.es antes que nadie</h1>
|
||||
<p class="lead">Monitoriza datasets, normaliza formatos y recibe alertas por email o Telegram. Configura perfiles sin complicaciones.</p>
|
||||
<div class="hero-actions">
|
||||
<NuxtLink to="/catalog" class="btn primary">Ver catálogo</NuxtLink>
|
||||
<NuxtLink to="/alerts" class="btn ghost">Crear alertas</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-card" v-if="summary">
|
||||
<div class="metric">
|
||||
<span class="metric-value">{{ summary.totals.items }}</span>
|
||||
<span class="metric-label">Datasets</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-value">{{ summary.totals.changes }}</span>
|
||||
<span class="metric-label">Cambios recientes</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-value">{{ summary.totals.alertProfiles }}</span>
|
||||
<span class="metric-label">Perfiles activos</span>
|
||||
</div>
|
||||
<div class="card-footer">Actualizado automáticamente</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="trend" v-if="summary?.trend?.length">
|
||||
<h2>Tendencia (últimos {{ summary.trend.length }} días)</h2>
|
||||
<div class="trend-chart">
|
||||
<div v-for="point in summary.trend" :key="point.date" class="trend-bar">
|
||||
<span class="bar-fill" :style="{ height: barHeight(point.total) }"></span>
|
||||
<small>{{ formatShortDate(point.date) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="panel">
|
||||
<h2>Cambios recientes</h2>
|
||||
<ul>
|
||||
<li v-for="change in summary?.latestChanges || []" :key="change.id">
|
||||
<strong>{{ change.snapshot?.title || 'Sin título' }}</strong>
|
||||
<span class="muted">{{ change.snapshot?.publisher || '—' }}</span>
|
||||
<small class="muted">{{ formatDate(change.createdAt) }}</small>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<h2>Top publicadores</h2>
|
||||
<ul>
|
||||
<li v-for="pub in summary?.topPublishers || []" :key="pub.label">
|
||||
<span>{{ pub.label }}</span>
|
||||
<strong>{{ pub.value }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<h2>Formatos más comunes</h2>
|
||||
<ul>
|
||||
<li v-for="fmt in summary?.topFormats || []" :key="fmt.label">
|
||||
<span>{{ fmt.label }}</span>
|
||||
<strong>{{ fmt.value }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<h2>Top temas</h2>
|
||||
<ul>
|
||||
<li v-for="topic in summary?.topTopics || []" :key="topic.label">
|
||||
<span>{{ topic.label }}</span>
|
||||
<strong>{{ topic.value }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<h2>Top territorios</h2>
|
||||
<ul>
|
||||
<li v-for="territory in summary?.topTerritories || []" :key="territory.label">
|
||||
<span>{{ territory.label }}</span>
|
||||
<strong>{{ territory.value }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from '~/components/Header.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const { data: summary } = await useFetch(`${config.public.apiBase}/dashboard/summary`)
|
||||
const maxTrend = computed(() => {
|
||||
const values = summary.value?.trend?.map((p) => p.total) || []
|
||||
return Math.max(...values, 1)
|
||||
})
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return ''
|
||||
return new Date(value).toLocaleDateString('es-ES')
|
||||
}
|
||||
|
||||
function formatShortDate(value) {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
return date.toLocaleDateString('es-ES', { day: '2-digit', month: 'short' })
|
||||
}
|
||||
|
||||
function barHeight(value) {
|
||||
return `${Math.round((value / maxTrend.value) * 100)}%`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top, #f5f7ff 0%, #eef2f9 45%, #e5ecf5 100%);
|
||||
color: #0f172a;
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
.kicker {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
font-size: 0.75rem;
|
||||
color: #475569;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: clamp(2.2rem, 3vw, 3rem);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.lead {
|
||||
font-size: 1.05rem;
|
||||
color: #334155;
|
||||
}
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.7rem 1.4rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn.primary {
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
}
|
||||
.btn.ghost {
|
||||
border: 1px solid #0f172a;
|
||||
color: #0f172a;
|
||||
}
|
||||
.hero-card {
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
padding: 1.5rem;
|
||||
border-radius: 24px;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.metric-label {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.card-footer {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.grid {
|
||||
margin-top: 3rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.trend {
|
||||
margin-top: 2.5rem;
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.trend h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.trend-chart {
|
||||
margin-top: 1.2rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(32px, 1fr));
|
||||
gap: 0.6rem;
|
||||
align-items: end;
|
||||
}
|
||||
.trend-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
height: 140px;
|
||||
}
|
||||
.bar-fill {
|
||||
width: 100%;
|
||||
max-width: 18px;
|
||||
background: linear-gradient(180deg, #38bdf8 0%, #0f172a 100%);
|
||||
border-radius: 999px;
|
||||
display: block;
|
||||
min-height: 8px;
|
||||
}
|
||||
.panel {
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.panel h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.panel ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 1rem 0 0;
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.panel li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
110
apps/web/pages/ingest.vue
Normal file
110
apps/web/pages/ingest.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Header />
|
||||
<main class="container">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Ingesta manual</h1>
|
||||
<p>Crea un item de catálogo para pruebas rápidas.</p>
|
||||
</div>
|
||||
</header>
|
||||
<form class="panel" @submit.prevent="submit">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>Título</label>
|
||||
<input v-model="form.title" required />
|
||||
</div>
|
||||
<div>
|
||||
<label>Publisher</label>
|
||||
<input v-model="form.publisher" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Source URL</label>
|
||||
<input v-model="form.sourceUrl" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Formato</label>
|
||||
<input v-model="form.format" />
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" type="submit">Ingestar</button>
|
||||
</form>
|
||||
<div v-if="result" class="panel">Creado: {{ result.item?.id || result.id }}</div>
|
||||
<div v-if="err" class="panel error">Error: {{ err }}</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from '~/components/Header.vue'
|
||||
import { reactive, ref } from 'vue'
|
||||
const config = useRuntimeConfig()
|
||||
const form = reactive({ title: '', publisher: '', sourceUrl: '', format: '' })
|
||||
const result = ref(null)
|
||||
const err = ref(null)
|
||||
|
||||
async function submit() {
|
||||
err.value = null
|
||||
result.value = null
|
||||
try {
|
||||
const res = await $fetch(`${config.public.apiBase}/catalog/ingest`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
result.value = res
|
||||
} catch (e) {
|
||||
err.value = e.message || String(e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f8fafc;
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.panel.error {
|
||||
border: 1px solid #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #cbd5f5;
|
||||
}
|
||||
.btn {
|
||||
margin-top: 1.2rem;
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
163
apps/web/pages/landing.vue
Normal file
163
apps/web/pages/landing.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Header />
|
||||
<main class="container">
|
||||
<section class="hero">
|
||||
<div class="hero-text">
|
||||
<p class="kicker">Radar automático</p>
|
||||
<h1>Convierte datos públicos en señales accionables</h1>
|
||||
<p class="lead">gob-alert detecta cambios en datos.gob.es, normaliza formatos y alerta a tu equipo en minutos.</p>
|
||||
<div class="hero-actions">
|
||||
<NuxtLink to="/plans" class="btn primary">Ver planes</NuxtLink>
|
||||
<NuxtLink to="/discover" class="btn ghost">Explorar cambios</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-card">
|
||||
<div class="metric">
|
||||
<span class="metric-label">Detección</span>
|
||||
<span class="metric-value">< 1h</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Formatos</span>
|
||||
<span class="metric-value">CSV · JSON</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Alertas</span>
|
||||
<span class="metric-value">Email · Telegram</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="features">
|
||||
<article>
|
||||
<h3>Ingesta automatizada</h3>
|
||||
<p>Agenda ingestas periódicas y guarda histórico de cambios por dataset.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Clasificación inteligente</h3>
|
||||
<p>Detecta organismos, territorios y temas para segmentar alertas.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Panel de control</h3>
|
||||
<p>Visualiza novedades, tendencias y métricas clave en tiempo real.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="cta">
|
||||
<div>
|
||||
<h2>Listo para tu piloto en 30 días</h2>
|
||||
<p>Integra alertas personalizadas para consultoras, pymes y universidades.</p>
|
||||
</div>
|
||||
<NuxtLink to="/admin" class="btn primary">Ver demo</NuxtLink>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from '~/components/Header.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top left, #f4f7ff 0%, #eef2f9 40%, #e2e8f0 100%);
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
.kicker {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
font-size: 0.75rem;
|
||||
color: #475569;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: clamp(2.4rem, 3vw, 3.2rem);
|
||||
margin: 0.6rem 0 1rem;
|
||||
}
|
||||
.lead {
|
||||
color: #334155;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.7rem 1.5rem;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn.primary {
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
}
|
||||
.btn.ghost {
|
||||
border: 1px solid #0f172a;
|
||||
color: #0f172a;
|
||||
}
|
||||
.hero-card {
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
padding: 1.8rem;
|
||||
border-radius: 24px;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
}
|
||||
.metric-label {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.features {
|
||||
margin-top: 3rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.features article {
|
||||
background: #fff;
|
||||
padding: 1.4rem;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.cta {
|
||||
margin-top: 3rem;
|
||||
background: #38bdf8;
|
||||
padding: 2rem;
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
.cta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
95
apps/web/pages/plans.vue
Normal file
95
apps/web/pages/plans.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Header />
|
||||
<main class="container">
|
||||
<header class="page-header">
|
||||
<h1>Planes y precios</h1>
|
||||
<p>Elige el plan que mejor se adapta a tu equipo.</p>
|
||||
</header>
|
||||
<section class="grid">
|
||||
<article v-for="plan in plans" :key="plan.id" class="card">
|
||||
<h2>{{ plan.name }}</h2>
|
||||
<p class="muted">{{ plan.description }}</p>
|
||||
<div class="price">
|
||||
<span class="value">{{ plan.priceMonthly }}</span>
|
||||
<span class="currency">{{ plan.currency }}/mes</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li v-for="(value, key) in plan.features || {}" :key="key">
|
||||
<strong>{{ key }}</strong>: {{ value }}
|
||||
</li>
|
||||
</ul>
|
||||
<button class="btn">Solicitar acceso</button>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from '~/components/Header.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const { data } = await useFetch(`${config.public.apiBase}/plans`)
|
||||
const plans = computed(() => data.value ?? [])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #eef2f9;
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 1.8rem;
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.currency {
|
||||
color: #64748b;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.btn {
|
||||
margin-top: auto;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user