diff --git a/admin/src/app/professionals/[id]/page.tsx b/admin/src/app/professionals/[id]/page.tsx index 9dc80bd..57d9109 100644 --- a/admin/src/app/professionals/[id]/page.tsx +++ b/admin/src/app/professionals/[id]/page.tsx @@ -7,89 +7,68 @@ import { api } from '@/lib/api'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { toast } from 'sonner'; -import { ArrowLeft } from 'lucide-react'; - -interface User { - name: string; - email?: string; - phone?: string; - city?: string; - picture?: string; -} - -interface Schedule { - id: string; - day_of_week: number; - enabled: boolean; - range1_hour1?: string; - range1_hour2?: string; - range2_hour1?: string; - range2_hour2?: string; -} - -interface Specialization { - id: string; - name: string; - picture?: string; -} - -interface PaymentMethod { - id: string; - nequi: boolean; - datafono: boolean; - transferencia: boolean; -} +import { ArrowLeft, Pencil, X, Check } from 'lucide-react'; +interface User { id: string; name: string; email?: string; phone?: string; city?: string; picture?: string; } +interface Schedule { id: string; day_of_week: number; enabled: boolean; range1_hour1?: string; range1_hour2?: string; } +interface Specialization { id: string; name: string; } +interface PaymentMethod { id: string; nequi: boolean; datafono: boolean; transferencia: boolean; } interface Professional { - id: string; - user_id: string; - is_active: boolean; - profession?: string; - identification?: string; - address?: string; - rate?: number; - average_score?: number; - users?: User; - schedules?: Schedule[]; - specializations?: Specialization[]; - payment_methods?: PaymentMethod[]; -} - -interface Service { - id: string; - professional_id: string; - user_id: string; - description?: string; - rate?: number; - status: string; - day: string; - created_at: string; + id: string; user_id: string; is_active: boolean; + profession?: string; identification?: string; address?: string; + rate?: number; average_score?: number; + users?: User; schedules?: Schedule[]; + specializations?: Specialization[]; payment_methods?: PaymentMethod[]; } +interface Service { id: string; description?: string; rate?: number; status: string; day: string; } const DAY_NAMES = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado']; +const PAYMENT_LABELS: Record = { nequi: 'Nequi', datafono: 'Datáfono', transferencia: 'Transferencia' }; + +interface ProForm { profession: string; identification: string; address: string; rate: string; } +interface UserForm { name: string; email: string; phone: string; city: string; } export default function ProfessionalDetailPage() { - const params = useParams<{ id: string }>(); - const id = params.id; - + const { id } = useParams<{ id: string }>(); const [professional, setProfessional] = useState(null); const [services, setServices] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Edit state + const [editingPro, setEditingPro] = useState(false); + const [editingUser, setEditingUser] = useState(false); + const [savingPro, setSavingPro] = useState(false); + const [savingUser, setSavingUser] = useState(false); + const [proForm, setProForm] = useState({ profession: '', identification: '', address: '', rate: '' }); + const [userForm, setUserForm] = useState({ name: '', email: '', phone: '', city: '' }); + const load = useCallback(() => { if (!id) return; setLoading(true); setError(null); Promise.all([ api.get(`/professionals/${id}`), - api.get<{ data: Service[]; meta: any }>('/services?page=1&limit=50'), + api.get<{ data: Service[] }>('/services?page=1&limit=50'), ]) .then(([prof, svcRes]) => { setProfessional(prof); - setServices(svcRes.data.filter((s) => s.professional_id === id)); + setProForm({ + profession: prof.profession || '', + identification: prof.identification || '', + address: prof.address || '', + rate: prof.rate != null ? String(prof.rate) : '', + }); + setUserForm({ + name: prof.users?.name || '', + email: prof.users?.email || '', + phone: prof.users?.phone || '', + city: prof.users?.city || '', + }); + setServices(svcRes.data.filter((s: any) => s.professional_id === id)); }) .catch(() => setError('Error al cargar el profesional')) .finally(() => setLoading(false)); @@ -97,114 +76,195 @@ export default function ProfessionalDetailPage() { useEffect(() => { load(); }, [load]); - const approve = async () => { - if (!id) return; + const savePro = async () => { + setSavingPro(true); try { - await api.post(`/professionals/${id}/approve`); - toast.success('Profesional aprobado'); + await api.patch(`/professionals/${id}`, { + profession: proForm.profession || undefined, + identification: proForm.identification || undefined, + address: proForm.address || undefined, + rate: proForm.rate ? Number(proForm.rate) : undefined, + }); + toast.success('Perfil profesional actualizado'); + setEditingPro(false); load(); - } catch { - toast.error('Error al aprobar el profesional'); + } catch (e: any) { + toast.error(e?.message || 'Error al guardar'); + } finally { + setSavingPro(false); } }; + const saveUser = async () => { + if (!professional?.user_id) return; + setSavingUser(true); + try { + await api.patch(`/users/${professional.user_id}`, { + name: userForm.name || undefined, + phone: userForm.phone || undefined, + city: userForm.city || undefined, + }); + toast.success('Datos del usuario actualizados'); + setEditingUser(false); + load(); + } catch (e: any) { + toast.error(e?.message || 'Error al guardar'); + } finally { + setSavingUser(false); + } + }; + + const approve = async () => { + try { await api.post(`/professionals/${id}/approve`); toast.success('Profesional aprobado'); load(); } + catch { toast.error('Error al aprobar'); } + }; + const deny = async () => { - if (!id) return; - try { - await api.post(`/professionals/${id}/deny`); - toast.success('Solicitud rechazada'); - load(); - } catch { - toast.error('Error al rechazar la solicitud'); - } + try { await api.post(`/professionals/${id}/deny`); toast.success('Solicitud rechazada'); load(); } + catch { toast.error('Error al rechazar'); } }; - if (loading) { - return ( -
-
- - - -

