fase 1 productizacion: capa de configuracion (glosario multi-giro, sucursales y categorias configurables, ajustes de clinica por instancia, colores de marca, horarios, URLs de pago dinamicas) + script de provisioning de instancia nueva con datos demo + frontend consumiendo la config
This commit is contained in:
@@ -26,8 +26,10 @@ import {
|
||||
badgeForAppointmentState,
|
||||
} from '../components/ui';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { odooApi, type Appointment, type AppointmentStateLog, type Patient, type Service, type Doctor, type Bloqueo, type ListaEspera, type PatientWallet, type ReceiptData, type AgendaPerm, AGENDA_PERM_ITEMS } from '../services/odoo';
|
||||
import { odooApi, type Appointment, type AppointmentStateLog, type Patient, type Service, type Doctor, type Bloqueo, type ListaEspera, type PatientWallet, type ReceiptData, type AgendaPerm, type GlossaryKey, AGENDA_PERM_ITEMS } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
import { useSucursales } from '../lib/sucursales';
|
||||
import { todayLocal } from '../lib/utils';
|
||||
import { useAutoRefresh, AutoRefreshBadge } from '../lib/autoRefresh';
|
||||
|
||||
@@ -55,6 +57,10 @@ export const APT_STATE_LABELS: Record<string, string> = {
|
||||
waitlist: 'Lista de espera',
|
||||
};
|
||||
|
||||
/** Etiqueta de estado de cita traducida ('En cabina' sigue el término de sala). */
|
||||
export const aptStateLabel = (state: string, term: (key: GlossaryKey) => string): string =>
|
||||
state === 'arrived' ? `En ${term('room_singular').toLowerCase()}` : (APT_STATE_LABELS[state] || state);
|
||||
|
||||
const stateOptions = [
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
{ value: 'pending', label: 'Agendada' },
|
||||
@@ -140,6 +146,8 @@ const Agenda: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { user, canAgenda } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const { sucursales, defaultCode } = useSucursales();
|
||||
const { openPatient } = usePatientDrawer();
|
||||
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor).
|
||||
// Cosmetología ve la agenda completa (es personal clínico, sin la privacidad
|
||||
@@ -253,6 +261,7 @@ const Agenda: FC = () => {
|
||||
notes: '',
|
||||
sessions: 1,
|
||||
duration: '',
|
||||
branch: '',
|
||||
});
|
||||
const quickToggleClass = editingId
|
||||
? 'hidden'
|
||||
@@ -272,6 +281,7 @@ const Agenda: FC = () => {
|
||||
time: '',
|
||||
notes: '',
|
||||
duration: '',
|
||||
branch: '',
|
||||
});
|
||||
const [pkgUpdating, setPkgUpdating] = useState<number | null>(null);
|
||||
const [expressOpen, setExpressOpen] = useState(false);
|
||||
@@ -943,7 +953,7 @@ const Agenda: FC = () => {
|
||||
const cambiarSala = async (entry: ListaEspera, sala: string) => {
|
||||
try {
|
||||
await odooApi.updateListaEspera(entry.id, { sala });
|
||||
const etiqueta = sala === 'consultoria' ? 'Consultoría' : sala === 'cabina' ? 'Cabina' : 'Sin sala';
|
||||
const etiqueta = sala === 'consultoria' ? 'Consultoría' : sala === 'cabina' ? term('room_singular') : 'Sin sala';
|
||||
toast.success(`${entry.patient}: ${etiqueta}`);
|
||||
await recargarListaEspera();
|
||||
} catch (err) {
|
||||
@@ -1249,7 +1259,7 @@ const Agenda: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const aptStateLabels = APT_STATE_LABELS;
|
||||
const aptStateLabels = (state: string) => aptStateLabel(state, term);
|
||||
|
||||
const handlePhoneConfirm = async (apt: Appointment, confirmed: boolean) => {
|
||||
try {
|
||||
@@ -1413,6 +1423,7 @@ const Agenda: FC = () => {
|
||||
notes: form.notes,
|
||||
sessions,
|
||||
duration: form.duration ? parseInt(form.duration, 10) : 0,
|
||||
branch: form.branch || defaultCode || undefined,
|
||||
} as never);
|
||||
toast.success(sessions > 1 ? `Paquete creado: ${sessions} citas` : 'Cita creada');
|
||||
// Si la cita salió de la lista de espera, se marca como agendada
|
||||
@@ -1427,6 +1438,7 @@ const Agenda: FC = () => {
|
||||
notes: '',
|
||||
sessions: 1,
|
||||
duration: '',
|
||||
branch: '',
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -1453,6 +1465,7 @@ const Agenda: FC = () => {
|
||||
notes: apt.notes || '',
|
||||
sessions: 1,
|
||||
duration: String(apt.duration || apt.service_duration || DEFAULT_DURATION),
|
||||
branch: apt.branch || '',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
};
|
||||
@@ -1473,6 +1486,7 @@ const Agenda: FC = () => {
|
||||
time: parseTimeToFloat(form.time) ?? 0,
|
||||
notes: form.notes,
|
||||
duration: form.duration ? parseInt(form.duration, 10) : 0,
|
||||
branch: form.branch || defaultCode || undefined,
|
||||
} as never);
|
||||
toast.success('Cita actualizada');
|
||||
setCreateOpen(false);
|
||||
@@ -1492,7 +1506,7 @@ const Agenda: FC = () => {
|
||||
setQuickMode(false);
|
||||
setEditingId(null);
|
||||
setPickedPatient(null);
|
||||
setForm((f) => ({ ...f, patient_id: '', duration: '', ...(medicoEmployeeId ? { doctor_id: String(medicoEmployeeId) } : {}) }));
|
||||
setForm((f) => ({ ...f, patient_id: '', duration: '', branch: '', ...(medicoEmployeeId ? { doctor_id: String(medicoEmployeeId) } : {}) }));
|
||||
setQuickForm({
|
||||
name: '',
|
||||
phone: '',
|
||||
@@ -1501,6 +1515,7 @@ const Agenda: FC = () => {
|
||||
time: '',
|
||||
notes: '',
|
||||
duration: '',
|
||||
branch: '',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
};
|
||||
@@ -1519,7 +1534,7 @@ const Agenda: FC = () => {
|
||||
? String(medicoEmployeeId)
|
||||
: (opts?.doctorId != null ? String(opts.doctorId) : doctorFilter);
|
||||
const t = opts?.time ?? '';
|
||||
setForm((f) => ({ ...f, patient_id: '', date: d, time: t, doctor_id: docId }));
|
||||
setForm((f) => ({ ...f, patient_id: '', date: d, time: t, doctor_id: docId, branch: '' }));
|
||||
setQuickForm({
|
||||
name: '',
|
||||
phone: '',
|
||||
@@ -1528,6 +1543,7 @@ const Agenda: FC = () => {
|
||||
time: t,
|
||||
notes: '',
|
||||
duration: '',
|
||||
branch: '',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
};
|
||||
@@ -1564,6 +1580,7 @@ const Agenda: FC = () => {
|
||||
doctor_id: quickForm.doctor_id ? parseInt(quickForm.doctor_id, 10) : undefined,
|
||||
notes: quickForm.notes || undefined,
|
||||
duration: quickForm.duration ? parseInt(quickForm.duration, 10) : undefined,
|
||||
branch: quickForm.branch || defaultCode || undefined,
|
||||
});
|
||||
if (res.patient_created) {
|
||||
toast.success('Paciente y cita rápida creados');
|
||||
@@ -1764,7 +1781,7 @@ const Agenda: FC = () => {
|
||||
{puedeAgendar && (
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nueva cita
|
||||
Nueva {term('appointment_singular').toLowerCase()}
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
@@ -1822,7 +1839,7 @@ const Agenda: FC = () => {
|
||||
</div>
|
||||
<Select
|
||||
label="Estado"
|
||||
options={stateOptions}
|
||||
options={stateOptions.map((o) => (o.value === 'arrived' ? { ...o, label: `En ${term('room_singular').toLowerCase()}` } : o))}
|
||||
value={stateFilter}
|
||||
onChange={(e) => setStateFilter(e.target.value)}
|
||||
className="sm:max-w-[180px]"
|
||||
@@ -2485,7 +2502,7 @@ const Agenda: FC = () => {
|
||||
<EmptyState
|
||||
title="Sin citas"
|
||||
subtitle="No hay citas para los filtros seleccionados."
|
||||
actionLabel="Nueva cita"
|
||||
actionLabel={`Nueva ${term('appointment_singular').toLowerCase()}`}
|
||||
onAction={openCreate}
|
||||
/>
|
||||
) : (
|
||||
@@ -2553,7 +2570,7 @@ const Agenda: FC = () => {
|
||||
</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_LABELS[apt.state] || apt.state}</Badge>
|
||||
<Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term)}</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<button
|
||||
@@ -2586,7 +2603,7 @@ const Agenda: FC = () => {
|
||||
rows={[
|
||||
{ label: 'Hora', value: apt.time },
|
||||
{ label: 'Doctor', value: apt.doctor || '-' },
|
||||
{ label: 'Estado', value: <Badge variant={badgeForAppointmentState(apt.state)}>{APT_STATE_LABELS[apt.state] || apt.state}</Badge> },
|
||||
{ label: 'Estado', value: <Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term)}</Badge> },
|
||||
{ label: 'Confirmación', value: <span title={confirmTitle(apt)}>{confirmIndicator(apt, 14)}</span> },
|
||||
{ label: 'Pago', value: apt.payment_state },
|
||||
]}
|
||||
@@ -2672,7 +2689,7 @@ const Agenda: FC = () => {
|
||||
<Modal
|
||||
isOpen={createOpen}
|
||||
onClose={() => cerrarModalCita()}
|
||||
title={editingId ? 'Editar cita' : 'Nueva cita'}
|
||||
title={editingId ? `Editar ${term('appointment_singular').toLowerCase()}` : `Nueva ${term('appointment_singular').toLowerCase()}`}
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
@@ -2717,6 +2734,12 @@ const Agenda: FC = () => {
|
||||
onChange={(e) => setQuickForm({ ...quickForm, doctor_id: e.target.value })}
|
||||
disabled={isMedico}
|
||||
/>
|
||||
<Select
|
||||
label={term('branch_singular')}
|
||||
options={sucursales.map((s) => ({ value: s.code, label: s.name }))}
|
||||
value={quickForm.branch || defaultCode}
|
||||
onChange={(e) => setQuickForm({ ...quickForm, branch: e.target.value })}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Fecha *"
|
||||
@@ -2816,6 +2839,12 @@ const Agenda: FC = () => {
|
||||
onChange={(e) => setForm({ ...form, doctor_id: e.target.value })}
|
||||
disabled={isMedico}
|
||||
/>
|
||||
<Select
|
||||
label={term('branch_singular')}
|
||||
options={sucursales.map((s) => ({ value: s.code, label: s.name }))}
|
||||
value={form.branch || defaultCode}
|
||||
onChange={(e) => setForm({ ...form, branch: e.target.value })}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Fecha *"
|
||||
@@ -2977,7 +3006,7 @@ const Agenda: FC = () => {
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={badgeForAppointmentState(detailApt.state)}>
|
||||
{aptStateLabels[detailApt.state] || detailApt.state}
|
||||
{aptStateLabels(detailApt.state)}
|
||||
</Badge>
|
||||
{detailApt.is_first_visit && (
|
||||
<Badge variant="primary">Primera vez</Badge>
|
||||
|
||||
Reference in New Issue
Block a user