Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
This commit is contained in:
642
frontend/src/pages/Agenda.tsx
Normal file
642
frontend/src/pages/Agenda.tsx
Normal file
@@ -0,0 +1,642 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, Plus, PackageCheck, Sparkles, RotateCcw } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
TextArea,
|
||||
Modal,
|
||||
Badge,
|
||||
EmptyState,
|
||||
MobileCard,
|
||||
PageHeader,
|
||||
Skeleton,
|
||||
toast,
|
||||
badgeForAppointmentState,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Appointment, type Patient, type Service, type Doctor } from '../services/odoo';
|
||||
|
||||
const stateOptions = [
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
{ value: 'pending', label: 'Pendiente' },
|
||||
{ value: 'confirmed', label: 'Confirmada' },
|
||||
{ value: 'done', label: 'Completada' },
|
||||
{ value: 'cancelled', label: 'Cancelada' },
|
||||
];
|
||||
|
||||
const Agenda: FC = () => {
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [updating, setUpdating] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [date, setDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
|
||||
const [stateFilter, setStateFilter] = useState('');
|
||||
const [doctorFilter, setDoctorFilter] = useState('');
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [slots, setSlots] = useState<{ time: number; time_str: string }[]>([]);
|
||||
const [slotsLoading, setSlotsLoading] = useState(false);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
patient_id: '',
|
||||
service_id: '',
|
||||
doctor_id: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
time: '',
|
||||
notes: '',
|
||||
sessions: 1,
|
||||
});
|
||||
const [patientSearch, setPatientSearch] = useState('');
|
||||
const [pkgUpdating, setPkgUpdating] = useState<number | null>(null);
|
||||
const [expressOpen, setExpressOpen] = useState(false);
|
||||
const [expressSubmitting, setExpressSubmitting] = useState(false);
|
||||
const [expressForm, setExpressForm] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
notes: '',
|
||||
withAppointment: false,
|
||||
service_id: '',
|
||||
doctor_id: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
time: '',
|
||||
});
|
||||
|
||||
const filteredAppointments = appointments.filter((apt) => {
|
||||
if (stateFilter && apt.state !== stateFilter) return false;
|
||||
if (doctorFilter && String(apt.doctor_id) !== doctorFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const params: Record<string, string> = date ? { date } : {};
|
||||
const res = await odooApi.getAppointments(params);
|
||||
if (res.status === 'success') setAppointments(res.appointments);
|
||||
} catch (err) {
|
||||
setError('Error al cargar citas');
|
||||
toast.error('Error al cargar citas');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [date]);
|
||||
|
||||
const loadReferences = useCallback(async () => {
|
||||
try {
|
||||
const [servicesRes, doctorsRes] = await Promise.all([
|
||||
odooApi.getServices(),
|
||||
odooApi.getDoctors(),
|
||||
]);
|
||||
if (servicesRes.status === 'success') setServices(servicesRes.services);
|
||||
if (doctorsRes.status === 'success') setDoctors(doctorsRes.doctors);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
loadReferences();
|
||||
}, [load, loadReferences]);
|
||||
|
||||
// Cargar pacientes para el selector (búsqueda server-side)
|
||||
useEffect(() => {
|
||||
if (!createOpen) return;
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const res = await odooApi.getPatients({ search: patientSearch, page_size: 50 });
|
||||
if (res.status === 'success') setPatients(res.patients);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [createOpen, patientSearch]);
|
||||
|
||||
const fetchSlots = useCallback(async (serviceId: string, aptDate: string) => {
|
||||
if (!serviceId || !aptDate) {
|
||||
setSlots([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSlotsLoading(true);
|
||||
const res = await odooApi.getAvailableSlots(aptDate, parseInt(serviceId, 10));
|
||||
if (res.status === 'success') setSlots(res.slots);
|
||||
} catch {
|
||||
toast.error('Error al cargar horarios disponibles');
|
||||
setSlots([]);
|
||||
} finally {
|
||||
setSlotsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSlots(form.service_id, form.date);
|
||||
}, [form.service_id, form.date, fetchSlots]);
|
||||
|
||||
const handleStatus = async (id: number, action: string) => {
|
||||
try {
|
||||
setUpdating(id);
|
||||
await odooApi.updateAppointmentStatus(id, action);
|
||||
toast.success('Estado actualizado');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al actualizar estado');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePackageFinished = async (id: number, finished: boolean) => {
|
||||
try {
|
||||
setPkgUpdating(id);
|
||||
await odooApi.packageFinished(id, finished);
|
||||
toast.success(finished ? 'Paquete marcado como terminado' : 'Marca de paquete revertida');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al actualizar paquete');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setPkgUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const parseTimeToFloat = (t: string): number | undefined => {
|
||||
if (!t) return undefined;
|
||||
const [h, m] = t.split(':').map((x) => parseInt(x, 10));
|
||||
if (Number.isNaN(h)) return undefined;
|
||||
return h + (Number.isNaN(m) ? 0 : m / 60);
|
||||
};
|
||||
|
||||
const resetExpressForm = () =>
|
||||
setExpressForm({
|
||||
name: '', phone: '', notes: '', withAppointment: false,
|
||||
service_id: '', doctor_id: '', date: new Date().toISOString().split('T')[0], time: '',
|
||||
});
|
||||
|
||||
const submitExpress = async () => {
|
||||
if (!expressForm.name.trim() || !expressForm.phone.trim()) {
|
||||
toast.error('Nombre y teléfono son obligatorios');
|
||||
return;
|
||||
}
|
||||
if (expressForm.withAppointment && (!expressForm.service_id || !expressForm.date || !expressForm.time)) {
|
||||
toast.error('Para la cita de valoración selecciona servicio, fecha y hora');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setExpressSubmitting(true);
|
||||
const patientPayload: Partial<Patient> & { notes?: string } = {
|
||||
name: expressForm.name.trim(),
|
||||
phone: expressForm.phone.trim(),
|
||||
source: 'walkin',
|
||||
notes: expressForm.notes || undefined,
|
||||
};
|
||||
const res = await odooApi.createPatient(patientPayload);
|
||||
const newId = res.patient?.id;
|
||||
if (expressForm.withAppointment && newId) {
|
||||
await odooApi.createAppointment({
|
||||
patient_id: newId,
|
||||
service_id: parseInt(expressForm.service_id, 10),
|
||||
doctor_id: expressForm.doctor_id ? parseInt(expressForm.doctor_id, 10) : undefined,
|
||||
date: expressForm.date,
|
||||
time: parseTimeToFloat(expressForm.time) as unknown as string,
|
||||
notes: 'Valoración express',
|
||||
state: 'confirmed',
|
||||
});
|
||||
}
|
||||
toast.success(expressForm.withAppointment ? 'Prospecto y cita de valoración creados' : 'Prospecto creado');
|
||||
setExpressOpen(false);
|
||||
resetExpressForm();
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error en valoración express');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExpressSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createAppointment = async () => {
|
||||
if (!form.patient_id || !form.service_id || !form.date || !form.time) {
|
||||
toast.error('Completa los campos obligatorios');
|
||||
return;
|
||||
}
|
||||
const service = services.find((s) => String(s.id) === form.service_id);
|
||||
const sessions = form.sessions > 1 ? form.sessions : ((service?.package_sessions ?? 0) > 1 ? (service?.package_sessions ?? 1) : 1);
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await odooApi.createAppointment({
|
||||
patient_id: parseInt(form.patient_id, 10),
|
||||
service_id: parseInt(form.service_id, 10),
|
||||
doctor_id: form.doctor_id ? parseInt(form.doctor_id, 10) : undefined,
|
||||
date: form.date,
|
||||
time: form.time,
|
||||
notes: form.notes,
|
||||
sessions,
|
||||
});
|
||||
toast.success(sessions > 1 ? `Paquete creado: ${sessions} citas` : 'Cita creada');
|
||||
setCreateOpen(false);
|
||||
setForm({
|
||||
patient_id: '',
|
||||
service_id: '',
|
||||
doctor_id: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
time: '',
|
||||
notes: '',
|
||||
sessions: 1,
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al crear cita');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doctorOptions = [
|
||||
{ value: '', label: 'Todos los médicos' },
|
||||
...doctors.map((d) => ({ value: String(d.id), label: d.name })),
|
||||
];
|
||||
|
||||
const patientOptions = [
|
||||
{ value: '', label: 'Seleccionar paciente...' },
|
||||
...patients.map((p) => ({ value: String(p.id), label: `${p.name} (${p.phone})` })),
|
||||
];
|
||||
|
||||
const serviceOptions = [
|
||||
{ value: '', label: 'Seleccionar servicio...' },
|
||||
...services.map((s) => ({
|
||||
value: String(s.id),
|
||||
label: (s.package_price ?? 0) > 0 && (s.package_sessions ?? 0) > 0
|
||||
? `${s.name} — $${s.price} / ${s.package_sessions} sesiones $${s.package_price}`
|
||||
: `${s.name} — $${s.price}`,
|
||||
})),
|
||||
];
|
||||
|
||||
const slotOptions = slots.length
|
||||
? [
|
||||
{ value: '', label: 'Seleccionar hora...' },
|
||||
...slots.map((s) => ({ value: s.time_str, label: s.time_str })),
|
||||
]
|
||||
: [
|
||||
{ value: '', label: 'Selecciona servicio y fecha primero' },
|
||||
];
|
||||
|
||||
const TableHeader = () => (
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Hora</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Servicio</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Doctor</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Pago</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
|
||||
const ActionButtons = ({ apt }: { apt: Appointment }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
{apt.state !== 'confirmed' && apt.state !== 'done' && apt.state !== 'cancelled' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleStatus(apt.id, 'confirm')}
|
||||
disabled={updating === apt.id}
|
||||
title="Confirmar"
|
||||
>
|
||||
<CheckCircle size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
)}
|
||||
{apt.state === 'confirmed' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleStatus(apt.id, 'arrive')}
|
||||
disabled={updating === apt.id}
|
||||
title="Llegada"
|
||||
>
|
||||
<UserCheck size={16} className="text-blue-600" />
|
||||
</Button>
|
||||
)}
|
||||
{apt.state !== 'done' && apt.state !== 'cancelled' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleStatus(apt.id, 'done')}
|
||||
disabled={updating === apt.id}
|
||||
title="Completar"
|
||||
>
|
||||
<CheckCheck size={16} className="text-blue-600" />
|
||||
</Button>
|
||||
)}
|
||||
{apt.service_category === 'paquete' && apt.state !== 'cancelled' && (
|
||||
apt.package_finished ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePackageFinished(apt.id, false)}
|
||||
disabled={pkgUpdating === apt.id}
|
||||
title="Paquete terminado — click para revertir"
|
||||
>
|
||||
<RotateCcw size={16} className="text-amber-600" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePackageFinished(apt.id, true)}
|
||||
disabled={pkgUpdating === apt.id}
|
||||
title="Marcar paquete terminado"
|
||||
>
|
||||
<PackageCheck size={16} className="text-violet-600" />
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{apt.state !== 'cancelled' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleStatus(apt.id, 'cancel')}
|
||||
disabled={updating === apt.id}
|
||||
title="Cancelar"
|
||||
>
|
||||
<XCircle size={16} className="text-rose-600" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout title="Agenda" subtitle="Gestión de citas">
|
||||
<PageHeader title="Agenda" subtitle="Filtra por fecha, estado o médico">
|
||||
<Button variant="outline" onClick={() => setExpressOpen(true)}>
|
||||
<Sparkles size={16} className="mr-2" />
|
||||
Valoración express
|
||||
</Button>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nueva cita
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4 mb-4 sm:mb-6">
|
||||
<Input
|
||||
label="Fecha"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="sm:max-w-[180px]"
|
||||
/>
|
||||
<Select
|
||||
label="Estado"
|
||||
options={stateOptions}
|
||||
value={stateFilter}
|
||||
onChange={(e) => setStateFilter(e.target.value)}
|
||||
className="sm:max-w-[180px]"
|
||||
/>
|
||||
<Select
|
||||
label="Médico"
|
||||
options={doctorOptions}
|
||||
value={doctorFilter}
|
||||
onChange={(e) => setDoctorFilter(e.target.value)}
|
||||
className="sm:max-w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-12 w-full" />
|
||||
) : filteredAppointments.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin citas"
|
||||
subtitle="No hay citas para los filtros seleccionados."
|
||||
actionLabel="Nueva cita"
|
||||
onAction={() => setCreateOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<TableHeader />
|
||||
<tbody className="divide-y">
|
||||
{filteredAppointments.map((apt) => (
|
||||
<tr key={apt.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm text-theme-heading">
|
||||
<Clock size={14} className="inline mr-1 text-theme-muted" />
|
||||
{apt.time}
|
||||
</td>
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">{apt.patient}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{apt.service}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{apt.doctor || '-'}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{apt.payment_state}</td>
|
||||
<td className="p-3">
|
||||
<ActionButtons apt={apt} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="sm:hidden space-y-3">
|
||||
{filteredAppointments.map((apt) => (
|
||||
<MobileCard
|
||||
key={apt.id}
|
||||
title={apt.patient}
|
||||
subtitle={apt.service}
|
||||
rows={[
|
||||
{ label: 'Hora', value: apt.time },
|
||||
{ label: 'Doctor', value: apt.doctor || '-' },
|
||||
{ label: 'Estado', value: <Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge> },
|
||||
{ label: 'Pago', value: apt.payment_state },
|
||||
]}
|
||||
actions={<ActionButtons apt={apt} />}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="Nueva cita"
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={createAppointment} loading={submitting}>Guardar cita</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Buscar paciente"
|
||||
placeholder="Escribe nombre o teléfono..."
|
||||
value={patientSearch}
|
||||
onChange={(e) => setPatientSearch(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Paciente *"
|
||||
options={patientOptions}
|
||||
value={form.patient_id}
|
||||
onChange={(e) => setForm({ ...form, patient_id: e.target.value })}
|
||||
/>
|
||||
<Select
|
||||
label="Servicio *"
|
||||
options={serviceOptions}
|
||||
value={form.service_id}
|
||||
onChange={(e) => setForm({ ...form, service_id: e.target.value })}
|
||||
/>
|
||||
<Select
|
||||
label="Médico"
|
||||
options={[{ value: '', label: 'Sin preferencia' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
|
||||
value={form.doctor_id}
|
||||
onChange={(e) => setForm({ ...form, doctor_id: e.target.value })}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Fecha *"
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||
/>
|
||||
<Select
|
||||
label="Hora *"
|
||||
options={slotOptions}
|
||||
value={form.time}
|
||||
onChange={(e) => setForm({ ...form, time: e.target.value })}
|
||||
disabled={slotsLoading || !form.service_id || !form.date}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Sesiones"
|
||||
options={[
|
||||
{ value: '1', label: '1 sesión' },
|
||||
{ value: '2', label: '2 sesiones' },
|
||||
{ value: '3', label: '3 sesiones' },
|
||||
{ value: '4', label: '4 sesiones' },
|
||||
{ value: '5', label: '5 sesiones' },
|
||||
{ value: '6', label: '6 sesiones' },
|
||||
]}
|
||||
value={String(form.sessions)}
|
||||
onChange={(e) => setForm({ ...form, sessions: parseInt(e.target.value, 10) })}
|
||||
/>
|
||||
<div className="flex items-end">
|
||||
<p className="text-xs text-theme-muted pb-2">
|
||||
Si el servicio tiene paquete, se sugerirá el número de sesiones del paquete.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{slotsLoading && <p className="text-xs text-theme-muted">Cargando horarios disponibles...</p>}
|
||||
<TextArea
|
||||
label="Notas"
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={expressOpen}
|
||||
onClose={() => setExpressOpen(false)}
|
||||
title="Valoración express"
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setExpressOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={submitExpress} loading={expressSubmitting}>Guardar</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-theme-muted">
|
||||
Captura rápida de prospecto. Opcionalmente agenda su cita de valoración en el mismo paso.
|
||||
</p>
|
||||
<Input
|
||||
label="Nombre *"
|
||||
placeholder="Nombre del prospecto"
|
||||
value={expressForm.name}
|
||||
onChange={(e) => setExpressForm({ ...expressForm, name: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Teléfono *"
|
||||
placeholder="+52 ..."
|
||||
value={expressForm.phone}
|
||||
onChange={(e) => setExpressForm({ ...expressForm, phone: e.target.value })}
|
||||
/>
|
||||
<TextArea
|
||||
label="Notas"
|
||||
placeholder="Motivo de consulta, zona a tratar, etc."
|
||||
value={expressForm.notes}
|
||||
onChange={(e) => setExpressForm({ ...expressForm, notes: e.target.value })}
|
||||
/>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={expressForm.withAppointment}
|
||||
onChange={(e) => setExpressForm({ ...expressForm, withAppointment: e.target.checked })}
|
||||
className="rounded border-theme-border-strong"
|
||||
/>
|
||||
Crear cita de valoración ahora
|
||||
</label>
|
||||
{expressForm.withAppointment && (
|
||||
<div className="space-y-4 rounded-xl border border-theme-border p-4 bg-theme-bg">
|
||||
<Select
|
||||
label="Servicio *"
|
||||
options={serviceOptions}
|
||||
value={expressForm.service_id}
|
||||
onChange={(e) => setExpressForm({ ...expressForm, service_id: e.target.value })}
|
||||
/>
|
||||
<Select
|
||||
label="Médico"
|
||||
options={[{ value: '', label: 'Sin preferencia' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
|
||||
value={expressForm.doctor_id}
|
||||
onChange={(e) => setExpressForm({ ...expressForm, doctor_id: e.target.value })}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Fecha *"
|
||||
type="date"
|
||||
value={expressForm.date}
|
||||
onChange={(e) => setExpressForm({ ...expressForm, date: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Hora *"
|
||||
type="time"
|
||||
value={expressForm.time}
|
||||
onChange={(e) => setExpressForm({ ...expressForm, time: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Agenda;
|
||||
Reference in New Issue
Block a user