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:
co-authored by
Claude Sonnet 4.6
parent
6da45ac9d1
commit
ce769dfe8d
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user