feat: WhatsApp Nucleo con Nuxt 4 + Baileys v7
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 6m46s
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 6m46s
Reemplazo completo de Evolution API por implementación directa con Baileys. Características: - Dashboard completo con Nuxt UI v4 - Soporte para múltiples instancias de WhatsApp - Conexión via QR code o pairing code - Persistencia de mensajes en PostgreSQL - API REST para integraciones externas - Webhooks con firma HMAC - SSE para actualizaciones en tiempo real - Autenticación con Authentik
This commit is contained in:
8
app/app.config.ts
Normal file
8
app/app.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'green',
|
||||
neutral: 'slate'
|
||||
}
|
||||
}
|
||||
})
|
||||
20
app/app.vue
Normal file
20
app/app.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<UApp>
|
||||
<div class="whatsapp-shell text-[var(--wa-text)]">
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</UApp>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
onMounted(() => {
|
||||
if (import.meta.client) {
|
||||
setTimeout(() => {
|
||||
document.documentElement.classList.add('nuxt-ready')
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
94
app/assets/css/main.css
Normal file
94
app/assets/css/main.css
Normal file
@@ -0,0 +1,94 @@
|
||||
/* WhatsApp Nucleo - Variables de tema */
|
||||
:root {
|
||||
/* WhatsApp colors */
|
||||
--wa-green-dark: #075e54;
|
||||
--wa-green-light: #25d366;
|
||||
--wa-teal: #128c7e;
|
||||
--wa-blue: #34b7f1;
|
||||
|
||||
/* UI colors */
|
||||
--wa-bg: #111b21;
|
||||
--wa-bg-light: #202c33;
|
||||
--wa-bg-lighter: #2a3942;
|
||||
--wa-surface: #1f2c34;
|
||||
--wa-border: #2a3942;
|
||||
--wa-text: #e9edef;
|
||||
--wa-text-muted: #8696a0;
|
||||
--wa-text-dark: #667781;
|
||||
|
||||
/* Message bubbles */
|
||||
--wa-bubble-out: #005c4b;
|
||||
--wa-bubble-in: #202c33;
|
||||
}
|
||||
|
||||
/* Shell base */
|
||||
.whatsapp-shell {
|
||||
background-color: var(--wa-bg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Override Nuxt UI dashboard colors */
|
||||
.whatsapp-shell [class*="UDashboard"] {
|
||||
--ui-bg: var(--wa-bg);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.whatsapp-shell ::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.whatsapp-shell ::-webkit-scrollbar-track {
|
||||
background: var(--wa-bg);
|
||||
}
|
||||
|
||||
.whatsapp-shell ::-webkit-scrollbar-thumb {
|
||||
background: var(--wa-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.whatsapp-shell ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--wa-text-muted);
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.status-connected {
|
||||
color: var(--wa-green-light);
|
||||
}
|
||||
|
||||
.status-disconnected {
|
||||
color: #f15c6d;
|
||||
}
|
||||
|
||||
.status-connecting {
|
||||
color: var(--wa-blue);
|
||||
}
|
||||
|
||||
/* Message bubbles */
|
||||
.bubble-out {
|
||||
background-color: var(--wa-bubble-out);
|
||||
}
|
||||
|
||||
.bubble-in {
|
||||
background-color: var(--wa-bubble-in);
|
||||
}
|
||||
|
||||
/* Instance card */
|
||||
.instance-card {
|
||||
background-color: var(--wa-surface);
|
||||
border: 1px solid var(--wa-border);
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.instance-card:hover {
|
||||
border-color: var(--wa-green-dark);
|
||||
}
|
||||
|
||||
/* Loading overlay */
|
||||
html:not(.nuxt-ready) body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--wa-bg);
|
||||
z-index: 9999;
|
||||
}
|
||||
59
app/components/app/AppSidebar.vue
Normal file
59
app/components/app/AppSidebar.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<UDashboardSidebar class="bg-[var(--wa-surface)] border-[var(--wa-border)]">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3 p-4">
|
||||
<div class="w-10 h-10 rounded-full bg-[var(--wa-green-dark)] flex items-center justify-center">
|
||||
<UIcon name="i-lucide-message-circle" class="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-semibold text-[var(--wa-text)]">WhatsApp Nucleo</span>
|
||||
<span class="text-xs text-[var(--wa-text-muted)]">Multi-Instance Manager</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<UDashboardSidebarContent>
|
||||
<UDashboardSidebarGroup>
|
||||
<UDashboardSidebarItem
|
||||
to="/"
|
||||
icon="i-lucide-layout-dashboard"
|
||||
label="Dashboard"
|
||||
exact
|
||||
/>
|
||||
<UDashboardSidebarItem
|
||||
to="/instances"
|
||||
icon="i-lucide-smartphone"
|
||||
label="Instancias"
|
||||
/>
|
||||
<UDashboardSidebarItem
|
||||
to="/messages"
|
||||
icon="i-lucide-message-square"
|
||||
label="Mensajes"
|
||||
/>
|
||||
</UDashboardSidebarGroup>
|
||||
|
||||
<UDashboardSidebarGroup label="Integraciones">
|
||||
<UDashboardSidebarItem
|
||||
to="/webhooks"
|
||||
icon="i-lucide-webhook"
|
||||
label="Webhooks"
|
||||
/>
|
||||
<UDashboardSidebarItem
|
||||
to="/api-docs"
|
||||
icon="i-lucide-code"
|
||||
label="API Docs"
|
||||
/>
|
||||
</UDashboardSidebarGroup>
|
||||
|
||||
<UDashboardSidebarGroup label="Sistema">
|
||||
<UDashboardSidebarItem
|
||||
to="/settings"
|
||||
icon="i-lucide-settings"
|
||||
label="Configuracion"
|
||||
/>
|
||||
</UDashboardSidebarGroup>
|
||||
</UDashboardSidebarContent>
|
||||
</template>
|
||||
</UDashboardSidebar>
|
||||
</template>
|
||||
24
app/components/app/ConnectionStatus.vue
Normal file
24
app/components/app/ConnectionStatus.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 px-3 py-1 rounded-full bg-[var(--wa-surface)] border border-[var(--wa-border)]">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="statusClass"
|
||||
/>
|
||||
<span class="text-sm text-[var(--wa-text-muted)]">
|
||||
{{ connectedCount }} / {{ totalCount }} conectadas
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// TODO: Conectar con useInstances cuando esté implementado
|
||||
const connectedCount = ref(0)
|
||||
const totalCount = ref(0)
|
||||
|
||||
const statusClass = computed(() => {
|
||||
if (totalCount.value === 0) return 'bg-gray-500'
|
||||
if (connectedCount.value === totalCount.value) return 'bg-[var(--wa-green-light)]'
|
||||
if (connectedCount.value === 0) return 'bg-red-500'
|
||||
return 'bg-yellow-500'
|
||||
})
|
||||
</script>
|
||||
36
app/components/app/UserMenu.vue
Normal file
36
app/components/app/UserMenu.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<UDropdownMenu v-if="user">
|
||||
<UButton
|
||||
variant="ghost"
|
||||
class="rounded-full p-0"
|
||||
>
|
||||
<UAvatar
|
||||
:src="user.avatar"
|
||||
:alt="user.name || user.username"
|
||||
size="sm"
|
||||
/>
|
||||
</UButton>
|
||||
|
||||
<template #content>
|
||||
<div class="px-3 py-2 border-b border-[var(--wa-border)]">
|
||||
<p class="font-medium text-[var(--wa-text)]">{{ user.name || user.username }}</p>
|
||||
<p class="text-sm text-[var(--wa-text-muted)]">{{ user.email }}</p>
|
||||
</div>
|
||||
|
||||
<UDropdownMenuItem
|
||||
icon="i-lucide-user"
|
||||
label="Mi Perfil"
|
||||
@click="goToProfile"
|
||||
/>
|
||||
<UDropdownMenuItem
|
||||
icon="i-lucide-log-out"
|
||||
label="Cerrar Sesion"
|
||||
@click="logout"
|
||||
/>
|
||||
</template>
|
||||
</UDropdownMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { user, logout, goToProfile } = useAuthentik()
|
||||
</script>
|
||||
54
app/components/common/MetricCard.vue
Normal file
54
app/components/common/MetricCard.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="instance-card p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
:class="iconBgClass"
|
||||
>
|
||||
<UIcon :name="icon" class="w-5 h-5" :class="iconClass" />
|
||||
</div>
|
||||
<div v-if="total" class="text-sm text-[var(--wa-text-muted)]">
|
||||
de {{ total }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-2xl font-bold text-[var(--wa-text)]">{{ value }}</div>
|
||||
<div class="text-sm text-[var(--wa-text-muted)]">{{ title }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
value: number | string
|
||||
total?: number
|
||||
icon: string
|
||||
color?: 'green' | 'blue' | 'purple' | 'amber' | 'red'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
color: 'green'
|
||||
})
|
||||
|
||||
const iconBgClass = computed(() => {
|
||||
const classes: Record<string, string> = {
|
||||
green: 'bg-green-500/20',
|
||||
blue: 'bg-blue-500/20',
|
||||
purple: 'bg-purple-500/20',
|
||||
amber: 'bg-amber-500/20',
|
||||
red: 'bg-red-500/20'
|
||||
}
|
||||
return classes[props.color]
|
||||
})
|
||||
|
||||
const iconClass = computed(() => {
|
||||
const classes: Record<string, string> = {
|
||||
green: 'text-green-500',
|
||||
blue: 'text-blue-500',
|
||||
purple: 'text-purple-500',
|
||||
amber: 'text-amber-500',
|
||||
red: 'text-red-500'
|
||||
}
|
||||
return classes[props.color]
|
||||
})
|
||||
</script>
|
||||
42
app/components/common/StatusBadge.vue
Normal file
42
app/components/common/StatusBadge.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<span
|
||||
class="px-2 py-1 rounded-full text-xs font-medium"
|
||||
:class="badgeClass"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
status: 'connected' | 'disconnected' | 'connecting' | 'qr_ready' | 'pairing'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const statusConfig: Record<string, { label: string; class: string }> = {
|
||||
connected: {
|
||||
label: 'Conectado',
|
||||
class: 'bg-green-500/20 text-green-400'
|
||||
},
|
||||
disconnected: {
|
||||
label: 'Desconectado',
|
||||
class: 'bg-red-500/20 text-red-400'
|
||||
},
|
||||
connecting: {
|
||||
label: 'Conectando...',
|
||||
class: 'bg-blue-500/20 text-blue-400'
|
||||
},
|
||||
qr_ready: {
|
||||
label: 'Escanear QR',
|
||||
class: 'bg-amber-500/20 text-amber-400'
|
||||
},
|
||||
pairing: {
|
||||
label: 'Emparejando...',
|
||||
class: 'bg-purple-500/20 text-purple-400'
|
||||
}
|
||||
}
|
||||
|
||||
const label = computed(() => statusConfig[props.status]?.label || props.status)
|
||||
const badgeClass = computed(() => statusConfig[props.status]?.class || 'bg-gray-500/20 text-gray-400')
|
||||
</script>
|
||||
91
app/components/instances/CreateInstanceModal.vue
Normal file
91
app/components/instances/CreateInstanceModal.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<UModal v-model:open="isOpen">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-[var(--wa-text)]">Nueva Instancia</h3>
|
||||
<UButton
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="space-y-4">
|
||||
<UFormField label="Nombre de la instancia" required>
|
||||
<UInput
|
||||
v-model="form.name"
|
||||
placeholder="Ej: Ventas, Soporte, Marketing..."
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<div class="text-sm text-[var(--wa-text-muted)]">
|
||||
<p>Despues de crear la instancia, podras conectarla escaneando un codigo QR con WhatsApp.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<UButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
Cancelar
|
||||
</UButton>
|
||||
<UButton
|
||||
type="submit"
|
||||
:loading="loading"
|
||||
:disabled="!form.name.trim()"
|
||||
>
|
||||
Crear Instancia
|
||||
</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
created: [instance: any]
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit('update:open', value)
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const form = ref({
|
||||
name: ''
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.value.name.trim()) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const instance = await $fetch('/api/instances', {
|
||||
method: 'POST',
|
||||
body: { name: form.value.name }
|
||||
})
|
||||
|
||||
emit('created', instance)
|
||||
form.value.name = ''
|
||||
} catch (error) {
|
||||
console.error('Error creating instance:', error)
|
||||
// TODO: Show error toast
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
117
app/components/instances/InstanceCard.vue
Normal file
117
app/components/instances/InstanceCard.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="instance-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 rounded-full bg-[var(--wa-green-dark)] flex items-center justify-center">
|
||||
<UIcon name="i-lucide-smartphone" class="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-[var(--wa-text)]">{{ instance.name }}</h3>
|
||||
<p class="text-sm text-[var(--wa-text-muted)]">
|
||||
{{ instance.phoneNumber || 'Sin numero asignado' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge :status="instance.status" />
|
||||
</div>
|
||||
|
||||
<!-- Connection info -->
|
||||
<div v-if="instance.lastConnectedAt" class="text-xs text-[var(--wa-text-muted)] mb-4">
|
||||
Ultima conexion: {{ formatDate(instance.lastConnectedAt) }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="instance.status === 'disconnected'">
|
||||
<UButton
|
||||
size="sm"
|
||||
icon="i-lucide-qr-code"
|
||||
@click="$emit('connect', instance.id)"
|
||||
>
|
||||
Conectar
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
<template v-else-if="instance.status === 'connected'">
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="soft"
|
||||
icon="i-lucide-message-square"
|
||||
:to="`/messages?instance=${instance.id}`"
|
||||
>
|
||||
Mensajes
|
||||
</UButton>
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="red"
|
||||
icon="i-lucide-unplug"
|
||||
@click="$emit('disconnect', instance.id)"
|
||||
>
|
||||
Desconectar
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
<template v-else-if="instance.status === 'qr_ready'">
|
||||
<UButton
|
||||
size="sm"
|
||||
icon="i-lucide-qr-code"
|
||||
@click="$emit('connect', instance.id)"
|
||||
>
|
||||
Ver QR
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading
|
||||
>
|
||||
Procesando...
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
<!-- Delete button -->
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="red"
|
||||
icon="i-lucide-trash-2"
|
||||
class="ml-auto"
|
||||
@click="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Instance {
|
||||
id: string
|
||||
name: string
|
||||
phoneNumber: string | null
|
||||
status: 'connected' | 'disconnected' | 'connecting' | 'qr_ready' | 'pairing'
|
||||
lastConnectedAt: Date | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
instance: Instance
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
connect: [instanceId: string]
|
||||
disconnect: [instanceId: string]
|
||||
delete: [instanceId: string]
|
||||
}>()
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleString('es-AR')
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
// TODO: Show confirmation modal
|
||||
emit('delete', props.instance.id)
|
||||
}
|
||||
</script>
|
||||
111
app/components/instances/QRCodeModal.vue
Normal file
111
app/components/instances/QRCodeModal.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<UModal v-model:open="isOpen" :ui="{ width: 'max-w-md' }">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-[var(--wa-text)]">Escanear Codigo QR</h3>
|
||||
<UButton
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="text-center">
|
||||
<!-- QR Code Display -->
|
||||
<div v-if="qrCode" class="mb-4">
|
||||
<div class="bg-white p-4 rounded-lg inline-block">
|
||||
<img :src="qrCode" alt="QR Code" class="w-64 h-64" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mb-4 py-16">
|
||||
<UIcon
|
||||
name="i-lucide-loader-2"
|
||||
class="w-12 h-12 text-[var(--wa-text-muted)] animate-spin mx-auto"
|
||||
/>
|
||||
<p class="text-[var(--wa-text-muted)] mt-4">Generando codigo QR...</p>
|
||||
</div>
|
||||
|
||||
<!-- Instructions -->
|
||||
<div class="text-sm text-[var(--wa-text-muted)] space-y-2">
|
||||
<p>1. Abre WhatsApp en tu telefono</p>
|
||||
<p>2. Ve a Configuracion > Dispositivos vinculados</p>
|
||||
<p>3. Toca "Vincular un dispositivo"</p>
|
||||
<p>4. Escanea este codigo QR</p>
|
||||
</div>
|
||||
|
||||
<!-- Alternative: Pairing Code -->
|
||||
<div class="mt-6 pt-6 border-t border-[var(--wa-border)]">
|
||||
<p class="text-sm text-[var(--wa-text-muted)] mb-3">
|
||||
O usa un codigo de emparejamiento
|
||||
</p>
|
||||
<UButton
|
||||
variant="soft"
|
||||
@click="requestPairingCode"
|
||||
:loading="loadingPairing"
|
||||
>
|
||||
Obtener Codigo
|
||||
</UButton>
|
||||
|
||||
<div v-if="pairingCode" class="mt-4">
|
||||
<p class="text-2xl font-mono font-bold text-[var(--wa-green-light)] tracking-widest">
|
||||
{{ pairingCode }}
|
||||
</p>
|
||||
<p class="text-xs text-[var(--wa-text-muted)] mt-2">
|
||||
Ingresa este codigo en WhatsApp > Dispositivos vinculados > Vincular con numero de telefono
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
open: boolean
|
||||
instanceId: string | null
|
||||
qrCode: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit('update:open', value)
|
||||
})
|
||||
|
||||
const loadingPairing = ref(false)
|
||||
const pairingCode = ref<string | null>(null)
|
||||
|
||||
const requestPairingCode = async () => {
|
||||
if (!props.instanceId) return
|
||||
|
||||
loadingPairing.value = true
|
||||
try {
|
||||
const response = await $fetch<{ code: string }>(`/api/instances/${props.instanceId}/pairing-code`, {
|
||||
method: 'POST'
|
||||
})
|
||||
pairingCode.value = response.code
|
||||
} catch (error) {
|
||||
console.error('Error requesting pairing code:', error)
|
||||
} finally {
|
||||
loadingPairing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reset pairing code when modal closes
|
||||
watch(isOpen, (value) => {
|
||||
if (!value) {
|
||||
pairingCode.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
68
app/components/messages/ChatItem.vue
Normal file
68
app/components/messages/ChatItem.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 cursor-pointer transition-colors hover:bg-[var(--wa-bg-light)]"
|
||||
:class="{ 'bg-[var(--wa-bg-light)]': active }"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<UAvatar
|
||||
:alt="chat.name"
|
||||
size="md"
|
||||
:src="chat.profilePicture"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-medium text-[var(--wa-text)] truncate">{{ chat.name }}</p>
|
||||
<span class="text-xs text-[var(--wa-text-muted)]">{{ formatTime(chat.lastMessageAt) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-[var(--wa-text-muted)] truncate">{{ chat.lastMessage }}</p>
|
||||
<span
|
||||
v-if="chat.unreadCount > 0"
|
||||
class="bg-[var(--wa-green-light)] text-white text-xs rounded-full px-2 py-0.5"
|
||||
>
|
||||
{{ chat.unreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Chat {
|
||||
id: string
|
||||
name: string
|
||||
jid: string
|
||||
profilePicture?: string
|
||||
lastMessage: string
|
||||
lastMessageAt: Date
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
chat: Chat
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
const d = new Date(date)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (days === 0) {
|
||||
return d.toLocaleTimeString('es-AR', { hour: '2-digit', minute: '2-digit' })
|
||||
} else if (days === 1) {
|
||||
return 'Ayer'
|
||||
} else if (days < 7) {
|
||||
return d.toLocaleDateString('es-AR', { weekday: 'short' })
|
||||
} else {
|
||||
return d.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
85
app/components/messages/MessageBubble.vue
Normal file
85
app/components/messages/MessageBubble.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex"
|
||||
:class="message.fromMe ? 'justify-end' : 'justify-start'"
|
||||
>
|
||||
<div
|
||||
class="max-w-[70%] rounded-lg px-3 py-2"
|
||||
:class="message.fromMe ? 'bubble-out' : 'bubble-in'"
|
||||
>
|
||||
<!-- Message content -->
|
||||
<p class="text-[var(--wa-text)] whitespace-pre-wrap break-words">{{ message.content }}</p>
|
||||
|
||||
<!-- Image -->
|
||||
<img
|
||||
v-if="message.mediaUrl && message.type === 'image'"
|
||||
:src="message.mediaUrl"
|
||||
class="rounded-lg max-w-full mt-2"
|
||||
/>
|
||||
|
||||
<!-- Caption for media -->
|
||||
<p
|
||||
v-if="message.caption"
|
||||
class="text-[var(--wa-text)] mt-2"
|
||||
>
|
||||
{{ message.caption }}
|
||||
</p>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-1 mt-1">
|
||||
<span class="text-xs text-[var(--wa-text-muted)]">
|
||||
{{ formatTime(message.timestamp) }}
|
||||
</span>
|
||||
<UIcon
|
||||
v-if="message.fromMe"
|
||||
:name="statusIcon"
|
||||
class="w-4 h-4"
|
||||
:class="statusColor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Message {
|
||||
id: string
|
||||
content: string
|
||||
type: 'text' | 'image' | 'video' | 'document' | 'audio'
|
||||
mediaUrl?: string
|
||||
caption?: string
|
||||
fromMe: boolean
|
||||
timestamp: Date
|
||||
status: 'pending' | 'sent' | 'delivered' | 'read' | 'failed'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return new Date(date).toLocaleTimeString('es-AR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
const icons: Record<string, string> = {
|
||||
pending: 'i-lucide-clock',
|
||||
sent: 'i-lucide-check',
|
||||
delivered: 'i-lucide-check-check',
|
||||
read: 'i-lucide-check-check',
|
||||
failed: 'i-lucide-alert-circle'
|
||||
}
|
||||
return icons[props.message.status] || 'i-lucide-check'
|
||||
})
|
||||
|
||||
const statusColor = computed(() => {
|
||||
if (props.message.status === 'read') return 'text-[var(--wa-blue)]'
|
||||
if (props.message.status === 'failed') return 'text-red-500'
|
||||
return 'text-[var(--wa-text-muted)]'
|
||||
})
|
||||
</script>
|
||||
45
app/components/messages/MessageInput.vue
Normal file
45
app/components/messages/MessageInput.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="flex items-end gap-2">
|
||||
<!-- Attachment button -->
|
||||
<UButton
|
||||
variant="ghost"
|
||||
icon="i-lucide-paperclip"
|
||||
class="text-[var(--wa-text-muted)]"
|
||||
/>
|
||||
|
||||
<!-- Text input -->
|
||||
<div class="flex-1">
|
||||
<UTextarea
|
||||
v-model="message"
|
||||
placeholder="Escribe un mensaje..."
|
||||
:rows="1"
|
||||
autoresize
|
||||
:maxrows="5"
|
||||
class="bg-[var(--wa-bg-light)]"
|
||||
@keydown.enter.exact.prevent="handleSend"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Send button -->
|
||||
<UButton
|
||||
:icon="message.trim() ? 'i-lucide-send' : 'i-lucide-mic'"
|
||||
:disabled="!message.trim()"
|
||||
@click="handleSend"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
send: [content: string]
|
||||
}>()
|
||||
|
||||
const message = ref('')
|
||||
|
||||
const handleSend = () => {
|
||||
if (!message.value.trim()) return
|
||||
|
||||
emit('send', message.value)
|
||||
message.value = ''
|
||||
}
|
||||
</script>
|
||||
107
app/components/webhooks/WebhookCard.vue
Normal file
107
app/components/webhooks/WebhookCard.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="instance-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
:class="webhook.isActive ? 'bg-green-500/20' : 'bg-gray-500/20'"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-webhook"
|
||||
class="w-5 h-5"
|
||||
:class="webhook.isActive ? 'text-green-500' : 'text-gray-500'"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-[var(--wa-text)]">{{ webhook.name }}</h3>
|
||||
<p class="text-sm text-[var(--wa-text-muted)] truncate max-w-md">{{ webhook.url }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<USwitch
|
||||
:model-value="webhook.isActive"
|
||||
@update:model-value="$emit('toggle', webhook.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Events -->
|
||||
<div class="mb-4">
|
||||
<p class="text-xs text-[var(--wa-text-muted)] mb-2">Eventos:</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="event in webhook.events"
|
||||
:key="event"
|
||||
class="bg-[var(--wa-bg-light)] text-[var(--wa-text-muted)] text-xs px-2 py-1 rounded"
|
||||
>
|
||||
{{ event }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instance -->
|
||||
<div v-if="webhook.instanceId" class="mb-4">
|
||||
<p class="text-xs text-[var(--wa-text-muted)]">
|
||||
Instancia: <span class="text-[var(--wa-text)]">{{ webhook.instanceName || webhook.instanceId }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="mb-4">
|
||||
<p class="text-xs text-[var(--wa-text-muted)]">
|
||||
Aplica a: <span class="text-[var(--wa-green-light)]">Todas las instancias</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="soft"
|
||||
icon="i-lucide-play"
|
||||
@click="$emit('test', webhook.id)"
|
||||
>
|
||||
Probar
|
||||
</UButton>
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="i-lucide-pencil"
|
||||
@click="$emit('edit', webhook)"
|
||||
>
|
||||
Editar
|
||||
</UButton>
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="red"
|
||||
icon="i-lucide-trash-2"
|
||||
@click="$emit('delete', webhook.id)"
|
||||
>
|
||||
Eliminar
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Webhook {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
events: string[]
|
||||
isActive: boolean
|
||||
instanceId?: string
|
||||
instanceName?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
webhook: Webhook
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
edit: [webhook: Webhook]
|
||||
delete: [webhookId: string]
|
||||
test: [webhookId: string]
|
||||
toggle: [webhookId: string, active: boolean]
|
||||
}>()
|
||||
</script>
|
||||
182
app/components/webhooks/WebhookFormModal.vue
Normal file
182
app/components/webhooks/WebhookFormModal.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<UModal v-model:open="isOpen">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-[var(--wa-text)]">
|
||||
{{ webhook ? 'Editar Webhook' : 'Nuevo Webhook' }}
|
||||
</h3>
|
||||
<UButton
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="space-y-4">
|
||||
<UFormField label="Nombre" required>
|
||||
<UInput
|
||||
v-model="form.name"
|
||||
placeholder="Ej: Notificaciones a n8n"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="URL" required>
|
||||
<UInput
|
||||
v-model="form.url"
|
||||
placeholder="https://..."
|
||||
type="url"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Secret (opcional)">
|
||||
<UInput
|
||||
v-model="form.secret"
|
||||
placeholder="Para firmar las peticiones con HMAC"
|
||||
type="password"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Eventos">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<UCheckbox
|
||||
v-for="event in availableEvents"
|
||||
:key="event.value"
|
||||
v-model="form.events"
|
||||
:value="event.value"
|
||||
:label="event.label"
|
||||
/>
|
||||
</div>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Instancia">
|
||||
<USelectMenu
|
||||
v-model="form.instanceId"
|
||||
:items="instanceOptions"
|
||||
placeholder="Todas las instancias"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<UButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
Cancelar
|
||||
</UButton>
|
||||
<UButton
|
||||
type="submit"
|
||||
:loading="loading"
|
||||
:disabled="!isValid"
|
||||
>
|
||||
{{ webhook ? 'Guardar Cambios' : 'Crear Webhook' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Webhook {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
secret?: string
|
||||
events: string[]
|
||||
instanceId?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
webhook?: Webhook | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
saved: [webhook: any]
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit('update:open', value)
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
url: '',
|
||||
secret: '',
|
||||
events: [] as string[],
|
||||
instanceId: null as string | null
|
||||
})
|
||||
|
||||
const availableEvents = [
|
||||
{ value: 'message.received', label: 'Mensaje recibido' },
|
||||
{ value: 'message.sent', label: 'Mensaje enviado' },
|
||||
{ value: 'message.status', label: 'Estado de mensaje' },
|
||||
{ value: 'instance.connected', label: 'Instancia conectada' },
|
||||
{ value: 'instance.disconnected', label: 'Instancia desconectada' },
|
||||
{ value: 'instance.qr', label: 'QR disponible' }
|
||||
]
|
||||
|
||||
// TODO: Cargar instancias reales
|
||||
const instanceOptions = ref<any[]>([])
|
||||
|
||||
const isValid = computed(() => {
|
||||
return form.value.name.trim() && form.value.url.trim() && form.value.events.length > 0
|
||||
})
|
||||
|
||||
// Watch for webhook changes to populate form
|
||||
watch(() => props.webhook, (webhook) => {
|
||||
if (webhook) {
|
||||
form.value = {
|
||||
name: webhook.name,
|
||||
url: webhook.url,
|
||||
secret: webhook.secret || '',
|
||||
events: [...webhook.events],
|
||||
instanceId: webhook.instanceId || null
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
name: '',
|
||||
url: '',
|
||||
secret: '',
|
||||
events: [],
|
||||
instanceId: null
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!isValid.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const method = props.webhook ? 'PUT' : 'POST'
|
||||
const url = props.webhook
|
||||
? `/api/webhooks/${props.webhook.id}`
|
||||
: '/api/webhooks'
|
||||
|
||||
const result = await $fetch(url, {
|
||||
method,
|
||||
body: form.value
|
||||
})
|
||||
|
||||
emit('saved', result)
|
||||
} catch (error) {
|
||||
console.error('Error saving webhook:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
68
app/composables/useAuthentik.ts
Normal file
68
app/composables/useAuthentik.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Composable para leer información de usuario de Authentik
|
||||
* Los headers son inyectados por Authentik Proxy Outpost
|
||||
*/
|
||||
|
||||
interface AuthentikUser {
|
||||
username: string
|
||||
email: string | undefined
|
||||
name: string | undefined
|
||||
groups: string[]
|
||||
uid: string | undefined
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export const useAuthentik = () => {
|
||||
const authentikUser = useState<AuthentikUser | null>('authentikUser', () => {
|
||||
if (import.meta.server) {
|
||||
const headers = useRequestHeaders()
|
||||
|
||||
const username = headers['x-authentik-username']
|
||||
const email = headers['x-authentik-email']
|
||||
const name = headers['x-authentik-name']
|
||||
const groups = headers['x-authentik-groups']
|
||||
const uid = headers['x-authentik-uid']
|
||||
|
||||
if (!username) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
username,
|
||||
email,
|
||||
name,
|
||||
groups: groups ? groups.split('|').filter(g => g.trim()) : [],
|
||||
uid,
|
||||
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(name || username)}&background=075e54&color=fff&size=128`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const user = computed(() => authentikUser.value)
|
||||
const isAuthenticated = computed(() => !!user.value)
|
||||
|
||||
const logout = () => {
|
||||
const authentikUrl = useRuntimeConfig().public.authentikUrl || 'https://authentik.nucleoriofrio.com'
|
||||
navigateTo(`${authentikUrl}/flows/-/default/invalidation/`, { external: true })
|
||||
}
|
||||
|
||||
const goToProfile = () => {
|
||||
const authentikUrl = useRuntimeConfig().public.authentikUrl || 'https://authentik.nucleoriofrio.com'
|
||||
navigateTo(`${authentikUrl}/if/user/`, { external: true, open: { target: '_blank' } })
|
||||
}
|
||||
|
||||
const hasGroup = (groupName: string): boolean => {
|
||||
if (!user.value) return false
|
||||
return user.value.groups.includes(groupName)
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
isAuthenticated,
|
||||
logout,
|
||||
goToProfile,
|
||||
hasGroup
|
||||
}
|
||||
}
|
||||
124
app/composables/useInstances.ts
Normal file
124
app/composables/useInstances.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Composable for managing WhatsApp instances
|
||||
*/
|
||||
|
||||
export interface Instance {
|
||||
id: string
|
||||
name: string
|
||||
phoneNumber: string | null
|
||||
status: 'disconnected' | 'connecting' | 'connected' | 'qr_ready' | 'pairing'
|
||||
qrCode: string | null
|
||||
pairingCode: string | null
|
||||
lastConnectedAt: Date | null
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export const useInstances = () => {
|
||||
const instances = useState<Instance[]>('instances', () => [])
|
||||
const loading = useState('instancesLoading', () => false)
|
||||
const error = useState<string | null>('instancesError', () => null)
|
||||
|
||||
const fetchInstances = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await $fetch<Instance[]>('/api/instances')
|
||||
instances.value = data
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message
|
||||
console.error('Error fetching instances:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createInstance = async (name: string): Promise<Instance | null> => {
|
||||
try {
|
||||
const instance = await $fetch<Instance>('/api/instances', {
|
||||
method: 'POST',
|
||||
body: { name }
|
||||
})
|
||||
instances.value.push(instance)
|
||||
return instance
|
||||
} catch (e) {
|
||||
console.error('Error creating instance:', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const deleteInstance = async (id: string) => {
|
||||
try {
|
||||
await $fetch(`/api/instances/${id}`, { method: 'DELETE' })
|
||||
instances.value = instances.value.filter(i => i.id !== id)
|
||||
} catch (e) {
|
||||
console.error('Error deleting instance:', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const connectInstance = async (id: string): Promise<{ qrCode: string | null; status: string }> => {
|
||||
const result = await $fetch<{ qrCode: string | null; status: string }>(`/api/instances/${id}/connect`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
// Update local state
|
||||
const idx = instances.value.findIndex(i => i.id === id)
|
||||
if (idx !== -1) {
|
||||
instances.value[idx].status = result.status as Instance['status']
|
||||
instances.value[idx].qrCode = result.qrCode
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const disconnectInstance = async (id: string) => {
|
||||
await $fetch(`/api/instances/${id}/disconnect`, { method: 'POST' })
|
||||
|
||||
// Update local state
|
||||
const idx = instances.value.findIndex(i => i.id === id)
|
||||
if (idx !== -1) {
|
||||
instances.value[idx].status = 'disconnected'
|
||||
instances.value[idx].qrCode = null
|
||||
}
|
||||
}
|
||||
|
||||
const getQRCode = async (id: string): Promise<string | null> => {
|
||||
const result = await $fetch<{ qrCode: string | null }>(`/api/instances/${id}/qr`)
|
||||
return result.qrCode
|
||||
}
|
||||
|
||||
const requestPairingCode = async (id: string, phoneNumber: string): Promise<string> => {
|
||||
const result = await $fetch<{ code: string }>(`/api/instances/${id}/pairing-code`, {
|
||||
method: 'POST',
|
||||
body: { phoneNumber }
|
||||
})
|
||||
return result.code
|
||||
}
|
||||
|
||||
const getInstanceStatus = async (id: string) => {
|
||||
return await $fetch(`/api/instances/${id}/status`)
|
||||
}
|
||||
|
||||
// Computed helpers
|
||||
const connectedCount = computed(() =>
|
||||
instances.value.filter(i => i.status === 'connected').length
|
||||
)
|
||||
|
||||
const totalCount = computed(() => instances.value.length)
|
||||
|
||||
return {
|
||||
instances,
|
||||
loading,
|
||||
error,
|
||||
fetchInstances,
|
||||
createInstance,
|
||||
deleteInstance,
|
||||
connectInstance,
|
||||
disconnectInstance,
|
||||
getQRCode,
|
||||
requestPairingCode,
|
||||
getInstanceStatus,
|
||||
connectedCount,
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
22
app/composables/useSidebarState.ts
Normal file
22
app/composables/useSidebarState.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Composable para gestionar el estado del sidebar
|
||||
*/
|
||||
export const useSidebarState = () => {
|
||||
const isOpen = useState('sidebarOpen', () => true)
|
||||
const isCollapsed = useState('sidebarCollapsed', () => false)
|
||||
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
const collapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
isCollapsed,
|
||||
toggle,
|
||||
collapse
|
||||
}
|
||||
}
|
||||
4
app/composables/useToast.ts
Normal file
4
app/composables/useToast.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Re-export useToast from Nuxt UI
|
||||
*/
|
||||
export { useToast } from '#imports'
|
||||
39
app/layouts/dashboard.vue
Normal file
39
app/layouts/dashboard.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="whatsapp-shell min-h-screen text-[var(--wa-text)]">
|
||||
<UDashboardGroup storage-key="whatsapp-dashboard" class="h-full">
|
||||
<AppSidebar />
|
||||
|
||||
<UDashboardPanel class="bg-transparent">
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-4 px-4 py-4 lg:px-6">
|
||||
<UDashboardNavbar :title="pageTitle" :icon="pageIcon" toggle-side="left">
|
||||
<template #leading>
|
||||
<UDashboardSidebarCollapse variant="subtle" />
|
||||
</template>
|
||||
<template #toggle>
|
||||
<UDashboardSidebarToggle variant="subtle" />
|
||||
</template>
|
||||
<template #trailing>
|
||||
<ConnectionStatus />
|
||||
<UserMenu />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="px-4 pb-10 lg:px-8">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
</UDashboardGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
|
||||
const pageTitle = computed(() => (route.meta.title as string) || 'WhatsApp Nucleo')
|
||||
const pageIcon = computed(() => (route.meta.icon as string) || 'i-lucide-message-circle')
|
||||
</script>
|
||||
103
app/pages/api-docs.vue
Normal file
103
app/pages/api-docs.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[var(--wa-text)]">Documentacion de API</h1>
|
||||
<p class="text-[var(--wa-text-muted)]">Guia para integrar WhatsApp Nucleo con tus sistemas</p>
|
||||
</div>
|
||||
|
||||
<!-- Authentication -->
|
||||
<div class="instance-card p-6">
|
||||
<h2 class="text-lg font-semibold text-[var(--wa-text)] mb-4">Autenticacion</h2>
|
||||
<p class="text-[var(--wa-text-muted)] mb-4">
|
||||
Todas las llamadas a la API externa requieren un API Key en el header <code class="bg-[var(--wa-bg-light)] px-2 py-1 rounded">Authorization</code>.
|
||||
</p>
|
||||
|
||||
<div class="bg-[var(--wa-bg)] p-4 rounded-lg font-mono text-sm">
|
||||
<span class="text-[var(--wa-text-muted)]"># Ejemplo de autenticacion</span><br>
|
||||
<span class="text-[var(--wa-blue)]">curl</span> -X POST https://whatsapp.nucleoriofrio.com/api/messages/send \<br>
|
||||
-H <span class="text-[var(--wa-green-light)]">"Authorization: Bearer YOUR_API_KEY"</span> \<br>
|
||||
-H <span class="text-[var(--wa-green-light)]">"Content-Type: application/json"</span> \<br>
|
||||
-d '{"instanceId": "...", "to": "...", "message": "..."}'
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Endpoints -->
|
||||
<div class="instance-card p-6">
|
||||
<h2 class="text-lg font-semibold text-[var(--wa-text)] mb-4">Endpoints Disponibles</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Send Message -->
|
||||
<div class="border border-[var(--wa-border)] rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="bg-green-600 text-white text-xs px-2 py-1 rounded font-semibold">POST</span>
|
||||
<code class="text-[var(--wa-text)]">/api/messages/send</code>
|
||||
</div>
|
||||
<p class="text-[var(--wa-text-muted)] text-sm mb-3">Envia un mensaje de texto a un numero de WhatsApp</p>
|
||||
|
||||
<div class="bg-[var(--wa-bg)] p-3 rounded text-sm font-mono">
|
||||
<span class="text-[var(--wa-text-muted)]">// Request body</span><br>
|
||||
{<br>
|
||||
"instanceId": <span class="text-[var(--wa-green-light)]">"uuid-de-instancia"</span>,<br>
|
||||
"to": <span class="text-[var(--wa-green-light)]">"5491123456789"</span>,<br>
|
||||
"message": <span class="text-[var(--wa-green-light)]">"Hola desde la API!"</span><br>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List Instances -->
|
||||
<div class="border border-[var(--wa-border)] rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="bg-blue-600 text-white text-xs px-2 py-1 rounded font-semibold">GET</span>
|
||||
<code class="text-[var(--wa-text)]">/api/instances</code>
|
||||
</div>
|
||||
<p class="text-[var(--wa-text-muted)] text-sm">Obtiene la lista de instancias disponibles</p>
|
||||
</div>
|
||||
|
||||
<!-- Get Instance Status -->
|
||||
<div class="border border-[var(--wa-border)] rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="bg-blue-600 text-white text-xs px-2 py-1 rounded font-semibold">GET</span>
|
||||
<code class="text-[var(--wa-text)]">/api/instances/:id/status</code>
|
||||
</div>
|
||||
<p class="text-[var(--wa-text-muted)] text-sm">Obtiene el estado de conexion de una instancia</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook Events -->
|
||||
<div class="instance-card p-6">
|
||||
<h2 class="text-lg font-semibold text-[var(--wa-text)] mb-4">Eventos de Webhook</h2>
|
||||
<p class="text-[var(--wa-text-muted)] mb-4">
|
||||
Los webhooks envian notificaciones cuando ocurren estos eventos:
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="bg-[var(--wa-bg-light)] p-4 rounded-lg">
|
||||
<code class="text-[var(--wa-green-light)]">message.received</code>
|
||||
<p class="text-sm text-[var(--wa-text-muted)] mt-1">Se recibio un mensaje</p>
|
||||
</div>
|
||||
<div class="bg-[var(--wa-bg-light)] p-4 rounded-lg">
|
||||
<code class="text-[var(--wa-green-light)]">message.sent</code>
|
||||
<p class="text-sm text-[var(--wa-text-muted)] mt-1">Se envio un mensaje</p>
|
||||
</div>
|
||||
<div class="bg-[var(--wa-bg-light)] p-4 rounded-lg">
|
||||
<code class="text-[var(--wa-green-light)]">instance.connected</code>
|
||||
<p class="text-sm text-[var(--wa-text-muted)] mt-1">Instancia conectada</p>
|
||||
</div>
|
||||
<div class="bg-[var(--wa-bg-light)] p-4 rounded-lg">
|
||||
<code class="text-[var(--wa-green-light)]">instance.disconnected</code>
|
||||
<p class="text-sm text-[var(--wa-text-muted)] mt-1">Instancia desconectada</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'dashboard',
|
||||
title: 'API Docs',
|
||||
icon: 'i-lucide-code'
|
||||
})
|
||||
</script>
|
||||
113
app/pages/index.vue
Normal file
113
app/pages/index.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
title="Instancias Activas"
|
||||
:value="stats.connectedInstances"
|
||||
:total="stats.totalInstances"
|
||||
icon="i-lucide-smartphone"
|
||||
color="green"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Mensajes Hoy"
|
||||
:value="stats.messagesToday"
|
||||
icon="i-lucide-message-square"
|
||||
color="blue"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Webhooks Activos"
|
||||
:value="stats.activeWebhooks"
|
||||
icon="i-lucide-webhook"
|
||||
color="purple"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Chats Activos"
|
||||
:value="stats.activeChats"
|
||||
icon="i-lucide-users"
|
||||
color="amber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Instances Overview -->
|
||||
<div class="instance-card p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-[var(--wa-text)]">Instancias</h3>
|
||||
<UButton
|
||||
to="/instances"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
trailing-icon="i-lucide-arrow-right"
|
||||
>
|
||||
Ver todas
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div v-if="instances.length === 0" class="text-center py-8">
|
||||
<UIcon name="i-lucide-smartphone" class="w-12 h-12 text-[var(--wa-text-muted)] mx-auto mb-3" />
|
||||
<p class="text-[var(--wa-text-muted)]">No hay instancias configuradas</p>
|
||||
<UButton
|
||||
to="/instances"
|
||||
variant="soft"
|
||||
class="mt-4"
|
||||
>
|
||||
Crear primera instancia
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="instance in instances.slice(0, 3)"
|
||||
:key="instance.id"
|
||||
class="flex items-center justify-between p-3 rounded-lg bg-[var(--wa-bg-light)]"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="instance.status === 'connected' ? 'bg-[var(--wa-green-light)]' : 'bg-red-500'"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium text-[var(--wa-text)]">{{ instance.name }}</p>
|
||||
<p class="text-sm text-[var(--wa-text-muted)]">{{ instance.phoneNumber || 'Sin conectar' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge :status="instance.status" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="instance-card p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-[var(--wa-text)]">Actividad Reciente</h3>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-8">
|
||||
<UIcon name="i-lucide-activity" class="w-12 h-12 text-[var(--wa-text-muted)] mx-auto mb-3" />
|
||||
<p class="text-[var(--wa-text-muted)]">La actividad aparecera aqui</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'dashboard',
|
||||
title: 'Dashboard',
|
||||
icon: 'i-lucide-layout-dashboard'
|
||||
})
|
||||
|
||||
// TODO: Conectar con datos reales
|
||||
const stats = ref({
|
||||
totalInstances: 0,
|
||||
connectedInstances: 0,
|
||||
messagesToday: 0,
|
||||
activeWebhooks: 0,
|
||||
activeChats: 0
|
||||
})
|
||||
|
||||
const instances = ref<any[]>([])
|
||||
</script>
|
||||
91
app/pages/instances/index.vue
Normal file
91
app/pages/instances/index.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[var(--wa-text)]">Instancias de WhatsApp</h1>
|
||||
<p class="text-[var(--wa-text-muted)]">Gestiona tus conexiones de WhatsApp</p>
|
||||
</div>
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
@click="showCreateModal = true"
|
||||
>
|
||||
Nueva Instancia
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Instances Grid -->
|
||||
<div v-if="instances.length === 0" class="instance-card p-12 text-center">
|
||||
<UIcon name="i-lucide-smartphone" class="w-16 h-16 text-[var(--wa-text-muted)] mx-auto mb-4" />
|
||||
<h3 class="text-xl font-semibold text-[var(--wa-text)] mb-2">No hay instancias</h3>
|
||||
<p class="text-[var(--wa-text-muted)] mb-6">Crea tu primera instancia para comenzar a usar WhatsApp Nucleo</p>
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
@click="showCreateModal = true"
|
||||
>
|
||||
Crear Instancia
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<InstanceCard
|
||||
v-for="instance in instances"
|
||||
:key="instance.id"
|
||||
:instance="instance"
|
||||
@connect="handleConnect"
|
||||
@disconnect="handleDisconnect"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Create Instance Modal -->
|
||||
<CreateInstanceModal
|
||||
v-model:open="showCreateModal"
|
||||
@created="handleCreated"
|
||||
/>
|
||||
|
||||
<!-- QR Code Modal -->
|
||||
<QRCodeModal
|
||||
v-model:open="showQRModal"
|
||||
:instance-id="selectedInstanceId"
|
||||
:qr-code="qrCode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'dashboard',
|
||||
title: 'Instancias',
|
||||
icon: 'i-lucide-smartphone'
|
||||
})
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const showQRModal = ref(false)
|
||||
const selectedInstanceId = ref<string | null>(null)
|
||||
const qrCode = ref<string | null>(null)
|
||||
|
||||
// TODO: Conectar con API real
|
||||
const instances = ref<any[]>([])
|
||||
|
||||
const handleConnect = async (instanceId: string) => {
|
||||
selectedInstanceId.value = instanceId
|
||||
showQRModal.value = true
|
||||
// TODO: Iniciar conexion y obtener QR
|
||||
}
|
||||
|
||||
const handleDisconnect = async (instanceId: string) => {
|
||||
// TODO: Desconectar instancia
|
||||
console.log('Disconnecting', instanceId)
|
||||
}
|
||||
|
||||
const handleDelete = async (instanceId: string) => {
|
||||
// TODO: Eliminar instancia
|
||||
console.log('Deleting', instanceId)
|
||||
}
|
||||
|
||||
const handleCreated = (instance: any) => {
|
||||
instances.value.push(instance)
|
||||
showCreateModal.value = false
|
||||
}
|
||||
</script>
|
||||
116
app/pages/messages/index.vue
Normal file
116
app/pages/messages/index.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[var(--wa-text)]">Mensajes</h1>
|
||||
<p class="text-[var(--wa-text-muted)]">Vista de conversaciones de todas las instancias</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instance Selector -->
|
||||
<div class="flex items-center gap-4">
|
||||
<USelectMenu
|
||||
v-model="selectedInstance"
|
||||
:items="instanceOptions"
|
||||
placeholder="Seleccionar instancia"
|
||||
class="w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Chat Interface -->
|
||||
<div class="grid grid-cols-12 gap-4 h-[calc(100vh-300px)]">
|
||||
<!-- Chat List -->
|
||||
<div class="col-span-4 instance-card overflow-hidden flex flex-col">
|
||||
<div class="p-4 border-b border-[var(--wa-border)]">
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
placeholder="Buscar conversacion..."
|
||||
icon="i-lucide-search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div v-if="chats.length === 0" class="p-8 text-center">
|
||||
<UIcon name="i-lucide-message-square" class="w-12 h-12 text-[var(--wa-text-muted)] mx-auto mb-3" />
|
||||
<p class="text-[var(--wa-text-muted)]">No hay conversaciones</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<ChatItem
|
||||
v-for="chat in filteredChats"
|
||||
:key="chat.id"
|
||||
:chat="chat"
|
||||
:active="selectedChat?.id === chat.id"
|
||||
@click="selectedChat = chat"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message View -->
|
||||
<div class="col-span-8 instance-card overflow-hidden flex flex-col">
|
||||
<div v-if="!selectedChat" class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<UIcon name="i-lucide-message-circle" class="w-16 h-16 text-[var(--wa-text-muted)] mx-auto mb-4" />
|
||||
<p class="text-[var(--wa-text-muted)]">Selecciona una conversacion</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Chat Header -->
|
||||
<div class="p-4 border-b border-[var(--wa-border)] flex items-center gap-3">
|
||||
<UAvatar :alt="selectedChat.name" size="md" />
|
||||
<div>
|
||||
<p class="font-medium text-[var(--wa-text)]">{{ selectedChat.name }}</p>
|
||||
<p class="text-sm text-[var(--wa-text-muted)]">{{ selectedChat.jid }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
<MessageBubble
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="p-4 border-t border-[var(--wa-border)]">
|
||||
<MessageInput @send="handleSendMessage" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'dashboard',
|
||||
title: 'Mensajes',
|
||||
icon: 'i-lucide-message-square'
|
||||
})
|
||||
|
||||
const selectedInstance = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const selectedChat = ref<any>(null)
|
||||
|
||||
// TODO: Conectar con API real
|
||||
const instanceOptions = ref<any[]>([])
|
||||
const chats = ref<any[]>([])
|
||||
const messages = ref<any[]>([])
|
||||
|
||||
const filteredChats = computed(() => {
|
||||
if (!searchQuery.value) return chats.value
|
||||
return chats.value.filter(chat =>
|
||||
chat.name.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
const handleSendMessage = async (content: string) => {
|
||||
console.log('Sending:', content)
|
||||
// TODO: Implementar envio de mensajes
|
||||
}
|
||||
</script>
|
||||
138
app/pages/settings.vue
Normal file
138
app/pages/settings.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[var(--wa-text)]">Configuracion</h1>
|
||||
<p class="text-[var(--wa-text-muted)]">Ajustes del sistema WhatsApp Nucleo</p>
|
||||
</div>
|
||||
|
||||
<!-- API Keys -->
|
||||
<div class="instance-card p-6">
|
||||
<h2 class="text-lg font-semibold text-[var(--wa-text)] mb-4">API Keys</h2>
|
||||
<p class="text-[var(--wa-text-muted)] mb-4">
|
||||
Gestiona las claves de acceso para la API externa
|
||||
</p>
|
||||
|
||||
<div v-if="apiKeys.length === 0" class="text-center py-8">
|
||||
<p class="text-[var(--wa-text-muted)]">No hay API Keys configuradas</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3 mb-4">
|
||||
<div
|
||||
v-for="key in apiKeys"
|
||||
:key="key.id"
|
||||
class="flex items-center justify-between p-3 rounded-lg bg-[var(--wa-bg-light)]"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium text-[var(--wa-text)]">{{ key.name }}</p>
|
||||
<p class="text-sm text-[var(--wa-text-muted)]">{{ key.keyPrefix }}... | Creada: {{ key.createdAt }}</p>
|
||||
</div>
|
||||
<UButton
|
||||
variant="ghost"
|
||||
color="red"
|
||||
icon="i-lucide-trash-2"
|
||||
@click="deleteApiKey(key.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
variant="soft"
|
||||
@click="showCreateKeyModal = true"
|
||||
>
|
||||
Crear API Key
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- General Settings -->
|
||||
<div class="instance-card p-6">
|
||||
<h2 class="text-lg font-semibold text-[var(--wa-text)] mb-4">Configuracion General</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-[var(--wa-text)]">Limite de instancias</p>
|
||||
<p class="text-sm text-[var(--wa-text-muted)]">Maximo numero de instancias permitidas</p>
|
||||
</div>
|
||||
<UInput
|
||||
v-model.number="settings.maxInstances"
|
||||
type="number"
|
||||
class="w-24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-[var(--wa-text)]">Timeout de Webhooks</p>
|
||||
<p class="text-sm text-[var(--wa-text-muted)]">Tiempo maximo de espera para webhooks (ms)</p>
|
||||
</div>
|
||||
<UInput
|
||||
v-model.number="settings.webhookTimeout"
|
||||
type="number"
|
||||
class="w-24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-[var(--wa-text)]">Reintentos de Webhook</p>
|
||||
<p class="text-sm text-[var(--wa-text-muted)]">Intentos de reenvio si falla</p>
|
||||
</div>
|
||||
<UInput
|
||||
v-model.number="settings.webhookRetries"
|
||||
type="number"
|
||||
class="w-24"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Info -->
|
||||
<div class="instance-card p-6">
|
||||
<h2 class="text-lg font-semibold text-[var(--wa-text)] mb-4">Informacion del Sistema</h2>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-[var(--wa-text-muted)]">Version</p>
|
||||
<p class="text-[var(--wa-text)]">1.0.0</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[var(--wa-text-muted)]">Baileys</p>
|
||||
<p class="text-[var(--wa-text)]">v7.0.0-rc.9</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[var(--wa-text-muted)]">Node.js</p>
|
||||
<p class="text-[var(--wa-text)]">v22.x</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[var(--wa-text-muted)]">PostgreSQL</p>
|
||||
<p class="text-[var(--wa-text)]">v16</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'dashboard',
|
||||
title: 'Configuracion',
|
||||
icon: 'i-lucide-settings'
|
||||
})
|
||||
|
||||
const showCreateKeyModal = ref(false)
|
||||
|
||||
// TODO: Conectar con API real
|
||||
const apiKeys = ref<any[]>([])
|
||||
|
||||
const settings = ref({
|
||||
maxInstances: 10,
|
||||
webhookTimeout: 5000,
|
||||
webhookRetries: 3
|
||||
})
|
||||
|
||||
const deleteApiKey = async (keyId: string) => {
|
||||
console.log('Deleting API key', keyId)
|
||||
}
|
||||
</script>
|
||||
86
app/pages/webhooks/index.vue
Normal file
86
app/pages/webhooks/index.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[var(--wa-text)]">Webhooks</h1>
|
||||
<p class="text-[var(--wa-text-muted)]">Configura notificaciones para eventos de WhatsApp</p>
|
||||
</div>
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
@click="showCreateModal = true"
|
||||
>
|
||||
Nuevo Webhook
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Webhooks List -->
|
||||
<div v-if="webhooks.length === 0" class="instance-card p-12 text-center">
|
||||
<UIcon name="i-lucide-webhook" class="w-16 h-16 text-[var(--wa-text-muted)] mx-auto mb-4" />
|
||||
<h3 class="text-xl font-semibold text-[var(--wa-text)] mb-2">No hay webhooks configurados</h3>
|
||||
<p class="text-[var(--wa-text-muted)] mb-6">Los webhooks te permiten recibir notificaciones en tiempo real cuando ocurren eventos en WhatsApp</p>
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
@click="showCreateModal = true"
|
||||
>
|
||||
Crear Webhook
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<WebhookCard
|
||||
v-for="webhook in webhooks"
|
||||
:key="webhook.id"
|
||||
:webhook="webhook"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
@test="handleTest"
|
||||
@toggle="handleToggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Create Webhook Modal -->
|
||||
<WebhookFormModal
|
||||
v-model:open="showCreateModal"
|
||||
:webhook="editingWebhook"
|
||||
@saved="handleSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'dashboard',
|
||||
title: 'Webhooks',
|
||||
icon: 'i-lucide-webhook'
|
||||
})
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const editingWebhook = ref<any>(null)
|
||||
|
||||
// TODO: Conectar con API real
|
||||
const webhooks = ref<any[]>([])
|
||||
|
||||
const handleEdit = (webhook: any) => {
|
||||
editingWebhook.value = webhook
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (webhookId: string) => {
|
||||
console.log('Deleting webhook', webhookId)
|
||||
}
|
||||
|
||||
const handleTest = async (webhookId: string) => {
|
||||
console.log('Testing webhook', webhookId)
|
||||
}
|
||||
|
||||
const handleToggle = async (webhookId: string, active: boolean) => {
|
||||
console.log('Toggle webhook', webhookId, active)
|
||||
}
|
||||
|
||||
const handleSaved = (webhook: any) => {
|
||||
showCreateModal.value = false
|
||||
editingWebhook.value = null
|
||||
// TODO: Refresh list
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user