291 lines
7.9 KiB
Vue
291 lines
7.9 KiB
Vue
<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>
|