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:
co-authored by
Claude Sonnet 4.6
parent
8a97b52eb7
commit
2180e0480c
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user