Agenda: horario 8-19h, bloques de 15 min, cita rapida, vista restringida para rol medico y chips de pacientes por doctor
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
} from '../components/ui';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { odooApi, type Appointment, type Patient, type Service, type Doctor, type Bloqueo } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
const stateOptions = [
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
@@ -28,8 +29,24 @@ const stateOptions = [
|
||||
{ value: 'cancelled', label: 'Cancelada' },
|
||||
];
|
||||
|
||||
// Opciones de hora en bloques de 15 min dentro del horario de agenda (8:00-19:00)
|
||||
const QUARTER_TIME_OPTIONS = (() => {
|
||||
const opts = [{ value: '', label: 'Seleccionar hora...' }];
|
||||
for (let h = 8; h < 19; h++) {
|
||||
for (const m of [0, 15, 30, 45]) {
|
||||
const v = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
opts.push({ value: v, label: v });
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
})();
|
||||
|
||||
const Agenda: FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { user } = useAuth();
|
||||
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor)
|
||||
const isMedico = user?.role === 'medico';
|
||||
const medicoEmployeeId = isMedico && user?.employee_id ? user.employee_id : null;
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
@@ -40,7 +57,7 @@ const Agenda: FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [date, setDate] = useState<string>(() => searchParams.get('date') || new Date().toISOString().split('T')[0]);
|
||||
const [stateFilter, setStateFilter] = useState('');
|
||||
const [doctorFilter, setDoctorFilter] = useState('');
|
||||
const [doctorFilter, setDoctorFilter] = useState(() => (medicoEmployeeId ? String(medicoEmployeeId) : ''));
|
||||
const [viewMode, setViewMode] = useState<'lista' | 'doctores' | 'horas'>('doctores');
|
||||
const [quickFilter, setQuickFilter] = useState<'' | 'libres' | 'primera' | 'checkin' | 'noshow'>('');
|
||||
const [detailApt, setDetailApt] = useState<Appointment | null>(null);
|
||||
@@ -67,6 +84,16 @@ const Agenda: FC = () => {
|
||||
sessions: 1,
|
||||
});
|
||||
const [patientSearch, setPatientSearch] = useState('');
|
||||
// Cita rápida: solo nombre y teléfono (+ fecha/hora/médico del contexto)
|
||||
const [quickMode, setQuickMode] = useState(false);
|
||||
const [quickForm, setQuickForm] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
doctor_id: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
time: '',
|
||||
notes: '',
|
||||
});
|
||||
const [pkgUpdating, setPkgUpdating] = useState<number | null>(null);
|
||||
const [expressOpen, setExpressOpen] = useState(false);
|
||||
const [expressSubmitting, setExpressSubmitting] = useState(false);
|
||||
@@ -121,26 +148,31 @@ const Agenda: FC = () => {
|
||||
const countCheckin = filteredAppointments.filter(isCheckinDue).length;
|
||||
const countNoShow = filteredAppointments.filter(isNoShowDue).length;
|
||||
|
||||
// ---- Vista por médico (grid doctores x horas) ----
|
||||
const GRID_BASE_START = 9; // horario clínica: 9:00
|
||||
const GRID_BASE_END = 18; // horario clínica: 18:00
|
||||
// ---- Vista por médico (grid doctores x bloques de 15 min) ----
|
||||
const GRID_START = 8; // horario clínica: 8:00
|
||||
const GRID_END = 19; // horario clínica: 19:00 (último bloque inicia 18:45)
|
||||
const SLOT_MIN = 15;
|
||||
const SLOTS_PER_HOUR = 60 / SLOT_MIN;
|
||||
|
||||
const aptHour = (t: string): number | null => {
|
||||
// Bloque de 15 min al que pertenece una hora "HH:MM" (null si fuera de 8:00-19:00)
|
||||
const aptSlot = (t: string): number | null => {
|
||||
const m = /^(\d{1,2}):?(\d{2})?/.exec((t || '').trim());
|
||||
if (!m) return null;
|
||||
const h = parseInt(m[1], 10);
|
||||
return Number.isNaN(h) ? null : h;
|
||||
const mins = m[2] ? parseInt(m[2], 10) : 0;
|
||||
if (Number.isNaN(h) || Number.isNaN(mins)) return null;
|
||||
const f = h + mins / 60;
|
||||
if (f < GRID_START || f >= GRID_END) return null;
|
||||
return GRID_START + Math.floor((f - GRID_START) * SLOTS_PER_HOUR + 1e-6) / SLOTS_PER_HOUR;
|
||||
};
|
||||
|
||||
// Las canceladas no ocupan horario: el hueco queda libre
|
||||
const gridAppointments = visibleAppointments.filter((a) => a.state !== 'cancelled');
|
||||
|
||||
const bookedHours = gridAppointments
|
||||
.map((a) => aptHour(a.time))
|
||||
.filter((h): h is number => h !== null);
|
||||
const gridStart = bookedHours.length ? Math.min(GRID_BASE_START, ...bookedHours) : GRID_BASE_START;
|
||||
const gridEnd = bookedHours.length ? Math.max(GRID_BASE_END, ...bookedHours) : GRID_BASE_END;
|
||||
const gridHours = Array.from({ length: gridEnd - gridStart + 1 }, (_, i) => gridStart + i);
|
||||
const gridSlots: number[] = Array.from(
|
||||
{ length: (GRID_END - GRID_START) * SLOTS_PER_HOUR },
|
||||
(_, i) => GRID_START + i / SLOTS_PER_HOUR
|
||||
);
|
||||
|
||||
const gridDoctors = doctorFilter
|
||||
? doctors.filter((d) => String(d.id) === doctorFilter)
|
||||
@@ -151,34 +183,35 @@ const Agenda: FC = () => {
|
||||
...(hasUnassigned ? [{ key: 'none', label: 'Sin asignar', subtitle: '', doctorId: null }] : []),
|
||||
];
|
||||
|
||||
const gridCellAppointments = (doctorId: number | null, hour: number) =>
|
||||
const gridCellAppointments = (doctorId: number | null, slot: number) =>
|
||||
gridAppointments.filter((a) =>
|
||||
(doctorId ? a.doctor_id === doctorId : !a.doctor_id) && aptHour(a.time) === hour);
|
||||
(doctorId ? a.doctor_id === doctorId : !a.doctor_id) && aptSlot(a.time) === slot);
|
||||
|
||||
const gridDoctorCount = (doctorId: number | null) =>
|
||||
gridAppointments.filter((a) => (doctorId ? a.doctor_id === doctorId : !a.doctor_id)).length;
|
||||
|
||||
// Bloqueo que cubre una celda (médico × hora)
|
||||
const bloqueoEnCelda = (doctorId: number | null, hour: number) =>
|
||||
// Bloqueo que cubre una celda (médico × bloque de 15 min)
|
||||
const bloqueoEnCelda = (doctorId: number | null, slot: number) =>
|
||||
doctorId
|
||||
? bloqueos.find((b) => b.doctor_id === doctorId && (b.all_day || (hour >= b.time_from && hour < b.time_to)))
|
||||
? bloqueos.find((b) => b.doctor_id === doctorId &&
|
||||
(b.all_day || (slot < b.time_to && slot + SLOT_MIN / 60 > b.time_from)))
|
||||
: undefined;
|
||||
|
||||
// Drag & drop para mover citas (solo pendientes/confirmadas)
|
||||
const DRAGGABLE_STATES = ['pending', 'confirmed'];
|
||||
const handleDrop = async (doctorId: number | null, hour: number) => {
|
||||
const handleDrop = async (doctorId: number | null, slot: number) => {
|
||||
if (!dragApt) return;
|
||||
const apt = dragApt;
|
||||
setDragApt(null);
|
||||
setDropTarget(null);
|
||||
if ((apt.doctor_id ?? null) === doctorId && aptHour(apt.time) === hour) return;
|
||||
if ((apt.doctor_id ?? null) === doctorId && aptSlot(apt.time) === slot) return;
|
||||
const nombreCol = doctorId ? (doctors.find((d) => d.id === doctorId)?.name || '') : 'Sin asignar';
|
||||
const horaStr = `${String(hour).padStart(2, '0')}:00`;
|
||||
const horaStr = horaFloatStr(slot);
|
||||
if (!window.confirm(`¿Mover la cita de ${apt.patient} a ${nombreCol} ${horaStr}?`)) return;
|
||||
try {
|
||||
await odooApi.updateAppointment(apt.id, {
|
||||
doctor_id: doctorId ?? null,
|
||||
time: hour as unknown as string,
|
||||
time: slot as unknown as string,
|
||||
});
|
||||
toast.success('Cita movida');
|
||||
await load();
|
||||
@@ -518,10 +551,52 @@ const Agenda: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const doctorOptions = [
|
||||
{ value: '', label: 'Todos los médicos' },
|
||||
...doctors.map((d) => ({ value: String(d.id), label: d.name })),
|
||||
];
|
||||
const openCreate = () => {
|
||||
setQuickMode(false);
|
||||
if (medicoEmployeeId) {
|
||||
setForm((f) => ({ ...f, doctor_id: String(medicoEmployeeId) }));
|
||||
}
|
||||
setQuickForm({
|
||||
name: '',
|
||||
phone: '',
|
||||
doctor_id: medicoEmployeeId ? String(medicoEmployeeId) : doctorFilter,
|
||||
date,
|
||||
time: '',
|
||||
notes: '',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const submitQuick = async () => {
|
||||
if (!quickForm.name.trim() || !quickForm.phone.trim()) {
|
||||
toast.error('Nombre y teléfono son obligatorios');
|
||||
return;
|
||||
}
|
||||
if (!quickForm.date || !quickForm.time) {
|
||||
toast.error('Selecciona fecha y hora');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const res = await odooApi.createQuickAppointment({
|
||||
name: quickForm.name.trim(),
|
||||
phone: quickForm.phone.trim(),
|
||||
date: quickForm.date,
|
||||
time: parseTimeToFloat(quickForm.time) ?? 0,
|
||||
doctor_id: quickForm.doctor_id ? parseInt(quickForm.doctor_id, 10) : undefined,
|
||||
notes: quickForm.notes || undefined,
|
||||
});
|
||||
toast.success(res.patient_created ? 'Paciente y cita rápida creados' : 'Cita rápida creada (paciente existente)');
|
||||
setCreateOpen(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al crear la cita rápida');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const patientOptions = [
|
||||
{ value: '', label: 'Seleccionar paciente...' },
|
||||
@@ -655,7 +730,7 @@ const Agenda: FC = () => {
|
||||
<Sparkles size={16} className="mr-2" />
|
||||
Valoración express
|
||||
</Button>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nueva cita
|
||||
</Button>
|
||||
@@ -678,13 +753,6 @@ const Agenda: FC = () => {
|
||||
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 className="flex gap-1 sm:ml-auto">
|
||||
<Button
|
||||
variant={viewMode === 'lista' ? 'primary' : 'outline'}
|
||||
@@ -716,6 +784,65 @@ const Agenda: FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtro de médicos: pill por médico con chip de pacientes asignados */}
|
||||
<div className="mb-4 sm:mb-5">
|
||||
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Médico</p>
|
||||
{isMedico ? (
|
||||
medicoEmployeeId ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border bg-theme-accent text-theme-inverse border-theme-accent"
|
||||
title="Tu agenda: solo ves tus propias citas">
|
||||
{user?.employee_name || 'Mi agenda'}
|
||||
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] bg-theme-surface/30 text-theme-inverse">
|
||||
{doctors.find((d) => d.id === medicoEmployeeId)?.patient_count ?? 0}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<p className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-xl px-3 py-2 inline-block">
|
||||
Tu usuario no está vinculado a un médico (hr.employee). Pide a un administrador que vincule tu usuario.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDoctorFilter('')}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
|
||||
!doctorFilter
|
||||
? 'bg-theme-accent text-theme-inverse border-theme-accent'
|
||||
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
|
||||
}`}
|
||||
>
|
||||
Todos
|
||||
</button>
|
||||
{doctors.map((d) => {
|
||||
const active = doctorFilter === String(d.id);
|
||||
return (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
onClick={() => setDoctorFilter(active ? '' : String(d.id))}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
|
||||
active
|
||||
? 'bg-theme-accent text-theme-inverse border-theme-accent'
|
||||
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
|
||||
}`}
|
||||
>
|
||||
{d.name}
|
||||
<span
|
||||
title="Pacientes asignados"
|
||||
className={`inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] ${
|
||||
active ? 'bg-theme-surface/30 text-theme-inverse' : 'bg-theme-surface text-theme-heading border border-theme-border'
|
||||
}`}
|
||||
>
|
||||
{d.patient_count ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filtros rápidos */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
|
||||
{viewMode !== 'lista' && (
|
||||
@@ -805,34 +932,34 @@ const Agenda: FC = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{gridHours.map((hour) => (
|
||||
<tr key={hour}>
|
||||
<td className="sticky left-0 z-10 bg-theme-surface p-3 text-xs font-semibold text-theme-muted align-top whitespace-nowrap border-b border-theme-border">
|
||||
{`${String(hour).padStart(2, '0')}:00`}
|
||||
{gridSlots.map((slot) => (
|
||||
<tr key={slot}>
|
||||
<td className="sticky left-0 z-10 bg-theme-surface px-2 py-1 text-[11px] font-semibold text-theme-muted align-top whitespace-nowrap border-b border-theme-border">
|
||||
{horaFloatStr(slot)}
|
||||
</td>
|
||||
{gridColumns.map((col) => {
|
||||
const cell = gridCellAppointments(col.doctorId, hour);
|
||||
const bloqueo = bloqueoEnCelda(col.doctorId, hour);
|
||||
const cellKey = `${col.key}-${hour}`;
|
||||
const cell = gridCellAppointments(col.doctorId, slot);
|
||||
const bloqueo = bloqueoEnCelda(col.doctorId, slot);
|
||||
const cellKey = `${col.key}-${slot}`;
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
|
||||
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
|
||||
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, hour); }}
|
||||
className={`p-1.5 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
|
||||
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, slot); }}
|
||||
className={`p-1 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
|
||||
>
|
||||
{bloqueo ? (
|
||||
<div
|
||||
className="rounded-xl border border-theme-border py-3.5 text-center text-[11px] font-medium text-theme-muted"
|
||||
className="rounded-lg border border-theme-border py-1.5 text-center text-[10px] font-medium text-theme-muted"
|
||||
style={{ backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0,0,0,0.05) 6px, rgba(0,0,0,0.05) 12px)' }}
|
||||
title={`Bloqueado — ${bloqueo.motivo || 'sin motivo'}`}
|
||||
>
|
||||
<Ban size={12} className="inline mr-1" />
|
||||
<Ban size={11} className="inline mr-1" />
|
||||
Bloqueado{bloqueo.motivo ? ` — ${bloqueo.motivo}` : ''}
|
||||
</div>
|
||||
) : cell.length === 0 ? (
|
||||
<div className={`rounded-xl border py-3.5 text-center text-[11px] font-medium transition-colors ${
|
||||
<div className={`rounded-lg border py-1.5 text-center text-[10px] font-medium transition-colors ${
|
||||
quickFilter === 'libres'
|
||||
? 'bg-brand-mint border-emerald-700 text-theme-heading font-semibold'
|
||||
: 'border-dashed border-theme-border-strong text-theme-muted hover:bg-theme-accent-bg'
|
||||
@@ -845,7 +972,7 @@ const Agenda: FC = () => {
|
||||
key={apt.id}
|
||||
onClick={() => openAptDetail(apt)}
|
||||
{...chipDragProps(apt)}
|
||||
className={`mb-1.5 rounded-xl border-l-4 px-2.5 py-1.5 text-xs shadow-card transition-shadow hover:shadow-soft ${
|
||||
className={`mb-1 rounded-lg border-l-4 px-2 py-1 text-[11px] leading-tight shadow-card transition-shadow hover:shadow-soft ${
|
||||
DRAGGABLE_STATES.includes(apt.state) ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
|
||||
} ${gridStateClass[apt.state] || 'border-theme-border-strong bg-theme-bg text-theme-heading'} ${quickFilter === 'libres' ? 'opacity-40' : ''}`}
|
||||
title={`${apt.time} · ${apt.patient} · ${apt.service} · ${apt.state}`}
|
||||
@@ -883,9 +1010,9 @@ const Agenda: FC = () => {
|
||||
<th className="sticky left-0 z-10 min-w-[190px] bg-theme-surface p-2.5 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
|
||||
Médico
|
||||
</th>
|
||||
{gridHours.map((hour) => (
|
||||
<th key={hour} className="min-w-[96px] p-2.5 text-center text-xs font-medium text-theme-muted border-b border-l border-theme-border">
|
||||
{`${String(hour).padStart(2, '0')}:00`}
|
||||
{gridSlots.map((slot) => (
|
||||
<th key={slot} className="min-w-[52px] p-1 text-center text-[10px] font-medium text-theme-muted border-b border-l border-theme-border">
|
||||
{horaFloatStr(slot)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -908,34 +1035,34 @@ const Agenda: FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{gridHours.map((hour) => {
|
||||
const cell = gridCellAppointments(col.doctorId, hour);
|
||||
const bloqueo = bloqueoEnCelda(col.doctorId, hour);
|
||||
const cellKey = `${col.key}-${hour}`;
|
||||
{gridSlots.map((slot) => {
|
||||
const cell = gridCellAppointments(col.doctorId, slot);
|
||||
const bloqueo = bloqueoEnCelda(col.doctorId, slot);
|
||||
const cellKey = `${col.key}-${slot}`;
|
||||
return (
|
||||
<td
|
||||
key={hour}
|
||||
key={slot}
|
||||
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
|
||||
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
|
||||
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, hour); }}
|
||||
className={`p-1 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
|
||||
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, slot); }}
|
||||
className={`p-0.5 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
|
||||
>
|
||||
{bloqueo ? (
|
||||
<div
|
||||
className="rounded-lg border border-theme-border py-2 text-center text-[10px] font-medium text-theme-muted"
|
||||
className="rounded border border-theme-border py-1.5 text-center text-[10px] font-medium text-theme-muted"
|
||||
style={{ backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0,0,0,0.05) 6px, rgba(0,0,0,0.05) 12px)' }}
|
||||
title={`Bloqueado — ${bloqueo.motivo || 'sin motivo'}`}
|
||||
>
|
||||
<Ban size={11} className="inline" />
|
||||
<Ban size={10} className="inline" />
|
||||
</div>
|
||||
) : cell.length === 0 ? (
|
||||
<div
|
||||
className={`rounded-lg border py-2 transition-colors ${
|
||||
className={`rounded border py-1.5 transition-colors ${
|
||||
quickFilter === 'libres'
|
||||
? 'bg-brand-mint border-emerald-700'
|
||||
: 'border-dashed border-theme-border hover:bg-theme-accent-bg'
|
||||
}`}
|
||||
title="Libre"
|
||||
title={`Libre ${horaFloatStr(slot)}`}
|
||||
/>
|
||||
) : (
|
||||
cell.map((apt) => (
|
||||
@@ -943,7 +1070,7 @@ const Agenda: FC = () => {
|
||||
key={apt.id}
|
||||
onClick={() => openAptDetail(apt)}
|
||||
{...chipDragProps(apt)}
|
||||
className={`mb-1 rounded-lg border-l-2 px-1.5 py-1 text-[11px] leading-tight shadow-card transition-shadow hover:shadow-soft ${
|
||||
className={`mb-0.5 rounded border-l-2 px-1 py-0.5 text-[10px] leading-tight shadow-card transition-shadow hover:shadow-soft ${
|
||||
DRAGGABLE_STATES.includes(apt.state) ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
|
||||
} ${gridStateClass[apt.state] || 'border-theme-border-strong bg-theme-bg text-theme-heading'} ${quickFilter === 'libres' ? 'opacity-40' : ''}`}
|
||||
title={`${apt.time} · ${apt.patient} · ${apt.service} · ${apt.state}`}
|
||||
@@ -970,7 +1097,7 @@ const Agenda: FC = () => {
|
||||
title="Sin citas"
|
||||
subtitle="No hay citas para los filtros seleccionados."
|
||||
actionLabel="Nueva cita"
|
||||
onAction={() => setCreateOpen(true)}
|
||||
onAction={openCreate}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -1045,6 +1172,7 @@ const Agenda: FC = () => {
|
||||
options={[{ value: '', label: 'Seleccionar médico...' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
|
||||
value={bloqueoForm.doctor_id}
|
||||
onChange={(e) => setBloqueoForm({ ...bloqueoForm, doctor_id: e.target.value })}
|
||||
disabled={isMedico}
|
||||
/>
|
||||
<Input
|
||||
label="Fecha *"
|
||||
@@ -1127,11 +1255,68 @@ const Agenda: FC = () => {
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={createAppointment} loading={submitting}>Guardar cita</Button>
|
||||
<Button onClick={quickMode ? submitQuick : createAppointment} loading={submitting}>
|
||||
{quickMode ? 'Guardar cita rápida' : 'Guardar cita'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={quickMode}
|
||||
onChange={(e) => setQuickMode(e.target.checked)}
|
||||
className="rounded border-theme-border-strong"
|
||||
/>
|
||||
Cita rápida (solo nombre y teléfono)
|
||||
</label>
|
||||
{quickMode ? (
|
||||
<>
|
||||
<p className="text-xs text-theme-muted">
|
||||
Si el teléfono ya existe, se reutiliza ese paciente; si no, se crea con datos mínimos.
|
||||
</p>
|
||||
<Input
|
||||
label="Nombre *"
|
||||
placeholder="Nombre del paciente"
|
||||
value={quickForm.name}
|
||||
onChange={(e) => setQuickForm({ ...quickForm, name: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Teléfono *"
|
||||
placeholder="+52 ..."
|
||||
value={quickForm.phone}
|
||||
onChange={(e) => setQuickForm({ ...quickForm, phone: e.target.value })}
|
||||
/>
|
||||
<Select
|
||||
label="Médico"
|
||||
options={[{ value: '', label: 'Sin preferencia' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
|
||||
value={quickForm.doctor_id}
|
||||
onChange={(e) => setQuickForm({ ...quickForm, doctor_id: e.target.value })}
|
||||
disabled={isMedico}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Fecha *"
|
||||
type="date"
|
||||
value={quickForm.date}
|
||||
onChange={(e) => setQuickForm({ ...quickForm, date: e.target.value })}
|
||||
/>
|
||||
<Select
|
||||
label="Hora *"
|
||||
options={QUARTER_TIME_OPTIONS}
|
||||
value={quickForm.time}
|
||||
onChange={(e) => setQuickForm({ ...quickForm, time: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<TextArea
|
||||
label="Notas"
|
||||
value={quickForm.notes}
|
||||
onChange={(e) => setQuickForm({ ...quickForm, notes: e.target.value })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
label="Buscar paciente"
|
||||
placeholder="Escribe nombre o teléfono..."
|
||||
@@ -1155,6 +1340,7 @@ const Agenda: FC = () => {
|
||||
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 })}
|
||||
disabled={isMedico}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
@@ -1197,6 +1383,8 @@ const Agenda: FC = () => {
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1256,6 +1444,7 @@ const Agenda: FC = () => {
|
||||
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 })}
|
||||
disabled={isMedico}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
@@ -1267,6 +1456,9 @@ const Agenda: FC = () => {
|
||||
<Input
|
||||
label="Hora *"
|
||||
type="time"
|
||||
min="08:00"
|
||||
max="19:00"
|
||||
step={900}
|
||||
value={expressForm.time}
|
||||
onChange={(e) => setExpressForm({ ...expressForm, time: e.target.value })}
|
||||
/>
|
||||
@@ -1375,6 +1567,9 @@ const Agenda: FC = () => {
|
||||
<Input
|
||||
label="Hora *"
|
||||
type="time"
|
||||
min="08:00"
|
||||
max="19:00"
|
||||
step={900}
|
||||
value={editForm.time}
|
||||
onChange={(e) => setEditForm({ ...editForm, time: e.target.value })}
|
||||
/>
|
||||
@@ -1384,6 +1579,7 @@ const Agenda: FC = () => {
|
||||
options={[{ value: '', label: 'Sin cambios' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
|
||||
value={editForm.doctor_id}
|
||||
onChange={(e) => setEditForm({ ...editForm, doctor_id: e.target.value })}
|
||||
disabled={isMedico}
|
||||
/>
|
||||
<TextArea
|
||||
label="Notas"
|
||||
|
||||
Reference in New Issue
Block a user