first commit

This commit is contained in:
alexandrump
2026-02-09 01:02:53 +01:00
commit 82f3464565
90 changed files with 4788 additions and 0 deletions

290
apps/web/pages/alerts.vue Normal file
View 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>