first commit
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user