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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft, Percent, Check, Calculator } from 'lucide-react';
|
||||
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft, Percent, Check, Calculator, Palette } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import UsuariosPanel from '../components/UsuariosPanel';
|
||||
import RolesPanel from '../components/RolesPanel';
|
||||
@@ -10,12 +10,18 @@ import CatalogoPanel from '../components/CatalogoPanel';
|
||||
import AuditoriaPanel from '../components/AuditoriaPanel';
|
||||
import TicketDesigner from '../components/TicketDesigner';
|
||||
import ImpactoModal from '../components/ImpactoModal';
|
||||
import SucursalesPanel from '../components/SucursalesPanel';
|
||||
import CategoriasPanel from '../components/CategoriasPanel';
|
||||
import TerminologiaPanel from '../components/TerminologiaPanel';
|
||||
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
||||
import { odooApi, type Doctor, BRAND_DEFAULT } from '../services/odoo';
|
||||
import { odooApi, type Doctor, type ClinicSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'clinica', label: 'Clínica' },
|
||||
{ key: 'sucursales', label: 'Sucursales' },
|
||||
{ key: 'categorias', label: 'Categorías' },
|
||||
{ key: 'terminologia', label: 'Terminología' },
|
||||
{ key: 'usuarios', label: 'Usuarios' },
|
||||
{ key: 'roles', label: 'Roles' },
|
||||
{ key: 'recetas', label: 'Recetas' },
|
||||
@@ -24,38 +30,25 @@ const TABS = [
|
||||
{ key: 'auditoria', label: 'Auditoría' },
|
||||
] as const;
|
||||
|
||||
// Secciones que ve gerencia (admin del sistema ve todas).
|
||||
const TABS_GERENCIA = ['sucursales', 'categorias', 'terminologia', 'usuarios', 'roles', 'tickets'];
|
||||
|
||||
const SUBCATALOGOS = [
|
||||
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
||||
{ key: 'procedimientos', label: 'Procedimientos' },
|
||||
] as const;
|
||||
|
||||
interface ClinicSettings {
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
website: string;
|
||||
}
|
||||
|
||||
const defaultSettings: ClinicSettings = {
|
||||
name: 'SKEEN Derma Experts',
|
||||
phone: '+52 (661) 100-2172',
|
||||
email: 'hola@skeen.mx',
|
||||
address: 'Playas de Rosarito, Baja California',
|
||||
website: 'https://skeen.mx',
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'skeen_clinic_settings';
|
||||
// Clave histórica del navegador: antes los ajustes de la clínica vivían solo en
|
||||
// localStorage. Se migran al backend una vez y deja de usarse.
|
||||
const LEGACY_STORAGE_KEY = 'skeen_clinic_settings';
|
||||
|
||||
const Configuracion: FC = () => {
|
||||
const { user, brand, setBrand } = useAuth();
|
||||
// Gerencia gestiona usuarios y roles; la sección "Clínica" (tipo de cambio y
|
||||
// comisiones) queda para administración del sistema.
|
||||
// Gerencia gestiona catálogos, usuarios y roles; la sección "Clínica" (datos
|
||||
// de contacto, tipo de cambio y comisiones) queda para administración del sistema.
|
||||
const esAdmin = user?.role === 'admin';
|
||||
const tabsVisibles = useMemo(
|
||||
() => (esAdmin
|
||||
? TABS
|
||||
: TABS.filter((t) => t.key === 'usuarios' || t.key === 'roles' || t.key === 'tickets')),
|
||||
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key))),
|
||||
[esAdmin],
|
||||
);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -66,31 +59,60 @@ const Configuracion: FC = () => {
|
||||
const sub = searchParams.get('sub');
|
||||
return sub === 'procedimientos' ? 'procedimientos' : 'diagnosticos';
|
||||
});
|
||||
const [settings, setSettings] = useState<ClinicSettings>(() => ({
|
||||
...defaultSettings,
|
||||
// El nombre default sigue al brand (white-label); teléfono/dirección
|
||||
// quedan en localStorage o los valores históricos de la clínica.
|
||||
name: brand.nombre,
|
||||
}));
|
||||
const [settings, setSettings] = useState<ClinicSettings>(CLINIC_SETTINGS_DEFAULT);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Ajustes de la clínica desde el backend. Migración de una sola vez: si este
|
||||
// navegador tiene los datos viejos en localStorage y el backend sigue con los
|
||||
// defaults (contacto vacío), se precargan y se suben; después se deja de usar
|
||||
// localStorage como fuente de verdad.
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) setSettings(JSON.parse(saved));
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
let alive = true;
|
||||
odooApi.getClinicSettings()
|
||||
.then(async (res) => {
|
||||
if (!alive || res.status !== 'success') return;
|
||||
let next = res.settings;
|
||||
try {
|
||||
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const legacy = JSON.parse(raw) as Partial<{ name: string; phone: string; email: string; address: string; website: string }>;
|
||||
const backendVacio = !next.telefono && !next.email && !next.direccion && !next.web;
|
||||
const legacyTieneDatos = Boolean(legacy?.phone || legacy?.email || legacy?.address || legacy?.website);
|
||||
if (backendVacio && legacyTieneDatos) {
|
||||
next = {
|
||||
...next,
|
||||
telefono: legacy?.phone || next.telefono,
|
||||
email: legacy?.email || next.email,
|
||||
direccion: legacy?.address || next.direccion,
|
||||
web: legacy?.website || next.web,
|
||||
};
|
||||
const up = await odooApi.putClinicSettings({ ...next, motivo: 'Migración desde localStorage' });
|
||||
if (up.status === 'success') next = up.settings;
|
||||
}
|
||||
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
}
|
||||
if (alive) setSettings(next);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('No se pudieron cargar los ajustes de la clínica');
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
const save = () => {
|
||||
const save = async () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
const res = await odooApi.putClinicSettings({ ...settings, motivo: 'Ajustes desde Configuración' });
|
||||
if (res.status === 'success') setSettings(res.settings);
|
||||
toast.success('Configuración guardada');
|
||||
} catch {
|
||||
toast.error('Error al guardar configuración');
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al guardar configuración');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -104,7 +126,7 @@ const Configuracion: FC = () => {
|
||||
const [tcSaving, setTcSaving] = useState(false);
|
||||
// ---- Identidad de la clínica (brand white-label) ----
|
||||
const [brandForm, setBrandForm] = useState({
|
||||
nombre: '', pie: '', logo_claro: '', logo_oscuro: '',
|
||||
nombre: '', pie: '', logo_claro: '', logo_oscuro: '', url_publica: '', primario: '', acento: '',
|
||||
});
|
||||
const [brandSaving, setBrandSaving] = useState(false);
|
||||
const brandCargadoRef = useRef(false);
|
||||
@@ -116,6 +138,9 @@ const Configuracion: FC = () => {
|
||||
pie: brand.pie === BRAND_DEFAULT.pie ? '' : brand.pie,
|
||||
logo_claro: brand.logo_claro === BRAND_DEFAULT.logo_claro ? '' : brand.logo_claro,
|
||||
logo_oscuro: brand.logo_oscuro === BRAND_DEFAULT.logo_oscuro ? '' : brand.logo_oscuro,
|
||||
url_publica: brand.url_publica || '',
|
||||
primario: brand.colores?.primario || '',
|
||||
acento: brand.colores?.acento || '',
|
||||
});
|
||||
}, [brand]);
|
||||
|
||||
@@ -128,13 +153,16 @@ const Configuracion: FC = () => {
|
||||
pie: brandForm.pie.trim() || BRAND_DEFAULT.pie,
|
||||
logo_claro: brandForm.logo_claro.trim() || BRAND_DEFAULT.logo_claro,
|
||||
logo_oscuro: brandForm.logo_oscuro.trim() || BRAND_DEFAULT.logo_oscuro,
|
||||
url_publica: brandForm.url_publica.trim(),
|
||||
colores: { primario: brandForm.primario.trim(), acento: brandForm.acento.trim() },
|
||||
},
|
||||
motivo: 'Identidad desde Configuración',
|
||||
});
|
||||
setBrand(res.brand);
|
||||
toast.success('Identidad de la clínica actualizada (se ve al recargar en los demás equipos)');
|
||||
} catch {
|
||||
toast.error('No se pudo guardar la identidad de la clínica');
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo guardar la identidad de la clínica');
|
||||
} finally {
|
||||
setBrandSaving(false);
|
||||
}
|
||||
@@ -298,6 +326,24 @@ const Configuracion: FC = () => {
|
||||
<CatalogoPanel kind={subcatalogo as 'diagnosticos' | 'procedimientos'} titulo={subcatalogo === 'diagnosticos' ? 'Diagnósticos' : 'Procedimientos'} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'sucursales' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<SucursalesPanel />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'categorias' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<CategoriasPanel />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'terminologia' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<TerminologiaPanel />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
<Card>
|
||||
@@ -305,7 +351,7 @@ const Configuracion: FC = () => {
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-4">Información de la clínica</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<a
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(settings.address)}`}
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(settings.direccion)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
|
||||
@@ -315,12 +361,12 @@ const Configuracion: FC = () => {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-theme-heading">Dirección</p>
|
||||
<p className="text-sm text-theme-muted truncate">{settings.address}</p>
|
||||
<p className="text-sm text-theme-muted truncate">{settings.direccion}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={`tel:${settings.phone}`}
|
||||
href={`tel:${settings.telefono}`}
|
||||
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-theme-surface flex items-center justify-center text-theme-muted shadow-sm shrink-0">
|
||||
@@ -328,7 +374,7 @@ const Configuracion: FC = () => {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-theme-heading">Teléfono</p>
|
||||
<p className="text-sm text-theme-muted truncate">{settings.phone}</p>
|
||||
<p className="text-sm text-theme-muted truncate">{settings.telefono}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -346,7 +392,7 @@ const Configuracion: FC = () => {
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={settings.website}
|
||||
href={settings.web}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
|
||||
@@ -356,7 +402,7 @@ const Configuracion: FC = () => {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-theme-heading">Sitio web</p>
|
||||
<p className="text-sm text-theme-muted truncate">{settings.website.replace(/^https?:\/\//, '')}</p>
|
||||
<p className="text-sm text-theme-muted truncate">{(settings.web || '').replace(/^https?:\/\//, '')}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -375,30 +421,78 @@ const Configuracion: FC = () => {
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Nombre de la clínica"
|
||||
value={settings.name}
|
||||
onChange={(e) => update('name', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Teléfono"
|
||||
value={settings.phone}
|
||||
onChange={(e) => update('phone', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={settings.email}
|
||||
onChange={(e) => update('email', e.target.value)}
|
||||
value={settings.nombre}
|
||||
onChange={(e) => update('nombre', e.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Teléfono"
|
||||
value={settings.telefono}
|
||||
onChange={(e) => update('telefono', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="RFC"
|
||||
value={settings.rfc}
|
||||
onChange={(e) => update('rfc', e.target.value)}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={settings.email}
|
||||
onChange={(e) => update('email', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Sitio web"
|
||||
value={settings.web}
|
||||
onChange={(e) => update('web', e.target.value)}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Dirección"
|
||||
value={settings.address}
|
||||
onChange={(e) => update('address', e.target.value)}
|
||||
value={settings.direccion}
|
||||
onChange={(e) => update('direccion', e.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Horario de apertura"
|
||||
type="time"
|
||||
value={settings.horario_apertura}
|
||||
onChange={(e) => update('horario_apertura', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Horario de cierre"
|
||||
type="time"
|
||||
value={settings.horario_cierre}
|
||||
onChange={(e) => update('horario_cierre', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Zona horaria"
|
||||
value={settings.zona_horaria}
|
||||
onChange={(e) => update('zona_horaria', e.target.value)}
|
||||
placeholder="America/Tijuana"
|
||||
/>
|
||||
<Input
|
||||
label="URL pública"
|
||||
value={settings.url_publica}
|
||||
onChange={(e) => update('url_publica', e.target.value)}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Sitio web"
|
||||
value={settings.website}
|
||||
onChange={(e) => update('website', e.target.value)}
|
||||
label="Nota del ticket"
|
||||
value={settings.nota_ticket}
|
||||
onChange={(e) => update('nota_ticket', e.target.value)}
|
||||
placeholder="Se imprime al pie del ticket"
|
||||
/>
|
||||
<p className="text-xs text-theme-muted">
|
||||
Moneda base: <span className="font-medium text-theme-heading">{settings.moneda_base || 'MXN'}</span>
|
||||
</p>
|
||||
<div className="pt-2">
|
||||
<Button onClick={save}>
|
||||
<Save size={16} className="mr-2" />
|
||||
@@ -445,7 +539,53 @@ const Configuracion: FC = () => {
|
||||
onChange={(e) => setBrandForm((f) => ({ ...f, logo_oscuro: e.target.value }))}
|
||||
placeholder={BRAND_DEFAULT.logo_oscuro}
|
||||
/>
|
||||
<Input
|
||||
label="URL pública de la instancia"
|
||||
value={brandForm.url_publica}
|
||||
onChange={(e) => setBrandForm((f) => ({ ...f, url_publica: e.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Colores de marca: vacíos = se conserva el color del tema activo. */}
|
||||
<div className="mt-5">
|
||||
<p className="text-sm font-medium text-theme-heading mb-1 flex items-center">
|
||||
<Palette size={16} className="mr-2" /> Colores de marca
|
||||
</p>
|
||||
<p className="text-xs text-theme-muted mb-3">
|
||||
Si se definen, pisan el color del tema en los tres temas. Dejarlos vacíos conserva el color original.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{([
|
||||
{ key: 'primario' as const, label: 'Color primario' },
|
||||
{ key: 'acento' as const, label: 'Color de acento' },
|
||||
]).map(({ key, label }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-xs font-medium text-theme-muted mb-1.5">{label}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={brandForm[key] || (key === 'primario' ? '#eda588' : '#c7bcdd')}
|
||||
onChange={(e) => setBrandForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||
className="h-10 w-12 shrink-0 rounded-lg border border-theme-border-strong bg-theme-surface cursor-pointer"
|
||||
aria-label={label}
|
||||
/>
|
||||
<Input
|
||||
value={brandForm[key]}
|
||||
onChange={(e) => setBrandForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||
placeholder="Vacío = color del tema"
|
||||
/>
|
||||
{brandForm[key] && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setBrandForm((f) => ({ ...f, [key]: '' }))} title="Usar el color del tema">
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
{(brandForm.logo_claro || brandForm.logo_oscuro) && (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -51,8 +51,9 @@ import { odooApi, type DashboardStats, type Appointment, type Service, type Char
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
import { useTheme } from '../lib/theme';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
import { waLink } from '../lib/whatsapp';
|
||||
import { APT_STATE_LABELS } from './Agenda';
|
||||
import { aptStateLabel } from './Agenda';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
@@ -161,6 +162,7 @@ const TodayTile: FC<TodayTileProps> = ({ label, icon, onClick, children, classNa
|
||||
const Dashboard: FC = () => {
|
||||
const { theme } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const navigate = useNavigate();
|
||||
const isHomeNest = theme === 'homenest';
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
@@ -436,7 +438,7 @@ const Dashboard: FC = () => {
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 shrink-0">
|
||||
<Button variant="primary" size="md" onClick={() => navigate('/agenda')}>
|
||||
<Calendar size={16} className="mr-2" /> Nueva cita
|
||||
<Calendar size={16} className="mr-2" /> Nueva {term('appointment_singular').toLowerCase()}
|
||||
</Button>
|
||||
<Button variant="outline" size="md" onClick={() => navigate('/reportes')}>
|
||||
<TrendingUp size={16} className="mr-2" /> Ver reportes
|
||||
@@ -706,7 +708,7 @@ const Dashboard: FC = () => {
|
||||
<p className="text-sm font-medium text-theme-heading truncate">{apt.patient}</p>
|
||||
<p className="text-xs text-theme-muted truncate">{apt.service} • {apt.time}</p>
|
||||
</div>
|
||||
<Badge variant={badgeForAppointmentState(apt.state)}>{APT_STATE_LABELS[apt.state] || apt.state}</Badge>
|
||||
<Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term)}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Mail, Phone, Stethoscope, Briefcase, Save, Percent, Eye, CalendarDays,
|
||||
import Layout from '../components/Layout';
|
||||
import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, Modal, Select, toast } from '../components/ui';
|
||||
import { odooApi, type Doctor, type DoctorHistory, type HorarioDia } from '../services/odoo';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
|
||||
const DIAS = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'];
|
||||
|
||||
@@ -130,6 +131,7 @@ const StatBadges: FC<{ d: Doctor }> = ({ d }) => (
|
||||
);
|
||||
|
||||
const Medicos: FC = () => {
|
||||
const { term } = useGlosario();
|
||||
const [employees, setEmployees] = useState<Doctor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -326,8 +328,8 @@ const Medicos: FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Médicos" subtitle="Equipo médico">
|
||||
<PageHeader title="Médicos" subtitle="Especialistas de la clínica">
|
||||
<Layout title={term('doctor_plural')} subtitle="Equipo médico">
|
||||
<PageHeader title={term('doctor_plural')} subtitle="Especialistas de la clínica">
|
||||
{countBaja > 0 && (
|
||||
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
|
||||
<input
|
||||
|
||||
@@ -32,6 +32,8 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import { odooApi, type Patient, type Appointment, type Sale, type Visita, type Doctor, type EstadoCuenta, type PatientWallet, type ReceiptData } from '../services/odoo';
|
||||
import { exportToExcel } from '../lib/exporter';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
import { useSucursales } from '../lib/sucursales';
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||
@@ -100,7 +102,6 @@ const visitaStateLabels: Record<string, string> = {
|
||||
const paymentStateLabels: Record<string, string> = {
|
||||
not_paid: 'No pagado', partial: 'Pago parcial', paid: 'Pagado',
|
||||
};
|
||||
const branchLabels: Record<string, string> = { rosarito: 'Rosarito', tijuana: 'Tijuana' };
|
||||
const mediumLabels: Record<string, string> = { onsite: 'Presencial', videocall: 'Videollamada' };
|
||||
|
||||
const completionBadgeClasses = (pct?: number) => {
|
||||
@@ -122,6 +123,8 @@ const CRITICAL_ALERTS: { key: keyof Patient; label: string }[] = [
|
||||
|
||||
const Pacientes: FC = () => {
|
||||
const { brand } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const { labelFor: branchLabel } = useSucursales();
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -429,7 +432,7 @@ const Pacientes: FC = () => {
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Teléfono</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Edad</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Última visita</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Visitas</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">{term('visit_plural')}</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Puntos</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
|
||||
</tr>
|
||||
@@ -520,15 +523,15 @@ const Pacientes: FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Pacientes" subtitle="Directorio de pacientes">
|
||||
<PageHeader title="Pacientes" subtitle="Busca, crea y gestiona pacientes">
|
||||
<Layout title={term('patient_plural')} subtitle="Directorio de pacientes">
|
||||
<PageHeader title={term('patient_plural')} subtitle="Busca, crea y gestiona pacientes">
|
||||
<Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Exportar CSV
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo paciente
|
||||
Nuevo {term('patient_singular').toLowerCase()}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
@@ -611,7 +614,7 @@ const Pacientes: FC = () => {
|
||||
<EmptyState
|
||||
title="Sin pacientes"
|
||||
subtitle="No se encontraron pacientes."
|
||||
actionLabel="Nuevo paciente"
|
||||
actionLabel={`Nuevo ${term('patient_singular').toLowerCase()}`}
|
||||
onAction={openCreate}
|
||||
/>
|
||||
) : (
|
||||
@@ -717,7 +720,7 @@ const Pacientes: FC = () => {
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={editing ? 'Editar paciente' : 'Nuevo paciente'}
|
||||
title={editing ? `Editar ${term('patient_singular').toLowerCase()}` : `Nuevo ${term('patient_singular').toLowerCase()}`}
|
||||
maxWidth="2xl"
|
||||
footer={
|
||||
<>
|
||||
@@ -1263,12 +1266,12 @@ const Pacientes: FC = () => {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<InfoRow label="Referencia" value={entryDetail.item.reference} icon={FileText} />
|
||||
<InfoRow label="Fecha y hora" value={`${entryDetail.item.date} · ${entryDetail.item.time}`} icon={Calendar} />
|
||||
<InfoRow label="Servicio" value={entryDetail.item.service} icon={Stethoscope} />
|
||||
<InfoRow label="Médico" value={entryDetail.item.doctor || 'Sin asignar'} icon={User} />
|
||||
<InfoRow label={term('service_singular')} value={entryDetail.item.service} icon={Stethoscope} />
|
||||
<InfoRow label={term('doctor_singular')} value={entryDetail.item.doctor || 'Sin asignar'} icon={User} />
|
||||
<InfoRow label="Duración" value={entryDetail.item.duration ? `${entryDetail.item.duration} min` : '-'} />
|
||||
<InfoRow label="Precio" value={`$${entryDetail.item.price}`} />
|
||||
<InfoRow label="Monto pagado" value={`$${entryDetail.item.amount_paid ?? 0}`} />
|
||||
<InfoRow label="Sucursal" value={branchLabels[entryDetail.item.branch || ''] || entryDetail.item.branch || '-'} icon={MapPin} />
|
||||
<InfoRow label="Sucursal" value={branchLabel(entryDetail.item.branch) || '-'} icon={MapPin} />
|
||||
<InfoRow label="Medio" value={mediumLabels[entryDetail.item.medium || ''] || entryDetail.item.medium || '-'} />
|
||||
{entryDetail.item.package_finished && (
|
||||
<InfoRow label="Paquete terminado" value={entryDetail.item.package_finished_date || 'Sí'} />
|
||||
@@ -1351,8 +1354,8 @@ const Pacientes: FC = () => {
|
||||
<InfoRow label="Folio" value={entryDetail.item.name} icon={FileText} />
|
||||
<InfoRow label="Inicio" value={entryDetail.item.date_start || '-'} icon={Calendar} />
|
||||
<InfoRow label="Fin" value={entryDetail.item.date_end || '-'} icon={Calendar} />
|
||||
<InfoRow label="Servicio" value={entryDetail.item.servicio || '-'} icon={Stethoscope} />
|
||||
<InfoRow label="Médico" value={entryDetail.item.doctor || 'Sin asignar'} icon={User} />
|
||||
<InfoRow label={term('service_singular')} value={entryDetail.item.servicio || '-'} icon={Stethoscope} />
|
||||
<InfoRow label={term('doctor_singular')} value={entryDetail.item.doctor || 'Sin asignar'} icon={User} />
|
||||
<InfoRow label="Cosmetóloga" value={entryDetail.item.cosmetologa || 'Sin asignar'} icon={User} />
|
||||
</div>
|
||||
{[
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { odooApi } from '../services/odoo';
|
||||
import type { Paquete, PaqueteCompra, PaqueteConsumo, Service } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||
@@ -51,6 +52,7 @@ const emptyForm: FormPaquete = {
|
||||
};
|
||||
|
||||
const Paquetes: FC = () => {
|
||||
const { term } = useGlosario();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = searchParams.get('tab') === 'saldos' ? 'saldos' : 'catalogo';
|
||||
const setTab = (key: string) =>
|
||||
@@ -327,9 +329,9 @@ const Paquetes: FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Paquetes" subtitle="Paquetes de sesiones">
|
||||
<Layout title={term('package_plural')} subtitle="Paquetes de sesiones">
|
||||
<PageHeader
|
||||
title="Paquetes de sesiones"
|
||||
title={`${term('package_plural')} de sesiones`}
|
||||
subtitle="Combos de sesiones a precio cerrado y el saldo que lleva cada paciente"
|
||||
>
|
||||
<Button variant="outline" onClick={() => (tab === 'catalogo' ? loadCatalogo() : loadSaldos())}>
|
||||
@@ -337,7 +339,7 @@ const Paquetes: FC = () => {
|
||||
</Button>
|
||||
{tab === 'catalogo' && esAdmin && (
|
||||
<Button onClick={abrirNuevo}>
|
||||
<Plus size={16} className="mr-2" /> Nuevo paquete
|
||||
<Plus size={16} className="mr-2" /> Nuevo {term('package_singular').toLowerCase()}
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
@@ -607,7 +609,7 @@ const Paquetes: FC = () => {
|
||||
<Modal
|
||||
isOpen={formOpen}
|
||||
onClose={() => setFormOpen(false)}
|
||||
title={form.id ? 'Editar paquete' : 'Nuevo paquete de sesiones'}
|
||||
title={form.id ? `Editar ${term('package_singular').toLowerCase()}` : `Nuevo ${term('package_singular').toLowerCase()} de sesiones`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Star, Wallet, Edit2, Plus, Clock, Tag, Package, Search, Copy, ChevronUp, ChevronDown, ArrowUpToLine } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
@@ -16,23 +16,8 @@ import {
|
||||
} from '../components/ui';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { odooApi, type Service } from '../services/odoo';
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: '', label: 'Todas las categorías' },
|
||||
{ value: 'consulta', label: 'Consulta' },
|
||||
{ value: 'tratamiento', label: 'Tratamiento' },
|
||||
{ value: 'procedimiento', label: 'Procedimiento' },
|
||||
{ value: 'paquete', label: 'Paquete' },
|
||||
];
|
||||
|
||||
const categoryFormOptions = categoryOptions.filter((o) => o.value !== '');
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
consulta: 'Consulta',
|
||||
tratamiento: 'Tratamiento',
|
||||
procedimiento: 'Procedimiento',
|
||||
paquete: 'Paquete',
|
||||
};
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
import { useServiceCategories } from '../lib/service-categories';
|
||||
|
||||
interface ServiceForm {
|
||||
name: string;
|
||||
@@ -63,6 +48,16 @@ const emptyForm: ServiceForm = {
|
||||
};
|
||||
|
||||
const Servicios: FC = () => {
|
||||
const { term } = useGlosario();
|
||||
const { categories, labelFor } = useServiceCategories();
|
||||
const categoryOptions = useMemo(
|
||||
() => [{ value: '', label: 'Todas las categorías' }, ...categories.map((c) => ({ value: c.code, label: c.name }))],
|
||||
[categories],
|
||||
);
|
||||
const categoryFormOptions = useMemo(
|
||||
() => categories.map((c) => ({ value: c.code, label: c.name })),
|
||||
[categories],
|
||||
);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -289,11 +284,11 @@ const Servicios: FC = () => {
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
return (
|
||||
<Layout title="Servicios" subtitle="Catálogo de tratamientos">
|
||||
<PageHeader title="Servicios" subtitle="Gestiona precios, paquetes y favoritos">
|
||||
<Layout title={term('service_plural')} subtitle="Catálogo de tratamientos">
|
||||
<PageHeader title={term('service_plural')} subtitle="Gestiona precios, paquetes y favoritos">
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo servicio
|
||||
Nuevo {term('service_singular').toLowerCase()}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
@@ -325,7 +320,7 @@ const Servicios: FC = () => {
|
||||
<EmptyState
|
||||
title="Sin servicios"
|
||||
subtitle={search || categoryFilter ? 'No hay servicios que coincidan con los filtros.' : 'No hay servicios registrados.'}
|
||||
actionLabel="Nuevo servicio"
|
||||
actionLabel={`Nuevo ${term('service_singular').toLowerCase()}`}
|
||||
onAction={openCreate}
|
||||
/>
|
||||
) : (
|
||||
@@ -371,7 +366,7 @@ const Servicios: FC = () => {
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-theme-muted mb-4">
|
||||
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
|
||||
<Tag size={12} className="mr-1" />
|
||||
{categoryLabels[s.category] || s.category || 'Sin categoría'}
|
||||
{labelFor(s.category) || 'Sin categoría'}
|
||||
</span>
|
||||
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
|
||||
<Clock size={12} className="mr-1" />
|
||||
@@ -468,7 +463,7 @@ const Servicios: FC = () => {
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => { setModalOpen(false); setEditing(null); setDuplicando(false); }}
|
||||
title={editing ? `Editar: ${editing.name}` : duplicando ? 'Duplicar servicio' : 'Nuevo servicio'}
|
||||
title={editing ? `Editar: ${editing.name}` : duplicando ? `Duplicar ${term('service_singular').toLowerCase()}` : `Nuevo ${term('service_singular').toLowerCase()}`}
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
@@ -484,7 +479,7 @@ const Servicios: FC = () => {
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => { setModalOpen(false); setEditing(null); setDuplicando(false); }}>Cancelar</Button>
|
||||
<Button onClick={save} loading={submitting}>{editing ? 'Guardar cambios' : duplicando ? 'Crear copia' : 'Crear servicio'}</Button>
|
||||
<Button onClick={save} loading={submitting}>{editing ? 'Guardar cambios' : duplicando ? 'Crear copia' : `Crear ${term('service_singular').toLowerCase()}`}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { odooApi, type Visita, type VisitaAdjunto, type Doctor, type InventoryItem } from '../services/odoo';
|
||||
import type { BadgeVariant } from '../components/ui/Badge';
|
||||
import { todayLocal } from '../lib/utils';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
|
||||
const visitaStateLabels: Record<string, string> = {
|
||||
en_curso: 'En curso',
|
||||
@@ -48,6 +49,7 @@ const MAX_ADJUNTO_BYTES = 10 * 1024 * 1024;
|
||||
const hoy = () => todayLocal();
|
||||
|
||||
const Visitas: FC = () => {
|
||||
const { term } = useGlosario();
|
||||
const [visitas, setVisitas] = useState<Visita[]>([]);
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -294,8 +296,8 @@ const Visitas: FC = () => {
|
||||
const hora = (v: Visita) => (v.date_start ? v.date_start.split(' ')[1] || '' : '-');
|
||||
|
||||
return (
|
||||
<Layout title="Visitas" subtitle="Registro de visitas clínicas">
|
||||
<PageHeader title="Visitas" subtitle="Consulta y completa las visitas clínicas del día" />
|
||||
<Layout title={term('visit_plural')} subtitle="Registro de visitas clínicas">
|
||||
<PageHeader title={term('visit_plural')} subtitle="Consulta y completa las visitas clínicas del día" />
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
@@ -325,9 +327,9 @@ const Visitas: FC = () => {
|
||||
<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 md:table-cell">Servicio</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Médico</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">{term('patient_singular')}</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">{term('service_singular')}</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">{term('doctor_singular')}</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden lg:table-cell">Motivo</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user