diff --git a/admin/src/app/comments/page.tsx b/admin/src/app/comments/page.tsx
new file mode 100644
index 0000000..634dfbf
--- /dev/null
+++ b/admin/src/app/comments/page.tsx
@@ -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 (
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+ {score}
+
+ );
+}
+
+export default function CommentsPage() {
+ const [comments, setComments] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(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 (
+
+
Comentarios y Reseñas
+
+
+
{comments.length}
Total
+
{avg}
Promedio
+
{fromUser.length}
De usuarios
+
{fromPro.length}
De profesionales
+
+
+ {error ? (
+
+ ) : (
+
+ Últimas reseñas
+
+
+
+
+ Autor
+ Destinatario
+ Tipo
+ Puntuación
+ Comentario
+ Fecha
+
+
+
+ {loading ? (
+ Cargando...
+ ) : comments.length === 0 ? (
+ Sin comentarios
+ ) : (
+ comments.map((c) => (
+
+ {c.users_comments_author_idTousers?.name || '—'}
+ {c.users_comments_destination_idTousers?.name || '—'}
+
+
+ {c.is_from_user ? 'Usuario → Pro' : 'Pro → Usuario'}
+
+
+
+ {c.content || '—'}
+ {new Date(c.created_at).toLocaleDateString()}
+
+ ))
+ )}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/admin/src/app/services/[id]/page.tsx b/admin/src/app/services/[id]/page.tsx
index 08586f6..ba31a35 100644
--- a/admin/src/app/services/[id]/page.tsx
+++ b/admin/src/app/services/[id]/page.tsx
@@ -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 = {
@@ -45,6 +36,7 @@ const statusColors: Record = {
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 = {
@@ -71,8 +63,8 @@ export default function ServiceDetailPage() {
const [service, setService] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(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 (
-
-
-
Volver
-
-
Cargando...
-
- );
- }
+ 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 (
-
-
-
Volver
-
-
-
{error}
-
-
+ if (loading) return (
+
+
+
Volver
+
+
Cargando...
+
+ );
+
+ if (error) return (
+
+
+
Volver
+
+
+
{error}
+
- );
- }
+
+ );
if (!service) return null;
-
const s = service;
return (
@@ -119,39 +120,43 @@ export default function ServiceDetailPage() {
Volver
-
+
Servicio #{s.id.slice(0, 8)}
-
- {statusLabels[s.status] || s.status}
-
+
+
+ {statusLabels[s.status] || s.status}
+
+
+ Cambiar estado:
+
+
+
-
- Cliente
-
+ Cliente
{s.users ? (
<>
{s.users.picture && (
-

+

)}
-
+
{s.users.name}
-
- {s.users.phone || '—'}
-
+ {s.users.phone}
>
) : (
Sin información
@@ -160,79 +165,52 @@ export default function ServiceDetailPage() {
-
- Profesional
-
+ Profesional
{s.professionals ? (
{s.professionals.users?.picture && (
-

+

)}
-
+
{s.professionals.users?.name || '—'}
) : (
- Sin información
+ Sin profesional asignado
)}
-
- Detalles del servicio
-
+ Detalles del servicio
-
- {new Date(s.day).toLocaleDateString()}
-
+ {new Date(s.day).toLocaleDateString()}
{s.address}
{s.description}
${s.rate}
-
- {s.location_preference}
-
+ {s.location_preference}
- {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}
-
- Información adicional
-
+ Información adicional
-
- {new Date(s.created_at).toLocaleString()}
-
-
- {new Date(s.updated_at).toLocaleString()}
-
+ {new Date(s.created_at).toLocaleString()}
+ {new Date(s.updated_at).toLocaleString()}
-
+
{s.user_scored ? 'Sí' : 'No'}
-
+
{s.professional_scored ? 'Sí' : 'No'}
diff --git a/admin/src/app/services/page.tsx b/admin/src/app/services/page.tsx
index 4e44883..f080bd3 100644
--- a/admin/src/app/services/page.tsx
+++ b/admin/src/app/services/page.tsx
@@ -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() {
Dirección
Tarifa
Estado
+
{loading ? (
- Cargando...
+ Cargando...
) : filtered.length === 0 ? (
- Sin resultados
+ Sin resultados
) : (
filtered.map((s) => (
@@ -118,6 +121,11 @@ export default function ServicesPage() {
{statusLabels[s.status] || s.status}
+
+
+
+
+
))
)}
diff --git a/admin/src/app/sms/page.tsx b/admin/src/app/sms/page.tsx
new file mode 100644
index 0000000..775ced3
--- /dev/null
+++ b/admin/src/app/sms/page.tsx
@@ -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(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('/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 (
+
+
+
Proveedor SMS
+
+ Configuración del servicio de envío de mensajes de texto (OTP y notificaciones).
+
+
+
+ {/* Estado */}
+
+
+ Estado del proveedor
+
+
+ {config === null ? (
+ Cargando...
+ ) : config.configured ? (
+ <>
+
+ Configurado
+
+ {config.api_key_preview}
+
+ >
+ ) : (
+ <>
+
+ No configurado
+ >
+ )}
+
+
+
+ {/* Configurar API Key */}
+
+
+ API Key
+
+ Encuentra tu API Key en la sección API Key (envío externo) del panel de U-Site.
+
+
+
+
+
+ setNewKey(e.target.value)}
+ className="pr-10 font-mono"
+ />
+
+
+
+
+
+ La clave se almacena cifrada. Si ya tienes una configurada, ingresa una nueva para reemplazarla.
+
+
+
+
+ {/* Prueba de envío */}
+
+
+ Probar envío
+
+ Envía un SMS de prueba para verificar que la configuración funciona correctamente.
+
+
+
+
+
+
setTestPhone(e.target.value)}
+ />
+
+ Formato internacional sin + (ej: 573001234567 para Colombia)
+
+
+
+
+
setTestMessage(e.target.value.slice(0, 160))}
+ />
+
+ {testMessage.length}/160 caracteres
+
+
+
+ {!config?.configured && (
+ Debes configurar la API Key antes de probar.
+ )}
+ {testResult && (
+
+ {testResult.ok ? (
+ ✓ Enviado — ID: {testResult.id}
+ ) : (
+ ✗ Error: {testResult.error}
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/admin/src/app/users/[id]/page.tsx b/admin/src/app/users/[id]/page.tsx
index ac9f8d2..b961abf 100644
--- a/admin/src/app/users/[id]/page.tsx
+++ b/admin/src/app/users/[id]/page.tsx
@@ -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(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(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(`/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 (
-
- );
- }
-
- if (error) {
- return (
-
-
{error}
-
-
- );
- }
+ const save = async () => {
+ setSaving(true);
+ try {
+ const updated = await api.patch(`/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 Cargando...
;
+ if (error) return (
+
+ );
if (!user) return null;
return (
-
-
-