diff --git a/admin/src/app/professionals/[id]/page.tsx b/admin/src/app/professionals/[id]/page.tsx index 3960d67..3046a28 100644 --- a/admin/src/app/professionals/[id]/page.tsx +++ b/admin/src/app/professionals/[id]/page.tsx @@ -10,10 +10,18 @@ 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, Pencil, X, Check, ShieldCheck, ShieldAlert, ExternalLink, Copy } from 'lucide-react'; +import { + ArrowLeft, Pencil, X, Check, ShieldCheck, ShieldAlert, + ExternalLink, Copy, ChevronLeft, ChevronRight, Calendar, +} 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; } +// DB convention: day_of_week 0=Mon … 6=Sun +interface Schedule { + id: string; day_of_week: number; enabled: boolean; continuous_day?: boolean; + range1_hour1?: string; range1_hour2?: string; + range2_hour1?: string; range2_hour2?: string; +} interface Specialization { id: string; name: string; } interface PaymentMethod { id: string; nequi: boolean; datafono: boolean; transferencia: boolean; } interface Professional { @@ -26,11 +34,162 @@ interface Professional { specializations?: Specialization[]; payment_methods?: PaymentMethod | PaymentMethod[] | null; } -interface Service { id: string; description?: string; rate?: number; status: string; day: string; } +interface ServiceItem { + id: string; description?: string; rate?: number; status: string; + day: string; range1_hour1?: string; range1_hour2?: string; + users?: { id: string; name: string; phone?: string; picture?: string } | null; +} -const DAY_NAMES = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado']; +// DB: 0=Mon … 6=Sun +const DAY_NAMES = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo']; const PAYMENT_LABELS: Record = { nequi: 'Nequi', datafono: 'Datáfono', transferencia: 'Transferencia' }; +const STATUS_LABEL: Record = { + pending: 'Pendiente', accepted: 'Aceptado', active: 'Activo', + completed: 'Completado', cancelled: 'Cancelado', denied: 'Rechazado', self_booked: 'Bloqueado', +}; +const STATUS_CLASS: Record = { + pending: 'bg-amber-100 text-amber-800 border-amber-200', + accepted: 'bg-blue-100 text-blue-800 border-blue-200', + active: 'bg-green-100 text-green-800 border-green-200', + completed: 'bg-emerald-700 text-white border-emerald-800', + cancelled: 'bg-red-100 text-red-800 border-red-200', + denied: 'bg-red-200 text-red-900 border-red-300', + self_booked: 'bg-orange-100 text-orange-800 border-orange-200', +}; + +function fmtTime(raw?: string | null): string { + if (!raw) return ''; + // raw may be ISO date-time string from prisma (Date stored as time) + const match = raw.match(/T(\d{2}:\d{2})/); + if (match) return match[1]; + // already "HH:MM" + if (/^\d{2}:\d{2}/.test(raw)) return raw.slice(0, 5); + return raw; +} + +// ── Compact month calendar ────────────────────────────────────────────────── +function MonthCalendar({ + services, + selectedDay, + onSelect, +}: { + services: ServiceItem[]; + selectedDay: string | null; + onSelect: (day: string | null) => void; +}) { + const [cursor, setCursor] = useState(() => { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), 1); + }); + + const year = cursor.getFullYear(); + const month = cursor.getMonth(); + const firstDow = new Date(year, month, 1).getDay(); // 0=Sun + // shift so Mon=0 + const startOffset = (firstDow + 6) % 7; + const daysInMonth = new Date(year, month + 1, 0).getDate(); + + const servicesByDay = new Map(); + for (const s of services) { + const key = s.day.slice(0, 10); + if (!servicesByDay.has(key)) servicesByDay.set(key, []); + servicesByDay.get(key)!.push(s.status); + } + + const monthNames = [ + 'Enero','Febrero','Marzo','Abril','Mayo','Junio', + 'Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre', + ]; + + function dotColor(statuses: string[]): string { + if (statuses.includes('active')) return 'bg-green-500'; + if (statuses.includes('accepted')) return 'bg-blue-500'; + if (statuses.includes('pending')) return 'bg-amber-500'; + if (statuses.includes('self_booked')) return 'bg-orange-400'; + if (statuses.includes('completed')) return 'bg-emerald-600'; + return 'bg-gray-400'; + } + + const cells: (number | null)[] = [ + ...Array(startOffset).fill(null), + ...Array.from({ length: daysInMonth }, (_, i) => i + 1), + ]; + + return ( +
+
+ + {monthNames[month]} {year} + +
+ +
+ {['L','M','X','J','V','S','D'].map((d) => ( +
{d}
+ ))} + {cells.map((day, i) => { + if (!day) return
; + const key = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const statuses = servicesByDay.get(key); + const isSelected = selectedDay === key; + return ( + + ); + })} +
+ + {selectedDay && ( +
+ Filtrando: {selectedDay} —{' '} + +
+ )} +
+ ); +} + +// ── Schedule display helpers ──────────────────────────────────────────────── +function ScheduleRow({ s }: { s: Schedule }) { + if (!s.enabled) { + return ( + + {DAY_NAMES[s.day_of_week] ?? s.day_of_week} + Descanso + + ); + } + let hours = ''; + if (s.continuous_day) { + hours = `${fmtTime(s.range1_hour1)} – ${fmtTime(s.range2_hour2)} (continuo)`; + } else { + const r1 = s.range1_hour1 ? `${fmtTime(s.range1_hour1)} – ${fmtTime(s.range1_hour2)}` : ''; + const r2 = s.range2_hour1 ? `${fmtTime(s.range2_hour1)} – ${fmtTime(s.range2_hour2)}` : ''; + hours = [r1, r2].filter(Boolean).join(' · ') || '—'; + } + return ( + + {DAY_NAMES[s.day_of_week] ?? s.day_of_week} + {hours} + + ); +} + +// ── Page ─────────────────────────────────────────────────────────────────── interface ProForm { profession: string; identification: string; address: string; rate: string; rethus_code: string; } interface UserForm { name: string; email: string; phone: string; city: string; } @@ -38,11 +197,11 @@ export default function ProfessionalDetailPage() { const params = useParams<{ id: string }>(); const id = params?.id; const [professional, setProfessional] = useState(null); - const [services, setServices] = useState([]); + const [services, setServices] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [selectedDay, setSelectedDay] = useState(null); - // Edit state const [editingPro, setEditingPro] = useState(false); const [editingUser, setEditingUser] = useState(false); const [savingPro, setSavingPro] = useState(false); @@ -59,7 +218,7 @@ export default function ProfessionalDetailPage() { setError(null); Promise.all([ api.get(`/professionals/${id}`), - api.get<{ data: Service[] }>('/services?page=1&limit=50'), + api.get<{ data: ServiceItem[]; meta: any }>(`/services/admin/professional/${id}?limit=200`), ]) .then(([prof, svcRes]) => { setProfessional(prof); @@ -76,7 +235,7 @@ export default function ProfessionalDetailPage() { phone: prof.users?.phone || '', city: prof.users?.city || '', }); - setServices(svcRes.data.filter((s: any) => s.professional_id === id)); + setServices(svcRes.data); }) .catch(() => setError('Error al cargar el profesional')) .finally(() => setLoading(false)); @@ -179,6 +338,13 @@ export default function ProfessionalDetailPage() { ); const user = professional.users; + const visibleServices = selectedDay + ? services.filter((s) => s.day.slice(0, 10) === selectedDay) + : services; + + const sortedSchedules = professional.schedules + ? [...professional.schedules].sort((a, b) => a.day_of_week - b.day_of_week) + : []; return (
@@ -188,7 +354,7 @@ export default function ProfessionalDetailPage() {

{user?.name || 'Profesional'}

-

ID profesional: {id}

+

ID: {id}

@@ -220,7 +386,7 @@ export default function ProfessionalDetailPage() { ) : (
- @@ -356,7 +521,6 @@ export default function ProfessionalDetailPage() { @@ -365,36 +529,20 @@ export default function ProfessionalDetailPage() { )}
- - {!professional.rethus_validated && ( - )}
- {rethusResult && (

{rethusResult.fullName || `${rethusResult.firstName} ${rethusResult.lastName}`}

@@ -417,7 +565,6 @@ export default function ProfessionalDetailPage() { )}
)} - {!professional.rethus_validated && (

Consulta automáticamente con Verifik o búscalo en el portal de Minsalud. Una vez verificado, haz clic en "Marcar como validado". @@ -430,7 +577,7 @@ export default function ProfessionalDetailPage() { - {/* Documentos subidos */} + {/* Documentos */} Documentos y fotos @@ -483,9 +630,9 @@ export default function ProfessionalDetailPage() { )} {/* Horarios */} - {professional.schedules && professional.schedules.length > 0 && ( + {sortedSchedules.length > 0 && ( - Horarios + Horarios configurados @@ -495,16 +642,7 @@ export default function ProfessionalDetailPage() { - {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} - - - ))} + {sortedSchedules.map((s) => )}
@@ -523,39 +661,102 @@ export default function ProfessionalDetailPage() {
)} - {/* Servicios */} - {services.length > 0 && ( - - Servicios recientes - - - - - Fecha - Descripción - Tarifa - Estado - - - - {services.map((s) => ( - - {new Date(s.day).toLocaleDateString()} - {s.description || '—'} - {s.rate != null ? `$${s.rate}` : '—'} - {s.status} - + {/* Calendario + Servicios agendados */} + + + + + Servicios agendados + {services.length} total + + + +
+ {/* Calendar */} +
+ + {/* Legend */} +
+ {[ + { color: 'bg-amber-500', label: 'Pendiente' }, + { color: 'bg-blue-500', label: 'Aceptado' }, + { color: 'bg-green-500', label: 'Activo' }, + { color: 'bg-emerald-600', label: 'Completado' }, + { color: 'bg-orange-400', label: 'Bloqueado' }, + { color: 'bg-gray-400', label: 'Cancelado / Rechazado' }, + ].map(({ color, label }) => ( +
+ + {label} +
))} - -
-
-
- )} +

+
- {/* Aprobar / Rechazar */} + {/* Services table */} +
+ {visibleServices.length === 0 ? ( +

+ {selectedDay ? 'Sin servicios este día.' : 'Sin servicios registrados.'} +

+ ) : ( + + + + Fecha + Hora + Cliente + Tarifa + Estado + Descripción + + + + {visibleServices.map((s) => ( + + + {new Date(s.day).toLocaleDateString('es-CO', { day: '2-digit', month: 'short', year: 'numeric' })} + + + {fmtTime(s.range1_hour1) || '—'} + + + {s.users ? ( +
+

{s.users.name}

+ {s.users.phone &&

{s.users.phone}

} +
+ ) : ( + + )} +
+ {s.rate != null ? `$${s.rate}` : '—'} + + + {STATUS_LABEL[s.status] || s.status} + + + + {s.description || '—'} + +
+ ))} +
+
+ )} +
+ + + + + {/* Approve / Deny bottom CTA */} {!professional.is_active && (
- +
)} diff --git a/backend/src/services/services.controller.ts b/backend/src/services/services.controller.ts index 2afdf8b..8a39009 100644 --- a/backend/src/services/services.controller.ts +++ b/backend/src/services/services.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req, Query } from '@nestjs/common'; +import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req, Query, ParseIntPipe, Optional } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { ServicesService } from './services.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; @@ -101,6 +101,19 @@ export class ServicesController { return this.services.getPublicCalendar(id); } + @Get('admin/professional/:professionalId') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'limit', required: false }) + getByProfessionalAdmin( + @Param('professionalId') professionalId: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.services.getServicesByProfessionalAdmin(professionalId, +(page || 1), +(limit || 50)); + } + @Get(':id') @UseGuards(JwtAuthGuard) @ApiBearerAuth() diff --git a/backend/src/services/services.service.ts b/backend/src/services/services.service.ts index ed06c67..eddfabb 100644 --- a/backend/src/services/services.service.ts +++ b/backend/src/services/services.service.ts @@ -296,6 +296,22 @@ export class ServicesService { return { schedules, services }; } + async getServicesByProfessionalAdmin(professionalId: string, page = 1, limit = 50) { + const skip = (page - 1) * limit; + const where = { professional_id: professionalId }; + const [data, total] = await Promise.all([ + this.prisma.services.findMany({ + where, + skip, + take: limit, + include: { users: { select: { id: true, name: true, picture: true, phone: true } } }, + orderBy: { day: 'desc' }, + }), + this.prisma.services.count({ where }), + ]); + return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } }; + } + async findAll(page = 1, limit = 20) { const skip = (page - 1) * limit; const [data, total] = await Promise.all([