Fix professional 500 error, add SMTP config UI and message history page

- Fix route ordering in professionals.controller: PATCH me was captured by PATCH :id causing 500
- Admin settings: add SMTP email config section (host, port, user, pass, sender)
- Admin sidebar: add Mensajes link
- Admin: new /mensajes page with SMS/email history, channel filter and pagination

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-06-28 17:08:59 -05:00
co-authored by Claude Sonnet 4.6
parent 6da45ac9d1
commit ce769dfe8d
4 changed files with 313 additions and 24 deletions
+184
View File
@@ -0,0 +1,184 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { api } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { Mail, MessageSquare, ChevronLeft, ChevronRight, AlertCircle, CheckCircle2 } from 'lucide-react';
function fmtDate(iso: string) {
const d = new Date(iso);
return d.toLocaleString('es-CO', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
interface MessageLog {
id: string;
channel: string;
recipient: string;
body: string;
status: string;
error?: string;
created_at: string;
}
interface Meta {
page: number;
limit: number;
total: number;
pages: number;
}
type ChannelFilter = 'all' | 'sms' | 'email';
export default function MensajesPage() {
const [logs, setLogs] = useState<MessageLog[]>([]);
const [meta, setMeta] = useState<Meta>({ page: 1, limit: 50, total: 0, pages: 1 });
const [loading, setLoading] = useState(true);
const [channel, setChannel] = useState<ChannelFilter>('all');
const [page, setPage] = useState(1);
const load = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), limit: '50' });
if (channel !== 'all') params.set('channel', channel);
const data = await api.get<{ data: MessageLog[]; meta: Meta }>(`/settings/message-logs?${params}`);
setLogs(data.data);
setMeta(data.meta);
} catch {
toast.error('Error al cargar historial');
} finally {
setLoading(false);
}
}, [page, channel]);
useEffect(() => { load(); }, [load]);
const handleChannelChange = (c: ChannelFilter) => {
setChannel(c);
setPage(1);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Historial de mensajes</h1>
<p className="text-muted-foreground text-sm mt-1">
Registro de todos los SMS y correos enviados desde la plataforma.
</p>
</div>
{/* Filtros */}
<div className="flex gap-2">
{(['all', 'sms', 'email'] as ChannelFilter[]).map((c) => (
<Button
key={c}
variant={channel === c ? 'default' : 'outline'}
size="sm"
onClick={() => handleChannelChange(c)}
>
{c === 'all' ? 'Todos' : c === 'sms' ? <><MessageSquare size={14} className="mr-1" />SMS</> : <><Mail size={14} className="mr-1" />Email</>}
</Button>
))}
<span className="ml-auto text-sm text-muted-foreground self-center">
{meta.total} mensaje{meta.total !== 1 ? 's' : ''}
</span>
</div>
{/* Tabla */}
<Card>
<CardHeader>
<CardTitle className="text-base">Registros</CardTitle>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">Cargando...</div>
) : logs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground gap-2">
<MessageSquare size={32} className="opacity-30" />
<p className="text-sm">No hay mensajes registrados</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Canal</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Destinatario</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Mensaje</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Estado</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Fecha</th>
</tr>
</thead>
<tbody className="divide-y">
{logs.map((log) => (
<tr key={log.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
{log.channel === 'email'
? <Mail size={14} className="text-blue-500" />
: <MessageSquare size={14} className="text-green-500" />}
<span className="capitalize font-medium">{log.channel}</span>
</div>
</td>
<td className="px-4 py-3 text-muted-foreground font-mono text-xs">{log.recipient}</td>
<td className="px-4 py-3 max-w-xs">
<p className="truncate text-xs" title={log.body}>{log.body}</p>
{log.error && (
<p className="text-destructive text-xs mt-0.5 truncate" title={log.error}>
{log.error}
</p>
)}
</td>
<td className="px-4 py-3">
{log.status === 'sent' ? (
<Badge variant="outline" className="text-green-600 border-green-200 bg-green-50 gap-1">
<CheckCircle2 size={11} /> Enviado
</Badge>
) : (
<Badge variant="outline" className="text-destructive border-red-200 bg-red-50 gap-1">
<AlertCircle size={11} /> Error
</Badge>
)}
</td>
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap">
{fmtDate(log.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Paginación */}
{meta.pages > 1 && (
<div className="flex items-center justify-center gap-3">
<Button
variant="outline"
size="sm"
disabled={page === 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft size={16} />
</Button>
<span className="text-sm text-muted-foreground">
Página {page} de {meta.pages}
</span>
<Button
variant="outline"
size="sm"
disabled={page === meta.pages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight size={16} />
</Button>
</div>
)}
</div>
);
}
+105 -2
View File
@@ -9,11 +9,19 @@ import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import {
Save, ExternalLink, Copy, FileText, Shield, Map,
Eye, EyeOff, CheckCircle2, XCircle, Clock, Smartphone, Phone, Mail,
Eye, EyeOff, CheckCircle2, XCircle, Clock, Smartphone, Phone, Mail, Server,
} from 'lucide-react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
interface SmtpConfig {
host: string;
port: string;
user: string;
pass: string;
from?: string;
}
interface GlobalSettings {
app_version_android?: string;
app_version_ios?: string;
@@ -49,17 +57,24 @@ export default function SettingsPage() {
const [policies, setPolicies] = useState<Record<string, string>>({ privacy: '', terms: '' });
const [savingPolicy, setSavingPolicy] = useState<string | null>(null);
// SMTP
const [smtp, setSmtp] = useState<SmtpConfig>({ host: '', port: '587', user: '', pass: '', from: '' });
const [showSmtpPass, setShowSmtpPass] = useState(false);
const [savingSmtp, setSavingSmtp] = useState(false);
const load = useCallback(() => {
setLoading(true);
Promise.all([
api.get<GlobalSettings>('/settings'),
api.get<{ configured: boolean }>('/settings/maps-key'),
api.get<{ privacy: string | null; terms: string | null }>('/settings/policies'),
api.get<SmtpConfig | null>('/settings/smtp').catch(() => null),
])
.then(([globalData, mapsData, policiesData]) => {
.then(([globalData, mapsData, policiesData, smtpData]) => {
setGlobal(globalData || {});
setMapsConfigured(mapsData.configured);
setPolicies({ privacy: policiesData.privacy || '', terms: policiesData.terms || '' });
if (smtpData) setSmtp({ host: smtpData.host || '', port: smtpData.port || '587', user: smtpData.user || '', pass: smtpData.pass || '', from: smtpData.from || '' });
})
.catch(() => toast.error('Error al cargar configuración'))
.finally(() => setLoading(false));
@@ -106,6 +121,19 @@ export default function SettingsPage() {
}
};
const saveSmtp = async () => {
if (!smtp.host || !smtp.user || !smtp.pass) return toast.error('Host, usuario y contraseña son obligatorios');
setSavingSmtp(true);
try {
await api.patch('/settings/smtp', smtp);
toast.success('Configuración SMTP guardada');
} catch (e: any) {
toast.error(e?.message || 'Error al guardar SMTP');
} finally {
setSavingSmtp(false);
}
};
const copyLink = (key: string) => {
navigator.clipboard.writeText(`${API_BASE}/settings/policy/${key}`);
toast.success('Enlace copiado');
@@ -289,6 +317,81 @@ export default function SettingsPage() {
</Card>
</section>
{/* ── SMTP Email ── */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Correo electrónico (SMTP)</h2>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Server size={18} className="text-muted-foreground" />
Servidor SMTP
</CardTitle>
<p className="text-sm text-muted-foreground">
Configuración del servidor de correo para envío de OTP y notificaciones. Tiene prioridad sobre las variables de entorno.
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-sm font-medium">Host SMTP</label>
<Input
placeholder="smtp.gmail.com"
value={smtp.host}
onChange={(e) => setSmtp((s) => ({ ...s, host: e.target.value }))}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Puerto</label>
<Input
placeholder="587"
value={smtp.port}
onChange={(e) => setSmtp((s) => ({ ...s, port: e.target.value }))}
/>
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Usuario / correo</label>
<Input
placeholder="noreply@prosapp.co"
value={smtp.user}
onChange={(e) => setSmtp((s) => ({ ...s, user: e.target.value }))}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Contraseña / App Password</label>
<div className="relative">
<Input
type={showSmtpPass ? 'text' : 'password'}
placeholder="••••••••"
value={smtp.pass}
onChange={(e) => setSmtp((s) => ({ ...s, pass: e.target.value }))}
className="pr-10"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowSmtpPass(!showSmtpPass)}
>
{showSmtpPass ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Nombre / correo remitente <span className="text-muted-foreground text-xs">(opcional)</span></label>
<Input
placeholder="ProsApp <noreply@prosapp.co>"
value={smtp.from || ''}
onChange={(e) => setSmtp((s) => ({ ...s, from: e.target.value }))}
/>
</div>
<Button onClick={saveSmtp} disabled={savingSmtp}>
<Save className="mr-1 h-4 w-4" />
{savingSmtp ? 'Guardando...' : 'Guardar configuración SMTP'}
</Button>
</CardContent>
</Card>
</section>
{/* ── Documentos legales ── */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Documentos legales</h2>
+2
View File
@@ -14,6 +14,7 @@ import {
MessageSquare,
Star,
Lightbulb,
Inbox,
LogOut,
ChevronLeft,
Menu,
@@ -33,6 +34,7 @@ const menu = [
{ href: '/comments', label: 'Comentarios', icon: Star },
{ href: '/admin-sugerencias', label: 'Sugerencias', icon: Lightbulb },
{ href: '/sms', label: 'SMS', icon: MessageSquare },
{ href: '/mensajes', label: 'Mensajes', icon: Inbox },
{ href: '/settings', label: 'Configuración', icon: Settings },
];
@@ -60,6 +60,28 @@ export class ProfessionalsController {
return this.pros.findByUserId(req.user.sub);
}
@Post('request')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
request(@Req() req, @Body() dto: CreateProfessionalDto) {
return this.pros.requestProfessional(req.user.sub, dto);
}
@Patch('me/schedules')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
async updateSchedules(@Req() req, @Body() dto: UpdateSchedulesDto) {
const prof = await this.pros.findByUserId(req.user.sub);
return this.pros.updateSchedules(prof.id, dto.schedules);
}
@Patch('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
async update(@Req() req, @Body() dto: UpdateProfessionalDto) {
return this.pros.upsert(req.user.sub, dto);
}
@Patch(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@@ -71,26 +93,4 @@ export class ProfessionalsController {
findById(@Param('id') id: string) {
return this.pros.findById(id);
}
@Post('request')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
request(@Req() req, @Body() dto: CreateProfessionalDto) {
return this.pros.requestProfessional(req.user.sub, dto);
}
@Patch('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
async update(@Req() req, @Body() dto: UpdateProfessionalDto) {
return this.pros.upsert(req.user.sub, dto);
}
@Patch('me/schedules')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
async updateSchedules(@Req() req, @Body() dto: UpdateSchedulesDto) {
const prof = await this.pros.findByUserId(req.user.sub);
return this.pros.updateSchedules(prof.id, dto.schedules);
}
}