Cargando...

-
-
- ); - } - - if (error || !professional) { - return ( -
-
- - - -

Profesional

-
-
-

{error || 'No se encontró el profesional'}

- -
-
- ); - } + if (loading) return
Cargando...
; + if (error || !professional) return ( +
+

{error || 'No se encontró el profesional'}

+ +
+ ); const user = professional.users; - const paymentLabel: Record = { - nequi: 'Nequi', - datafono: 'Datáfono', - transferencia: 'Transferencia', - }; return (
-
- - - -

{user?.name || 'Profesional'}

- + {/* Header */} +
+
+ +
+

{user?.name || 'Profesional'}

+

ID profesional: {id}

+
+
+ {professional.is_active ? 'Activo' : 'Pendiente'}
-
- - Información general - -
Email: {user?.email || '—'}
-
Teléfono: {user?.phone || '—'}
-
Ciudad: {user?.city || '—'}
-
Profesión: {professional.profession || '—'}
-
Identificación: {professional.identification || '—'}
-
Dirección: {professional.address || '—'}
-
-
+ {/* Datos del usuario */} + + +
+ Datos personales + {!editingUser ? ( + + ) : ( +
+ + +
+ )} +
+
+ +
+ Nombre + {editingUser + ? setUserForm({ ...userForm, name: e.target.value })} /> + :

{user?.name || '—'}

} +
+
+ Email +

{user?.email || '—'}

+
+
+ Teléfono + {editingUser + ? setUserForm({ ...userForm, phone: e.target.value })} placeholder="+57300..." /> + :

{user?.phone || '—'}

} +
+
+ Ciudad + {editingUser + ? setUserForm({ ...userForm, city: e.target.value })} /> + :

{user?.city || '—'}

} +
+
+
- - Tarifa y puntuación - -
Tarifa: ${professional.rate ?? '—'}
-
Puntaje promedio: {professional.average_score != null ? `${professional.average_score.toFixed(1)} / 5` : '—'}
-
-
-
+ {/* Perfil profesional */} + + +
+ Perfil profesional + {!editingPro ? ( + + ) : ( +
+ + +
+ )} +
+
+ +
+ Profesión + {editingPro + ? setProForm({ ...proForm, profession: e.target.value })} /> + :

{professional.profession || '—'}

} +
+
+ Identificación + {editingPro + ? setProForm({ ...proForm, identification: e.target.value })} /> + :

{professional.identification || '—'}

} +
+
+ Dirección + {editingPro + ? setProForm({ ...proForm, address: e.target.value })} /> + :

{professional.address || '—'}

} +
+
+ Tarifa ($/hora) + {editingPro + ? setProForm({ ...proForm, rate: e.target.value })} placeholder="0" /> + :

{professional.rate != null ? `$${professional.rate}` : '—'}

} +
+
+ Puntuación promedio +

{professional.average_score != null ? `${professional.average_score.toFixed(1)} / 5` : '—'}

