feat: SMS OTP auth, phone verification gate, admin comments/edit/status pages

Backend:
- Add SmsService + SmsModule: send OTP via u-site.app provider, 5-min TTL
- Auth endpoints: POST /auth/send-otp, POST /auth/phone (login by phone+code),
  POST /auth/verify-phone (link), PATCH /auth/change-password
- is_phone_verified included in JWT token response
- GET /comments (admin, JWT-protected) with author/destination names

Admin:
- Users list: link to detail page per row
- User detail: inline edit form (name, city, phone) with PATCH /users/:id
- Services list: link to detail page per row
- Service detail: status change dropdown (PATCH /services/:id/status)
- New Comments page: summary stats + full table with star ratings
- New SMS settings page: configure API key + send test SMS
- Sidebar: added Comments and SMS entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-06-25 15:53:32 -05:00
co-authored by Claude Sonnet 4.6
parent 1c2f0ca71a
commit 7783cac3fe
15 changed files with 675 additions and 234 deletions
+114
View File
@@ -0,0 +1,114 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { api } from '@/lib/api';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Star } from 'lucide-react';
interface Comment {
id: string;
score: number;
content?: string;
is_from_user: boolean;
created_at: string;
users_comments_author_idTousers?: { name: string };
users_comments_destination_idTousers?: { name: string };
}
function Stars({ score }: { score: number }) {
return (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((i) => (
<Star
key={i}
className={`h-3.5 w-3.5 ${i <= score ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'}`}
/>
))}
<span className="ml-1 text-xs text-muted-foreground">{score}</span>
</div>
);
}
export default function CommentsPage() {
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true);
setError(null);
api.get<{ data: Comment[]; meta: any }>('/comments?limit=100')
.then((res) => setComments(res.data))
.catch(() => setError('Error al cargar los comentarios'))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const fromUser = comments.filter((c) => c.is_from_user);
const fromPro = comments.filter((c) => !c.is_from_user);
const avg = comments.length > 0 ? (comments.reduce((s, c) => s + c.score, 0) / comments.length).toFixed(1) : '—';
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Comentarios y Reseñas</h1>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card><CardContent className="pt-4 text-center"><p className="text-3xl font-bold">{comments.length}</p><p className="text-sm text-muted-foreground">Total</p></CardContent></Card>
<Card><CardContent className="pt-4 text-center"><p className="text-3xl font-bold">{avg}</p><p className="text-sm text-muted-foreground">Promedio</p></CardContent></Card>
<Card><CardContent className="pt-4 text-center"><p className="text-3xl font-bold">{fromUser.length}</p><p className="text-sm text-muted-foreground">De usuarios</p></CardContent></Card>
<Card><CardContent className="pt-4 text-center"><p className="text-3xl font-bold">{fromPro.length}</p><p className="text-sm text-muted-foreground">De profesionales</p></CardContent></Card>
</div>
{error ? (
<div className="flex flex-col items-center justify-center py-8 text-destructive">
<p>{error}</p>
<Button variant="outline" size="sm" onClick={load} className="mt-2">Reintentar</Button>
</div>
) : (
<Card>
<CardHeader><CardTitle>Últimas reseñas</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Autor</TableHead>
<TableHead>Destinatario</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Puntuación</TableHead>
<TableHead>Comentario</TableHead>
<TableHead>Fecha</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={6} className="text-center">Cargando...</TableCell></TableRow>
) : comments.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground">Sin comentarios</TableCell></TableRow>
) : (
comments.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.users_comments_author_idTousers?.name || '—'}</TableCell>
<TableCell>{c.users_comments_destination_idTousers?.name || '—'}</TableCell>
<TableCell>
<Badge variant="outline" className={c.is_from_user ? 'bg-blue-50 text-blue-700' : 'bg-purple-50 text-purple-700'}>
{c.is_from_user ? 'Usuario → Pro' : 'Pro → Usuario'}
</Badge>
</TableCell>
<TableCell><Stars score={c.score} /></TableCell>
<TableCell className="max-w-48 truncate text-sm text-muted-foreground">{c.content || '—'}</TableCell>
<TableCell className="text-sm">{new Date(c.created_at).toLocaleDateString()}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}
+76 -98
View File
@@ -7,7 +7,9 @@ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ArrowLeft, Star } from 'lucide-react';
import { toast } from 'sonner';
interface Service {
id: string;
@@ -23,19 +25,8 @@ interface Service {
updated_at: string;
professional_scored: boolean;
user_scored: boolean;
users?: {
id: string;
name: string;
phone?: string;
picture?: string;
};
professionals?: {
id: string;
users?: {
name: string;
picture?: string;
};
};
users?: { id: string; name: string; phone?: string; picture?: string; };
professionals?: { id: string; users?: { name: string; picture?: string; }; };
}
const statusColors: Record<string, string> = {
@@ -45,6 +36,7 @@ const statusColors: Record<string, string> = {
completed: 'bg-gray-100 text-gray-800',
cancelled: 'bg-red-100 text-red-800',
denied: 'bg-red-100 text-red-800',
self_booked: 'bg-purple-100 text-purple-800',
};
const statusLabels: Record<string, string> = {
@@ -71,8 +63,8 @@ export default function ServiceDetailPage() {
const [service, setService] = useState<Service | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [retryCounter, setRetryCounter] = useState(0);
const [updatingStatus, setUpdatingStatus] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -82,35 +74,44 @@ export default function ServiceDetailPage() {
return () => { cancelled = true; };
}, [params.id, retryCounter]);
if (loading) {
return (
<div className="space-y-4">
<Link href="/services" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="mr-1 h-4 w-4" /> Volver
</Link>
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
</div>
);
}
const changeStatus = async (newStatus: string) => {
if (!service || newStatus === service.status) return;
setUpdatingStatus(true);
try {
await api.patch(`/services/${params.id}/status`, { status: newStatus });
setService({ ...service, status: newStatus });
toast.success(`Estado cambiado a "${statusLabels[newStatus] || newStatus}"`);
} catch (e: any) {
toast.error(e?.message || 'Error al cambiar estado');
} finally {
setUpdatingStatus(false);
}
};
if (error) {
return (
<div className="space-y-4">
<Link href="/services" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="mr-1 h-4 w-4" /> Volver
</Link>
<div className="flex flex-col items-center justify-center py-8 text-destructive">
<p>{error}</p>
<Button variant="outline" size="sm" onClick={() => setRetryCounter((c) => c + 1)} className="mt-2">
Reintentar
</Button>
</div>
if (loading) return (
<div className="space-y-4">
<Link href="/services" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="mr-1 h-4 w-4" /> Volver
</Link>
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
</div>
);
if (error) return (
<div className="space-y-4">
<Link href="/services" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="mr-1 h-4 w-4" /> Volver
</Link>
<div className="flex flex-col items-center justify-center py-8 text-destructive">
<p>{error}</p>
<Button variant="outline" size="sm" onClick={() => setRetryCounter((c) => c + 1)} className="mt-2">
Reintentar
</Button>
</div>
);
}
</div>
);
if (!service) return null;
const s = service;
return (
@@ -119,39 +120,43 @@ export default function ServiceDetailPage() {
<ArrowLeft className="mr-1 h-4 w-4" /> Volver
</Link>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-bold">Servicio #{s.id.slice(0, 8)}</h1>
<Badge className={statusColors[s.status]} variant="outline">
{statusLabels[s.status] || s.status}
</Badge>
<div className="flex items-center gap-3">
<Badge className={statusColors[s.status]} variant="outline">
{statusLabels[s.status] || s.status}
</Badge>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Cambiar estado:</span>
<Select value={s.status} onValueChange={changeStatus} disabled={updatingStatus}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(statusLabels).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>Cliente</CardTitle>
</CardHeader>
<CardHeader><CardTitle>Cliente</CardTitle></CardHeader>
<CardContent className="space-y-2">
{s.users ? (
<>
<div className="flex items-center gap-3">
{s.users.picture && (
<img
src={s.users.picture}
alt={s.users.name}
className="h-10 w-10 rounded-full object-cover"
/>
<img src={s.users.picture} alt={s.users.name} className="h-10 w-10 rounded-full object-cover" />
)}
<Link
href={`/users/${s.users.id}`}
className="text-sm font-medium hover:underline"
>
<Link href={`/users/${s.users.id}`} className="text-sm font-medium hover:underline">
{s.users.name}
</Link>
</div>
<DetailRow label="Teléfono">
{s.users.phone || '—'}
</DetailRow>
<DetailRow label="Teléfono">{s.users.phone}</DetailRow>
</>
) : (
<p className="text-sm text-muted-foreground">Sin información</p>
@@ -160,79 +165,52 @@ export default function ServiceDetailPage() {
</Card>
<Card>
<CardHeader>
<CardTitle>Profesional</CardTitle>
</CardHeader>
<CardHeader><CardTitle>Profesional</CardTitle></CardHeader>
<CardContent>
{s.professionals ? (
<div className="flex items-center gap-3">
{s.professionals.users?.picture && (
<img
src={s.professionals.users.picture}
alt={s.professionals.users.name}
className="h-10 w-10 rounded-full object-cover"
/>
<img src={s.professionals.users.picture} alt={s.professionals.users.name} className="h-10 w-10 rounded-full object-cover" />
)}
<Link
href={`/professionals/${s.professionals.id}`}
className="text-sm font-medium hover:underline"
>
<Link href={`/professionals/${s.professionals.id}`} className="text-sm font-medium hover:underline">
{s.professionals.users?.name || '—'}
</Link>
</div>
) : (
<p className="text-sm text-muted-foreground">Sin información</p>
<p className="text-sm text-muted-foreground">Sin profesional asignado</p>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Detalles del servicio</CardTitle>
</CardHeader>
<CardHeader><CardTitle>Detalles del servicio</CardTitle></CardHeader>
<CardContent>
<DetailRow label="Fecha">
{new Date(s.day).toLocaleDateString()}
</DetailRow>
<DetailRow label="Fecha">{new Date(s.day).toLocaleDateString()}</DetailRow>
<DetailRow label="Dirección">{s.address}</DetailRow>
<DetailRow label="Descripción">{s.description}</DetailRow>
<DetailRow label="Tarifa">${s.rate}</DetailRow>
<DetailRow label="Preferencia de ubicación">
{s.location_preference}
</DetailRow>
<DetailRow label="Preferencia de ubicación">{s.location_preference}</DetailRow>
<DetailRow label="Horario">
{s.range1_hour1 && s.range1_hour2
? `${s.range1_hour1}${s.range1_hour2}`
: '—'}
{s.range1_hour1 && s.range1_hour2 ? `${s.range1_hour1}${s.range1_hour2}` : undefined}
</DetailRow>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Información adicional</CardTitle>
</CardHeader>
<CardHeader><CardTitle>Información adicional</CardTitle></CardHeader>
<CardContent>
<DetailRow label="Creado">
{new Date(s.created_at).toLocaleString()}
</DetailRow>
<DetailRow label="Actualizado">
{new Date(s.updated_at).toLocaleString()}
</DetailRow>
<DetailRow label="Creado">{new Date(s.created_at).toLocaleString()}</DetailRow>
<DetailRow label="Actualizado">{new Date(s.updated_at).toLocaleString()}</DetailRow>
<DetailRow label="Cliente puntuó">
<div className="flex items-center gap-1">
<Star
className={`h-4 w-4 ${s.user_scored ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'}`}
/>
<Star className={`h-4 w-4 ${s.user_scored ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'}`} />
{s.user_scored ? 'Sí' : 'No'}
</div>
</DetailRow>
<DetailRow label="Profesional puntuó">
<div className="flex items-center gap-1">
<Star
className={`h-4 w-4 ${s.professional_scored ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'}`}
/>
<Star className={`h-4 w-4 ${s.professional_scored ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'}`} />
{s.professional_scored ? 'Sí' : 'No'}
</div>
</DetailRow>
+10 -2
View File
@@ -7,6 +7,8 @@ import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import Link from 'next/link';
import { Eye } from 'lucide-react';
interface Service {
id: string;
@@ -97,13 +99,14 @@ export default function ServicesPage() {
<TableHead>Dirección</TableHead>
<TableHead>Tarifa</TableHead>
<TableHead>Estado</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={7} className="text-center">Cargando...</TableCell></TableRow>
<TableRow><TableCell colSpan={8} className="text-center">Cargando...</TableCell></TableRow>
) : filtered.length === 0 ? (
<TableRow><TableCell colSpan={7} className="text-center">Sin resultados</TableCell></TableRow>
<TableRow><TableCell colSpan={8} className="text-center">Sin resultados</TableCell></TableRow>
) : (
filtered.map((s) => (
<TableRow key={s.id}>
@@ -118,6 +121,11 @@ export default function ServicesPage() {
{statusLabels[s.status] || s.status}
</Badge>
</TableCell>
<TableCell>
<Link href={`/services/${s.id}`}>
<Button variant="ghost" size="icon"><Eye className="h-4 w-4" /></Button>
</Link>
</TableCell>
</TableRow>
))
)}
+199
View File
@@ -0,0 +1,199 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { api } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { Save, Send, Eye, EyeOff, CheckCircle2, XCircle } from 'lucide-react';
interface SmsConfig {
configured: boolean;
api_key_preview: string;
}
export default function SmsPage() {
const [config, setConfig] = useState<SmsConfig | null>(null);
const [newKey, setNewKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [savingKey, setSavingKey] = useState(false);
const [testPhone, setTestPhone] = useState('');
const [testMessage, setTestMessage] = useState('Hola, este es un mensaje de prueba desde ProsApp.');
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; id?: string; error?: string } | null>(null);
const loadConfig = useCallback(() => {
api.get<SmsConfig>('/sms/config')
.then(setConfig)
.catch(() => toast.error('Error al cargar configuración SMS'));
}, []);
useEffect(() => { loadConfig(); }, [loadConfig]);
const saveKey = async () => {
if (!newKey.trim()) return toast.error('Ingresa una API Key');
setSavingKey(true);
try {
await api.patch('/sms/config', { api_key: newKey.trim() });
toast.success('API Key guardada');
setNewKey('');
loadConfig();
} catch (e: any) {
toast.error(e?.message || 'Error al guardar');
} finally {
setSavingKey(false);
}
};
const sendTest = async () => {
if (!testPhone.trim()) return toast.error('Ingresa un número de teléfono');
if (!testMessage.trim()) return toast.error('Ingresa un mensaje');
setTesting(true);
setTestResult(null);
try {
const result = await api.post<{ ok: boolean; id: string }>('/sms/test', {
numero: testPhone.trim(),
mensaje: testMessage.trim(),
});
setTestResult(result);
toast.success('SMS enviado correctamente');
} catch (e: any) {
setTestResult({ ok: false, error: e?.message || 'Error desconocido' });
toast.error(e?.message || 'Error al enviar SMS');
} finally {
setTesting(false);
}
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Proveedor SMS</h1>
<p className="text-muted-foreground text-sm mt-1">
Configuración del servicio de envío de mensajes de texto (OTP y notificaciones).
</p>
</div>
{/* Estado */}
<Card>
<CardHeader>
<CardTitle className="text-base">Estado del proveedor</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-3">
{config === null ? (
<span className="text-muted-foreground text-sm">Cargando...</span>
) : config.configured ? (
<>
<CheckCircle2 className="text-green-500 h-5 w-5" />
<span className="text-sm font-medium">Configurado</span>
<Badge variant="secondary" className="font-mono text-xs">
{config.api_key_preview}
</Badge>
</>
) : (
<>
<XCircle className="text-destructive h-5 w-5" />
<span className="text-sm font-medium text-destructive">No configurado</span>
</>
)}
</CardContent>
</Card>
{/* Configurar API Key */}
<Card>
<CardHeader>
<CardTitle className="text-base">API Key</CardTitle>
<CardDescription>
Encuentra tu API Key en la sección <strong>API Key (envío externo)</strong> del panel de U-Site.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showKey ? 'text' : 'password'}
placeholder="sms_xxxxxxxxxxxxxxxxxxxx"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
className="pr-10 font-mono"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowKey(!showKey)}
>
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<Button onClick={saveKey} disabled={savingKey || !newKey.trim()}>
<Save className="mr-1 h-4 w-4" />
{savingKey ? 'Guardando...' : 'Guardar'}
</Button>
</div>
<p className="text-xs text-muted-foreground">
La clave se almacena cifrada. Si ya tienes una configurada, ingresa una nueva para reemplazarla.
</p>
</CardContent>
</Card>
{/* Prueba de envío */}
<Card>
<CardHeader>
<CardTitle className="text-base">Probar envío</CardTitle>
<CardDescription>
Envía un SMS de prueba para verificar que la configuración funciona correctamente.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Número de teléfono</label>
<Input
placeholder="573001234567"
value={testPhone}
onChange={(e) => setTestPhone(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Formato internacional sin + (ej: 573001234567 para Colombia)
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Mensaje</label>
<Input
placeholder="Hola, este es un mensaje de prueba"
value={testMessage}
onChange={(e) => setTestMessage(e.target.value.slice(0, 160))}
/>
<p className="text-xs text-muted-foreground">
{testMessage.length}/160 caracteres
</p>
</div>
<Button onClick={sendTest} disabled={testing || !config?.configured}>
<Send className="mr-1 h-4 w-4" />
{testing ? 'Enviando...' : 'Enviar SMS de prueba'}
</Button>
{!config?.configured && (
<p className="text-xs text-destructive">Debes configurar la API Key antes de probar.</p>
)}
{testResult && (
<div
className={`rounded-md border p-3 text-sm font-mono ${
testResult.ok
? 'border-green-200 bg-green-50 text-green-800'
: 'border-red-200 bg-red-50 text-red-800'
}`}
>
{testResult.ok ? (
<span> Enviado ID: {testResult.id}</span>
) : (
<span> Error: {testResult.error}</span>
)}
</div>
)}
</CardContent>
</Card>
</div>
);
}
+92 -124
View File
@@ -7,37 +7,17 @@ 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 { ArrowLeft } from 'lucide-react';
interface Professional {
id: string;
profession?: string;
rate?: number;
identification?: string;
is_active?: boolean;
}
interface Reputation {
total: number;
average: number;
total_pro: number;
average_pro: number;
}
import { Input } from '@/components/ui/input';
import { ArrowLeft, Pencil, X, Check } from 'lucide-react';
import { toast } from 'sonner';
interface Professional { id: string; profession?: string; rate?: number; identification?: string; }
interface Reputation { total: number; average: number; total_pro: number; average_pro: number; }
interface UserDetail {
id: string;
name: string;
email?: string;
phone?: string;
city?: string;
gender?: string;
birthday?: string;
is_email_verified?: boolean;
is_phone_verified?: boolean;
pro_state?: number;
created_at: string;
professionals?: Professional | null;
reputations?: Reputation | null;
id: string; name: string; email?: string; phone?: string; city?: string;
gender?: string; birthday?: string; is_email_verified?: boolean;
is_phone_verified?: boolean; pro_state?: number; created_at: string;
professionals?: Professional | null; reputations?: Reputation | null;
}
const PRO_STATE_LABELS = ['Usuario', 'Solicitó', 'Profesional', 'Rechazado'];
@@ -47,87 +27,107 @@ export default function UserDetailPage() {
const [user, setUser] = useState<UserDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ name: '', city: '', phone: '', email: '' });
const load = useCallback(() => {
if (!id) return;
setLoading(true);
setError(null);
api.get<UserDetail>(`/users/${id}`)
.then(setUser)
.then((u) => { setUser(u); setForm({ name: u.name, city: u.city || '', phone: u.phone || '', email: u.email || '' }); })
.catch(() => setError('Error al cargar el usuario'))
.finally(() => setLoading(false));
}, [id]);
useEffect(() => { load(); }, [load]);
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<p className="text-muted-foreground">Cargando...</p>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-16 text-destructive">
<p>{error}</p>
<Button variant="outline" size="sm" onClick={load} className="mt-2">
Reintentar
</Button>
</div>
);
}
const save = async () => {
setSaving(true);
try {
const updated = await api.patch<UserDetail>(`/users/${id}`, {
name: form.name || undefined,
city: form.city || undefined,
phone: form.phone || undefined,
});
setUser(updated);
setEditing(false);
toast.success('Usuario actualizado');
} catch (e: any) {
toast.error(e?.message || 'Error al guardar');
} finally {
setSaving(false);
}
};
if (loading) return <div className="flex items-center justify-center py-16 text-muted-foreground">Cargando...</div>;
if (error) return (
<div className="flex flex-col items-center justify-center py-16 text-destructive">
<p>{error}</p><Button variant="outline" size="sm" onClick={load} className="mt-2">Reintentar</Button>
</div>
);
if (!user) return null;
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href="/users">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-5 w-5" />
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/users"><Button variant="ghost" size="icon"><ArrowLeft className="h-5 w-5" /></Button></Link>
<h1 className="text-2xl font-bold">{user.name}</h1>
</div>
{!editing ? (
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<Pencil className="mr-1 h-4 w-4" /> Editar
</Button>
</Link>
<h1 className="text-2xl font-bold">{user.name}</h1>
) : (
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setEditing(false)} disabled={saving}>
<X className="mr-1 h-4 w-4" /> Cancelar
</Button>
<Button size="sm" onClick={save} disabled={saving}>
<Check className="mr-1 h-4 w-4" /> {saving ? 'Guardando...' : 'Guardar'}
</Button>
</div>
)}
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Información general</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<CardHeader><CardTitle>Información general</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div>
<span className="text-sm text-muted-foreground">Nombre</span>
{editing
? <Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="mt-1" />
: <p className="font-medium">{user.name}</p>}
</div>
<div>
<span className="text-sm text-muted-foreground">Email</span>
<p className="flex items-center gap-2">
{user.email || '—'}
{user.is_email_verified && (
<Badge variant="default" className="bg-green-600">Email verificado</Badge>
)}
{user.is_email_verified && <Badge className="bg-green-600 text-white">Verificado</Badge>}
</p>
</div>
<div>
<span className="text-sm text-muted-foreground">Teléfono</span>
<p className="flex items-center gap-2">
{user.phone || '—'}
{user.is_phone_verified && (
<Badge variant="default" className="bg-green-600">Teléfono verificado</Badge>
)}
</p>
{editing
? <Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className="mt-1" placeholder="+57300..." />
: <p className="flex items-center gap-2">
{user.phone || '—'}
{user.is_phone_verified && <Badge className="bg-green-600 text-white">Verificado</Badge>}
</p>}
</div>
<div>
<span className="text-sm text-muted-foreground">Ciudad</span>
<p>{user.city || '—'}</p>
{editing
? <Input value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} className="mt-1" />
: <p>{user.city || '—'}</p>}
</div>
<div>
<span className="text-sm text-muted-foreground">Género</span>
<p>{user.gender || '—'}</p>
</div>
<div>
<span className="text-sm text-muted-foreground">Cumpleaños</span>
<p>{user.birthday ? new Date(user.birthday).toLocaleDateString() : '—'}</p>
</div>
<div>
<span className="text-sm text-muted-foreground">Registro</span>
<p>{new Date(user.created_at).toLocaleDateString()}</p>
@@ -136,69 +136,37 @@ export default function UserDetailPage() {
</Card>
<Card>
<CardHeader>
<CardTitle>Estado</CardTitle>
</CardHeader>
<CardHeader><CardTitle>Estado</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div>
<span className="text-sm text-muted-foreground">Estado profesional</span>
<p>
<Badge>{PRO_STATE_LABELS[user.pro_state ?? 0] || '—'}</Badge>
</p>
<p className="mt-1"><Badge>{PRO_STATE_LABELS[user.pro_state ?? 0]}</Badge></p>
</div>
{user.reputations && (
<div className="grid grid-cols-2 gap-4 pt-2">
<div><span className="text-xs text-muted-foreground">Puntuación prom.</span><p className="text-2xl font-bold">{user.reputations.average.toFixed(1)}</p></div>
<div><span className="text-xs text-muted-foreground">Total reseñas</span><p className="text-2xl font-bold">{user.reputations.total}</p></div>
<div><span className="text-xs text-muted-foreground">Prom. como pro</span><p className="text-2xl font-bold">{user.reputations.average_pro.toFixed(1)}</p></div>
<div><span className="text-xs text-muted-foreground">Reseñas como pro</span><p className="text-2xl font-bold">{user.reputations.total_pro}</p></div>
</div>
)}
</CardContent>
</Card>
</div>
{user.professionals && (
<Card>
<CardHeader>
<CardTitle>Profesional</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<span className="font-medium">{user.professionals.profession || '—'}</span>
<CardHeader><CardTitle>Perfil profesional</CardTitle></CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="grid grid-cols-2 gap-4">
<div><span className="text-muted-foreground">Profesión</span><p>{user.professionals.profession || '—'}</p></div>
<div><span className="text-muted-foreground">Tarifa</span><p>{user.professionals.rate != null ? `$${user.professionals.rate}` : '—'}</p></div>
<div><span className="text-muted-foreground">Identificación</span><p>{user.professionals.identification || '—'}</p></div>
</div>
<div className="pt-2">
<Link href={`/professionals/${user.professionals.id}`}>
<Button variant="outline" size="sm">Ver detalle</Button>
</Link>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-muted-foreground">Tarifa</span>
<p>{user.professionals.rate != null ? `$${user.professionals.rate}` : '—'}</p>
</div>
<div>
<span className="text-muted-foreground">Identificación</span>
<p>{user.professionals.identification || '—'}</p>
</div>
</div>
</CardContent>
</Card>
)}
{user.reputations && (
<Card>
<CardHeader>
<CardTitle>Reputación</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<span className="text-sm text-muted-foreground">Total</span>
<p className="text-2xl font-bold">{user.reputations.total}</p>
</div>
<div>
<span className="text-sm text-muted-foreground">Promedio</span>
<p className="text-2xl font-bold">{user.reputations.average.toFixed(1)}</p>
</div>
<div>
<span className="text-sm text-muted-foreground">Total Pro</span>
<p className="text-2xl font-bold">{user.reputations.total_pro}</p>
</div>
<div>
<span className="text-sm text-muted-foreground">Promedio Pro</span>
<p className="text-2xl font-bold">{user.reputations.average_pro.toFixed(1)}</p>
</div>
<Button variant="outline" size="sm">Ver perfil profesional completo</Button>
</Link>
</div>
</CardContent>
</Card>
+8 -1
View File
@@ -6,7 +6,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Search } from 'lucide-react';
import { Search, Eye } from 'lucide-react';
import Link from 'next/link';
interface User {
id: string;
@@ -75,6 +76,7 @@ export default function UsersPage() {
<TableHead>Ciudad</TableHead>
<TableHead>Estado</TableHead>
<TableHead>Registro</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -91,6 +93,11 @@ export default function UsersPage() {
<TableCell>{u.city || '—'}</TableCell>
<TableCell>{['Usuario', 'Solicitó', 'Profesional', 'Rechazado'][u.pro_state ?? 0] || '—'}</TableCell>
<TableCell>{new Date(u.created_at).toLocaleDateString()}</TableCell>
<TableCell>
<Link href={`/users/${u.id}`}>
<Button variant="ghost" size="icon"><Eye className="h-4 w-4" /></Button>
</Link>
</TableCell>
</TableRow>
))
)}
+4
View File
@@ -11,6 +11,8 @@ import {
MapPin,
Wrench,
Settings,
MessageSquare,
Star,
LogOut,
ChevronLeft,
Menu,
@@ -27,6 +29,8 @@ const menu = [
{ href: '/services', label: 'Servicios', icon: ClipboardList },
{ href: '/cities', label: 'Ciudades', icon: MapPin },
{ href: '/professions', label: 'Profesiones', icon: Wrench },
{ href: '/comments', label: 'Comentarios', icon: Star },
{ href: '/sms', label: 'SMS', icon: MessageSquare },
{ href: '/settings', label: 'Configuración', icon: Settings },
];
+2
View File
@@ -12,6 +12,7 @@ import { SettingsModule } from './settings/settings.module';
import { ProfessionsModule } from './professions/professions.module';
import { StorageModule } from './storage/storage.module';
import { NotificationsModule } from './notifications/notifications.module';
import { SmsModule } from './sms/sms.module';
@Module({
imports: [
@@ -28,6 +29,7 @@ import { NotificationsModule } from './notifications/notifications.module';
ProfessionsModule,
StorageModule,
NotificationsModule,
SmsModule,
],
})
export class AppModule {}
+52 -5
View File
@@ -1,8 +1,43 @@
import { Controller, Post, Body, UseGuards, Get, Req, Patch } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { IsString, IsOptional, MinLength } from 'class-validator';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
import { RegisterDto, LoginDto, PhoneDto, UpdateUserDto, FcmTokenDto } from './dto/auth.dto';
import { RegisterDto, LoginDto } from './dto/auth.dto';
class SendOtpDto {
@IsString()
phone: string;
}
class PhoneLoginDto {
@IsString()
phone: string;
@IsString()
code: string;
@IsOptional()
@IsString()
name?: string;
}
class VerifyPhoneDto {
@IsString()
phone: string;
@IsString()
code: string;
}
class ChangePasswordDto {
@IsString()
current_password: string;
@IsString()
@MinLength(6)
new_password: string;
}
@ApiTags('Auth')
@Controller('auth')
@@ -19,16 +54,21 @@ export class AuthController {
return this.auth.login(dto.email, dto.password);
}
@Post('send-otp')
sendOtp(@Body() dto: SendOtpDto) {
return this.auth.sendPhoneOtp(dto.phone);
}
@Post('phone')
phone(@Body() dto: PhoneDto) {
return this.auth.loginOrCreateByPhone(dto.phone, dto.name);
phone(@Body() dto: PhoneLoginDto) {
return this.auth.loginOrCreateByPhone(dto.phone, dto.code, dto.name);
}
@Post('verify-phone')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
verifyPhone(@Req() req, @Body() dto: { phone: string }) {
return this.auth.verifyOtpAndLinkPhone(req.user.sub, dto.phone);
verifyPhone(@Req() req, @Body() dto: VerifyPhoneDto) {
return this.auth.verifyOtpAndLinkPhone(req.user.sub, dto.phone, dto.code);
}
@Post('link-email')
@@ -38,6 +78,13 @@ export class AuthController {
return this.auth.linkEmail(req.user.sub, dto.email, dto.password);
}
@Patch('change-password')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
changePassword(@Req() req, @Body() dto: ChangePasswordDto) {
return this.auth.changePassword(req.user.sub, dto.current_password, dto.new_password);
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
+2
View File
@@ -5,6 +5,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { SmsModule } from '../sms/sms.module';
@Module({
imports: [
@@ -17,6 +18,7 @@ import { JwtStrategy } from './jwt.strategy';
signOptions: { expiresIn: '7d' },
}),
}),
SmsModule,
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
+33 -3
View File
@@ -2,12 +2,14 @@ import { Injectable, UnauthorizedException, ConflictException, BadRequestExcepti
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../sms/sms.service';
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwt: JwtService,
private sms: SmsService,
) {}
async register(email: string, password: string, name: string) {
@@ -32,17 +34,32 @@ export class AuthService {
return this.generateToken(user);
}
async loginOrCreateByPhone(phone: string, name?: string) {
async sendPhoneOtp(phone: string): Promise<void> {
await this.sms.sendOtp(phone);
}
async loginOrCreateByPhone(phone: string, code: string, name?: string) {
const valid = this.sms.verifyOtp(phone, code);
if (!valid) throw new BadRequestException('Código OTP inválido o expirado');
let user = await this.prisma.users.findUnique({ where: { phone } });
if (!user) {
user = await this.prisma.users.create({
data: { phone, name: name || phone },
data: { phone, name: name || phone, is_phone_verified: true },
});
} else {
user = await this.prisma.users.update({
where: { id: user.id },
data: { is_phone_verified: true },
});
}
return this.generateToken(user);
}
async verifyOtpAndLinkPhone(userId: string, phone: string) {
async verifyOtpAndLinkPhone(userId: string, phone: string, code: string) {
const valid = this.sms.verifyOtp(phone, code);
if (!valid) throw new BadRequestException('Código OTP inválido o expirado');
const existing = await this.prisma.users.findUnique({ where: { phone } });
if (existing && existing.id !== userId) {
throw new ConflictException('Teléfono ya registrado por otro usuario');
@@ -64,6 +81,18 @@ export class AuthService {
});
}
async changePassword(userId: string, currentPassword: string, newPassword: string) {
const user = await this.prisma.users.findUnique({ where: { id: userId } });
if (!user || !user.password_hash) throw new BadRequestException('El usuario no tiene contraseña configurada');
const valid = await bcrypt.compare(currentPassword, user.password_hash);
if (!valid) throw new UnauthorizedException('Contraseña actual incorrecta');
const password_hash = await bcrypt.hash(newPassword, 10);
await this.prisma.users.update({ where: { id: userId }, data: { password_hash } });
return { ok: true };
}
async me(userId: string) {
const user = await this.prisma.users.findUnique({
where: { id: userId },
@@ -92,6 +121,7 @@ export class AuthService {
email: user.email,
phone: user.phone,
name: user.name,
is_phone_verified: user.is_phone_verified ?? false,
},
};
}
+8 -1
View File
@@ -1,4 +1,4 @@
import { Controller, Get, Post, Param, Body, UseGuards, Req } from '@nestjs/common';
import { Controller, Get, Post, Param, Body, UseGuards, Req, Query } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { CommentsService } from './comments.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -9,6 +9,13 @@ import { CreateCommentDto } from './dto/comment.dto';
export class CommentsController {
constructor(private comments: CommentsService) {}
@Get()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
findAll(@Query('page') page = '1', @Query('limit') limit = '50') {
return this.comments.findAll(+page, +limit);
}
@Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
+17
View File
@@ -104,6 +104,23 @@ export class CommentsService {
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
}
async findAll(page = 1, limit = 50) {
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
this.prisma.comments.findMany({
skip,
take: limit,
include: {
users_comments_author_idTousers: { select: { name: true, picture: true } },
users_comments_destination_idTousers: { select: { name: true } },
},
orderBy: { created_at: 'desc' },
}),
this.prisma.comments.count(),
]);
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
}
async getReputation(userId: string) {
const rep = await this.prisma.reputations.findUnique({ where: { user_id: userId } });
if (!rep) return { total: 0, average: 0, total_pro: 0, average_pro: 0 };
+46
View File
@@ -0,0 +1,46 @@
import { Controller, Get, Patch, Post, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { SmsService } from './sms.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
class SaveConfigDto {
@IsString()
api_key: string;
}
class TestSmsDto {
@IsString()
numero: string;
@IsString()
mensaje: string;
}
@ApiTags('SMS')
@Controller('sms')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class SmsController {
constructor(private sms: SmsService) {}
@Get('config')
async getConfig() {
const config = await this.sms.getConfig();
if (!config?.api_key) return { configured: false, api_key_preview: '' };
const k = config.api_key;
const preview = k.length > 8 ? `${k.slice(0, 8)}••••••••${k.slice(-4)}` : '••••••••';
return { configured: true, api_key_preview: preview };
}
@Patch('config')
async saveConfig(@Body() dto: SaveConfigDto) {
await this.sms.saveConfig(dto.api_key);
return { ok: true };
}
@Post('test')
test(@Body() dto: TestSmsDto) {
return this.sms.send(dto.numero, dto.mensaje);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { SmsService } from './sms.service';
import { SmsController } from './sms.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [SmsController],
providers: [SmsService],
exports: [SmsService],
})
export class SmsModule {}