Oleada UX 5: Servicios (duplicar, favoritos y orden para POS, mas vendidos) y Medicos (semana tipo con validacion en agenda, meta mensual con progreso)

This commit is contained in:
2026-09-17 21:28:43 +00:00
parent 9ee3343e22
commit cd945df6a5
10 changed files with 885 additions and 41 deletions

View File

@@ -392,6 +392,21 @@ const Agenda: FC = () => {
(b.all_day || (slot < b.time_to && slot + SLOT_MIN / 60 > b.time_from)))
: undefined;
// Horario semanal del médico: celda fuera de su semana tipo.
// Devuelve null si el médico no tiene horario configurado (sin restricción).
const fueraDeHorario = (doctorId: number | null, slot: number): string | null => {
if (!doctorId) return null;
const doc = doctors.find((d) => d.id === doctorId);
if (!doc?.tiene_horario || !doc.horario?.length) return null;
const dow = String((new Date(`${date}T12:00:00`).getDay() + 6) % 7);
const linea = doc.horario.find((h) => h.day_of_week === dow);
if (!linea) return `Fuera de horario (no atiende ese día · ${doc.horario_resumen})`;
if (slot < linea.hour_from - 1e-6 || slot >= linea.hour_to - 1e-6) {
return `Fuera de horario (trabaja ${horaFloatStr(linea.hour_from)}-${horaFloatStr(linea.hour_to)})`;
}
return null;
};
// Drag & drop para mover citas (solo pendientes/confirmadas)
const DRAGGABLE_STATES = ['pending', 'confirmed'];
const handleDrop = async (doctorId: number | null, slot: number) => {
@@ -1629,6 +1644,7 @@ const Agenda: FC = () => {
{gridColumns.map((col) => {
const cell = gridCellAppointments(col.doctorId, slot);
const bloqueo = bloqueoEnCelda(col.doctorId, slot);
const fuera = fueraDeHorario(col.doctorId, slot);
const cellKey = `${col.key}-${slot}`;
return (
<td
@@ -1636,7 +1652,8 @@ const Agenda: FC = () => {
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
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' : ''} ${isNowSlot ? 'border-t-2 border-t-theme-accent' : ''}`}
className={`p-1 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''} ${isNowSlot ? 'border-t-2 border-t-theme-accent' : ''} ${fuera ? 'opacity-50' : ''}`}
title={fuera || undefined}
>
{bloqueo ? (
<div
@@ -1649,15 +1666,17 @@ const Agenda: FC = () => {
</div>
) : cell.length === 0 ? (
<div
onClick={(e) => clickCeldaLibre(e, date, slot, col.doctorId)}
className={`cursor-pointer 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'
onClick={(e) => { if (fuera) return; clickCeldaLibre(e, date, slot, col.doctorId); }}
className={`rounded-lg border py-1.5 text-center text-[10px] font-medium transition-colors ${
fuera
? 'border-theme-border bg-theme-bg text-theme-muted cursor-not-allowed'
: quickFilter === 'libres'
? 'cursor-pointer bg-brand-mint border-emerald-700 text-theme-heading font-semibold'
: 'cursor-pointer border-dashed border-theme-border-strong text-theme-muted hover:bg-theme-accent-bg'
}`}
title={`Nueva cita aquí — ${horaFloatStr(slot)}`}
title={fuera || `Nueva cita aquí — ${horaFloatStr(slot)}`}
>
Libre
{fuera ? 'Fuera de horario' : 'Libre'}
</div>
) : (
cell.map((apt) => (
@@ -1739,6 +1758,7 @@ const Agenda: FC = () => {
{gridSlots.map((slot) => {
const cell = gridCellAppointments(col.doctorId, slot);
const bloqueo = bloqueoEnCelda(col.doctorId, slot);
const fuera = fueraDeHorario(col.doctorId, slot);
const cellKey = `${col.key}-${slot}`;
const isNowSlot = nowSlot !== null && slot === nowSlot;
return (
@@ -1747,7 +1767,8 @@ const Agenda: FC = () => {
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, slot); }}
className={`p-0.5 align-top border-b border-theme-border transition-colors ${isNowSlot ? 'border-l-2 border-l-theme-accent bg-theme-accent-bg/40' : 'border-l'} ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
className={`p-0.5 align-top border-b border-theme-border transition-colors ${isNowSlot ? 'border-l-2 border-l-theme-accent bg-theme-accent-bg/40' : 'border-l'} ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''} ${fuera ? 'opacity-50' : ''}`}
title={fuera || undefined}
>
{bloqueo ? (
<div
@@ -1759,13 +1780,15 @@ const Agenda: FC = () => {
</div>
) : cell.length === 0 ? (
<div
onClick={(e) => clickCeldaLibre(e, date, slot, col.doctorId)}
className={`cursor-pointer 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'
onClick={(e) => { if (fuera) return; clickCeldaLibre(e, date, slot, col.doctorId); }}
className={`rounded border py-1.5 transition-colors ${
fuera
? 'cursor-not-allowed border-theme-border bg-theme-bg'
: quickFilter === 'libres'
? 'cursor-pointer bg-brand-mint border-emerald-700'
: 'cursor-pointer border-dashed border-theme-border hover:bg-theme-accent-bg'
}`}
title={`Libre ${horaFloatStr(slot)}`}
title={fuera || `Libre ${horaFloatStr(slot)}`}
/>
) : (
cell.map((apt) => (