+
+
+
+ {/* Métodos de pago */} {professional.payment_methods && professional.payment_methods.length > 0 && ( - Métodos de pago + Métodos de pago aceptados
- {professional.payment_methods && ['nequi', 'datafono', 'transferencia'].filter((k) => (professional.payment_methods![0] as any)[k]).map((k) => ( - {paymentLabel[k]} - ))} + {['nequi', 'datafono', 'transferencia'] + .filter((k) => (professional.payment_methods![0] as any)[k]) + .map((k) => {PAYMENT_LABELS[k]})}
)} + {/* Horarios */} {professional.schedules && professional.schedules.length > 0 && ( Horarios @@ -213,14 +273,18 @@ export default function ProfessionalDetailPage() { Día - Horas + Horario {professional.schedules.map((s) => ( - {DAY_NAMES[s.day_of_week] || s.day_of_week} - {s.enabled ? [s.range1_hour1, s.range1_hour2].filter(Boolean).join(' — ') || '—' : 'Descanso'} + {DAY_NAMES[s.day_of_week] ?? s.day_of_week} + + {s.enabled + ? [s.range1_hour1, s.range1_hour2].filter(Boolean).join(' – ') || '—' + : Descanso} + ))} @@ -229,19 +293,19 @@ export default function ProfessionalDetailPage() { )} + {/* Especializaciones */} {professional.specializations && professional.specializations.length > 0 && ( Especializaciones
- {professional.specializations.map((s) => ( - {s.name} - ))} + {professional.specializations.map((s) => {s.name})}
)} + {/* Servicios */} {services.length > 0 && ( Servicios recientes @@ -258,9 +322,9 @@ export default function ProfessionalDetailPage() { {services.map((s) => ( - {new Date(s.day).toLocaleDateString()} + {new Date(s.day).toLocaleDateString()} {s.description || '—'} - ${s.rate ?? '—'} + {s.rate != null ? `$${s.rate}` : '—'} {s.status} ))} @@ -270,10 +334,11 @@ export default function ProfessionalDetailPage() { )} + {/* Aprobar / Rechazar */} {!professional.is_active && ( -
- - +
+ +
)}
diff --git a/admin/src/app/users/[id]/page.tsx b/admin/src/app/users/[id]/page.tsx index b961abf..af3fc85 100644 --- a/admin/src/app/users/[id]/page.tsx +++ b/admin/src/app/users/[id]/page.tsx @@ -20,7 +20,12 @@ interface UserDetail { professionals?: Professional | null; reputations?: Reputation | null; } -const PRO_STATE_LABELS = ['Usuario', 'Solicitó', 'Profesional', 'Rechazado']; +const PRO_STATE_LABELS = ['Usuario', 'Solicitó ser Pro', 'Profesional', 'Rechazado']; +const PRO_STATE_VARIANTS: Record = { + 0: 'secondary', 1: 'outline', 2: 'default', 3: 'destructive', +}; + +interface Form { name: string; city: string; phone: string; gender: string; } export default function UserDetailPage() { const { id } = useParams<{ id: string }>(); @@ -29,14 +34,17 @@ export default function UserDetailPage() { const [error, setError] = useState(null); const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false); - const [form, setForm] = useState({ name: '', city: '', phone: '', email: '' }); + const [form, setForm] = useState
({ name: '', city: '', phone: '', gender: '' }); const load = useCallback(() => { if (!id) return; setLoading(true); setError(null); api.get(`/users/${id}`) - .then((u) => { setUser(u); setForm({ name: u.name, city: u.city || '', phone: u.phone || '', email: u.email || '' }); }) + .then((u) => { + setUser(u); + setForm({ name: u.name, city: u.city || '', phone: u.phone || '', gender: u.gender || '' }); + }) .catch(() => setError('Error al cargar el usuario')) .finally(() => setLoading(false)); }, [id]); @@ -50,6 +58,7 @@ export default function UserDetailPage() { name: form.name || undefined, city: form.city || undefined, phone: form.phone || undefined, + gender: form.gender || undefined, }); setUser(updated); setEditing(false); @@ -63,18 +72,25 @@ export default function UserDetailPage() { if (loading) return
Cargando...
; if (error) return ( -
-

{error}

+
+

{error}

+
); if (!user) return null; + const proState = user.pro_state ?? 0; + return (
+ {/* Header */}
-

{user.name}

+
+

{user.name}

+

ID: {user.id}

+
{!editing ? ( ) : (
-
+ {/* Info general */} Información general - -
- Nombre + +
+ Nombre {editing - ? setForm({ ...form, name: e.target.value })} className="mt-1" /> + ? setForm({ ...form, name: e.target.value })} /> :

{user.name}

}
-
- Email -

+

+ Email +

{user.email || '—'} - {user.is_email_verified && Verificado} + {user.is_email_verified && Verificado}

-
- Teléfono +
+ Teléfono {editing - ? setForm({ ...form, phone: e.target.value })} className="mt-1" placeholder="+57300..." /> - :

+ ? setForm({ ...form, phone: e.target.value })} placeholder="+57300..." /> + :

{user.phone || '—'} - {user.is_phone_verified && Verificado} + {user.is_phone_verified && Verificado}

}
-
- Ciudad +
+ Ciudad {editing - ? setForm({ ...form, city: e.target.value })} className="mt-1" /> - :

{user.city || '—'}

} + ? setForm({ ...form, city: e.target.value })} /> + :

{user.city || '—'}

}
-
- Género -

{user.gender || '—'}

+
+ Género + {editing + ? ( + + ) + :

{user.gender || '—'}

}
-
- Registro -

{new Date(user.created_at).toLocaleDateString()}

+
+ Registro +

{new Date(user.created_at).toLocaleDateString('es-CO', { year: 'numeric', month: 'long', day: 'numeric' })}

+ {/* Estado */} Estado - +
Estado profesional -

{PRO_STATE_LABELS[user.pro_state ?? 0]}

+
+ {PRO_STATE_LABELS[proState] ?? `Estado ${proState}`} +
+ {user.reputations && ( -
-
Puntuación prom.

{user.reputations.average.toFixed(1)}

-
Total reseñas

{user.reputations.total}

-
Prom. como pro

{user.reputations.average_pro.toFixed(1)}

-
Reseñas como pro

{user.reputations.total_pro}

+
+
+ Puntuación prom. +

{user.reputations.average.toFixed(1)}

+
+
+ Total reseñas +

{user.reputations.total}

+
+
+ Prom. como pro +

{user.reputations.average_pro.toFixed(1)}

+
+
+ Reseñas como pro +

{user.reputations.total_pro}

+
)}
+ {/* Perfil profesional (solo lectura, con link) */} {user.professionals && ( - Perfil profesional - -
-
Profesión

{user.professionals.profession || '—'}

-
Tarifa

{user.professionals.rate != null ? `$${user.professionals.rate}` : '—'}

-
Identificación

{user.professionals.identification || '—'}

-
-
+ +
+ Perfil profesional - +
+
+ +
+ Profesión +

{user.professionals.profession || '—'}

+
+
+ Tarifa +

{user.professionals.rate != null ? `$${user.professionals.rate}` : '—'}

+
+
+ Identificación +

{user.professionals.identification || '—'}

+
)} diff --git a/backend/src/professionals/professionals.controller.ts b/backend/src/professionals/professionals.controller.ts index 849869d..9e23f35 100644 --- a/backend/src/professionals/professionals.controller.ts +++ b/backend/src/professionals/professionals.controller.ts @@ -52,6 +52,13 @@ export class ProfessionalsController { return this.pros.findByUserId(req.user.sub); } + @Patch(':id') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + updateById(@Param('id') id: string, @Body() dto: UpdateProfessionalDto) { + return this.pros.updateById(id, dto); + } + @Get(':id') findById(@Param('id') id: string) { return this.pros.findById(id); diff --git a/backend/src/professionals/professionals.service.ts b/backend/src/professionals/professionals.service.ts index 5d60bcf..f608efb 100644 --- a/backend/src/professionals/professionals.service.ts +++ b/backend/src/professionals/professionals.service.ts @@ -59,6 +59,12 @@ export class ProfessionalsService { return prof; } + async updateById(id: string, data: any) { + const prof = await this.prisma.professionals.findUnique({ where: { id } }); + if (!prof) throw new NotFoundException('Profesional no encontrado'); + return this.prisma.professionals.update({ where: { id }, data }); + } + async upsert(userId: string, data: any) { const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } }); if (!prof) throw new NotFoundException('Debes solicitar ser profesional primero'); diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index fea338e..b3ca423 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -37,6 +37,13 @@ export class UsersController { return this.users.updateFcmToken(req.user.sub, dto.token); } + @Patch(':id') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + updateById(@Param('id') id: string, @Body() dto: UpdateUserDto) { + return this.users.update(id, dto); + } + @Get(':id') findById(@Param('id') id: string) { return this.users.findById(id);