add admin services-by-professional endpoint + calendar view in admin panel

Backend:
- GET /services/admin/professional/:id (JWT-guarded) returns paginated
  services for any professional with embedded user (client) info

Admin professional detail page:
- Replace broken all-services-then-filter with new endpoint
- Add month calendar with colored dots per day (click to filter table)
- Services table shows client name/phone, time, colored status badges
- Fix DAY_NAMES array (was 0=Domingo, now 0=Lunes per DB convention)
- Schedule row shows both ranges + continuous_day flag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-12 18:58:42 -05:00
co-authored by Claude Sonnet 4.6
parent 8a97b52eb7
commit 2180e0480c
3 changed files with 306 additions and 76 deletions
+276 -75
View File
@@ -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<string, string> = { nequi: 'Nequi', datafono: 'Datáfono', transferencia: 'Transferencia' };
const STATUS_LABEL: Record<string, string> = {
pending: 'Pendiente', accepted: 'Aceptado', active: 'Activo',
completed: 'Completado', cancelled: 'Cancelado', denied: 'Rechazado', self_booked: 'Bloqueado',
};
const STATUS_CLASS: Record<string, string> = {
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<string, string[]>();
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 (
<div className="space-y-3">
<div className="flex items-center justify-between">
<button onClick={() => setCursor(new Date(year, month - 1, 1))} className="p-1 hover:bg-muted rounded">
<ChevronLeft className="h-4 w-4" />
</button>
<span className="font-semibold text-sm">{monthNames[month]} {year}</span>
<button onClick={() => setCursor(new Date(year, month + 1, 1))} className="p-1 hover:bg-muted rounded">
<ChevronRight className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-7 gap-0.5 text-xs">
{['L','M','X','J','V','S','D'].map((d) => (
<div key={d} className="text-center text-muted-foreground font-medium py-1">{d}</div>
))}
{cells.map((day, i) => {
if (!day) return <div key={`e-${i}`} />;
const key = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const statuses = servicesByDay.get(key);
const isSelected = selectedDay === key;
return (
<button
key={key}
onClick={() => onSelect(isSelected ? null : key)}
className={`relative flex flex-col items-center justify-center rounded py-1 text-xs font-medium transition-colors
${isSelected ? 'bg-primary text-primary-foreground' : statuses ? 'hover:bg-muted bg-muted/50' : 'hover:bg-muted text-muted-foreground'}`}
>
{day}
{statuses && !isSelected && (
<span className={`absolute bottom-0.5 h-1.5 w-1.5 rounded-full ${dotColor(statuses)}`} />
)}
</button>
);
})}
</div>
{selectedDay && (
<div className="text-xs text-muted-foreground text-center">
Filtrando: {selectedDay} {' '}
<button className="text-primary underline" onClick={() => onSelect(null)}>ver todos</button>
</div>
)}
</div>
);
}
// ── Schedule display helpers ────────────────────────────────────────────────
function ScheduleRow({ s }: { s: Schedule }) {
if (!s.enabled) {
return (
<TableRow key={s.id}>
<TableCell className="font-medium">{DAY_NAMES[s.day_of_week] ?? s.day_of_week}</TableCell>
<TableCell className="text-muted-foreground">Descanso</TableCell>
</TableRow>
);
}
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 (
<TableRow key={s.id}>
<TableCell className="font-medium">{DAY_NAMES[s.day_of_week] ?? s.day_of_week}</TableCell>
<TableCell>{hours}</TableCell>
</TableRow>
);
}
// ── 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<Professional | null>(null);
const [services, setServices] = useState<Service[]>([]);
const [services, setServices] = useState<ServiceItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedDay, setSelectedDay] = useState<string | null>(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<Professional>(`/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 (
<div className="space-y-6">
@@ -188,7 +354,7 @@ export default function ProfessionalDetailPage() {
<Link href="/professionals"><Button variant="ghost" size="icon"><ArrowLeft className="h-5 w-5" /></Button></Link>
<div>
<h1 className="text-2xl font-bold">{user?.name || 'Profesional'}</h1>
<p className="text-sm text-muted-foreground">ID profesional: {id}</p>
<p className="text-sm text-muted-foreground">ID: {id}</p>
</div>
</div>
<div className="flex items-center gap-2">
@@ -220,7 +386,7 @@ export default function ProfessionalDetailPage() {
</Button>
) : (
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => { setEditingUser(false); }} disabled={savingUser}>
<Button variant="outline" size="sm" onClick={() => setEditingUser(false)} disabled={savingUser}>
<X className="mr-1 h-4 w-4" /> Cancelar
</Button>
<Button size="sm" onClick={saveUser} disabled={savingUser}>
@@ -342,7 +508,6 @@ export default function ProfessionalDetailPage() {
<button
onClick={() => { navigator.clipboard.writeText(professional.rethus_code!); toast.success('Código copiado'); }}
className="text-muted-foreground hover:text-foreground"
title="Copiar código"
>
<Copy className="h-3.5 w-3.5" />
</button>
@@ -356,7 +521,6 @@ export default function ProfessionalDetailPage() {
<button
onClick={() => { navigator.clipboard.writeText(professional.identification!); toast.success('Cédula copiada'); }}
className="text-muted-foreground hover:text-foreground"
title="Copiar cédula"
>
<Copy className="h-3.5 w-3.5" />
</button>
@@ -365,36 +529,20 @@ export default function ProfessionalDetailPage() {
)}
</div>
<div className="flex flex-wrap gap-3">
<Button
size="sm"
variant="outline"
onClick={consultRethus}
disabled={loadingRethus}
>
<Button size="sm" variant="outline" onClick={consultRethus} disabled={loadingRethus}>
<ShieldCheck className="mr-2 h-4 w-4" />
{loadingRethus ? 'Consultando...' : 'Consultar en RETHUS'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => window.open('https://www.minsalud.gov.co/salud/Paginas/rethus.aspx', '_blank')}
>
<ExternalLink className="mr-2 h-4 w-4" />
Portal Minsalud
<Button variant="outline" size="sm" onClick={() => window.open('https://www.minsalud.gov.co/salud/Paginas/rethus.aspx', '_blank')}>
<ExternalLink className="mr-2 h-4 w-4" /> Portal Minsalud
</Button>
{!professional.rethus_validated && (
<Button
size="sm"
onClick={validateRethus}
disabled={validatingRethus}
className="bg-green-600 hover:bg-green-700 text-white"
>
<Button size="sm" onClick={validateRethus} disabled={validatingRethus} className="bg-green-600 hover:bg-green-700 text-white">
<ShieldCheck className="mr-2 h-4 w-4" />
{validatingRethus ? 'Guardando...' : 'Marcar como validado'}
</Button>
)}
</div>
{rethusResult && (
<div className="mt-3 rounded-lg border bg-muted/40 p-4 space-y-2 text-sm">
<p className="font-semibold">{rethusResult.fullName || `${rethusResult.firstName} ${rethusResult.lastName}`}</p>
@@ -417,7 +565,6 @@ export default function ProfessionalDetailPage() {
)}
</div>
)}
{!professional.rethus_validated && (
<p className="text-xs text-muted-foreground">
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() {
</CardContent>
</Card>
{/* Documentos subidos */}
{/* Documentos */}
<Card>
<CardHeader><CardTitle>Documentos y fotos</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
@@ -483,9 +630,9 @@ export default function ProfessionalDetailPage() {
)}
{/* Horarios */}
{professional.schedules && professional.schedules.length > 0 && (
{sortedSchedules.length > 0 && (
<Card>
<CardHeader><CardTitle>Horarios</CardTitle></CardHeader>
<CardHeader><CardTitle>Horarios configurados</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader>
@@ -495,16 +642,7 @@ export default function ProfessionalDetailPage() {
</TableRow>
</TableHeader>
<TableBody>
{professional.schedules.map((s) => (
<TableRow key={s.id}>
<TableCell>{DAY_NAMES[s.day_of_week] ?? s.day_of_week}</TableCell>
<TableCell>
{s.enabled
? [s.range1_hour1, s.range1_hour2].filter(Boolean).join(' ') || '—'
: <span className="text-muted-foreground">Descanso</span>}
</TableCell>
</TableRow>
))}
{sortedSchedules.map((s) => <ScheduleRow key={s.id} s={s} />)}
</TableBody>
</Table>
</CardContent>
@@ -523,39 +661,102 @@ export default function ProfessionalDetailPage() {
</Card>
)}
{/* Servicios */}
{services.length > 0 && (
<Card>
<CardHeader><CardTitle>Servicios recientes</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Fecha</TableHead>
<TableHead>Descripción</TableHead>
<TableHead>Tarifa</TableHead>
<TableHead>Estado</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.map((s) => (
<TableRow key={s.id}>
<TableCell>{new Date(s.day).toLocaleDateString()}</TableCell>
<TableCell>{s.description || '—'}</TableCell>
<TableCell>{s.rate != null ? `$${s.rate}` : '—'}</TableCell>
<TableCell>{s.status}</TableCell>
</TableRow>
{/* Calendario + Servicios agendados */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Servicios agendados
<Badge variant="outline" className="ml-auto text-xs">{services.length} total</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col lg:flex-row gap-6">
{/* Calendar */}
<div className="lg:w-64 shrink-0">
<MonthCalendar
services={services}
selectedDay={selectedDay}
onSelect={setSelectedDay}
/>
{/* Legend */}
<div className="mt-4 space-y-1 text-xs text-muted-foreground">
{[
{ 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 }) => (
<div key={label} className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${color}`} />
{label}
</div>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
</div>
{/* Aprobar / Rechazar */}
{/* Services table */}
<div className="flex-1 overflow-auto">
{visibleServices.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">
{selectedDay ? 'Sin servicios este día.' : 'Sin servicios registrados.'}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Fecha</TableHead>
<TableHead>Hora</TableHead>
<TableHead>Cliente</TableHead>
<TableHead>Tarifa</TableHead>
<TableHead>Estado</TableHead>
<TableHead>Descripción</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleServices.map((s) => (
<TableRow key={s.id}>
<TableCell className="whitespace-nowrap">
{new Date(s.day).toLocaleDateString('es-CO', { day: '2-digit', month: 'short', year: 'numeric' })}
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-sm">
{fmtTime(s.range1_hour1) || '—'}
</TableCell>
<TableCell>
{s.users ? (
<div>
<p className="font-medium text-sm">{s.users.name}</p>
{s.users.phone && <p className="text-xs text-muted-foreground">{s.users.phone}</p>}
</div>
) : (
<span className="text-muted-foreground text-sm"></span>
)}
</TableCell>
<TableCell>{s.rate != null ? `$${s.rate}` : '—'}</TableCell>
<TableCell>
<span className={`inline-flex items-center rounded border px-2 py-0.5 text-xs font-medium ${STATUS_CLASS[s.status] || 'bg-gray-100 text-gray-700 border-gray-200'}`}>
{STATUS_LABEL[s.status] || s.status}
</span>
</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-48 truncate">
{s.description || '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
</CardContent>
</Card>
{/* Approve / Deny bottom CTA */}
{!professional.is_active && (
<div className="flex gap-3 pt-2">
<Button onClick={approve}>Aprobar profesional</Button>
<Button onClick={approve} className="bg-green-600 hover:bg-green-700 text-white">Aprobar profesional</Button>
<Button variant="destructive" onClick={deny}>Rechazar solicitud</Button>
</div>
)}
+14 -1
View File
@@ -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()
+16
View File
@@ -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([