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:
2026-10-11 05:50:18 +00:00
parent 28118ce732
commit f92018f88e
50 changed files with 4314 additions and 210 deletions

224
docs/nueva-instancia.md Normal file
View File

@@ -0,0 +1,224 @@
# Nueva instancia white-label (provisioning automatizado)
Cómo levantar una clínica/spa nueva sobre el mismo código y la misma VPS, con
sus propios secretos, puerto, base de datos, vhost, marca y configuración. Es
la versión automatizada de `docs/segunda-instancia.md` (que sigue vigente como
referencia del procedimiento manual y del caso `clinica2`).
El código de los addons es compartido y read-only (`/root/skeen-odoo/addons`);
cada instancia es solo infra + base de datos + configuración. La marca no vive
en el código: sale del parámetro `skeen.clinic.brand` (API `/brand`).
## Requisitos previos
- VPS con Odoo, PostgreSQL, nginx, Node y el venv `/root/odoo-venv` ya
instalados (ver `instalacion.md`).
- Al menos una instancia de referencia (`skeen_odoo`) con los módulos `skeen_*`
instalados: de ahí se copia la lista de módulos a inicializar.
- Un slug, nombre comercial, dominio y puerto libres para la instancia nueva.
## Uso en 2 pasos
```bash
cd /root/skeen-proyecto/infra/provision
# Paso 1 — SIEMPRE simular primero. No escribe ni ejecuta nada.
./provision_clinica.py \
--slug spa-bella --nombre "Spa Bella" \
--dominio spa.consultoria-as.com --puerto 8071 --db spa_bella_odoo \
--pack spa --demo --dry-run
# Paso 2 — ejecución real (coordinada, fuera de horario pico).
./provision_clinica.py \
--slug spa-bella --nombre "Spa Bella" \
--dominio spa.consultoria-as.com --puerto 8071 --db spa_bella_odoo \
--pack spa --demo
```
## Opciones
| Opción | Default | Descripción |
|---|---|---|
| `--slug` | — | Identificador corto, `^[a-z0-9-]+$`. Nombra conf, unit, vhost y frontend. |
| `--nombre` | — | Nombre comercial (marca, empresa, menú raíz). |
| `--dominio` | — | Dominio público, p. ej. `spa.consultoria-as.com`. |
| `--puerto` | — | Puerto HTTP de Odoo; longpolling usa `puerto+3`. |
| `--db` | `<slug>_odoo` | Nombre de la base de datos. |
| `--db-role` | `<slug>` | Rol PostgreSQL. Por defecto se crea uno propio con password nueva; `--db-role skeen` usa el rol compartido (ver abajo). |
| `--pack` | `generico` | Giro para glosario y demo: `generico`, `spa`, `clinica`, `derma`. |
| `--demo` | off | Siembra servicios, clientes, empleados, citas, paquetes y monedero de ejemplo. |
| `--demo-limpia` | off | Corre `demo_limpiar.py` al final (deja la instancia sin datos demo). |
| `--dominio-lan` | — | Puerto LAN extra en el vhost (p. ej. `8881` → `http://<ip>:8881`). |
| `--admin-login` | `admin` | Login del usuario admin del frontend. |
| `--pie` | genérico | Línea del pie de página de la marca. |
| `--salida` | `/root/backups/<slug>-credenciales-<fecha>.txt` | Archivo de credenciales (chmod 600). |
| `--sin-wacrm` / `--con-wacrm` | bloquear | Por defecto el vhost corta `/api/odoo/skeen/frontend/v1/wacrm/` con 403. |
| `--skip-build` | off | No recompilar el frontend; reusar `/root/skeen-frontend/dist`. |
| `--force` | off | Continuar aunque existan colisiones (puerto/DB/unit/vhost/dirs). |
| `--dry-run` | off | Imprime pasos, contenido de archivos y comandos sin ejecutar nada. |
## Qué crea
Con `--slug spa-bella --puerto 8071 --db spa_bella_odoo`:
| Recurso | Valor |
|---|---|
| Rol PostgreSQL | `spa_bella` (LOGIN, password aleatoria) |
| Base de datos | `spa_bella_odoo` (owner = rol propio) |
| Conf Odoo | `/root/spa-bella-odoo.conf` (chmod 600) |
| data_dir | `/var/lib/odoo-spa-bella` |
| Log | `/tmp/odoo-spa-bella.log` |
| Unit systemd | `odoo-spa-bella.service` |
| Vhost nginx | `/etc/nginx/sites-available/spa-bella` + symlink en `sites-enabled` |
| Frontend | `/var/www/spa-bella-frontend` |
| Credenciales | `/root/backups/spa-bella-credenciales-<fecha>.txt` |
## Orden de ejecución del script
1. **Validaciones**: slug/DB/puerto libres, sin unit/vhost/dir/conf previos.
Aborta listando colisiones salvo `--force`.
2. **Secretos propios**: `admin_passwd` (master de Odoo), `db_password` y
password del admin del frontend, todos aleatorios.
3. **DB**: crea el rol y `su postgres -c "createdb -O <rol> <db>"` (el rol
`skeen` no tiene CREATEDB; se crea como `postgres`).
4. **`odoo.conf`** desde plantilla: puerto, db, addons_path, log propio,
`without_demo = all`, sin workers.
5. **Unit systemd** desde plantilla + `daemon-reload` + `enable`.
6. **Módulos**: corre `odoo-bin ... --stop-after-init --no-http -i <módulos>`.
7. **Post-instalación** (`postinstall_clinica.py`): marca, empresa, admin,
y siembra tolerante de glosario/sucursal/settings (abajo).
8. **Menú raíz**: actualiza el xmlid `skeen_pacientes.menu_skeen_root` a
`{"en_US": "<nombre>"}` (igual que `clinica2_odoo`).
9. **Vhost nginx** + `nginx -t` + `reload`.
10. **Frontend**: build compartido + `rsync --delete` + logos + `chown`.
11. **Arranque**: `systemctl start`.
12. **Credenciales** y resumen (público, LAN, logs, cómo limpiar).
### Lista de módulos a inicializar
Se toma de la instancia de referencia en vez de hardcodearla:
```bash
su postgres -c "psql -d skeen_odoo -Atc \
\"SELECT name FROM ir_module_module WHERE state='installed' AND name LIKE 'skeen%'\""
```
El script consulta eso y ordena los módulos topológicamente con
`ir_module_module_dependency` (dependencias primero). Odoo resuelve igual las
dependencias, pero el orden explícito evita la trampa documentada en
`segunda-instancia.md` (el menú raíz debe existir antes de que las vistas que
lo referencian se carguen).
## Post-instalación y el nuevo sistema de configuración
`postinstall_clinica.py` corre con el ORM de Odoo y hace:
- Brand neutro en `skeen.clinic.brand` (nombre comercial, logos
`/brand/logo-blanco.svg` y `/brand/logo-negro.svg`, pie genérico).
- `res.company.name` = nombre.
- Admin del frontend (`skeen.frontend.user`) con la password generada y
`must_change_password = True`.
- Oculta WACRM en `skeen.menu_roles` (salvo `--con-wacrm`).
Y **solo si existen en runtime** siembra los parámetros/modelos del nuevo
sistema de configuración (otro cambio en curso):
| Qué | Cómo lo detecta | Qué siembra |
|---|---|---|
| Glosario | llave `ir.config_parameter` `skeen.glosario` **o** modelo `skeen.glosario` | `pack` elegido |
| `skeen.clinic.settings` | el modelo existe | nombre, `url_publica = https://<dominio>`, horario 08:00–19:00, moneda MXN |
| `skeen.sucursal` | el modelo existe | 1 sucursal default con el nombre del negocio |
| `skeen.service.category` | el modelo existe | semilla de categorías del pack |
Lo que no exista se reporta como `TODO` en la salida y **no aborta** el
provisioning. Cuando ese cambio esté desplegado, una corrida nueva ya lo
siembra; para una instancia ya creada, se puede re-ejecutar solo el
post-install:
```bash
cat > /tmp/post.json <<'JSON'
{"conf": "/root/spa-bella-odoo.conf", "db": "spa_bella_odoo",
"nombre": "Spa Bella", "dominio": "spa.consultoria-as.com",
"pack": "spa", "admin_login": "admin", "admin_password": "<la-generada>",
"con_wacrm": false, "demo": false}
JSON
/root/odoo-venv/bin/python /root/skeen-proyecto/infra/provision/postinstall_clinica.py /tmp/post.json
```
## Datos demo
`--demo` siembra ~10 servicios con precios, ~15 clientes, 2–3 empleados con
`staff_type`, ~20 citas en la semana actual y algunos paquetes/monedero, todo
coherente con el `--pack`. Los registros quedan marcados (`DEMO-` en códigos de
servicio, `referred_by='DEMO'`, `[DEMO]` en notas) y sus ids en el parámetro
`skeen.demo.ids`.
Para borrarlos antes de entregar la instancia a un cliente:
```bash
/root/odoo-venv/bin/python /root/skeen-proyecto/infra/provision/demo_limpiar.py \
--conf /root/spa-bella-odoo.conf --db spa_bella_odoo
```
(o pasar `--demo-limpia` en la corrida de provisioning).
## Frontend
El build es **compartido**: un solo bundle (`npm run build` en
`/root/skeen-frontend`) se copia con `rsync -a --delete` a cada
`/var/www/<slug>-frontend`. La marca y el giro salen del API, no del build; por
eso no hace falta compilar por instancia (usar `--skip-build` cuando ya haya un
`dist/` fresco). Los logos genéricos se copian desde
`/var/www/clinica2-frontend/brand/` (o `frontend/public/brand/`) a
`<destino>/brand/`.
## Acceso público (paso manual)
Igual que con `clinica2`, el túnel cloudflared es remotamente administrado: hay
que agregar el ingress en el dashboard (Zero Trust → Networks → Tunnels):
```yaml
- hostname: spa.consultoria-as.com
service: http://localhost:80
```
El vhost ya escucha en el 80 con ese `server_name`. El `--dominio-lan` deja
además un acceso LAN permanente.
## Rol compartido vs rol propio (decisión de diseño)
El procedimiento manual creaba la DB con `-O skeen` y usaba
`db_password = skeen_dev_2026` para todas las instancias. Eso obliga a
reutilizar la misma password y no permite "secretos propios por instancia". Por
eso el script, por defecto, crea un **rol propio** (`<slug>`) con password
aleatoria y hace a ese rol owner de la DB. No toca el rol `skeen` ni ninguna
instancia existente.
Si se necesita reproducir el esquema compartido:
```bash
./provision_clinica.py ... --db-role skeen
```
En ese modo el script avisa que `db_password` en la conf debe coincidir con la
password real del rol (no la cambia).
## Fallos a medias y limpieza
Si algo falla, el script imprime **qué quedó hecho** y el comando para
deshacerlo (rol, DB, conf, unit, vhost, frontend, service). Opciones:
`--force` para saltar colisiones, o limpiar a mano con la lista de undo que
imprime.
## Verificación tras una corrida real
```bash
systemctl status odoo-<slug> # active (running)
tail -50 /tmp/odoo-<slug>.log
curl -s http://127.0.0.1:<puerto>/web/health
curl -s http://127.0.0.1:<puerto>/skeen/frontend/v1/brand # nombre comercial
nginx -t
```
Y en el navegador: login con el admin del frontend (fuerza cambio de password),
verificar el nombre en el menú, el logo y que WACRM esté bloqueado (403).

View File

@@ -4,6 +4,9 @@ import { MENU_ITEMS } from './lib/menu-items';
import { ToastProvider, Button } from './components/ui'; import { ToastProvider, Button } from './components/ui';
import ErrorBoundary from './components/ErrorBoundary'; import ErrorBoundary from './components/ErrorBoundary';
import { AuthProvider, useAuth } from './lib/auth'; import { AuthProvider, useAuth } from './lib/auth';
import { GlosarioProvider } from './lib/glosario';
import { SucursalesProvider } from './lib/sucursales';
import { ServiceCategoriesProvider } from './lib/service-categories';
import { QuickLinksProvider } from './lib/quicklinks'; import { QuickLinksProvider } from './lib/quicklinks';
import { PatientDrawerProvider } from './components/PatientDrawer'; import { PatientDrawerProvider } from './components/PatientDrawer';
import ChatNotifications from './lib/chat-notifications'; import ChatNotifications from './lib/chat-notifications';
@@ -122,6 +125,9 @@ const App: FC = () => {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<AuthProvider> <AuthProvider>
<GlosarioProvider>
<SucursalesProvider>
<ServiceCategoriesProvider>
<QuickLinksProvider> <QuickLinksProvider>
<Router> <Router>
<ToastProvider /> <ToastProvider />
@@ -160,6 +166,9 @@ const App: FC = () => {
</PatientDrawerProvider> </PatientDrawerProvider>
</Router> </Router>
</QuickLinksProvider> </QuickLinksProvider>
</ServiceCategoriesProvider>
</SucursalesProvider>
</GlosarioProvider>
</AuthProvider> </AuthProvider>
</ErrorBoundary> </ErrorBoundary>
); );

View File

@@ -0,0 +1,221 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Plus, Pencil, Power, Lock, Tag } from 'lucide-react';
import { Button, Input, Modal, EmptyState, Skeleton, toast } from './ui';
import { odooApi, type ServiceCategory } from '../services/odoo';
import { useServiceCategories } from '../lib/service-categories';
interface FormState { name: string; code: string; sequence: string; }
const emptyForm: FormState = { name: '', code: '', sequence: '10' };
/** Configuración › Categorías de servicios: CRUD de categorías del catálogo. */
const CategoriasPanel: FC = () => {
const { refresh } = useServiceCategories();
const [categories, setCategories] = useState<ServiceCategory[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<ServiceCategory | null>(null);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState<FormState>(emptyForm);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getServiceCategories({ includeInactive: true });
if (res.status === 'success') setCategories(res.categories);
} catch (err) {
toast.error('Error al cargar categorías');
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setEditing(null);
setForm(emptyForm);
setModalOpen(true);
};
const openEdit = (c: ServiceCategory) => {
setEditing(c);
setForm({ name: c.name, code: c.code, sequence: String(c.sequence) });
setModalOpen(true);
};
const submit = async () => {
if (!form.name.trim()) {
toast.error('El nombre es obligatorio');
return;
}
const payload = {
name: form.name.trim(),
code: form.code.trim() || undefined,
sequence: parseInt(form.sequence, 10) || 10,
};
try {
setSubmitting(true);
if (editing) {
await odooApi.updateServiceCategory(editing.id, payload);
toast.success('Categoría actualizada');
} else {
await odooApi.createServiceCategory(payload);
toast.success('Categoría creada');
}
setModalOpen(false);
await load();
refresh();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo guardar la categoría');
console.error(err);
} finally {
setSubmitting(false);
}
};
const toggleActiva = async (c: ServiceCategory) => {
if (c.active) {
if (c.is_system) {
toast.error('Las categorías del sistema no se pueden eliminar, solo renombrar.');
return;
}
if (!window.confirm(`¿Desactivar la categoría "${c.name}"?`)) return;
try {
const res = await odooApi.deleteServiceCategory(c.id);
toast.success(res.message || 'Categoría desactivada');
await load();
refresh();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo desactivar la categoría');
}
} else {
try {
await odooApi.updateServiceCategory(c.id, { active: true });
toast.success('Categoría activada');
await load();
refresh();
} catch {
toast.error('No se pudo activar la categoría');
}
}
};
return (
<>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
<div>
<h3 className="font-heading text-xl text-theme-heading">Categorías de servicios</h3>
<p className="text-sm text-theme-muted">
Ordenan y filtran el catálogo de servicios. Las del sistema se pueden renombrar, pero no eliminar.
</p>
</div>
<Button onClick={openCreate} className="sm:ml-auto">
<Plus size={16} className="mr-2" />
Nueva categoría
</Button>
</div>
{loading ? (
<Skeleton count={4} className="h-12 w-full" />
) : categories.length === 0 ? (
<EmptyState
title="Sin categorías"
subtitle="Agrega la primera categoría de servicios."
actionLabel="Nueva categoría"
onAction={openCreate}
icon={<Tag size={28} />}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Código</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Orden</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 w-32">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{categories.map((c) => (
<tr key={c.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm font-medium text-theme-heading">
<span className="inline-flex items-center gap-2">
{c.name}
{c.is_system && (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-theme-muted" title="Categoría del sistema">
<Lock size={11} /> Sistema
</span>
)}
</span>
</td>
<td className="p-3 text-sm text-theme-muted">{c.code}</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{c.sequence}</td>
<td className="p-3 text-sm">
<span className={c.active ? 'text-emerald-600 font-medium' : 'text-theme-muted'}>
{c.active ? 'Activa' : 'Inactiva'}
</span>
</td>
<td className="p-3">
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(c)} title="Editar">
<Pencil size={16} className="text-theme-muted" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => toggleActiva(c)}
title={c.active ? (c.is_system ? 'Del sistema: no se elimina' : 'Desactivar') : 'Activar'}
>
<Power size={16} className={c.active ? (c.is_system ? 'text-theme-muted opacity-40' : 'text-rose-500') : 'text-emerald-600'} />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Editar categoría' : 'Nueva categoría'}
maxWidth="md"
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button onClick={submit} loading={submitting}>{editing ? 'Guardar cambios' : 'Crear categoría'}</Button>
</>
}
>
<div className="space-y-4">
<Input label="Nombre *" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Código"
value={form.code}
onChange={(e) => setForm({ ...form, code: e.target.value })}
placeholder="Se genera del nombre si se deja vacío"
/>
<Input
label="Orden"
type="number"
value={form.sequence}
onChange={(e) => setForm({ ...form, sequence: e.target.value })}
/>
</div>
</div>
</Modal>
</>
);
};
export default CategoriasPanel;

View File

@@ -148,7 +148,7 @@ const menuLeaves = menuTree.flatMap((e) =>
export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => { export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, canSee, hasRole, logout } = useAuth(); const { user, canSee, hasRole, logout, brand } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread(); const chatUnread = useChatUnread();
@@ -515,8 +515,8 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
style={{ backgroundColor: 'var(--header-bg)' }} style={{ backgroundColor: 'var(--header-bg)' }}
> >
<div className="hidden lg:flex w-[230px] shrink-0 items-center px-5"> <div className="hidden lg:flex w-[230px] shrink-0 items-center px-5">
<span className="text-[26px] font-bold tracking-tight" style={{ color: '#333333' }}> <span className="text-[26px] font-bold tracking-tight truncate" style={{ color: '#333333' }}>
SKEEN<sup className="text-xs font-semibold">®</sup> {brand.nombre}
</span> </span>
</div> </div>
<div className="flex-1 flex items-center justify-between px-3 sm:px-4 min-w-0"> <div className="flex-1 flex items-center justify-between px-3 sm:px-4 min-w-0">
@@ -530,7 +530,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
> >
<Menu size={20} /> <Menu size={20} />
</Button> </Button>
<span className="lg:hidden text-lg font-bold truncate" style={{ color: '#333333' }}>SKEEN<sup className="text-[10px]">®</sup></span> <span className="lg:hidden text-lg font-bold truncate" style={{ color: '#333333' }}>{brand.nombre}</span>
</div> </div>
<div className="flex items-center space-x-2 sm:space-x-3 shrink-0"> <div className="flex items-center space-x-2 sm:space-x-3 shrink-0">

View File

@@ -43,6 +43,7 @@ import GlobalSearch from './GlobalSearch';
import ThemePicker from './ThemePicker'; import ThemePicker from './ThemePicker';
import NotificationBell from './NotificationBell'; import NotificationBell from './NotificationBell';
import { useAuth } from '../lib/auth'; import { useAuth } from '../lib/auth';
import { useGlosario, traducirMenu, traducirEtiqueta } from '../lib/glosario';
import { useChatUnread } from '../lib/chat'; import { useChatUnread } from '../lib/chat';
import { useQuickLinks } from '../lib/quicklinks'; import { useQuickLinks } from '../lib/quicklinks';
import { odooApi } from '../services/odoo'; import { odooApi } from '../services/odoo';
@@ -53,14 +54,14 @@ interface LayoutProps {
subtitle?: string; subtitle?: string;
} }
const topNavItems = [ const TOP_NAV_ITEMS = [
{ label: 'Dashboard', icon: LayoutDashboard, href: '/' }, { label: 'Dashboard', icon: LayoutDashboard, href: '/' },
{ label: 'Agenda', icon: Calendar, href: '/agenda' }, { label: 'Agenda', icon: Calendar, href: '/agenda' },
{ label: 'Pacientes', icon: Users, href: '/pacientes' }, { label: 'Pacientes', icon: Users, href: '/pacientes' },
{ label: 'Ventas', icon: ShoppingCart, href: '/ventas' }, { label: 'Ventas', icon: ShoppingCart, href: '/ventas' },
]; ];
const menuSections = [ const MENU_SECTIONS = [
{ {
label: 'Clínica', label: 'Clínica',
items: [ items: [
@@ -113,6 +114,12 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth(); const { user, canSee, hasRole, logout, brand } = useAuth();
const { term } = useGlosario();
const topNavItems = useMemo(
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })),
[term]
);
const menuSections = useMemo(() => traducirMenu(MENU_SECTIONS, term), [term]);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [expanded, setExpanded] = useState<string[]>(['Clínica']); const [expanded, setExpanded] = useState<string[]>(['Clínica']);
const chatUnread = useChatUnread(); const chatUnread = useChatUnread();
@@ -122,7 +129,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
menuSections menuSections
.map((s) => ({ ...s, items: s.items.filter((it) => canSee(it.href)) })) .map((s) => ({ ...s, items: s.items.filter((it) => canSee(it.href)) }))
.filter((s) => s.items.length > 0), .filter((s) => s.items.length > 0),
[canSee, user] [canSee, user, menuSections]
); );
// Accesos rápidos personalizables (localStorage por usuario) // Accesos rápidos personalizables (localStorage por usuario)
@@ -135,7 +142,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
seen.add(it.href); seen.add(it.href);
return true; return true;
}); });
}, [canSee]); }, [canSee, topNavItems, menuSections]);
const quickItems = useMemo( const quickItems = useMemo(
() => () =>
quick.links quick.links
@@ -148,7 +155,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
return null; return null;
}) })
.filter((it): it is (typeof menuSections)[number]['items'][number] => it !== null), .filter((it): it is (typeof menuSections)[number]['items'][number] => it !== null),
[quick.links, canSee] [quick.links, canSee, menuSections]
); );
const initials = (user?.name || 'US') const initials = (user?.name || 'US')

View File

@@ -4,13 +4,15 @@ import { Save, Info, Lock } from 'lucide-react';
import { Button, Skeleton, toast } from './ui'; import { Button, Skeleton, toast } from './ui';
import { odooApi, type FrontendRole } from '../services/odoo'; import { odooApi, type FrontendRole } from '../services/odoo';
import { ROLE_LABELS } from '../lib/auth'; import { ROLE_LABELS } from '../lib/auth';
import { MENU_ITEMS } from '../lib/menu-items'; import { MENU_ITEMS, menuItemLabel } from '../lib/menu-items';
import { useGlosario } from '../lib/glosario';
const ROLES = Object.keys(ROLE_LABELS) as FrontendRole[]; const ROLES = Object.keys(ROLE_LABELS) as FrontendRole[];
type Matrix = Record<string, FrontendRole[]>; type Matrix = Record<string, FrontendRole[]>;
const RolesPanel: FC = () => { const RolesPanel: FC = () => {
const { term } = useGlosario();
const [matrix, setMatrix] = useState<Matrix | null>(null); const [matrix, setMatrix] = useState<Matrix | null>(null);
const [saved, setSaved] = useState<Matrix | null>(null); const [saved, setSaved] = useState<Matrix | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -101,7 +103,7 @@ const RolesPanel: FC = () => {
{MENU_ITEMS.map((item) => ( {MENU_ITEMS.map((item) => (
<tr key={item.href} className="hover:bg-theme-bg"> <tr key={item.href} className="hover:bg-theme-bg">
<td className="px-3 py-2 font-medium text-theme-heading whitespace-nowrap sticky left-0 bg-theme-surface"> <td className="px-3 py-2 font-medium text-theme-heading whitespace-nowrap sticky left-0 bg-theme-surface">
{item.label} {menuItemLabel(item, term)}
</td> </td>
{ROLES.map((role) => { {ROLES.map((role) => {
const locked = item.href === '/configuracion' && role === 'admin'; const locked = item.href === '/configuracion' && role === 'admin';

View File

@@ -45,6 +45,7 @@ import GlobalSearch from './GlobalSearch';
import ThemePicker from './ThemePicker'; import ThemePicker from './ThemePicker';
import NotificationBell from './NotificationBell'; import NotificationBell from './NotificationBell';
import { useAuth, ROLE_LABELS } from '../lib/auth'; import { useAuth, ROLE_LABELS } from '../lib/auth';
import { useGlosario, traducirMenu } from '../lib/glosario';
import { useChatUnread } from '../lib/chat'; import { useChatUnread } from '../lib/chat';
import { useQuickLinks } from '../lib/quicklinks'; import { useQuickLinks } from '../lib/quicklinks';
import { odooApi } from '../services/odoo'; import { odooApi } from '../services/odoo';
@@ -121,6 +122,8 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth(); const { user, canSee, hasRole, logout, brand } = useAuth();
const { term } = useGlosario();
const menuGroupsT = useMemo(() => traducirMenu(menuGroups, term), [term]);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread(); const chatUnread = useChatUnread();
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas // Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas
@@ -139,31 +142,31 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
const visibleGroups = useMemo( const visibleGroups = useMemo(
() => () =>
menuGroups menuGroupsT
.map((g) => ({ ...g, items: g.items.filter((it) => canSee(it.href)) })) .map((g) => ({ ...g, items: g.items.filter((it) => canSee(it.href)) }))
.filter((g) => g.items.length > 0), .filter((g) => g.items.length > 0),
[canSee, user] [canSee, user, menuGroupsT]
); );
// Accesos rápidos personalizables (localStorage por usuario) // Accesos rápidos personalizables (localStorage por usuario)
const quick = useQuickLinks(); const quick = useQuickLinks();
const dockItems = useMemo( const dockItems = useMemo(
() => menuGroups.flatMap((g) => g.items).filter((it) => canSee(it.href)), () => menuGroupsT.flatMap((g) => g.items).filter((it) => canSee(it.href)),
[canSee] [canSee, menuGroupsT]
); );
const quickItems = useMemo( const quickItems = useMemo(
() => () =>
quick.links quick.links
.filter((href) => canSee(href)) .filter((href) => canSee(href))
.map((href) => { .map((href) => {
for (const g of menuGroups) { for (const g of menuGroupsT) {
const found = g.items.find((it) => it.href === href); const found = g.items.find((it) => it.href === href);
if (found) return found; if (found) return found;
} }
return null; return null;
}) })
.filter((it): it is (typeof menuGroups)[number]['items'][number] => it !== null), .filter((it): it is (typeof menuGroups)[number]['items'][number] => it !== null),
[quick.links, canSee] [quick.links, canSee, menuGroupsT]
); );
const initials = (user?.name || 'US') const initials = (user?.name || 'US')

View File

@@ -0,0 +1,251 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Plus, Pencil, Power, Star, MapPin } from 'lucide-react';
import { Button, Input, Modal, TextArea, EmptyState, Skeleton, toast } from './ui';
import { odooApi, type Sucursal } from '../services/odoo';
import { useSucursales } from '../lib/sucursales';
interface FormState {
name: string;
code: string;
sequence: string;
address: string;
phone: string;
notes: string;
is_default: boolean;
}
const emptyForm: FormState = { name: '', code: '', sequence: '10', address: '', phone: '', notes: '', is_default: false };
/** Configuración › Sucursales: alta, edición y activación de sucursales. */
const SucursalesPanel: FC = () => {
const { refresh } = useSucursales();
const [sucursales, setSucursales] = useState<Sucursal[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Sucursal | null>(null);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState<FormState>(emptyForm);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getSucursales({ includeInactive: true });
if (res.status === 'success') setSucursales(res.sucursales);
} catch (err) {
toast.error('Error al cargar sucursales');
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setEditing(null);
setForm(emptyForm);
setModalOpen(true);
};
const openEdit = (s: Sucursal) => {
setEditing(s);
setForm({
name: s.name,
code: s.code,
sequence: String(s.sequence),
address: s.address || '',
phone: s.phone || '',
notes: s.notes || '',
is_default: s.is_default,
});
setModalOpen(true);
};
const submit = async () => {
if (!form.name.trim()) {
toast.error('El nombre es obligatorio');
return;
}
const payload = {
name: form.name.trim(),
code: form.code.trim() || undefined,
sequence: parseInt(form.sequence, 10) || 10,
address: form.address,
phone: form.phone,
notes: form.notes,
is_default: form.is_default,
};
try {
setSubmitting(true);
if (editing) {
await odooApi.updateSucursal(editing.id, payload);
toast.success('Sucursal actualizada');
} else {
await odooApi.createSucursal(payload);
toast.success('Sucursal creada');
}
setModalOpen(false);
await load();
refresh();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo guardar la sucursal');
console.error(err);
} finally {
setSubmitting(false);
}
};
const toggleActiva = async (s: Sucursal) => {
if (s.active) {
if (!window.confirm(`¿Desactivar la sucursal "${s.name}"? Las citas ya registradas no se pierden.`)) return;
try {
const res = await odooApi.deleteSucursal(s.id);
toast.success(res.message || 'Sucursal desactivada');
await load();
refresh();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo desactivar la sucursal');
}
} else {
try {
await odooApi.updateSucursal(s.id, { active: true });
toast.success('Sucursal activada');
await load();
refresh();
} catch {
toast.error('No se pudo activar la sucursal');
}
}
};
return (
<>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
<div>
<h3 className="font-heading text-xl text-theme-heading">Sucursales</h3>
<p className="text-sm text-theme-muted">
Se usan al agendar citas y para etiquetar el lugar de atención. Una sucursal se desactiva, nunca se borra.
</p>
</div>
<Button onClick={openCreate} className="sm:ml-auto">
<Plus size={16} className="mr-2" />
Nueva sucursal
</Button>
</div>
{loading ? (
<Skeleton count={3} className="h-12 w-full" />
) : sucursales.length === 0 ? (
<EmptyState
title="Sin sucursales"
subtitle="Agrega la primera sucursal de la clínica."
actionLabel="Nueva sucursal"
onAction={openCreate}
icon={<MapPin size={28} />}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Código</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Orden</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 w-32">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{sucursales.map((s) => (
<tr key={s.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm font-medium text-theme-heading">
<span className="inline-flex items-center gap-2">
{s.name}
{s.is_default && (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-amber-600" title="Sucursal predeterminada">
<Star size={11} fill="currentColor" /> Predeterminada
</span>
)}
</span>
</td>
<td className="p-3 text-sm text-theme-muted">{s.code}</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{s.sequence}</td>
<td className="p-3 text-sm">
<span className={s.active ? 'text-emerald-600 font-medium' : 'text-theme-muted'}>
{s.active ? 'Activa' : 'Inactiva'}
</span>
</td>
<td className="p-3">
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(s)} title="Editar">
<Pencil size={16} className="text-theme-muted" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => toggleActiva(s)}
title={s.active ? 'Desactivar' : 'Activar'}
>
<Power size={16} className={s.active ? 'text-rose-500' : 'text-emerald-600'} />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Editar sucursal' : 'Nueva sucursal'}
maxWidth="lg"
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button onClick={submit} loading={submitting}>{editing ? 'Guardar cambios' : 'Crear sucursal'}</Button>
</>
}
>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input label="Nombre *" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<Input
label="Código"
value={form.code}
onChange={(e) => setForm({ ...form, code: e.target.value })}
placeholder="Se genera del nombre si se deja vacío"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Orden"
type="number"
value={form.sequence}
onChange={(e) => setForm({ ...form, sequence: e.target.value })}
/>
<Input label="Teléfono" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
</div>
<Input label="Dirección" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
<TextArea label="Notas" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
<input
type="checkbox"
checked={form.is_default}
onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
className="rounded border-theme-border-strong"
/>
Sucursal predeterminada
</label>
</div>
</Modal>
</>
);
};
export default SucursalesPanel;

View File

@@ -0,0 +1,235 @@
import type { FC } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Save, Languages, RotateCcw } from 'lucide-react';
import { Button, Input, Select, Skeleton, toast } from './ui';
import { odooApi, GLOSSARY_KEYS, type GlossaryKey, type GlossaryPack, type GlossaryTerms } from '../services/odoo';
import { GLOSARIO_CLINICA, useGlosario } from '../lib/glosario';
// Términos base de cada pack. Espejo de skeen_citas/glosario.py: el backend no
// expone un endpoint para "ver un pack sin guardarlo", así que el selector usa
// esta copia para precargar el formulario antes de guardar.
const PACK_TERMS: Record<string, GlossaryTerms> = {
clinica: GLOSARIO_CLINICA,
spa: {
patient_singular: 'Cliente', patient_plural: 'Clientes',
appointment_singular: 'Reserva', appointment_plural: 'Reservas',
visit_singular: 'Sesión', visit_plural: 'Sesiones',
record_singular: 'Ficha', record_plural: 'Fichas',
doctor_singular: 'Terapeuta', doctor_plural: 'Terapeutas',
staff_singular: 'Personal', staff_plural: 'Personal',
branch_singular: 'Sucursal', branch_plural: 'Sucursales',
room_singular: 'Sala', room_plural: 'Salas',
service_singular: 'Servicio', service_plural: 'Servicios',
package_singular: 'Paquete', package_plural: 'Paquetes',
prescription_singular: 'Recomendación', prescription_plural: 'Recomendaciones',
treatment_singular: 'Tratamiento', treatment_plural: 'Tratamientos',
},
estetica: {
patient_singular: 'Cliente', patient_plural: 'Clientes',
appointment_singular: 'Cita', appointment_plural: 'Citas',
visit_singular: 'Sesión', visit_plural: 'Sesiones',
record_singular: 'Ficha', record_plural: 'Fichas',
doctor_singular: 'Especialista', doctor_plural: 'Especialistas',
staff_singular: 'Personal', staff_plural: 'Personal',
branch_singular: 'Sucursal', branch_plural: 'Sucursales',
room_singular: 'Cabina', room_plural: 'Cabinas',
service_singular: 'Servicio', service_plural: 'Servicios',
package_singular: 'Paquete', package_plural: 'Paquetes',
prescription_singular: 'Receta', prescription_plural: 'Recetas',
treatment_singular: 'Tratamiento', treatment_plural: 'Tratamientos',
},
dental: {
patient_singular: 'Paciente', patient_plural: 'Pacientes',
appointment_singular: 'Cita', appointment_plural: 'Citas',
visit_singular: 'Consulta', visit_plural: 'Consultas',
record_singular: 'Expediente', record_plural: 'Expedientes',
doctor_singular: 'Odontólogo', doctor_plural: 'Odontólogos',
staff_singular: 'Personal', staff_plural: 'Personal',
branch_singular: 'Sucursal', branch_plural: 'Sucursales',
room_singular: 'Consultorio', room_plural: 'Consultorios',
service_singular: 'Tratamiento', service_plural: 'Tratamientos',
package_singular: 'Paquete', package_plural: 'Paquetes',
prescription_singular: 'Receta', prescription_plural: 'Recetas',
treatment_singular: 'Procedimiento', treatment_plural: 'Procedimientos',
},
};
PACK_TERMS.custom = GLOSARIO_CLINICA;
const KEY_LABELS: Record<GlossaryKey, string> = {
patient_singular: 'Paciente (singular)',
patient_plural: 'Paciente (plural)',
appointment_singular: 'Cita (singular)',
appointment_plural: 'Cita (plural)',
visit_singular: 'Visita (singular)',
visit_plural: 'Visita (plural)',
record_singular: 'Expediente (singular)',
record_plural: 'Expediente (plural)',
doctor_singular: 'Médico (singular)',
doctor_plural: 'Médico (plural)',
staff_singular: 'Personal (singular)',
staff_plural: 'Personal (plural)',
branch_singular: 'Sucursal (singular)',
branch_plural: 'Sucursal (plural)',
room_singular: 'Sala (singular)',
room_plural: 'Sala (plural)',
service_singular: 'Servicio (singular)',
service_plural: 'Servicio (plural)',
package_singular: 'Paquete (singular)',
package_plural: 'Paquete (plural)',
prescription_singular: 'Receta (singular)',
prescription_plural: 'Receta (plural)',
treatment_singular: 'Tratamiento (singular)',
treatment_plural: 'Tratamiento (plural)',
};
const PACK_FALLBACK: GlossaryPack[] = [
{ code: 'clinica', name: 'Clínica' },
{ code: 'spa', name: 'Spa / Wellness' },
{ code: 'estetica', name: 'Estética' },
{ code: 'dental', name: 'Dental' },
{ code: 'custom', name: 'Custom' },
];
/** Configuración › Terminología: pack + overrides de los 24 términos. */
const TerminologiaPanel: FC = () => {
const glosario = useGlosario();
const [pack, setPack] = useState(glosario.pack || 'clinica');
const [packs, setPacks] = useState<GlossaryPack[]>(glosario.packs.length ? glosario.packs : PACK_FALLBACK);
const [terms, setTerms] = useState<GlossaryTerms>({ ...GLOSARIO_CLINICA });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const base = useMemo(() => PACK_TERMS[pack] || GLOSARIO_CLINICA, [pack]);
useEffect(() => {
let alive = true;
(async () => {
try {
const [g, p] = await Promise.all([odooApi.getGlosario(), odooApi.getGlosarioPacks()]);
if (!alive) return;
if (g.status === 'success') {
setPack(g.pack || 'clinica');
const next: GlossaryTerms = { ...GLOSARIO_CLINICA };
for (const k of GLOSSARY_KEYS) if (g.terms?.[k]) next[k] = g.terms[k];
setTerms(next);
}
if (p.status === 'success' && p.packs?.length) setPacks(p.packs);
} catch {
toast.error('No se pudo cargar la terminología vigente');
} finally {
if (alive) setLoading(false);
}
})();
return () => { alive = false; };
}, []);
// Al cambiar de pack se precargan sus términos base para que el usuario vea
// qué va a guardar (los overrides del pack anterior se descartan).
const cambiarPack = (code: string) => {
setPack(code);
setTerms({ ...(PACK_TERMS[code] || GLOSARIO_CLINICA) });
};
const overrides = useMemo(() => {
const out: Partial<GlossaryTerms> = {};
for (const k of GLOSSARY_KEYS) {
const v = (terms[k] || '').trim();
if (v && v !== base[k]) out[k] = v;
}
return out;
}, [terms, base]);
const efectivos = useMemo(() => {
const out: GlossaryTerms = { ...base };
for (const k of GLOSSARY_KEYS) {
const v = (terms[k] || '').trim();
if (v) out[k] = v;
}
return out;
}, [terms, base]);
const guardar = async () => {
try {
setSaving(true);
const res = await odooApi.putGlosario({ pack, overrides, motivo: 'Terminología desde Configuración' });
if (res.status === 'success') {
glosario.setTerms(res.pack, res.terms);
const next: GlossaryTerms = { ...GLOSARIO_CLINICA };
for (const k of GLOSSARY_KEYS) if (res.terms?.[k]) next[k] = res.terms[k];
setTerms(next);
toast.success('Terminología actualizada');
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo guardar la terminología');
} finally {
setSaving(false);
}
};
const packLabel = packs.find((p) => p.code === pack)?.name || pack;
if (loading) return <Skeleton count={8} className="h-12 w-full" />;
return (
<>
<div className="flex flex-col sm:flex-row sm:items-start gap-3 mb-4">
<div>
<h3 className="font-heading text-xl text-theme-heading flex items-center gap-2">
<Languages size={20} /> Terminología de la clínica
</h3>
<p className="text-sm text-theme-muted">
Cambia las palabras que ve el personal (Paciente, Cita, Sucursal, Cabina...). Pack activo:
<span className="font-semibold text-theme-heading"> {packLabel}</span>.
</p>
</div>
<Button onClick={guardar} loading={saving} className="sm:ml-auto">
<Save size={16} className="mr-2" /> Guardar terminología
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<Select
label="Pack de terminología"
options={packs.map((p) => ({ value: p.code, label: p.name }))}
value={pack}
onChange={(e) => cambiarPack(e.target.value)}
/>
<div className="flex items-end">
<Button variant="outline" onClick={() => cambiarPack(pack)} title="Restaurar los términos base del pack">
<RotateCcw size={15} className="mr-2" /> Restaurar pack
</Button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-4 gap-y-3">
{GLOSSARY_KEYS.map((k) => (
<Input
key={k}
label={KEY_LABELS[k]}
value={terms[k] || ''}
placeholder={base[k]}
onChange={(e) => setTerms((prev) => ({ ...prev, [k]: e.target.value }))}
/>
))}
</div>
<div className="mt-6">
<h4 className="text-sm font-medium text-theme-heading mb-2">Términos efectivos (vista previa)</h4>
<div className="rounded-xl border border-theme-border bg-theme-bg p-3 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-x-4 gap-y-1.5 text-xs">
{GLOSSARY_KEYS.map((k) => (
<div key={k} className="flex items-center justify-between gap-2">
<span className="text-theme-muted truncate" title={KEY_LABELS[k]}>{KEY_LABELS[k]}</span>
<span className="font-medium text-theme-heading">{efectivos[k]}</span>
</div>
))}
</div>
<p className="text-xs text-theme-muted mt-2">
Los campos vacíos toman el valor del pack. Al guardar, solo se envían como ajuste los términos que cambian respecto al pack.
</p>
</div>
</>
);
};
export default TerminologiaPanel;

View File

@@ -4,7 +4,7 @@ import { Palette, Check } from 'lucide-react';
import { useTheme, type Theme } from '../lib/theme'; import { useTheme, type Theme } from '../lib/theme';
const THEME_LABELS: Record<Theme, string> = { const THEME_LABELS: Record<Theme, string> = {
skeen: 'SKEEN', skeen: 'Original',
homenest: 'HomeNest', homenest: 'HomeNest',
clasico: 'Clásico', clasico: 'Clásico',
}; };

View File

@@ -12,6 +12,7 @@ import {
} from '../lib/ticketConfig'; } from '../lib/ticketConfig';
import { odooApi } from '../services/odoo'; import { odooApi } from '../services/odoo';
import { useAuth } from '../lib/auth'; import { useAuth } from '../lib/auth';
import { useSucursales } from '../lib/sucursales';
// Editor del diseño del ticket (Configuración › Tickets). // Editor del diseño del ticket (Configuración › Tickets).
// //
@@ -136,6 +137,9 @@ const CLINICA_CAMPOS: { key: string; label: string; placeholder?: string }[] = [
export const TicketDesigner: FC = () => { export const TicketDesigner: FC = () => {
const config = useTicketConfig(); const config = useTicketConfig();
const { user } = useAuth(); const { user } = useAuth();
const { sucursales } = useSucursales();
// Nombre de la sucursal predeterminada para la vista previa (antes fijo).
const sucursalMuestra = (sucursales.find((s) => s.is_default) || sucursales[0])?.name || 'Rosarito Centro';
// Restaurar el formato original queda solo para administración del sistema; // Restaurar el formato original queda solo para administración del sistema;
// gerencia puede editar y guardar su propio diseño. // gerencia puede editar y guardar su propio diseño.
const esAdmin = user?.role === 'admin'; const esAdmin = user?.role === 'admin';
@@ -504,7 +508,7 @@ export const TicketDesigner: FC = () => {
<div className="xl:sticky xl:top-4"> <div className="xl:sticky xl:top-4">
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Vista previa</p> <p className="text-xs font-medium text-theme-muted uppercase mb-2">Vista previa</p>
<div className="rounded-2xl bg-theme-bg border border-theme-border p-4 overflow-auto max-h-[calc(100vh-8rem)]"> <div className="rounded-2xl bg-theme-bg border border-theme-border p-4 overflow-auto max-h-[calc(100vh-8rem)]">
<TicketDoc data={MUESTRA} config={previewConfig} preview /> <TicketDoc data={{ ...MUESTRA, sucursal: sucursalMuestra }} config={previewConfig} preview />
</div> </div>
<p className="text-[11px] text-theme-muted mt-2"> <p className="text-[11px] text-theme-muted mt-2">
La vista previa usa una venta de ejemplo con todos los rubros. Los datos reales se toman La vista previa usa una venta de ejemplo con todos los rubros. Los datos reales se toman

View File

@@ -14,7 +14,8 @@ import {
} from './ui'; } from './ui';
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, AGENDA_PERM_ITEMS } from '../services/odoo'; import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, AGENDA_PERM_ITEMS } from '../services/odoo';
import { ROLE_LABELS, useAuth } from '../lib/auth'; import { ROLE_LABELS, useAuth } from '../lib/auth';
import { MENU_ITEMS } from '../lib/menu-items'; import { MENU_ITEMS, menuItemLabel } from '../lib/menu-items';
import { useGlosario } from '../lib/glosario';
const roleOptions: { value: FrontendRole; label: string }[] = ( const roleOptions: { value: FrontendRole; label: string }[] = (
Object.keys(ROLE_LABELS) as FrontendRole[] Object.keys(ROLE_LABELS) as FrontendRole[]
@@ -35,6 +36,7 @@ const roleBadge = (role: FrontendRole) => {
const UsuariosPanel: FC = () => { const UsuariosPanel: FC = () => {
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const { term } = useGlosario();
const [users, setUsers] = useState<FrontendUser[]>([]); const [users, setUsers] = useState<FrontendUser[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
@@ -382,7 +384,7 @@ const UsuariosPanel: FC = () => {
onChange={() => togglePerm(item.href)} onChange={() => togglePerm(item.href)}
className="rounded border-theme-border-strong" className="rounded border-theme-border-strong"
/> />
{item.label} {menuItemLabel(item, term)}
</label> </label>
))} ))}
</div> </div>

View File

@@ -2,6 +2,7 @@ import type { FC } from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Check, ChevronLeft, ChevronRight, History, UserCheck, UserX, XCircle } from 'lucide-react'; import { Check, ChevronLeft, ChevronRight, History, UserCheck, UserX, XCircle } from 'lucide-react';
import type { Appointment, AppointmentStateLog } from '../../services/odoo'; import type { Appointment, AppointmentStateLog } from '../../services/odoo';
import { useGlosario } from '../../lib/glosario';
/** Pasos del checklist de atención (espejo de STATE_FLOW en skeen.cita) */ /** Pasos del checklist de atención (espejo de STATE_FLOW en skeen.cita) */
export const CHECKLIST_STEPS = [ export const CHECKLIST_STEPS = [
@@ -37,17 +38,22 @@ interface Props {
* siguen sincronizadas con phone_confirmed / arrived_at desde el backend. * siguen sincronizadas con phone_confirmed / arrived_at desde el backend.
*/ */
export const EstadoChecklist: FC<Props> = ({ apt, updating, history, onChange, compact, puedeCancelar = true, puedeNoShow = true }) => { export const EstadoChecklist: FC<Props> = ({ apt, updating, history, onChange, compact, puedeCancelar = true, puedeNoShow = true }) => {
const { term } = useGlosario();
const [confirmarSalida, setConfirmarSalida] = useState<'cancel' | 'no_show' | null>(null); const [confirmarSalida, setConfirmarSalida] = useState<'cancel' | 'no_show' | null>(null);
const idx = stepIndex(apt.state); const idx = stepIndex(apt.state);
const fueraDelFlujo = idx < 0; const fueraDelFlujo = idx < 0;
// El paso "En cabina" usa el término de sala configurable (Cabina/Sala/Consultorio).
const etiquetaPaso = (step: typeof CHECKLIST_STEPS[number]) =>
step.state === 'arrived' ? `En ${term('room_singular').toLowerCase()}` : step.label;
useEffect(() => { setConfirmarSalida(null); }, [apt.id, apt.state]); useEffect(() => { setConfirmarSalida(null); }, [apt.id, apt.state]);
const irA = (state: string) => { const irA = (state: string) => {
if (state === apt.state) return; if (state === apt.state) return;
const destino = stepIndex(state); const destino = stepIndex(state);
if (destino >= 0 && idx >= 0 && destino < idx) { if (destino >= 0 && idx >= 0 && destino < idx) {
if (!window.confirm(`¿Regresar la cita de "${CHECKLIST_STEPS[idx].label}" a "${CHECKLIST_STEPS[destino].label}"?`)) return; if (!window.confirm(`¿Regresar la cita de "${etiquetaPaso(CHECKLIST_STEPS[idx])}" a "${etiquetaPaso(CHECKLIST_STEPS[destino])}"?`)) return;
} }
onChange('set_state', state); onChange('set_state', state);
}; };
@@ -90,7 +96,7 @@ export const EstadoChecklist: FC<Props> = ({ apt, updating, history, onChange, c
}`} }`}
> >
{hecho ? <Check size={12} /> : <span className="text-[10px]">{i + 1}</span>} {hecho ? <Check size={12} /> : <span className="text-[10px]">{i + 1}</span>}
{step.label} {etiquetaPaso(step)}
</button> </button>
</li> </li>
); );
@@ -116,7 +122,7 @@ export const EstadoChecklist: FC<Props> = ({ apt, updating, history, onChange, c
className="inline-flex items-center gap-1 rounded-xl bg-theme-accent px-3 py-1.5 text-xs font-medium text-theme-inverse disabled:opacity-40" className="inline-flex items-center gap-1 rounded-xl bg-theme-accent px-3 py-1.5 text-xs font-medium text-theme-inverse disabled:opacity-40"
title="Avanzar un paso" title="Avanzar un paso"
> >
{idx >= 0 && idx < CHECKLIST_STEPS.length - 1 ? `Marcar ${CHECKLIST_STEPS[idx + 1].label}` : 'Siguiente'} {idx >= 0 && idx < CHECKLIST_STEPS.length - 1 ? `Marcar ${etiquetaPaso(CHECKLIST_STEPS[idx + 1])}` : 'Siguiente'}
<ChevronRight size={14} /> <ChevronRight size={14} />
</button> </button>
<span className="ml-auto flex items-center gap-2"> <span className="ml-auto flex items-center gap-2">

View File

@@ -4,6 +4,7 @@ import { CalendarPlus, Clock3, MessageCircle, Plus, Repeat, Trash2, Users } from
import { Badge, Button, Input, Modal, Select, TextArea, toast } from '../ui'; import { Badge, Button, Input, Modal, Select, TextArea, toast } from '../ui';
import PatientCombobox from '../PatientCombobox'; import PatientCombobox from '../PatientCombobox';
import { odooApi, type Doctor, type ListaEspera, type Patient, type Service } from '../../services/odoo'; import { odooApi, type Doctor, type ListaEspera, type Patient, type Service } from '../../services/odoo';
import { useGlosario } from '../../lib/glosario';
const PRIORIDADES = [ const PRIORIDADES = [
{ value: 'alta', label: 'Alta' }, { value: 'alta', label: 'Alta' },
@@ -50,6 +51,9 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
entries, loading, loadingAgendaId, onAgendar, onQuitar, onAdd, onRefresh, puedeGestionar = true, onChangeSala, entries, loading, loadingAgendaId, onAgendar, onQuitar, onAdd, onRefresh, puedeGestionar = true, onChangeSala,
}) => { }) => {
const [abierto, setAbierto] = useState(true); const [abierto, setAbierto] = useState(true);
const { term } = useGlosario();
// Etiqueta de la sala "cabina" toma el término configurable (Cabina/Sala/Consultorio).
const salas = SALAS.map((s) => (s.key === 'cabina' ? { ...s, label: term('room_singular') } : s));
// Filtro por sala: el toggle de arriba decide qué pacientes se muestran. // Filtro por sala: el toggle de arriba decide qué pacientes se muestran.
const [salaFiltro, setSalaFiltro] = useState<'todas' | 'consultoria' | 'cabina'>('todas'); const [salaFiltro, setSalaFiltro] = useState<'todas' | 'consultoria' | 'cabina'>('todas');
const visibles = salaFiltro === 'todas' ? entries : entries.filter((e) => (e.sala || '') === salaFiltro); const visibles = salaFiltro === 'todas' ? entries : entries.filter((e) => (e.sala || '') === salaFiltro);
@@ -72,7 +76,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
</button> </button>
{/* Toggle de sala: Consultoría / Cabina (filtra y muestra la etiqueta) */} {/* Toggle de sala: Consultoría / Cabina (filtra y muestra la etiqueta) */}
<div className="flex items-center gap-1 rounded-full border border-violet-300 bg-theme-surface p-0.5"> <div className="flex items-center gap-1 rounded-full border border-violet-300 bg-theme-surface p-0.5">
{([{ key: 'todas', label: 'Todas' }, ...SALAS] as const).map((s) => ( {[{ key: 'todas', label: 'Todas' }, ...salas].map((s) => (
<button <button
key={s.key} key={s.key}
type="button" type="button"
@@ -110,7 +114,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
? 'Cargando...' ? 'Cargando...'
: salaFiltro === 'todas' : salaFiltro === 'todas'
? 'Sin pacientes en espera. Cuando no haya hueco, agrégalos aquí para avisarles al liberarse una cita.' ? 'Sin pacientes en espera. Cuando no haya hueco, agrégalos aquí para avisarles al liberarse una cita.'
: `Sin pacientes esperando en ${SALAS.find((s) => s.key === salaFiltro)?.label}.`} : `Sin pacientes esperando en ${salas.find((s) => s.key === salaFiltro)?.label}.`}
</p> </p>
) : ( ) : (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -119,7 +123,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
e.phone, e.phone,
`Hola ${e.patient}, se liberó un espacio en la clínica${e.doctor ? ` con ${e.doctor}` : ''}. ¿Te gustaría tomarlo?`, `Hola ${e.patient}, se liberó un espacio en la clínica${e.doctor ? ` con ${e.doctor}` : ''}. ¿Te gustaría tomarlo?`,
); );
const salaActual = SALAS.find((s) => s.key === (e.sala || '')); const salaActual = salas.find((s) => s.key === (e.sala || ''));
return ( return (
<div <div
key={e.id} key={e.id}
@@ -145,7 +149,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
salaActual ? salaActual.chip : 'bg-theme-bg text-theme-muted border-theme-border' salaActual ? salaActual.chip : 'bg-theme-bg text-theme-muted border-theme-border'
} ${puedeGestionar ? 'hover:brightness-95 cursor-pointer' : 'cursor-default'}`} } ${puedeGestionar ? 'hover:brightness-95 cursor-pointer' : 'cursor-default'}`}
title={puedeGestionar title={puedeGestionar
? `Sala: ${salaActual?.label || 'Sin asignar'} — click para pasar a ${SALAS.find((s) => s.key === salaSiguiente(e.sala || ''))?.label}` ? `Sala: ${salaActual?.label || 'Sin asignar'} — click para pasar a ${salas.find((s) => s.key === salaSiguiente(e.sala || ''))?.label}`
: `Sala: ${salaActual?.label || 'Sin asignar'}`} : `Sala: ${salaActual?.label || 'Sin asignar'}`}
> >
{salaActual?.label || 'Sin sala'} {salaActual?.label || 'Sin sala'}
@@ -224,6 +228,7 @@ interface ModalProps {
export const ListaEsperaModal: FC<ModalProps> = ({ export const ListaEsperaModal: FC<ModalProps> = ({
isOpen, onClose, doctors, services, patient, defaultDoctorId, defaultServiceId, isMedico, onCreated, isOpen, onClose, doctors, services, patient, defaultDoctorId, defaultServiceId, isMedico, onCreated,
}) => { }) => {
const { term } = useGlosario();
const [patientId, setPatientId] = useState(''); const [patientId, setPatientId] = useState('');
const [patientName, setPatientName] = useState(''); const [patientName, setPatientName] = useState('');
const [doctorId, setDoctorId] = useState(defaultDoctorId || ''); const [doctorId, setDoctorId] = useState(defaultDoctorId || '');
@@ -350,7 +355,7 @@ export const ListaEsperaModal: FC<ModalProps> = ({
options={[ options={[
{ value: '', label: 'Sin asignar' }, { value: '', label: 'Sin asignar' },
{ value: 'consultoria', label: 'Consultoría' }, { value: 'consultoria', label: 'Consultoría' },
{ value: 'cabina', label: 'Cabina' }, { value: 'cabina', label: term('room_singular') },
]} ]}
value={sala} value={sala}
onChange={(e) => setSala(e.target.value)} onChange={(e) => setSala(e.target.value)}

View File

@@ -1,6 +1,7 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react'; import { createContext, useContext, useEffect, useState, useCallback } from 'react';
import type { FC, ReactNode } from 'react'; import type { FC, ReactNode } from 'react';
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, type ClinicBrand, BRAND_DEFAULT } from '../services/odoo'; import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, type ClinicBrand, BRAND_DEFAULT } from '../services/odoo';
import { applyBrandColors } from './brand-colors';
const TOKEN_KEY = 'skeen_token'; const TOKEN_KEY = 'skeen_token';
const USER_KEY = 'skeen_user'; const USER_KEY = 'skeen_user';
@@ -120,6 +121,11 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
} }
}, []); }, []);
// Colores de marca en runtime: solo pisan el tema si la clínica los configuró.
useEffect(() => {
applyBrandColors(brand);
}, [brand]);
const refreshBrand = useCallback(async () => { const refreshBrand = useCallback(async () => {
try { try {
const res = await odooApi.getBrand(); const res = await odooApi.getBrand();

View File

@@ -0,0 +1,67 @@
import type { ClinicBrand } from '../services/odoo';
// Colores de marca en runtime.
//
// Los temas (index.css) definen sus colores en variables CSS sobre el elemento
// raíz. Si la clínica configuró colores de marca, se pisan como estilo en línea
// de <html>, que tiene más prioridad que el selector [data-theme]. Si están
// vacíos se quitan, de modo que la instancia sin configurar se ve exactamente
// igual que siempre en los tres temas.
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
// Variables que controla el color primario de la marca.
const PROPS_PRIMARIO = ['--color-coral', '--color-coral-hover', '--accent', '--accent-hover', '--accent-bg'];
const PROPS_ACENTO = ['--color-lilac'];
const normalizeHex = (hex: string): string => {
let h = hex.trim();
if (h.length === 4) {
h = `#${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}`;
}
return h.toLowerCase();
};
const hexToRgb = (hex: string): [number, number, number] => {
const h = normalizeHex(hex);
return [
parseInt(h.slice(1, 3), 16),
parseInt(h.slice(3, 5), 16),
parseInt(h.slice(5, 7), 16),
];
};
/** Oscurece un color hex un porcentaje (0-1) para derivar el estado hover. */
const darken = (hex: string, amount: number): string => {
const [r, g, b] = hexToRgb(hex);
const f = (v: number) => Math.max(0, Math.round(v * (1 - amount)));
return `#${[f(r), f(g), f(b)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
};
const rgba = (hex: string, alpha: number): string => {
const [r, g, b] = hexToRgb(hex);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
/** Aplica (o limpia) los colores de marca sobre el tema activo. */
export const applyBrandColors = (brand: ClinicBrand): void => {
if (typeof document === 'undefined') return;
const root = document.documentElement;
for (const p of [...PROPS_PRIMARIO, ...PROPS_ACENTO]) root.style.removeProperty(p);
const colores = brand.colores || {};
const primario = colores.primario && HEX_RE.test(colores.primario) ? normalizeHex(colores.primario) : '';
const acento = colores.acento && HEX_RE.test(colores.acento) ? normalizeHex(colores.acento) : '';
if (primario) {
const hover = darken(primario, 0.12);
root.style.setProperty('--color-coral', primario);
root.style.setProperty('--color-coral-hover', hover);
root.style.setProperty('--accent', primario);
root.style.setProperty('--accent-hover', hover);
root.style.setProperty('--accent-bg', rgba(primario, 0.12));
}
if (acento) {
root.style.setProperty('--color-lilac', acento);
}
};

View File

@@ -0,0 +1,174 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { FC, ReactNode } from 'react';
import { odooApi, GLOSSARY_KEYS, type GlossaryKey, type GlossaryPack, type GlossaryTerms } from '../services/odoo';
import { useAuth } from './auth';
// Terminología por defecto = pack 'clinica' del backend, que coincide con los
// literales que usa hoy producción. Es también el respaldo si el endpoint falla,
// para que la interfaz nunca se rompa por un glosario no disponible.
export const GLOSARIO_CLINICA: GlossaryTerms = {
patient_singular: 'Paciente',
patient_plural: 'Pacientes',
appointment_singular: 'Cita',
appointment_plural: 'Citas',
visit_singular: 'Visita',
visit_plural: 'Visitas',
record_singular: 'Expediente',
record_plural: 'Expedientes',
doctor_singular: 'Médico',
doctor_plural: 'Médicos',
staff_singular: 'Personal',
staff_plural: 'Personal',
branch_singular: 'Sucursal',
branch_plural: 'Sucursales',
room_singular: 'Cabina',
room_plural: 'Cabinas',
service_singular: 'Servicio',
service_plural: 'Servicios',
package_singular: 'Paquete',
package_plural: 'Paquetes',
prescription_singular: 'Receta',
prescription_plural: 'Recetas',
treatment_singular: 'Tratamiento',
treatment_plural: 'Tratamientos',
};
const GLOSARIO_CACHE_KEY = 'skeen_glosario';
interface GlosarioContextValue {
terms: GlossaryTerms;
pack: string;
packs: GlossaryPack[];
loading: boolean;
/** Traduce una clave del glosario; cae al valor del pack clínica y luego a la clave. */
term: (key: GlossaryKey) => string;
/** Reemplaza los términos en memoria tras un PUT en Configuración. */
setTerms: (pack: string, terms: GlossaryTerms) => void;
refresh: () => void;
}
const GlosarioContext = createContext<GlosarioContextValue | undefined>(undefined);
const leerCache = (): { pack: string; terms: GlossaryTerms } => {
try {
const raw = localStorage.getItem(GLOSARIO_CACHE_KEY);
if (!raw) return { pack: 'clinica', terms: GLOSARIO_CLINICA };
const parsed = JSON.parse(raw) as { pack?: string; terms?: Partial<GlossaryTerms> };
const terms: GlossaryTerms = { ...GLOSARIO_CLINICA };
for (const k of GLOSSARY_KEYS) {
const v = parsed.terms?.[k];
if (v) terms[k] = v;
}
return { pack: parsed.pack || 'clinica', terms };
} catch {
return { pack: 'clinica', terms: GLOSARIO_CLINICA };
}
};
export const GlosarioProvider: FC<{ children: ReactNode }> = ({ children }) => {
const { token } = useAuth();
const cached = useMemo(leerCache, []);
const [terms, setTermsState] = useState<GlossaryTerms>(cached.terms);
const [pack, setPack] = useState<string>(cached.pack);
const [packs, setPacks] = useState<GlossaryPack[]>([]);
const [loading, setLoading] = useState(false);
const [attempt, setAttempt] = useState(0);
const refresh = useCallback(() => setAttempt((n) => n + 1), []);
const setTerms = useCallback((nextPack: string, nextTerms: GlossaryTerms) => {
setPack(nextPack);
setTermsState(nextTerms);
try {
localStorage.setItem(GLOSARIO_CACHE_KEY, JSON.stringify({ pack: nextPack, terms: nextTerms }));
} catch {
// localStorage bloqueado: seguimos en memoria
}
}, []);
// Carga el glosario vigente al iniciar sesión y refresca al reintentar.
useEffect(() => {
if (!token) return;
let alive = true;
setLoading(true);
odooApi.getGlosario()
.then((res) => {
if (!alive || res.status !== 'success') return;
const next: GlossaryTerms = { ...GLOSARIO_CLINICA };
for (const k of GLOSSARY_KEYS) {
if (res.terms?.[k]) next[k] = res.terms[k];
}
setTerms(res.pack || 'clinica', next);
})
.catch(() => {
// fallback: caché o pack clínica
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [token, attempt, setTerms]);
// Packs disponibles solo hacen falta en Configuración; se piden con la sesión.
useEffect(() => {
if (!token) return;
let alive = true;
odooApi.getGlosarioPacks()
.then((res) => {
if (alive && res.status === 'success' && res.packs) setPacks(res.packs);
})
.catch(() => {});
return () => {
alive = false;
};
}, [token]);
const term = useCallback(
(key: GlossaryKey): string => terms[key] || GLOSARIO_CLINICA[key] || key,
[terms],
);
const value = useMemo(
() => ({ terms, pack, packs, loading, term, setTerms, refresh }),
[terms, pack, packs, loading, term, setTerms, refresh],
);
return <GlosarioContext.Provider value={value}>{children}</GlosarioContext.Provider>;
};
export const useGlosario = (): GlosarioContextValue => {
const ctx = useContext(GlosarioContext);
if (!ctx) throw new Error('useGlosario debe usarse dentro de GlosarioProvider');
return ctx;
};
// Etiquetas del menú lateral que se traducen al glosario. Solo se listan las que
// hoy coinciden con un término clínico (Pacientes, Visitas, Médicos...); así con
// la configuración por defecto el menú se ve igual que siempre. Los nombres de
// módulo (Agenda, Dashboard, Punto de Venta...) no se tocan.
export const MENU_LABEL_TERMS: Record<string, GlossaryKey> = {
Pacientes: 'patient_plural',
Visitas: 'visit_plural',
Expedientes: 'record_plural',
Médicos: 'doctor_plural',
Servicios: 'service_plural',
Paquetes: 'package_plural',
};
/** Devuelve la etiqueta del menú traducida según el glosario vigente. */
export const traducirEtiqueta = (label: string, term: (key: GlossaryKey) => string): string => {
const key = MENU_LABEL_TERMS[label];
return key ? term(key) : label;
};
/** Copia superficial del menú (grupos con items) aplicando el glosario a las etiquetas. */
export const traducirMenu = <T extends { label: string; items: { label: string }[] }>(
groups: T[],
term: (key: GlossaryKey) => string,
): T[] =>
groups.map((g) => ({
...g,
items: g.items.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })),
}));

View File

@@ -1,11 +1,23 @@
// Lista canónica de secciones del sistema (para permisos de menú por usuario) import type { GlossaryKey } from '../services/odoo';
export const MENU_ITEMS: { label: string; href: string }[] = [
// Lista canónica de secciones del sistema (para permisos de menú por usuario).
// `termKey` marca las secciones cuyo nombre es un término configurable del
// glosario (Pacientes, Visitas, Médicos...): con el pack por defecto se ven
// exactamente como siempre. Los nombres de módulo (Agenda, Dashboard, Punto de
// Venta...) no son términos y no se traducen.
export interface MenuItem {
label: string;
href: string;
termKey?: GlossaryKey;
}
export const MENU_ITEMS: MenuItem[] = [
{ label: 'Dashboard', href: '/' }, { label: 'Dashboard', href: '/' },
{ label: 'Agenda', href: '/agenda' }, { label: 'Agenda', href: '/agenda' },
{ label: 'Pacientes', href: '/pacientes' }, { label: 'Pacientes', href: '/pacientes', termKey: 'patient_plural' },
{ label: 'Visitas', href: '/visitas' }, { label: 'Visitas', href: '/visitas', termKey: 'visit_plural' },
{ label: 'Médicos', href: '/medicos' }, { label: 'Médicos', href: '/medicos', termKey: 'doctor_plural' },
{ label: 'Servicios', href: '/servicios' }, { label: 'Servicios', href: '/servicios', termKey: 'service_plural' },
{ label: 'Punto de Venta', href: '/pos' }, { label: 'Punto de Venta', href: '/pos' },
{ label: 'Ventas', href: '/ventas' }, { label: 'Ventas', href: '/ventas' },
{ label: 'Pagos', href: '/pagos' }, { label: 'Pagos', href: '/pagos' },
@@ -16,7 +28,7 @@ export const MENU_ITEMS: { label: string; href: string }[] = [
{ label: 'Comisiones', href: '/reportes?tab=comisiones' }, { label: 'Comisiones', href: '/reportes?tab=comisiones' },
{ label: 'Adeudos', href: '/reportes?tab=adeudos' }, { label: 'Adeudos', href: '/reportes?tab=adeudos' },
{ label: 'Horas agenda', href: '/reportes?tab=horas-agenda' }, { label: 'Horas agenda', href: '/reportes?tab=horas-agenda' },
{ label: 'Paquetes', href: '/reportes?tab=paquetes' }, { label: 'Paquetes', href: '/reportes?tab=paquetes', termKey: 'package_plural' },
{ label: 'Vendedores', href: '/reportes?tab=vendedores' }, { label: 'Vendedores', href: '/reportes?tab=vendedores' },
{ label: 'Concentrado', href: '/reportes?tab=concentrado' }, { label: 'Concentrado', href: '/reportes?tab=concentrado' },
{ label: 'Recomendaciones', href: '/reportes?tab=recomendaciones' }, { label: 'Recomendaciones', href: '/reportes?tab=recomendaciones' },
@@ -28,3 +40,7 @@ export const MENU_ITEMS: { label: string; href: string }[] = [
{ label: 'Mensajes WACRM', href: '/wacrm/messages' }, { label: 'Mensajes WACRM', href: '/wacrm/messages' },
{ label: 'Leads WACRM', href: '/wacrm/leads' }, { label: 'Leads WACRM', href: '/wacrm/leads' },
]; ];
/** Etiqueta de la sección traducida al glosario vigente. */
export const menuItemLabel = (item: MenuItem, term: (key: GlossaryKey) => string): string =>
item.termKey ? term(item.termKey) : item.label;

View File

@@ -0,0 +1,87 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { FC, ReactNode } from 'react';
import { odooApi, type ServiceCategory } from '../services/odoo';
import { useAuth } from './auth';
// Respaldo histórico: las 4 categorías hardcodeadas antes del endpoint, en el
// mismo orden. Con la configuración por defecto el catálogo se ve igual.
export const CATEGORIAS_FALLBACK: ServiceCategory[] = [
{ id: -1, code: 'consulta', name: 'Consulta', sequence: 10, active: true, is_system: true },
{ id: -2, code: 'tratamiento', name: 'Tratamiento', sequence: 20, active: true, is_system: true },
{ id: -3, code: 'procedimiento', name: 'Procedimiento', sequence: 30, active: true, is_system: true },
{ id: -4, code: 'paquete', name: 'Paquete', sequence: 40, active: true, is_system: true },
];
const NOMBRES_HISTORICOS: Record<string, string> = {
consulta: 'Consulta',
tratamiento: 'Tratamiento',
procedimiento: 'Procedimiento',
paquete: 'Paquete',
};
interface ServiceCategoriesContextValue {
/** Categorías activas, ordenadas por sequence. */
categories: ServiceCategory[];
loading: boolean;
/** Nombre de la categoría por código; cae al histórico y luego al código. */
labelFor: (code?: string | null) => string;
refresh: () => void;
}
const ServiceCategoriesContext = createContext<ServiceCategoriesContextValue | undefined>(undefined);
export const ServiceCategoriesProvider: FC<{ children: ReactNode }> = ({ children }) => {
const { token } = useAuth();
const [categories, setCategories] = useState<ServiceCategory[]>(CATEGORIAS_FALLBACK);
const [loading, setLoading] = useState(false);
const [attempt, setAttempt] = useState(0);
const refresh = useCallback(() => setAttempt((n) => n + 1), []);
useEffect(() => {
if (!token) return;
let alive = true;
setLoading(true);
odooApi.getServiceCategories()
.then((res) => {
if (!alive || res.status !== 'success' || !res.categories?.length) return;
setCategories(res.categories);
})
.catch(() => {
// fallback: CATEGORIAS_FALLBACK
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [token, attempt]);
const porCodigo = useMemo(() => {
const m = new Map<string, ServiceCategory>();
for (const c of categories) m.set(c.code, c);
return m;
}, [categories]);
const labelFor = useCallback(
(code?: string | null) => {
if (!code) return '';
return porCodigo.get(code)?.name || NOMBRES_HISTORICOS[code] || code;
},
[porCodigo],
);
const value = useMemo(
() => ({ categories, loading, labelFor, refresh }),
[categories, loading, labelFor, refresh],
);
return <ServiceCategoriesContext.Provider value={value}>{children}</ServiceCategoriesContext.Provider>;
};
export const useServiceCategories = (): ServiceCategoriesContextValue => {
const ctx = useContext(ServiceCategoriesContext);
if (!ctx) throw new Error('useServiceCategories debe usarse dentro de ServiceCategoriesProvider');
return ctx;
};

View File

@@ -0,0 +1,86 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { FC, ReactNode } from 'react';
import { odooApi, type Sucursal } from '../services/odoo';
import { useAuth } from './auth';
// Respaldo histórico: las dos sucursales que existían hardcodeadas antes de que
// el endpoint existiera. Si /sucursales falla, el frontend se sigue viendo igual.
export const SUCURSALES_FALLBACK: Sucursal[] = [
{ id: -1, code: 'rosarito', name: 'Rosarito', sequence: 10, active: true, is_default: true, address: '', phone: '', notes: '' },
{ id: -2, code: 'tijuana', name: 'Tijuana', sequence: 20, active: true, is_default: false, address: '', phone: '', notes: '' },
];
const NOMBRES_HISTORICOS: Record<string, string> = { rosarito: 'Rosarito', tijuana: 'Tijuana' };
interface SucursalesContextValue {
/** Sucursales activas, ordenadas por sequence. */
sucursales: Sucursal[];
loading: boolean;
defaultCode: string;
/** Nombre de la sucursal por código; cae al histórico y luego al propio código. */
labelFor: (code?: string | null) => string;
refresh: () => void;
}
const SucursalesContext = createContext<SucursalesContextValue | undefined>(undefined);
export const SucursalesProvider: FC<{ children: ReactNode }> = ({ children }) => {
const { token } = useAuth();
const [sucursales, setSucursales] = useState<Sucursal[]>(SUCURSALES_FALLBACK);
const [loading, setLoading] = useState(false);
const [attempt, setAttempt] = useState(0);
const refresh = useCallback(() => setAttempt((n) => n + 1), []);
useEffect(() => {
if (!token) return;
let alive = true;
setLoading(true);
odooApi.getSucursales()
.then((res) => {
if (!alive || res.status !== 'success' || !res.sucursales?.length) return;
setSucursales(res.sucursales);
})
.catch(() => {
// fallback: SUCURSALES_FALLBACK
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [token, attempt]);
const porCodigo = useMemo(() => {
const m = new Map<string, Sucursal>();
for (const s of sucursales) m.set(s.code, s);
return m;
}, [sucursales]);
const defaultCode = useMemo(
() => (sucursales.find((s) => s.is_default) || sucursales[0])?.code || '',
[sucursales],
);
const labelFor = useCallback(
(code?: string | null) => {
if (!code) return '';
return porCodigo.get(code)?.name || NOMBRES_HISTORICOS[code] || code;
},
[porCodigo],
);
const value = useMemo(
() => ({ sucursales, loading, defaultCode, labelFor, refresh }),
[sucursales, loading, defaultCode, labelFor, refresh],
);
return <SucursalesContext.Provider value={value}>{children}</SucursalesContext.Provider>;
};
export const useSucursales = (): SucursalesContextValue => {
const ctx = useContext(SucursalesContext);
if (!ctx) throw new Error('useSucursales debe usarse dentro de SucursalesProvider');
return ctx;
};

View File

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

View File

@@ -1,7 +1,7 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; 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 Layout from '../components/Layout';
import UsuariosPanel from '../components/UsuariosPanel'; import UsuariosPanel from '../components/UsuariosPanel';
import RolesPanel from '../components/RolesPanel'; import RolesPanel from '../components/RolesPanel';
@@ -10,12 +10,18 @@ import CatalogoPanel from '../components/CatalogoPanel';
import AuditoriaPanel from '../components/AuditoriaPanel'; import AuditoriaPanel from '../components/AuditoriaPanel';
import TicketDesigner from '../components/TicketDesigner'; import TicketDesigner from '../components/TicketDesigner';
import ImpactoModal from '../components/ImpactoModal'; 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 { 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'; import { useAuth } from '../lib/auth';
const TABS = [ const TABS = [
{ key: 'clinica', label: 'Clínica' }, { key: 'clinica', label: 'Clínica' },
{ key: 'sucursales', label: 'Sucursales' },
{ key: 'categorias', label: 'Categorías' },
{ key: 'terminologia', label: 'Terminología' },
{ key: 'usuarios', label: 'Usuarios' }, { key: 'usuarios', label: 'Usuarios' },
{ key: 'roles', label: 'Roles' }, { key: 'roles', label: 'Roles' },
{ key: 'recetas', label: 'Recetas' }, { key: 'recetas', label: 'Recetas' },
@@ -24,38 +30,25 @@ const TABS = [
{ key: 'auditoria', label: 'Auditoría' }, { key: 'auditoria', label: 'Auditoría' },
] as const; ] as const;
// Secciones que ve gerencia (admin del sistema ve todas).
const TABS_GERENCIA = ['sucursales', 'categorias', 'terminologia', 'usuarios', 'roles', 'tickets'];
const SUBCATALOGOS = [ const SUBCATALOGOS = [
{ key: 'diagnosticos', label: 'Diagnósticos' }, { key: 'diagnosticos', label: 'Diagnósticos' },
{ key: 'procedimientos', label: 'Procedimientos' }, { key: 'procedimientos', label: 'Procedimientos' },
] as const; ] as const;
interface ClinicSettings { // Clave histórica del navegador: antes los ajustes de la clínica vivían solo en
name: string; // localStorage. Se migran al backend una vez y deja de usarse.
phone: string; const LEGACY_STORAGE_KEY = 'skeen_clinic_settings';
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';
const Configuracion: FC = () => { const Configuracion: FC = () => {
const { user, brand, setBrand } = useAuth(); const { user, brand, setBrand } = useAuth();
// Gerencia gestiona usuarios y roles; la sección "Clínica" (tipo de cambio y // Gerencia gestiona catálogos, usuarios y roles; la sección "Clínica" (datos
// comisiones) queda para administración del sistema. // de contacto, tipo de cambio y comisiones) queda para administración del sistema.
const esAdmin = user?.role === 'admin'; const esAdmin = user?.role === 'admin';
const tabsVisibles = useMemo( const tabsVisibles = useMemo(
() => (esAdmin () => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key))),
? TABS
: TABS.filter((t) => t.key === 'usuarios' || t.key === 'roles' || t.key === 'tickets')),
[esAdmin], [esAdmin],
); );
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@@ -66,31 +59,60 @@ const Configuracion: FC = () => {
const sub = searchParams.get('sub'); const sub = searchParams.get('sub');
return sub === 'procedimientos' ? 'procedimientos' : 'diagnosticos'; return sub === 'procedimientos' ? 'procedimientos' : 'diagnosticos';
}); });
const [settings, setSettings] = useState<ClinicSettings>(() => ({ const [settings, setSettings] = useState<ClinicSettings>(CLINIC_SETTINGS_DEFAULT);
...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 [loading, setLoading] = useState(true); 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(() => { useEffect(() => {
try { let alive = true;
const saved = localStorage.getItem(STORAGE_KEY); odooApi.getClinicSettings()
if (saved) setSettings(JSON.parse(saved)); .then(async (res) => {
} catch { if (!alive || res.status !== 'success') return;
// ignore let next = res.settings;
} finally { try {
setLoading(false); 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 { 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'); toast.success('Configuración guardada');
} catch { } catch (err) {
toast.error('Error al guardar configuración'); 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); const [tcSaving, setTcSaving] = useState(false);
// ---- Identidad de la clínica (brand white-label) ---- // ---- Identidad de la clínica (brand white-label) ----
const [brandForm, setBrandForm] = useState({ 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 [brandSaving, setBrandSaving] = useState(false);
const brandCargadoRef = useRef(false); const brandCargadoRef = useRef(false);
@@ -116,6 +138,9 @@ const Configuracion: FC = () => {
pie: brand.pie === BRAND_DEFAULT.pie ? '' : brand.pie, pie: brand.pie === BRAND_DEFAULT.pie ? '' : brand.pie,
logo_claro: brand.logo_claro === BRAND_DEFAULT.logo_claro ? '' : brand.logo_claro, logo_claro: brand.logo_claro === BRAND_DEFAULT.logo_claro ? '' : brand.logo_claro,
logo_oscuro: brand.logo_oscuro === BRAND_DEFAULT.logo_oscuro ? '' : brand.logo_oscuro, 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]); }, [brand]);
@@ -128,13 +153,16 @@ const Configuracion: FC = () => {
pie: brandForm.pie.trim() || BRAND_DEFAULT.pie, pie: brandForm.pie.trim() || BRAND_DEFAULT.pie,
logo_claro: brandForm.logo_claro.trim() || BRAND_DEFAULT.logo_claro, logo_claro: brandForm.logo_claro.trim() || BRAND_DEFAULT.logo_claro,
logo_oscuro: brandForm.logo_oscuro.trim() || BRAND_DEFAULT.logo_oscuro, 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', motivo: 'Identidad desde Configuración',
}); });
setBrand(res.brand); setBrand(res.brand);
toast.success('Identidad de la clínica actualizada (se ve al recargar en los demás equipos)'); toast.success('Identidad de la clínica actualizada (se ve al recargar en los demás equipos)');
} catch { } catch (err) {
toast.error('No se pudo guardar la identidad de la clínica'); 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 { } finally {
setBrandSaving(false); setBrandSaving(false);
} }
@@ -298,6 +326,24 @@ const Configuracion: FC = () => {
<CatalogoPanel kind={subcatalogo as 'diagnosticos' | 'procedimientos'} titulo={subcatalogo === 'diagnosticos' ? 'Diagnósticos' : 'Procedimientos'} /> <CatalogoPanel kind={subcatalogo as 'diagnosticos' | 'procedimientos'} titulo={subcatalogo === 'diagnosticos' ? 'Diagnósticos' : 'Procedimientos'} />
</Card.Body> </Card.Body>
</Card> </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"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<Card> <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> <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"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<a <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" target="_blank"
rel="noreferrer" rel="noreferrer"
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition" 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>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-theme-heading">Dirección</p> <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> </div>
</a> </a>
<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" 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"> <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>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-theme-heading">Teléfono</p> <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> </div>
</a> </a>
@@ -346,7 +392,7 @@ const Configuracion: FC = () => {
</a> </a>
<a <a
href={settings.website} href={settings.web}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition" 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>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-theme-heading">Sitio web</p> <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> </div>
</a> </a>
</div> </div>
@@ -375,30 +421,78 @@ const Configuracion: FC = () => {
<div className="space-y-4"> <div className="space-y-4">
<Input <Input
label="Nombre de la clínica" label="Nombre de la clínica"
value={settings.name} value={settings.nombre}
onChange={(e) => update('name', e.target.value)} onChange={(e) => update('nombre', 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)}
/> />
<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 <Input
label="Dirección" label="Dirección"
value={settings.address} value={settings.direccion}
onChange={(e) => update('address', e.target.value)} 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 <Input
label="Sitio web" label="Nota del ticket"
value={settings.website} value={settings.nota_ticket}
onChange={(e) => update('website', e.target.value)} 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"> <div className="pt-2">
<Button onClick={save}> <Button onClick={save}>
<Save size={16} className="mr-2" /> <Save size={16} className="mr-2" />
@@ -445,7 +539,53 @@ const Configuracion: FC = () => {
onChange={(e) => setBrandForm((f) => ({ ...f, logo_oscuro: e.target.value }))} onChange={(e) => setBrandForm((f) => ({ ...f, logo_oscuro: e.target.value }))}
placeholder={BRAND_DEFAULT.logo_oscuro} 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> </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"> <div className="flex items-center gap-3 mt-4">
{(brandForm.logo_claro || brandForm.logo_oscuro) && ( {(brandForm.logo_claro || brandForm.logo_oscuro) && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -51,8 +51,9 @@ import { odooApi, type DashboardStats, type Appointment, type Service, type Char
import { usePatientDrawer } from '../components/PatientDrawer'; import { usePatientDrawer } from '../components/PatientDrawer';
import { useTheme } from '../lib/theme'; import { useTheme } from '../lib/theme';
import { useAuth } from '../lib/auth'; import { useAuth } from '../lib/auth';
import { useGlosario } from '../lib/glosario';
import { waLink } from '../lib/whatsapp'; import { waLink } from '../lib/whatsapp';
import { APT_STATE_LABELS } from './Agenda'; import { aptStateLabel } from './Agenda';
const formatCurrency = (value: number) => const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value); 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 Dashboard: FC = () => {
const { theme } = useTheme(); const { theme } = useTheme();
const { user } = useAuth(); const { user } = useAuth();
const { term } = useGlosario();
const navigate = useNavigate(); const navigate = useNavigate();
const isHomeNest = theme === 'homenest'; const isHomeNest = theme === 'homenest';
const [stats, setStats] = useState<DashboardStats | null>(null); const [stats, setStats] = useState<DashboardStats | null>(null);
@@ -436,7 +438,7 @@ const Dashboard: FC = () => {
</div> </div>
<div className="flex flex-wrap gap-3 shrink-0"> <div className="flex flex-wrap gap-3 shrink-0">
<Button variant="primary" size="md" onClick={() => navigate('/agenda')}> <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>
<Button variant="outline" size="md" onClick={() => navigate('/reportes')}> <Button variant="outline" size="md" onClick={() => navigate('/reportes')}>
<TrendingUp size={16} className="mr-2" /> Ver 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-sm font-medium text-theme-heading truncate">{apt.patient}</p>
<p className="text-xs text-theme-muted truncate">{apt.service} • {apt.time}</p> <p className="text-xs text-theme-muted truncate">{apt.service} • {apt.time}</p>
</div> </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>
))} ))}
</div> </div>

View File

@@ -4,6 +4,7 @@ import { Mail, Phone, Stethoscope, Briefcase, Save, Percent, Eye, CalendarDays,
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, Modal, Select, toast } from '../components/ui'; 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 { 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']; 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 Medicos: FC = () => {
const { term } = useGlosario();
const [employees, setEmployees] = useState<Doctor[]>([]); const [employees, setEmployees] = useState<Doctor[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -326,8 +328,8 @@ const Medicos: FC = () => {
}; };
return ( return (
<Layout title="Médicos" subtitle="Equipo médico"> <Layout title={term('doctor_plural')} subtitle="Equipo médico">
<PageHeader title="Médicos" subtitle="Especialistas de la clínica"> <PageHeader title={term('doctor_plural')} subtitle="Especialistas de la clínica">
{countBaja > 0 && ( {countBaja > 0 && (
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer"> <label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
<input <input

View File

@@ -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 { 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 { exportToExcel } from '../lib/exporter';
import { useAuth } from '../lib/auth'; import { useAuth } from '../lib/auth';
import { useGlosario } from '../lib/glosario';
import { useSucursales } from '../lib/sucursales';
const fmtMoney = (n: number) => const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }); (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> = { const paymentStateLabels: Record<string, string> = {
not_paid: 'No pagado', partial: 'Pago parcial', paid: 'Pagado', 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 mediumLabels: Record<string, string> = { onsite: 'Presencial', videocall: 'Videollamada' };
const completionBadgeClasses = (pct?: number) => { const completionBadgeClasses = (pct?: number) => {
@@ -122,6 +123,8 @@ const CRITICAL_ALERTS: { key: keyof Patient; label: string }[] = [
const Pacientes: FC = () => { const Pacientes: FC = () => {
const { brand } = useAuth(); const { brand } = useAuth();
const { term } = useGlosario();
const { labelFor: branchLabel } = useSucursales();
const [patients, setPatients] = useState<Patient[]>([]); const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); 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 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">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 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">Puntos</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th> <th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
</tr> </tr>
@@ -520,15 +523,15 @@ const Pacientes: FC = () => {
}; };
return ( return (
<Layout title="Pacientes" subtitle="Directorio de pacientes"> <Layout title={term('patient_plural')} subtitle="Directorio de pacientes">
<PageHeader title="Pacientes" subtitle="Busca, crea y gestiona pacientes"> <PageHeader title={term('patient_plural')} subtitle="Busca, crea y gestiona pacientes">
<Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0}> <Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0}>
<Download size={16} className="mr-2" /> <Download size={16} className="mr-2" />
Exportar CSV Exportar CSV
</Button> </Button>
<Button onClick={openCreate}> <Button onClick={openCreate}>
<Plus size={16} className="mr-2" /> <Plus size={16} className="mr-2" />
Nuevo paciente Nuevo {term('patient_singular').toLowerCase()}
</Button> </Button>
</PageHeader> </PageHeader>
@@ -611,7 +614,7 @@ const Pacientes: FC = () => {
<EmptyState <EmptyState
title="Sin pacientes" title="Sin pacientes"
subtitle="No se encontraron pacientes." subtitle="No se encontraron pacientes."
actionLabel="Nuevo paciente" actionLabel={`Nuevo ${term('patient_singular').toLowerCase()}`}
onAction={openCreate} onAction={openCreate}
/> />
) : ( ) : (
@@ -717,7 +720,7 @@ const Pacientes: FC = () => {
<Modal <Modal
isOpen={modalOpen} isOpen={modalOpen}
onClose={() => setModalOpen(false)} onClose={() => setModalOpen(false)}
title={editing ? 'Editar paciente' : 'Nuevo paciente'} title={editing ? `Editar ${term('patient_singular').toLowerCase()}` : `Nuevo ${term('patient_singular').toLowerCase()}`}
maxWidth="2xl" maxWidth="2xl"
footer={ footer={
<> <>
@@ -1263,12 +1266,12 @@ const Pacientes: FC = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<InfoRow label="Referencia" value={entryDetail.item.reference} icon={FileText} /> <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="Fecha y hora" value={`${entryDetail.item.date} · ${entryDetail.item.time}`} icon={Calendar} />
<InfoRow label="Servicio" value={entryDetail.item.service} icon={Stethoscope} /> <InfoRow label={term('service_singular')} value={entryDetail.item.service} icon={Stethoscope} />
<InfoRow label="Médico" value={entryDetail.item.doctor || 'Sin asignar'} icon={User} /> <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="Duración" value={entryDetail.item.duration ? `${entryDetail.item.duration} min` : '-'} />
<InfoRow label="Precio" value={`$${entryDetail.item.price}`} /> <InfoRow label="Precio" value={`$${entryDetail.item.price}`} />
<InfoRow label="Monto pagado" value={`$${entryDetail.item.amount_paid ?? 0}`} /> <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 || '-'} /> <InfoRow label="Medio" value={mediumLabels[entryDetail.item.medium || ''] || entryDetail.item.medium || '-'} />
{entryDetail.item.package_finished && ( {entryDetail.item.package_finished && (
<InfoRow label="Paquete terminado" value={entryDetail.item.package_finished_date || 'Sí'} /> <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="Folio" value={entryDetail.item.name} icon={FileText} />
<InfoRow label="Inicio" value={entryDetail.item.date_start || '-'} icon={Calendar} /> <InfoRow label="Inicio" value={entryDetail.item.date_start || '-'} icon={Calendar} />
<InfoRow label="Fin" value={entryDetail.item.date_end || '-'} icon={Calendar} /> <InfoRow label="Fin" value={entryDetail.item.date_end || '-'} icon={Calendar} />
<InfoRow label="Servicio" value={entryDetail.item.servicio || '-'} icon={Stethoscope} /> <InfoRow label={term('service_singular')} value={entryDetail.item.servicio || '-'} icon={Stethoscope} />
<InfoRow label="Médico" value={entryDetail.item.doctor || 'Sin asignar'} icon={User} /> <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} /> <InfoRow label="Cosmetóloga" value={entryDetail.item.cosmetologa || 'Sin asignar'} icon={User} />
</div> </div>
{[ {[

View File

@@ -13,6 +13,7 @@ import {
import { odooApi } from '../services/odoo'; import { odooApi } from '../services/odoo';
import type { Paquete, PaqueteCompra, PaqueteConsumo, Service } from '../services/odoo'; import type { Paquete, PaqueteCompra, PaqueteConsumo, Service } from '../services/odoo';
import { useAuth } from '../lib/auth'; import { useAuth } from '../lib/auth';
import { useGlosario } from '../lib/glosario';
const fmtMoney = (n: number) => const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }); (n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
@@ -51,6 +52,7 @@ const emptyForm: FormPaquete = {
}; };
const Paquetes: FC = () => { const Paquetes: FC = () => {
const { term } = useGlosario();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') === 'saldos' ? 'saldos' : 'catalogo'; const tab = searchParams.get('tab') === 'saldos' ? 'saldos' : 'catalogo';
const setTab = (key: string) => const setTab = (key: string) =>
@@ -327,9 +329,9 @@ const Paquetes: FC = () => {
}; };
return ( return (
<Layout title="Paquetes" subtitle="Paquetes de sesiones"> <Layout title={term('package_plural')} subtitle="Paquetes de sesiones">
<PageHeader <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" subtitle="Combos de sesiones a precio cerrado y el saldo que lleva cada paciente"
> >
<Button variant="outline" onClick={() => (tab === 'catalogo' ? loadCatalogo() : loadSaldos())}> <Button variant="outline" onClick={() => (tab === 'catalogo' ? loadCatalogo() : loadSaldos())}>
@@ -337,7 +339,7 @@ const Paquetes: FC = () => {
</Button> </Button>
{tab === 'catalogo' && esAdmin && ( {tab === 'catalogo' && esAdmin && (
<Button onClick={abrirNuevo}> <Button onClick={abrirNuevo}>
<Plus size={16} className="mr-2" /> Nuevo paquete <Plus size={16} className="mr-2" /> Nuevo {term('package_singular').toLowerCase()}
</Button> </Button>
)} )}
</PageHeader> </PageHeader>
@@ -607,7 +609,7 @@ const Paquetes: FC = () => {
<Modal <Modal
isOpen={formOpen} isOpen={formOpen}
onClose={() => setFormOpen(false)} 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 className="space-y-3">
<div> <div>

View File

@@ -1,5 +1,5 @@
import type { FC } from 'react'; 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 { Star, Wallet, Edit2, Plus, Clock, Tag, Package, Search, Copy, ChevronUp, ChevronDown, ArrowUpToLine } from 'lucide-react';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import { import {
@@ -16,23 +16,8 @@ import {
} from '../components/ui'; } from '../components/ui';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { odooApi, type Service } from '../services/odoo'; import { odooApi, type Service } from '../services/odoo';
import { useGlosario } from '../lib/glosario';
const categoryOptions = [ import { useServiceCategories } from '../lib/service-categories';
{ 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',
};
interface ServiceForm { interface ServiceForm {
name: string; name: string;
@@ -63,6 +48,16 @@ const emptyForm: ServiceForm = {
}; };
const Servicios: FC = () => { 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 [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); 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); new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
return ( return (
<Layout title="Servicios" subtitle="Catálogo de tratamientos"> <Layout title={term('service_plural')} subtitle="Catálogo de tratamientos">
<PageHeader title="Servicios" subtitle="Gestiona precios, paquetes y favoritos"> <PageHeader title={term('service_plural')} subtitle="Gestiona precios, paquetes y favoritos">
<Button onClick={openCreate}> <Button onClick={openCreate}>
<Plus size={16} className="mr-2" /> <Plus size={16} className="mr-2" />
Nuevo servicio Nuevo {term('service_singular').toLowerCase()}
</Button> </Button>
</PageHeader> </PageHeader>
@@ -325,7 +320,7 @@ const Servicios: FC = () => {
<EmptyState <EmptyState
title="Sin servicios" title="Sin servicios"
subtitle={search || categoryFilter ? 'No hay servicios que coincidan con los filtros.' : 'No hay servicios registrados.'} 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} 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"> <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"> <span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
<Tag size={12} className="mr-1" /> <Tag size={12} className="mr-1" />
{categoryLabels[s.category] || s.category || 'Sin categoría'} {labelFor(s.category) || 'Sin categoría'}
</span> </span>
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5"> <span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
<Clock size={12} className="mr-1" /> <Clock size={12} className="mr-1" />
@@ -468,7 +463,7 @@ const Servicios: FC = () => {
<Modal <Modal
isOpen={modalOpen} isOpen={modalOpen}
onClose={() => { setModalOpen(false); setEditing(null); setDuplicando(false); }} 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" maxWidth="lg"
footer={ footer={
<> <>
@@ -484,7 +479,7 @@ const Servicios: FC = () => {
</Button> </Button>
)} )}
<Button variant="outline" onClick={() => { setModalOpen(false); setEditing(null); setDuplicando(false); }}>Cancelar</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>
</> </>
} }
> >

View File

@@ -20,6 +20,7 @@ import {
import { odooApi, type Visita, type VisitaAdjunto, type Doctor, type InventoryItem } from '../services/odoo'; import { odooApi, type Visita, type VisitaAdjunto, type Doctor, type InventoryItem } from '../services/odoo';
import type { BadgeVariant } from '../components/ui/Badge'; import type { BadgeVariant } from '../components/ui/Badge';
import { todayLocal } from '../lib/utils'; import { todayLocal } from '../lib/utils';
import { useGlosario } from '../lib/glosario';
const visitaStateLabels: Record<string, string> = { const visitaStateLabels: Record<string, string> = {
en_curso: 'En curso', en_curso: 'En curso',
@@ -48,6 +49,7 @@ const MAX_ADJUNTO_BYTES = 10 * 1024 * 1024;
const hoy = () => todayLocal(); const hoy = () => todayLocal();
const Visitas: FC = () => { const Visitas: FC = () => {
const { term } = useGlosario();
const [visitas, setVisitas] = useState<Visita[]>([]); const [visitas, setVisitas] = useState<Visita[]>([]);
const [doctors, setDoctors] = useState<Doctor[]>([]); const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -294,8 +296,8 @@ const Visitas: FC = () => {
const hora = (v: Visita) => (v.date_start ? v.date_start.split(' ')[1] || '' : '-'); const hora = (v: Visita) => (v.date_start ? v.date_start.split(' ')[1] || '' : '-');
return ( return (
<Layout title="Visitas" subtitle="Registro de visitas clínicas"> <Layout title={term('visit_plural')} subtitle="Registro de visitas clínicas">
<PageHeader title="Visitas" subtitle="Consulta y completa las visitas clínicas del día" /> <PageHeader title={term('visit_plural')} subtitle="Consulta y completa las visitas clínicas del día" />
<Card> <Card>
<Card.Body> <Card.Body>
@@ -325,9 +327,9 @@ const Visitas: FC = () => {
<thead className="bg-theme-bg"> <thead className="bg-theme-bg">
<tr> <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">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">{term('patient_singular')}</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">{term('service_singular')}</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 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 hidden lg:table-cell">Motivo</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">Estado</th>
</tr> </tr>

View File

@@ -905,6 +905,9 @@ export interface ClinicBrand {
logo_claro: string; // fondos oscuros (login SKEEN, sidebar) logo_claro: string; // fondos oscuros (login SKEEN, sidebar)
logo_oscuro: string; // fondos claros (tema HomeNest) logo_oscuro: string; // fondos claros (tema HomeNest)
pie: string; // línea del pie (login / documentos) pie: string; // línea del pie (login / documentos)
url_publica?: string; // URL pública de la instancia (opcional)
// Colores de marca (opcional). Vacíos = se usa el color del tema activo.
colores?: { primario?: string; acento?: string };
} }
export const BRAND_DEFAULT: ClinicBrand = { export const BRAND_DEFAULT: ClinicBrand = {
@@ -914,6 +917,80 @@ export const BRAND_DEFAULT: ClinicBrand = {
pie: 'SKEEN Derma Experts · Playas de Rosarito, B.C.', pie: 'SKEEN Derma Experts · Playas de Rosarito, B.C.',
}; };
// ---- Glosario / terminología white-label ----
export const GLOSSARY_KEYS = [
'patient_singular', 'patient_plural',
'appointment_singular', 'appointment_plural',
'visit_singular', 'visit_plural',
'record_singular', 'record_plural',
'doctor_singular', 'doctor_plural',
'staff_singular', 'staff_plural',
'branch_singular', 'branch_plural',
'room_singular', 'room_plural',
'service_singular', 'service_plural',
'package_singular', 'package_plural',
'prescription_singular', 'prescription_plural',
'treatment_singular', 'treatment_plural',
] as const;
export type GlossaryKey = typeof GLOSSARY_KEYS[number];
export type GlossaryTerms = Record<GlossaryKey, string>;
export interface GlossaryPack { code: string; name: string; }
// ---- Sucursales ----
export interface Sucursal {
id: number;
code: string;
name: string;
sequence: number;
active: boolean;
is_default: boolean;
address: string;
phone: string;
notes: string;
}
// ---- Categorías de servicio ----
export interface ServiceCategory {
id: number;
code: string;
name: string;
sequence: number;
active: boolean;
is_system: boolean;
}
// ---- Ajustes de la clínica ----
export interface ClinicSettings {
nombre: string;
telefono: string;
email: string;
direccion: string;
web: string;
rfc: string;
horario_apertura: string;
horario_cierre: string;
zona_horaria: string;
url_publica: string;
moneda_base: string;
nota_ticket: string;
}
export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
nombre: BRAND_DEFAULT.nombre,
telefono: '',
email: '',
direccion: '',
web: '',
rfc: '',
horario_apertura: '08:00',
horario_cierre: '19:00',
zona_horaria: 'America/Tijuana',
url_publica: '',
moneda_base: 'MXN',
nota_ticket: '',
};
export interface FrontendUser { export interface FrontendUser {
id: number; id: number;
login: string; login: string;
@@ -1397,6 +1474,79 @@ export const odooApi = {
return data; return data;
}, },
// ---- Glosario / terminología ----
async getGlosario(): Promise<{ status: string; pack: string; terms: GlossaryTerms; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/glosario`);
return data;
},
async getGlosarioPacks(): Promise<{ status: string; packs: GlossaryPack[]; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/glosario/packs`);
return data;
},
async putGlosario(payload: { pack?: string; overrides?: Partial<GlossaryTerms>; motivo?: string }): Promise<{ status: string; pack: string; terms: GlossaryTerms; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/glosario`, payload);
return data;
},
// ---- Sucursales ----
async getSucursales(opts?: { includeInactive?: boolean }): Promise<{ status: string; sucursales: Sucursal[]; total: number; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/sucursales`, {
params: opts?.includeInactive ? { include_inactive: 1 } : undefined,
});
return data;
},
async createSucursal(payload: Partial<Sucursal>): Promise<{ status: string; sucursal: Sucursal; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sucursales`, payload);
return data;
},
async updateSucursal(id: number, payload: Partial<Sucursal>): Promise<{ status: string; sucursal: Sucursal; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/sucursales/${id}`, payload);
return data;
},
async deleteSucursal(id: number): Promise<{ status: string; sucursal: Sucursal; en_uso: number; message: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/sucursales/${id}`);
return data;
},
// ---- Categorías de servicio ----
async getServiceCategories(opts?: { includeInactive?: boolean }): Promise<{ status: string; categories: ServiceCategory[]; total: number; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/service-categories`, {
params: opts?.includeInactive ? { include_inactive: 1 } : undefined,
});
return data;
},
async createServiceCategory(payload: { name: string; code?: string; sequence?: number; active?: boolean }): Promise<{ status: string; category: ServiceCategory; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/service-categories`, payload);
return data;
},
async updateServiceCategory(id: number, payload: { name?: string; code?: string; sequence?: number; active?: boolean }): Promise<{ status: string; category: ServiceCategory; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/service-categories/${id}`, payload);
return data;
},
async deleteServiceCategory(id: number): Promise<{ status: string; category?: ServiceCategory; en_uso?: number; message?: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/service-categories/${id}`);
return data;
},
// ---- Ajustes de la clínica ----
async getClinicSettings(): Promise<{ status: string; settings: ClinicSettings; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/clinic-settings`);
return data;
},
async putClinicSettings(settings: Partial<ClinicSettings> & { motivo?: string }): Promise<{ status: string; settings: ClinicSettings; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/clinic-settings`, settings);
return data;
},
async updateRolePermissions(matrix: Record<string, FrontendRole[]>): Promise<{ status: string; matrix: Record<string, FrontendRole[]>; message?: string }> { async updateRolePermissions(matrix: Record<string, FrontendRole[]>): Promise<{ status: string; matrix: Record<string, FrontendRole[]>; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/roles/permissions`, { matrix }); const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/roles/permissions`, { matrix });
return data; return data;
@@ -1430,7 +1580,7 @@ export const odooApi = {
return data; return data;
}, },
async createQuickAppointment(payload: { name: string; phone: string; date: string; time: number; doctor_id?: number; notes?: string; duration?: number }): Promise<{ status: string; appointment: Appointment; patient: Patient; patient_created: boolean; message?: string }> { async createQuickAppointment(payload: { name: string; phone: string; date: string; time: number; doctor_id?: number; notes?: string; duration?: number; branch?: string }): Promise<{ status: string; appointment: Appointment; patient: Patient; patient_created: boolean; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/quick`, payload); const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/quick`, payload);
return data; return data;
}, },

51
infra/provision/README.md Normal file
View File

@@ -0,0 +1,51 @@
# infra/provision — provisioning de instancias white-label
Levanta una clínica/spa nueva de SKEEN en la VPS con un comando, sin tocar las
instancias existentes (`skeen_odoo` y `clinica2_odoo`). Un solo código
(`/root/skeen-odoo/addons`), N bases de datos.
## Archivos
| Archivo | Para qué |
|---|---|
| `provision_clinica.py` | Script principal. Crea DB+rol, secretos, conf, unit, inicializa módulos, post-instala, renombra el menú raíz, arma el vhost nginx y despliega el frontend. |
| `postinstall_clinica.py` | Post-instalación vía ORM de Odoo: marca, empresa, admin del frontend y siembra de glosario/sucursal/settings **solo si existen**. Opcionalmente datos demo. |
| `demo_limpiar.py` | Borra los datos demo de una instancia (para no entregar basura a un cliente). |
| `templates/odoo.conf.tmpl` | Plantilla de `odoo.conf` con secretos/puerto/DB propios. |
| `templates/service.tmpl` | Plantilla del unit systemd `odoo-<slug>.service`. |
| `templates/nginx-vhost.tmpl` | Plantilla del vhost nginx (dominio + puerto LAN + bloqueo WACRM). |
## Uso rápido
```bash
cd /root/skeen-proyecto/infra/provision
# 1) Simular (no ejecuta nada) — SIEMPRE hacerlo primero
./provision_clinica.py --slug spa-bella --nombre "Spa Bella" \
--dominio spa.consultoria-as.com --puerto 8071 --db spa_bella_odoo \
--pack spa --demo --dry-run
# 2) Ejecutar de verdad (coordinado, en horario de bajo uso)
./provision_clinica.py --slug spa-bella --nombre "Spa Bella" \
--dominio spa.consultoria-as.com --puerto 8071 --db spa_bella_odoo \
--pack spa --demo
```
Guía completa: `../../docs/nueva-instancia.md`.
## Decisiones de diseño relevantes
- **Rol PostgreSQL propio por instancia.** El rol `skeen` es compartido y su
password (`skeen_dev_2026`) no se puede cambiar sin romper las dos instancias
existentes. Para cumplir "secretos propios" el script crea un rol nuevo
(`<slug>`) con password aleatoria y una DB cuyo owner es ese rol.
`--db-role skeen` reproduce el procedimiento manual anterior (rol compartido).
- **Dry-run por defecto recomendado.** `--dry-run` valida colisiones reales
(puerto, DB, unit, vhost, dirs) pero no escribe ni ejecuta nada.
- **Post-instalación tolerante.** Los modelos `skeen.sucursal`,
`skeen.service.category`, `skeen.clinic.settings` y el glosario están en
desarrollo por otro cambio; el post-install los detecta en runtime y deja
`TODO` en la salida para lo que aún no exista. Nunca aborta por eso.
- **Frontend compartido.** El bundle se compila una vez desde
`/root/skeen-frontend` y se copia con `rsync` a cada `/var/www/<slug>-frontend`;
la marca sale del API `/brand`, no del build.

89
infra/provision/demo_limpiar.py Executable file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Borra los datos de demostración sembrados por `--demo`.
/root/odoo-venv/bin/python demo_limpiar.py --conf /root/<slug>-odoo.conf --db <db> [--dry-run]
Usa la lista de ids guardada en ir.config_parameter `skeen.demo.ids` y, como
respaldo, los marcadores ('DEMO-' en servicios, referred_by='DEMO' en
pacientes, '[DEMO]' en citas/empleados). Pensado para no dejar basura en una
instancia de cliente antes de entregarla.
"""
import argparse
import json
import sys
import traceback
def load_env(conf_path, db_name):
sys.path.insert(0, '/root/skeen-odoo')
import odoo
from odoo.tools import config as odoo_config
odoo_config.parse_config(['-c', conf_path, '-d', db_name,
'--no-http', '--log-level', 'error'])
odoo.service.server.load_server_wide_modules()
return odoo, odoo.registry(db_name)
# Orden de borrado: primero lo que depende de lo demás.
ORDER = ['skeen.cita', 'skeen.paquete.compra', 'skeen.paquete',
'skeen.monedero', 'skeen.servicio', 'res.partner', 'hr.employee']
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--conf', required=True)
ap.add_argument('--db', required=True)
ap.add_argument('--dry-run', action='store_true')
args = ap.parse_args()
odoo, registry = load_env(args.conf, args.db)
with registry.cursor() as cr:
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
ICP = env['ir.config_parameter'].sudo()
raw = ICP.get_param('skeen.demo.ids') or '{}'
try:
ids = json.loads(raw)
except Exception:
ids = {}
# respaldo por marcadores cuando no hay ids guardados
markers = {}
if not any(ids.values()):
markers = {
'skeen.servicio': [('code', 'like', 'DEMO-%')],
'res.partner': [('referred_by', '=', 'DEMO')],
'hr.employee': [('notes', 'like', '[DEMO]')],
'skeen.cita': [('notes', 'like', '[DEMO]')],
}
for model in ORDER:
if model not in env:
continue
Model = env[model].sudo()
recs = Model.browse([])
for rid in ids.get(model.split('.')[-1], []):
recs |= Model.browse(rid).exists()
if model in markers:
recs |= Model.search(markers[model])
if not recs:
continue
print('%-22s %d registros' % (model, len(recs)))
if not args.dry_run:
try:
recs.unlink()
except Exception as e:
print(' no se pudo borrar del todo: %s' % e)
if not args.dry_run:
ICP.set_param('skeen.demo.ids', '{}')
cr.commit()
print('LIMPIEZA %s' % ('SIMULADA (dry-run)' if args.dry_run else 'OK'))
if __name__ == '__main__':
try:
main()
except Exception:
traceback.print_exc()
sys.exit(1)

View File

@@ -0,0 +1,445 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Post-instalación de una instancia white-label de SKEEN.
Se ejecuta con el Python del venv de Odoo después de inicializar los módulos:
/root/odoo-venv/bin/python postinstall_clinica.py /ruta/config.json
El config.json trae: conf, db, nombre, dominio, pack, admin_login,
admin_password, con_wacrm, demo, report.
Hace, en orden:
1. Brand neutro (ir.config_parameter 'skeen.clinic.brand').
2. Oculta WACRM (skeen.menu_roles) salvo que con_wacrm sea True.
3. Renombra res.company principal.
4. Usuario admin del frontend con password propia + cambio obligatorio.
5. Siembra, SOLO si existen en runtime, los parámetros/modelos del nuevo
sistema de configuración: glosario (pack), skeen.clinic.settings,
skeen.sucursal, skeen.service.category. Lo que no exista queda como TODO
en el reporte; nunca aborta por eso.
6. Datos demo opcionales (--demo), marcados con '[DEMO]'/'DEMO-' para poder
limpiarlos luego con demo_limpiar.py.
No toca ninguna otra base de datos: todo corre sobre `db` del config.
"""
import datetime
import json
import sys
import traceback
# ---------------------------------------------------------------- packs giro
# (nombre, precio, duración_min, categoría) por giro. La categoría usa la
# selección de skeen.servicio: consulta | tratamiento | procedimiento | paquete.
PACKS = {
'generico': [
('Consulta general', 600, 30, 'consulta'),
('Consulta de seguimiento', 450, 30, 'consulta'),
('Tratamiento básico', 800, 45, 'tratamiento'),
('Tratamiento intensivo', 1200, 60, 'tratamiento'),
('Procedimiento menor', 950, 30, 'procedimiento'),
('Procedimiento mayor', 1800, 90, 'procedimiento'),
('Valoración inicial', 350, 20, 'consulta'),
('Terapia de mantenimiento', 700, 45, 'tratamiento'),
('Sesión express', 400, 20, 'tratamiento'),
('Paquete 5 sesiones', 3000, 45, 'paquete'),
],
'spa': [
('Masaje relajante 60 min', 900, 60, 'tratamiento'),
('Masaje descontracturante', 1100, 60, 'tratamiento'),
('Facial hidratante', 850, 50, 'tratamiento'),
('Facial antiedad', 1400, 60, 'tratamiento'),
('Exfoliación corporal', 750, 45, 'tratamiento'),
('Envoltura de algas', 1200, 60, 'tratamiento'),
('Drenaje linfático', 1000, 60, 'procedimiento'),
('Depilación con cera (pierna)', 500, 40, 'procedimiento'),
('Manicure spa', 450, 40, 'procedimiento'),
('Paquete 5 masajes', 4000, 60, 'paquete'),
],
'clinica': [
('Consulta médica general', 700, 30, 'consulta'),
('Consulta de especialidad', 950, 40, 'consulta'),
('Consulta de seguimiento', 500, 20, 'consulta'),
('Aplicación de medicamento', 350, 15, 'procedimiento'),
('Curación de herida', 450, 25, 'procedimiento'),
('Toma de muestra', 300, 15, 'procedimiento'),
('Terapia física', 800, 50, 'tratamiento'),
('Rehabilitación', 900, 60, 'tratamiento'),
('Valoración preoperatoria', 1100, 45, 'consulta'),
('Paquete 10 terapias', 6500, 50, 'paquete'),
],
'derma': [
('Consulta dermatológica', 900, 30, 'consulta'),
('Consulta de seguimiento', 600, 20, 'consulta'),
('Limpieza facial profunda', 950, 50, 'tratamiento'),
('Peeling químico', 1600, 45, 'tratamiento'),
('Microdermoabrasión', 1300, 45, 'tratamiento'),
('Depilación láser (zona pequeña)', 1200, 30, 'procedimiento'),
('Depilación láser (zona grande)', 2200, 45, 'procedimiento'),
('Mesoterapia facial', 1800, 40, 'procedimiento'),
('Crioterapia', 700, 20, 'procedimiento'),
('Paquete 5 limpiezas', 4000, 50, 'paquete'),
],
}
DEMO_PATIENTS = [
('Ana López Márquez', '6681230001', 'F'), ('Carlos Ruiz Peña', '6681230002', 'M'),
('María Fernanda Soto', '6681230003', 'F'), ('Jorge Alberto Nava', '6681230004', 'M'),
('Lucía Ramírez Ortega', '6681230005', 'F'), ('Pedro Sánchez Luna', '6681230006', 'M'),
('Sofía Herrera Cano', '6681230007', 'F'), ('Diego Morales Vega', '6681230008', 'M'),
('Valeria Castro Ibarra', '6681230009', 'F'), ('Andrés Gil Mendoza', '6681230010', 'M'),
('Renata Flores Cuevas', '6681230011', 'F'), ('Miguel Ángel Ponce', '6681230012', 'M'),
('Camila Ríos Delgado', '6681230013', 'F'), ('Fernando Ochoa Palma', '6681230014', 'M'),
('Isabela Vargas Rocha', '6681230015', 'F'),
]
DEMO_EMPLOYEES = [
('Dra. Elena Márquez', 'doctor', 10.0),
('Dr. Hugo Salinas', 'doctor', 8.0),
('Cosmet. Paula Rincón', 'cosmetologa', 12.0),
]
def load_env(conf_path, db_name):
sys.path.insert(0, '/root/skeen-odoo')
import odoo
from odoo.tools import config as odoo_config
odoo_config.parse_config(['-c', conf_path, '-d', db_name,
'--no-http', '--log-level', 'error'])
odoo.service.server.load_server_wide_modules()
return odoo, odoo.registry(db_name)
def has_model(env, model):
try:
return model in env
except Exception:
return False
def write_first(record, candidates, value):
"""Escribe `value` en el primer campo de `candidates` que exista."""
for field in candidates:
if field in record._fields:
try:
record.write({field: value})
return field
except Exception:
continue
return None
def create_first(model, candidates, value, extra=None):
vals = {}
for field in candidates:
if field in model._fields:
vals[field] = value
break
vals.update(extra or {})
if not vals:
return None
return model.create(vals)
def seed_brand(ICP, cfg, report):
brand = {
'nombre': cfg['nombre'],
'logo_claro': '/brand/logo-blanco.svg',
'logo_oscuro': '/brand/logo-negro.svg',
'pie': cfg.get('pie') or 'Sistema de gestión',
}
ICP.set_param('skeen.clinic.brand', json.dumps(brand, ensure_ascii=False))
report['seeded'].append('skeen.clinic.brand')
def seed_menu_roles(ICP, cfg, report):
if cfg.get('con_wacrm'):
report['skipped'].append('skeen.menu_roles (se pidió --con-wacrm)')
return
roles = json.loads(ICP.get_param('skeen.menu_roles') or '{}')
roles['/wacrm/messages'] = []
roles['/wacrm/leads'] = []
ICP.set_param('skeen.menu_roles', json.dumps(roles, ensure_ascii=False))
report['seeded'].append('skeen.menu_roles (WACRM oculto)')
def seed_company(env, cfg, report):
company = env['res.company'].sudo().search([], limit=1, order='id')
if company:
company.write({'name': cfg['nombre']})
report['seeded'].append('res.company.name = %s' % cfg['nombre'])
def seed_admin(env, cfg, report):
User = env['skeen.frontend.user'].sudo()
admin = User.search([('login', '=', cfg['admin_login'])], limit=1)
if not admin:
vals = {'login': cfg['admin_login'], 'name': '%s Admin' % cfg['nombre'],
'role': 'admin', 'password_hash': 'pendiente'}
admin = User.create(vals)
report['seeded'].append('skeen.frontend.user %s (creado)' % cfg['admin_login'])
else:
report['seeded'].append('skeen.frontend.user %s (password propia)' % cfg['admin_login'])
admin.set_password(cfg['admin_password'])
if 'must_change_password' in admin._fields:
admin.write({'must_change_password': True})
# --------------------------------------------------------- config system v2
def seed_glossary(env, ICP, cfg, report):
"""Glosario con el pack elegido. Tolera los tres estados posibles:
- llave ir.config_parameter 'skeen.glosario' ya existe -> se actualiza;
- modelo 'skeen.glosario' existe -> se crea/actualiza un registro;
- ninguno de los dos existe -> TODO (no aborta)."""
pack = cfg.get('pack') or 'generico'
raw = ICP.get_param('skeen.glosario')
if raw is not None:
try:
data = json.loads(raw)
if not isinstance(data, dict):
data = {}
except Exception:
data = {}
data['pack'] = pack
ICP.set_param('skeen.glosario', json.dumps(data, ensure_ascii=False))
report['seeded'].append("skeen.glosario.pack = %s" % pack)
return
if has_model(env, 'skeen.glosario'):
Model = env['skeen.glosario'].sudo()
rec = Model.search([], limit=1)
if not rec:
rec = create_first(Model, ('pack', 'name', 'nombre'), pack)
elif 'pack' in Model._fields:
rec.write({'pack': pack})
if rec:
report['seeded'].append("skeen.glosario -> pack %s" % pack)
return
report['todo'].append(
"glosario: no existe la llave ir.config_parameter 'skeen.glosario' ni "
"el modelo skeen.glosario; sembrar el pack '%s' cuando el otro cambio "
"esté desplegado" % pack)
def seed_clinic_settings(env, cfg, report):
if not has_model(env, 'skeen.clinic.settings'):
report['todo'].append(
"skeen.clinic.settings: modelo ausente; cuando exista, sembrar "
"nombre=%r, url_publica=https://%s, horario 08:00-19:00, moneda MXN"
% (cfg['nombre'], cfg['dominio']))
return
Model = env['skeen.clinic.settings'].sudo()
rec = Model.search([], limit=1)
if not rec:
rec = Model.create({})
url = 'https://%s' % cfg['dominio']
write_first(rec, ('name', 'nombre', 'nombre_comercial'), cfg['nombre'])
write_first(rec, ('url_publica', 'url', 'website'), url)
write_first(rec, ('moneda', 'currency', 'currency_name'), 'MXN')
# 08:00 - 19:00 en los campos típicos de horario
write_first(rec, ('hora_apertura', 'horario_apertura', 'open_time',
'hour_from'), 8.0)
write_first(rec, ('hora_cierre', 'horario_cierre', 'close_time',
'hour_to'), 19.0)
write_first(rec, ('apertura', 'hora_inicio', 'inicio'), '08:00')
write_first(rec, ('cierre', 'hora_fin', 'fin'), '19:00')
report['seeded'].append('skeen.clinic.settings (nombre, url, horario, MXN)')
def seed_sucursal(env, cfg, report):
if not has_model(env, 'skeen.sucursal'):
report['todo'].append(
"skeen.sucursal: modelo ausente; cuando exista, crear 1 sucursal "
"'default' con nombre=%r" % cfg['nombre'])
return
Model = env['skeen.sucursal'].sudo()
if Model.search([], limit=1):
report['skipped'].append('skeen.sucursal (ya hay sucursales)')
return
rec = create_first(Model, ('name', 'nombre'), cfg['nombre'])
if rec:
report['seeded'].append('skeen.sucursal (1 sucursal default)')
def seed_service_category(env, cfg, report):
if not has_model(env, 'skeen.service.category'):
report['todo'].append(
"skeen.service.category: modelo ausente; cuando exista, sembrar la "
"semilla de categorías del pack '%s'" % (cfg.get('pack') or 'generico'))
return
Model = env['skeen.service.category'].sudo()
cats = []
for _n, _p, _d, cat in PACKS.get(cfg.get('pack') or 'generico', PACKS['generico']):
if cat not in cats:
cats.append(cat)
created = 0
for cat in cats:
label = cat.capitalize()
if Model.search([('name', '=', label)], limit=1):
continue
if create_first(Model, ('name', 'nombre'), label):
created += 1
report['seeded'].append('skeen.service.category (%d nuevas)' % created)
# ------------------------------------------------------------------- demo
def _mark(env, model, field, value, ids):
try:
recs = env[model].sudo().browse(ids)
if field in env[model]._fields:
recs.write({field: value})
except Exception:
pass
def seed_demo(env, cfg, report):
pack = PACKS.get(cfg.get('pack') or 'generico', PACKS['generico'])
today = datetime.date.today()
week_start = today - datetime.timedelta(days=today.weekday())
ids = {'servicio': [], 'paciente': [], 'empleado': [], 'cita': [],
'paquete': [], 'monedero': []}
Servicio = env['skeen.servicio'].sudo()
Serv = []
for i, (name, price, dur, cat) in enumerate(pack, start=1):
rec = Servicio.create({
'name': name, 'code': 'DEMO-%02d' % i, 'price': price,
'duration_min': dur, 'category': cat, 'is_favorite': i <= 3,
})
Serv.append(rec)
ids['servicio'].append(rec.id)
Partner = env['res.partner'].sudo()
Pacientes = []
for i, (name, phone, gender) in enumerate(DEMO_PATIENTS):
vals = {'name': name, 'is_patient': True, 'phone': phone,
'mobile': phone, 'referred_by': 'DEMO',
'email': 'demo%02d@demo.local' % (i + 1)}
if 'gender' in Partner._fields:
vals['gender'] = 'female' if gender == 'F' else 'male'
try:
rec = Partner.create(vals)
except Exception:
rec = Partner.create({'name': name, 'is_patient': True,
'referred_by': 'DEMO'})
Pacientes.append(rec)
ids['paciente'].append(rec.id)
Employee = env['hr.employee'].sudo()
Empleados = []
for name, stype, comm in DEMO_EMPLOYEES:
vals = {'name': name, 'commission_pct': comm}
if 'staff_type' in Employee._fields:
vals['staff_type'] = stype
if 'notes' in Employee._fields:
vals['notes'] = '[DEMO]'
Empleados.append(Employee.create(vals))
ids['empleado'] = [e.id for e in Empleados]
Cita = env['skeen.cita'].sudo()
hours = [9.0, 10.0, 11.0, 12.0, 13.0, 16.0, 17.0, 18.0]
for i in range(20):
day = week_start + datetime.timedelta(days=i % 6)
serv = Serv[i % len(Serv)]
vals = {
'partner_id': Pacientes[i % len(Pacientes)].id,
'servicio_id': serv.id,
'date': day.isoformat(),
'time': hours[i % len(hours)],
'doctor_id': Empleados[i % len(Empleados)].id,
'state': 'confirmed' if i % 3 else 'pending',
'medium': 'onsite',
}
if 'notes' in Cita._fields:
vals['notes'] = '[DEMO]'
try:
c = Cita.create(vals)
ids['cita'].append(c.id)
except Exception:
report['warnings'].append('demo: no se pudo crear cita %d' % i)
# Paquetes + alguna compra
Paquete = env['skeen.paquete'].sudo()
for i, serv in enumerate(Serv[:3]):
try:
p = Paquete.create({
'name': 'Paquete 5 %s' % serv.name, 'code': 'DEMO-P%02d' % (i + 1),
'service_id': serv.id, 'sessions': 5,
'price': serv.price * 4, 'validity_days': 180,
})
ids['paquete'].append(p.id)
if 'skeen.paquete.compra' in env:
env['skeen.paquete.compra'].sudo().create({
'partner_id': Pacientes[i].id, 'paquete_id': p.id,
'date': today.isoformat(), 'sessions_total': 5,
'price': p.price, 'user_login': 'demo',
})
except Exception as e:
report['warnings'].append('demo: paquete %d: %s' % (i, e))
# Monedero con saldo para algunos pacientes
Monedero = env['skeen.monedero'].sudo()
for i in range(5):
try:
m = Monedero.create({'partner_id': Pacientes[i].id,
'points': (i + 1) * 50})
ids['monedero'].append(m.id)
if 'skeen.monedero.transaction' in env:
env['skeen.monedero.transaction'].sudo().create({
'monedero_id': m.id, 'type': 'accrual',
'points': (i + 1) * 50,
'description': 'Saldo demo', 'operator': 'demo',
})
except Exception as e:
report['warnings'].append('demo: monedero %d: %s' % (i, e))
report['demo'] = ids
report['seeded'].append('datos demo (%s)' % ', '.join(
'%s:%d' % (k, len(v)) for k, v in ids.items()))
def main():
cfg = json.load(open(sys.argv[1], encoding='utf-8'))
report = {'seeded': [], 'skipped': [], 'warnings': [], 'todo': [], 'demo': {}}
odoo, registry = load_env(cfg['conf'], cfg['db'])
with registry.cursor() as cr:
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
ICP = env['ir.config_parameter'].sudo()
steps = [
lambda: seed_brand(ICP, cfg, report),
lambda: seed_menu_roles(ICP, cfg, report),
lambda: seed_company(env, cfg, report),
lambda: seed_admin(env, cfg, report),
lambda: seed_glossary(env, ICP, cfg, report),
lambda: seed_clinic_settings(env, cfg, report),
lambda: seed_sucursal(env, cfg, report),
lambda: seed_service_category(env, cfg, report),
]
for step in steps:
try:
step()
except Exception as e:
report['warnings'].append('%s: %s' % (step, e))
if cfg.get('demo'):
try:
seed_demo(env, cfg, report)
if any(report['demo'].values()):
ICP.set_param('skeen.demo.ids',
json.dumps(report['demo'], ensure_ascii=False))
except Exception as e:
report['warnings'].append('demo: %s' % e)
cr.commit()
print('POST-INSTALL OK')
print(json.dumps(report, ensure_ascii=False, indent=2))
if cfg.get('report'):
with open(cfg['report'], 'w', encoding='utf-8') as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
if __name__ == '__main__':
try:
main()
except Exception:
traceback.print_exc()
sys.exit(1)

View File

@@ -0,0 +1,582 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Provisiona una instancia white-label nueva de SKEEN en la VPS.
Levanta una clínica/spa completa en un comando sin tocar las instancias que ya
existen: base de datos y rol PostgreSQL propios, secretos propios, conf y unit
systemd, inicialización de los módulos skeen, post-instalación (marca, empresa,
admin, glosario/sucursal/settings si existen), renombrado del menú raíz, vhost
nginx y frontend.
Uso típico:
./provision_clinica.py --slug spa-bella --nombre "Spa Bella" \\
--dominio spa.consultoria-as.com --puerto 8071 --db spa_bella_odoo \\
--pack spa --demo --dry-run
Con --dry-run NO se ejecuta nada: solo se validan colisiones (lecturas) y se
imprimen los pasos, el contenido de los archivos y los comandos que correrían.
"""
import argparse
import datetime
import json
import os
import re
import secrets
import shlex
import string
import subprocess
import sys
import tempfile
from string import Template
# ------------------------------------------------------------------ rutas fijas
BASE = os.path.dirname(os.path.abspath(__file__))
TEMPLATES = os.path.join(BASE, 'templates')
ODOO_ROOT = '/root/skeen-odoo'
ODOO_BIN = os.path.join(ODOO_ROOT, 'odoo-bin')
ODOO_PYTHON = '/root/odoo-venv/bin/python'
ADDONS_PATH = '/root/skeen-odoo/addons,/root/skeen-odoo/odoo/addons'
FRONTEND_SRC = '/root/skeen-frontend'
BRAND_SRC_CANDIDATES = [
'/var/www/clinica2-frontend/brand',
'/root/skeen-proyecto/frontend/public/brand',
]
BACKUPS_DIR = '/root/backups'
NGINX_AVAILABLE = '/etc/nginx/sites-available'
NGINX_ENABLED = '/etc/nginx/sites-enabled'
SYSTEMD_DIR = '/etc/systemd/system'
REFERENCE_DB = 'skeen_odoo' # de aquí se toma la lista de módulos skeen
DEFAULT_PACK = 'generico'
SLUG_RE = re.compile(r'^[a-z0-9-]+$')
DB_RE = re.compile(r'^[a-z0-9_]+$')
# ------------------------------------------------------------------ utilidades
def gen_secret(n, alphabet=None):
alphabet = alphabet or (string.ascii_letters + string.digits)
return ''.join(secrets.choice(alphabet) for _ in range(n))
def read_template(name):
with open(os.path.join(TEMPLATES, name), encoding='utf-8') as fh:
return fh.read()
def render(name, ctx):
return Template(read_template(name)).substitute(**ctx)
def sh(cmd, check=False, input_text=None):
"""Ejecuta SIEMPRE (solo para lecturas y para el modo real)."""
p = subprocess.run(cmd, capture_output=True, text=True,
input=input_text, cwd=ODOO_ROOT)
if check and p.returncode != 0:
raise RuntimeError('comando falló (%d): %s\n%s' %
(p.returncode, ' '.join(map(str, cmd)), p.stderr))
return p
def psql(db, sql, tuples_only=True):
flag = '-Atc' if tuples_only else '-c'
return sh(['su', 'postgres', '-c',
'psql -d %s %s %s' % (shlex.quote(db), flag, shlex.quote(sql))])
class Ctx:
"""Ejecutor con modo dry-run y pista de deshacer."""
def __init__(self, dry, force):
self.dry = dry
self.force = force
self.n = 0
self.actions = [] # (descripcion, [comando de limpieza])
self.files = []
def step(self, title):
self.n += 1
print('\n%s\n[%d] %s\n%s' % ('=' * 74, self.n, title, '=' * 74))
def info(self, msg=''):
print(' ' + msg)
def warn(self, msg):
print(' !! ' + msg)
def run(self, cmd, check=True, undo=None, quiet=False, cwd=None):
pretty = ' '.join(shlex.quote(str(c)) for c in cmd)
if self.dry:
prefix = ('(cd %s && ' % cwd) if cwd else ''
suf = ')' if cwd else ''
print(' [dry-run] $ %s%s%s' % (prefix, pretty, suf))
return subprocess.CompletedProcess(cmd, 0, '', '')
if not quiet:
print(' $ %s%s' % (('(cd %s) ' % cwd) if cwd else '', pretty))
p = subprocess.run(cmd, cwd=cwd or ODOO_ROOT)
if check and p.returncode != 0:
raise RuntimeError('comando falló (%d): %s' % (p.returncode, pretty))
if undo:
self.actions.append((undo[0], undo[1]))
return p
def write(self, path, content, mode=None, undo=True):
if self.dry:
print(' [dry-run] escribir %s%s' %
(path, (' (chmod %o)' % mode) if mode else ''))
print(' ' + '-' * 66)
for line in content.splitlines():
print(' | ' + line)
print(' ' + '-' * 66)
else:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w', encoding='utf-8') as fh:
fh.write(content)
if mode:
os.chmod(path, mode)
print(' escrito %s' % path)
self.files.append(path)
if undo:
self.actions.append(('archivo %s' % path, ['rm', '-f', path]))
# --------------------------------------------------------------------- args
def parse_args(argv=None):
ap = argparse.ArgumentParser(
description='Provisiona una instancia white-label de SKEEN.',
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--slug', required=True,
help='identificador corto (^[a-z0-9-]+$), p.ej. spa-bella')
ap.add_argument('--nombre', required=True, help='nombre comercial')
ap.add_argument('--dominio', required=True,
help='dominio público, p.ej. spa.consultoria-as.com')
ap.add_argument('--puerto', required=True, type=int,
help='puerto HTTP de Odoo; longpolling usa puerto+3')
ap.add_argument('--db', help='nombre de la base (default <slug>_odoo)')
ap.add_argument('--db-role',
help='rol PostgreSQL (default <slug>: rol propio con '
'password nueva; usar "skeen" para el rol compartido)')
ap.add_argument('--pack', default=DEFAULT_PACK,
choices=['generico', 'spa', 'clinica', 'derma'],
help='giro para glosario y datos demo')
ap.add_argument('--demo', action='store_true', help='sembrar datos demo')
ap.add_argument('--demo-limpia', action='store_true',
help='borrar datos demo al final (usa demo_limpiar.py)')
ap.add_argument('--dominio-lan', type=int,
help='puerto LAN extra en el vhost nginx (p.ej. 8881)')
ap.add_argument('--admin-login', default='admin')
ap.add_argument('--pie', help='pie de página de la marca (default genérico)')
ap.add_argument('--salida', help='archivo de credenciales (default en '
'/root/backups/<slug>-credenciales-<fecha>.txt)')
ap.add_argument('--con-wacrm', action='store_true',
help='NO bloquear WACRM (por defecto se bloquea)')
ap.add_argument('--sin-wacrm', action='store_true',
help='bloquear WACRM (comportamiento por defecto)')
ap.add_argument('--skip-build', action='store_true',
help='no recompilar el frontend; reusar /root/skeen-frontend/dist')
ap.add_argument('--force', action='store_true',
help='continuar aunque existan colisiones')
ap.add_argument('--dry-run', action='store_true',
help='imprimir todo sin ejecutar nada')
return ap.parse_args(argv)
# ----------------------------------------------------------- lecturas de entorno
def used_ports():
p = sh(['ss', '-ltnH'])
return set(int(m) for m in re.findall(r':(\d+)\b', p.stdout))
def existing_dbs():
p = psql('postgres', 'SELECT datname FROM pg_database')
return set(l.strip() for l in p.stdout.splitlines() if l.strip())
def existing_roles():
p = psql('postgres', 'SELECT rolname FROM pg_roles')
return set(l.strip() for l in p.stdout.splitlines() if l.strip())
def reference_modules():
"""Lista de módulos skeen instalados en la instancia de referencia,
ordenada topológicamente (dependencias primero)."""
names = psql(REFERENCE_DB,
"SELECT name FROM ir_module_module "
"WHERE state='installed' AND name LIKE 'skeen%'").stdout
mods = [l.strip() for l in names.splitlines() if l.strip()]
if not mods:
return mods
deps_raw = psql(REFERENCE_DB,
"SELECT m.name || ' ' || d.name FROM ir_module_module m "
"JOIN ir_module_module_dependency d ON d.module_id=m.id "
"WHERE m.name LIKE 'skeen%'").stdout
deps = {m: set() for m in mods}
for line in deps_raw.splitlines():
parts = line.split()
if len(parts) == 2 and parts[0] in deps and parts[1] in deps:
deps[parts[0]].add(parts[1])
ordered, seen = [], set()
def visit(m):
if m in seen:
return
seen.add(m)
for d in sorted(deps.get(m, ())):
visit(d)
ordered.append(m)
for m in sorted(mods):
visit(m)
return ordered
def psql_file(ctx, db, sql, kind):
"""Ejecuta SQL como postgres. En dry-run solo lo imprime.
El SQL va por stdin (`-f -`) en vez de un archivo temporal: `su postgres`
pierde privilegios y no podría leer un archivo root-only 0600."""
if ctx.dry:
print(' [dry-run] SQL sobre %s (%s):' % (db, kind))
for line in sql.strip().splitlines():
print(' | ' + line)
return
p = subprocess.run(['su', 'postgres', '-c',
'psql -d %s -v ON_ERROR_STOP=1 -f -' % shlex.quote(db)],
input=sql, capture_output=True, text=True)
if p.returncode != 0:
raise RuntimeError('SQL falló:\n%s' % (p.stdout + p.stderr))
print(' SQL (%s) ok' % kind)
def sql_literal(value):
return "'" + str(value).replace("'", "''") + "'"
ctx = None
# --------------------------------------------------------------------- main
def main(argv=None):
global ctx
args = parse_args(argv)
ctx = Ctx(args.dry_run, args.force)
# ---------------------------------------------------------- derivados
slug = args.slug
db = args.db or (slug.replace('-', '_') + '_odoo')
role = args.db_role or slug.replace('-', '_')
http_port = args.puerto
longpolling_port = http_port + 3
conf_path = '/root/%s-odoo.conf' % slug
unit_name = 'odoo-%s.service' % slug
unit_path = os.path.join(SYSTEMD_DIR, unit_name)
data_dir = '/var/lib/odoo-%s' % slug
log_path = '/tmp/odoo-%s.log' % slug
screenshots_dir = '/tmp/odoo_screenshots_%s' % slug
frontend_dir = '/var/www/%s-frontend' % slug
vhost_avail = os.path.join(NGINX_AVAILABLE, slug)
vhost_enabled = os.path.join(NGINX_ENABLED, slug)
fecha = datetime.datetime.now().strftime('%Y%m%d-%H%M%S')
salida = args.salida or os.path.join(
BACKUPS_DIR, '%s-credenciales-%s.txt' % (slug, fecha))
pie = args.pie or 'Sistema de gestión'
print('=' * 74)
print('PROVISIONAMIENTO SKEEN — instancia "%s" (%s)' % (args.nombre, slug))
print('Modo: %s' % ('DRY-RUN (no se ejecuta nada)' if args.dry_run else 'REAL'))
print('=' * 74)
# ------------------------------------------------------- 1. validaciones
ctx.step('Validaciones previas')
errors = []
if not SLUG_RE.match(slug):
errors.append('slug inválido %r: debe cumplir ^[a-z0-9-]+$' % slug)
if not DB_RE.match(db):
errors.append('nombre de DB inválido %r: debe cumplir ^[a-z0-9_]+$' % db)
if not DB_RE.match(role):
errors.append('rol inválido %r' % role)
ports = used_ports()
dbs = existing_dbs()
roles = existing_roles()
collisions = []
if http_port in ports:
collisions.append('puerto %d ya está en uso' % http_port)
if longpolling_port in ports:
collisions.append('puerto longpolling %d ya está en uso' % longpolling_port)
if db in dbs:
collisions.append('la base de datos %r ya existe' % db)
if os.path.exists(unit_path):
collisions.append('el unit %s ya existe' % unit_path)
if os.path.exists(vhost_enabled) or os.path.exists(vhost_avail):
collisions.append('el vhost nginx %s ya existe' % slug)
if os.path.exists(frontend_dir):
collisions.append('el directorio frontend %s ya existe' % frontend_dir)
if os.path.exists(conf_path):
collisions.append('el archivo de conf %s ya existe' % conf_path)
if os.path.exists(data_dir):
collisions.append('el data_dir %s ya existe' % data_dir)
for e in errors:
ctx.warn(e)
for c in collisions:
ctx.warn('COLISIÓN: ' + c)
if role in roles and role != 'skeen':
ctx.warn('el rol PostgreSQL %r ya existe; no se cambiará su password' % role)
if errors or (collisions and not args.force):
print('\nABORTADO. Corrige los errores/colisiones o usa --force.')
return 2
if collisions and args.force:
ctx.warn('--force activo: se continúa pese a las colisiones anteriores.')
ctx.info('slug=%s db=%s rol=%s puerto=%d/%d dominio=%s'
% (slug, db, role, http_port, longpolling_port, args.dominio))
ctx.info('pack=%s demo=%s wacrm=%s'
% (args.pack, args.demo, 'NO' if args.con_wacrm else 'bloqueado'))
# ----------------------------------------------------------- 2. secretos
ctx.step('Secretos propios de la instancia')
admin_passwd = gen_secret(24)
db_password = gen_secret(28)
admin_password = gen_secret(16)
ctx.info('admin_passwd (master Odoo): %s' % admin_passwd)
ctx.info('db_password (rol %s): %s' % (role, db_password))
ctx.info('admin frontend %s: %s' % (args.admin_login, admin_password))
ctx.info('(se guardarán en %s, chmod 600)' % salida)
# ------------------------------------------------------------- 3. DB
ctx.step('Base de datos y rol PostgreSQL')
if role == 'skeen':
ctx.warn('Usando el rol COMPARTIDO "skeen". El db_password generado no '
'coincidirá con la password real del rol; ajusta '
'db_password a mano en %s o usa un rol propio (default).'
% conf_path)
elif role in roles:
ctx.warn('El rol %s ya existe: se reutiliza y NO se le cambia la '
'password. Verifica que db_password en la conf coincida.' % role)
else:
create_role_sql = ("CREATE ROLE %s WITH LOGIN PASSWORD %s;"
% (role, sql_literal(db_password)))
psql_file(ctx, 'postgres', create_role_sql, 'CREATE ROLE %s' % role)
ctx.actions.append(('rol %s' % role,
['su', 'postgres', '-c', 'dropuser %s' % role]))
createdb = ['su', 'postgres', '-c', 'createdb -O %s %s' % (role, db)]
ctx.run(createdb, undo=('base %s' % db,
['su', 'postgres', '-c', 'dropdb %s' % db]))
ctx.run(['mkdir', '-p', data_dir],
undo=('data_dir %s' % data_dir, ['rm', '-rf', data_dir]))
# --------------------------------------------------------- 4. odoo.conf
ctx.step('Configuración de Odoo (%s)' % conf_path)
conf = render('odoo.conf.tmpl', {
'ADDONS_PATH': ADDONS_PATH,
'ADMIN_PASSWD': admin_passwd,
'DATA_DIR': data_dir,
'DB_NAME': db,
'DB_PASSWORD': db_password,
'DB_USER': role,
'HTTP_PORT': http_port,
'LONGPOLLING_PORT': longpolling_port,
'LOG_PATH': log_path,
'SCREENSHOTS_DIR': screenshots_dir,
})
ctx.write(conf_path, conf, mode=0o600)
# ------------------------------------------------------ 5. unit systemd
ctx.step('Unit systemd (%s)' % unit_name)
service = render('service.tmpl', {
'NOMBRE': args.nombre, 'SLUG': slug,
'ODOO_PYTHON': ODOO_PYTHON, 'ODOO_BIN': ODOO_BIN,
'CONF_PATH': conf_path,
})
ctx.write(unit_path, service)
ctx.run(['systemctl', 'daemon-reload'])
ctx.run(['systemctl', 'enable', unit_name],
undo=('unit %s' % unit_name,
['systemctl', 'disable', '--now', unit_name]))
# -------------------------------------------------- 6. init de módulos
ctx.step('Inicialización de módulos skeen')
modules = reference_modules()
if not modules:
ctx.warn('No se pudieron leer los módulos de %s; usando la lista del '
'procedimiento manual.' % REFERENCE_DB)
modules = ['skeen_pacientes', 'skeen_citas', 'skeen_inventario',
'skeen_monedero', 'skeen_pagos', 'skeen_ventas',
'skeen_whatsapp', 'skeen_chat', 'skeen_visitas']
ctx.info('Módulos (orden topológico, tomados de %s): %s'
% (REFERENCE_DB, ','.join(modules)))
init_cmd = [ODOO_PYTHON, ODOO_BIN, '-c', conf_path, '-d', db,
'--without-demo=all', '--stop-after-init', '--no-http',
'-i', ','.join(modules), '--log-level=warn']
ctx.run(init_cmd)
ctx.info('(la service no se arranca hasta después del post-install)')
# ------------------------------------------------- 7. post-instalación
ctx.step('Post-instalación (marca, empresa, admin, config v2)%s'
% (', demo' if args.demo else ''))
report_path = os.path.join(tempfile.gettempdir(),
'postinstall-%s.json' % slug)
cfg_post = {
'conf': conf_path, 'db': db, 'nombre': args.nombre,
'dominio': args.dominio, 'pack': args.pack,
'admin_login': args.admin_login, 'admin_password': admin_password,
'con_wacrm': args.con_wacrm, 'demo': args.demo, 'pie': pie,
'report': report_path,
}
cfg_path = os.path.join(tempfile.gettempdir(), 'postinstall-%s.cfg.json' % slug)
ctx.write(cfg_path, json.dumps(cfg_post, ensure_ascii=False, indent=2),
mode=0o600)
helper = os.path.join(BASE, 'postinstall_clinica.py')
ctx.run([ODOO_PYTHON, helper, cfg_path])
if not ctx.dry and os.path.exists(report_path):
try:
with open(report_path, encoding='utf-8') as fh:
rep = json.load(fh)
for k in ('seeded', 'skipped', 'warnings', 'todo'):
for item in rep.get(k, []):
ctx.info('%-9s %s' % (k + ':', item))
except Exception as e:
ctx.warn('no se pudo leer el reporte de post-install: %s' % e)
else:
ctx.info('(el post-install reporta qué sembró y deja TODO para lo que '
'el modelo del nuevo config aún no exista)')
# --------------------------------------------- 8. renombrar menú raíz
ctx.step('Renombrar el menú raíz (%s)' % args.nombre)
menu_sql = (
"UPDATE ir_ui_menu SET name = jsonb_build_object('en_US', %s)\n"
"WHERE id = (SELECT res_id FROM ir_model_data\n"
" WHERE module='skeen_pacientes' AND name='menu_skeen_root'\n"
" AND model='ir.ui.menu');"
% sql_literal(args.nombre))
psql_file(ctx, db, menu_sql, 'menu_skeen_root -> %s' % args.nombre)
# ---------------------------------------------------- 9. vhost nginx
ctx.step('Vhost nginx (%s)' % slug)
lan_listen = ' listen %d;\n' % args.dominio_lan if args.dominio_lan else ''
lan_comment = ('# Acceso LAN: http://<ip-de-la-vps>:%d' % args.dominio_lan
if args.dominio_lan else '')
wacrm_block = '' if args.con_wacrm else (
"\n # Instancia sin WhatsApp: se corta la ruta WACRM aunque el\n"
" # backend tenga fallback. Quitar este bloque al activar WhatsApp.\n"
" location /api/odoo/skeen/frontend/v1/wacrm/ {\n"
" return 403;\n"
" }")
vhost = render('nginx-vhost.tmpl', {
'NOMBRE': args.nombre, 'SLUG': slug, 'DOMINIO': args.dominio,
'HTTP_PORT': http_port, 'FRONTEND_DIR': frontend_dir,
'LAN_LISTEN': lan_listen, 'LAN_COMMENT': lan_comment,
'LAN_PORT_OR_EMPTY': args.dominio_lan or '',
'WACRM_BLOCK': wacrm_block,
})
ctx.write(vhost_avail, vhost)
if ctx.dry:
print(' [dry-run] $ ln -s %s %s' % (vhost_avail, vhost_enabled))
else:
if os.path.lexists(vhost_enabled):
os.unlink(vhost_enabled)
os.symlink(vhost_avail, vhost_enabled)
print(' symlink %s -> %s' % (vhost_enabled, vhost_avail))
ctx.actions.append(('vhost %s' % slug,
['rm', '-f', vhost_enabled, vhost_avail]))
ctx.run(['nginx', '-t'])
ctx.run(['systemctl', 'reload', 'nginx'])
# ------------------------------------------------------- 10. frontend
ctx.step('Frontend React (%s)' % frontend_dir)
if args.skip_build:
ctx.info('--skip-build: se reutiliza %s/dist' % FRONTEND_SRC)
else:
ctx.info('Build compartido (un solo bundle sirve a todas las instancias; '
'la marca sale del API /brand). Se compila una vez desde %s.'
% FRONTEND_SRC)
ctx.run(['npm', 'run', 'build'], cwd=FRONTEND_SRC)
ctx.run(['mkdir', '-p', frontend_dir],
undo=('frontend %s' % frontend_dir, ['rm', '-rf', frontend_dir]))
ctx.run(['rsync', '-a', '--delete', FRONTEND_SRC + '/dist/', frontend_dir + '/'])
brand_src = next((p for p in BRAND_SRC_CANDIDATES if os.path.isdir(p)),
BRAND_SRC_CANDIDATES[-1])
ctx.info('Logos genéricos desde %s' % brand_src)
ctx.run(['mkdir', '-p', os.path.join(frontend_dir, 'brand')])
ctx.run(['cp', '-f'] + [os.path.join(brand_src, f)
for f in ('logo-blanco.svg', 'logo-negro.svg',
'favicon.svg')]
+ [os.path.join(frontend_dir, 'brand') + '/'])
ctx.run(['chown', '-R', 'www-data:www-data', frontend_dir])
# ------------------------------------------------- 11. arrancar service
ctx.step('Arranque de la instancia')
ctx.run(['systemctl', 'start', unit_name],
undo=('service %s' % unit_name,
['systemctl', 'stop', unit_name]))
ctx.run(['systemctl', 'is-active', unit_name], check=False)
# ------------------------------------------------- demo-limpia opcional
if args.demo_limpia:
ctx.step('Limpieza de datos demo')
ctx.run([ODOO_PYTHON, os.path.join(BASE, 'demo_limpiar.py'),
'--conf', conf_path, '--db', db])
# ------------------------------------------------------- 12. credenciales
ctx.step('Credenciales y resumen')
creds = (
'Instancia: %s (%s)\n'
'Generado: %s\n'
'Dominio: https://%s\n'
'Odoo HTTP: http://127.0.0.1:%d (longpolling %d)\n'
'Base de datos: %s\n'
'Rol PostgreSQL: %s\n'
'Conf: %s\n'
'Unit: %s\n'
'Frontend: %s\n'
'\n'
'Odoo master password (admin_passwd): %s\n'
'Frontend admin: %s / %s (cambio obligatorio al primer login)\n'
% (args.nombre, slug, datetime.datetime.now().isoformat(timespec='seconds'),
args.dominio, http_port, longpolling_port, db, role, conf_path,
unit_name, frontend_dir, admin_passwd, args.admin_login, admin_password)
)
if ctx.dry:
print(' [dry-run] escribir %s (chmod 600):' % salida)
for line in creds.splitlines():
print(' | ' + line)
else:
os.makedirs(BACKUPS_DIR, exist_ok=True)
with open(salida, 'w', encoding='utf-8') as fh:
fh.write(creds)
os.chmod(salida, 0o600)
print(' credenciales guardadas en %s (chmod 600)' % salida)
print('\n%s' % ('-' * 74))
print('RESUMEN — %s' % ('dry-run: nada se ejecutó' if ctx.dry else 'OK'))
print('-' * 74)
print(' Logs: tail -f %s' % log_path)
print(' Estado: systemctl status %s' % unit_name)
print(' Health: curl http://127.0.0.1:%d/web/health' % http_port)
print(' LAN: http://<ip-vps>:%s' % (args.dominio_lan or '-'))
print(' PÚBLICO: agregar ingress en el dashboard de cloudflared:')
print(' hostname %s -> service http://localhost:80' % args.dominio)
if args.demo and not args.demo_limpia:
print(' Demo: borrar con %s/demo_limpiar.py --conf %s --db %s'
% (BASE, conf_path, db))
print('-' * 74)
return 0
if __name__ == '__main__':
try:
sys.exit(main())
except KeyboardInterrupt:
print('\nInterrumpido.')
sys.exit(130)
except Exception as e:
print('\n' + '!' * 74)
print('FALLÓ: %s' % e)
print('!' * 74)
print('Estado parcial (limpia a mano si lo necesitas):')
if ctx is not None:
for desc, undo in ctx.actions:
print(' - %s -> %s' % (desc, ' '.join(map(str, undo))))
else:
print(' - aún no se ejecutó ningún paso')
sys.exit(1)

View File

@@ -0,0 +1,39 @@
# Instancia ${NOMBRE} (${SLUG}) — frontend React + API Odoo en puerto ${HTTP_PORT}.
# Acceso público: ${DOMINIO} (agregar el ingress en el dashboard del túnel
# cloudflared: hostname ${DOMINIO} -> service http://localhost:80).
${LAN_COMMENT}
server {
listen 80;
${LAN_LISTEN}
server_name ${DOMINIO};
# Frontend React (mismo build que SKEEN; la marca sale del API /brand)
location / {
root ${FRONTEND_DIR};
try_files $$uri $$uri/ /index.html;
add_header Cache-Control "no-cache";
}
# API Odoo para el frontend
location /api/odoo/ {
proxy_pass http://127.0.0.1:${HTTP_PORT}/;
proxy_http_version 1.1;
proxy_set_header Host $$host;
proxy_set_header X-Real-IP $$remote_addr;
proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $$scheme;
proxy_read_timeout 120s;
}
# Backend Odoo (administración directa, opcional)
location /web/ {
proxy_pass http://127.0.0.1:${HTTP_PORT}/web/;
proxy_http_version 1.1;
proxy_set_header Host $$host;
proxy_set_header X-Real-IP $$remote_addr;
proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $$scheme;
proxy_read_timeout 120s;
}
${WACRM_BLOCK}
}

View File

@@ -0,0 +1,44 @@
[options]
addons_path = ${ADDONS_PATH}
admin_passwd = ${ADMIN_PASSWD}
csv_internal_sep = ,
data_dir = ${DATA_DIR}
db_host = localhost
db_maxconn = 64
db_name = ${DB_NAME}
db_password = ${DB_PASSWORD}
db_port = 5432
db_sslmode = prefer
db_user = ${DB_USER}
demo = {}
email_from = False
http_enable = True
http_interface =
http_port = ${HTTP_PORT}
limit_memory_hard = 2684354560
limit_memory_soft = 2147483648
limit_request = 8192
limit_time_cpu = 60
limit_time_real = 120
limit_time_real_cron = -1
list_db = True
log_db = False
log_db_level = warning
log_handler = :INFO
log_level = info
logfile = ${LOG_PATH}
longpolling_port = ${LONGPOLLING_PORT}
max_cron_threads = 2
pg_path =
pidfile =
proxy_mode = True
reportgz = False
screenshots = ${SCREENSHOTS_DIR}
server_wide_modules = base,web
smtp_port = 25
smtp_server = localhost
test_tags = None
translate_modules = ['all']
unaccent = False
without_demo = all
workers = 0

View File

@@ -0,0 +1,19 @@
[Unit]
Description=Odoo ${NOMBRE} (${SLUG}) — skeen-odoo venv, instancia white-label
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=simple
User=root
WorkingDirectory=/root/skeen-odoo
# Instancia aislada: sin SUPABASE_URL / WACRM_API_* / HERMES_BRIDGE_URL.
# Los endpoints WACRM quedan sin credenciales (escenario B) y el vhost nginx
# corta la ruta con 403.
ExecStart=${ODOO_PYTHON} ${ODOO_BIN} -c ${CONF_PATH}
Restart=always
RestartSec=5
TimeoutStartSec=120
[Install]
WantedBy=multi-user.target

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
{ {
'name': 'SKEEN Citas', 'name': 'SKEEN Citas',
'version': '1.0.1', 'version': '1.1.0',
'category': 'Healthcare', 'category': 'Healthcare',
'summary': 'Agendamiento de citas para SKEEN Derma Experts', 'summary': 'Agendamiento de citas para SKEEN Derma Experts',
'description': """ 'description': """
@@ -14,6 +14,7 @@
'data': [ 'data': [
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/sequences.xml', 'data/sequences.xml',
'data/catalogos.xml',
# Menú padre primero: las vistas de abajo cuelgan de él. # Menú padre primero: las vistas de abajo cuelgan de él.
'views/menus.xml', 'views/menus.xml',
'views/cita_views.xml', 'views/cita_views.xml',

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
"""Utilidades para los catálogos configurables (sucursales, categorías)."""
import re
import unicodedata
def slugify(text):
"""'Playa de Rosarito' -> 'playa-de-rosarito' (sin acentos, [a-z0-9-])."""
text = unicodedata.normalize('NFKD', str(text or ''))
text = text.encode('ascii', 'ignore').decode('ascii')
text = re.sub(r'[^a-zA-Z0-9]+', '-', text).strip('-').lower()
return text
def unique_slug(model, base, exclude_id=None):
"""Slug único para `model` (sufija -2, -3... si ya existe).
Cuenta también los registros inactivos: el `code` es único a nivel base de
datos y las bajas son lógicas, así que un código usado por algo desactivado
sigue ocupado.
"""
model = model.with_context(active_test=False)
base = slugify(base) or 'item'
domain = [('code', '=', base)]
if exclude_id:
domain.append(('id', '!=', exclude_id))
if not model.search_count(domain):
return base
n = 2
while True:
cand = '%s-%d' % (base, n)
domain = [('code', '=', cand)]
if exclude_id:
domain.append(('id', '!=', exclude_id))
if not model.search_count(domain):
return cand
n += 1

View File

@@ -0,0 +1,166 @@
# -*- coding: utf-8 -*-
"""Ajustes de la clínica por instancia (white-label).
Vive en `skeen.clinic.settings` (JSON) y no tiene modelo propio. Antes estos
datos solo existían en el localStorage del navegador de cada equipo; ahora son
compartidos por toda la instancia y los consumen el frontend, la impresión de
tickets y las URLs de pago.
El nombre por defecto es el del brand (`skeen.clinic.brand` -> 'nombre') para no
cambiar lo que ya se ve en producción cuando el parámetro no está guardado.
"""
import json
from urllib.parse import urlparse
SETTINGS_KEY = 'skeen.clinic.settings'
BRAND_KEY = 'skeen.clinic.brand'
BRAND_NOMBRE_DEFAULT = 'SKEEN Derma Experts'
HORA_APERTURA_DEFAULT = '08:00'
HORA_CIERRE_DEFAULT = '19:00'
ZONA_HORARIA_DEFAULT = 'America/Tijuana'
MONEDA_DEFAULT = 'MXN'
# Campos del contrato del endpoint.
CAMPOS = (
'nombre', 'telefono', 'email', 'direccion', 'web', 'rfc',
'horario_apertura', 'horario_cierre', 'zona_horaria', 'url_publica',
'moneda_base', 'nota_ticket',
)
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0
def brand_dict(env):
"""Brand vigente (subconjunto suficiente), sin defaults de logos."""
raw = env['ir.config_parameter'].sudo().get_param(BRAND_KEY)
if raw:
try:
guardado = json.loads(raw)
except (ValueError, TypeError):
guardado = None
if isinstance(guardado, dict):
return guardado
return {}
def brand_nombre(env):
nombre = brand_dict(env).get('nombre')
nombre = str(nombre).strip() if nombre is not None else ''
return nombre or BRAND_NOMBRE_DEFAULT
def get_settings(env):
"""Objeto completo de ajustes, con defaults si no hay nada guardado."""
out = {
'nombre': brand_nombre(env),
'telefono': '',
'email': '',
'direccion': '',
'web': '',
'rfc': '',
'horario_apertura': HORA_APERTURA_DEFAULT,
'horario_cierre': HORA_CIERRE_DEFAULT,
'zona_horaria': ZONA_HORARIA_DEFAULT,
'url_publica': '',
'moneda_base': MONEDA_DEFAULT,
'nota_ticket': '',
}
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw:
try:
guardado = json.loads(raw)
except (ValueError, TypeError):
guardado = None
if isinstance(guardado, dict):
for k in CAMPOS:
if k in guardado and guardado[k] is not None:
out[k] = str(guardado[k])
return out
def set_settings(env, vals):
"""Guarda mergeando sobre lo actual. Devuelve el objeto resultante."""
if not isinstance(vals, dict):
raise ValueError('Los ajustes deben ser un objeto')
actual = get_settings(env)
for k in CAMPOS:
if k in vals and vals[k] is not None:
actual[k] = str(vals[k]).strip()
env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env)
def _hhmm_to_float(valor):
"""'08:00' -> 8.0. None si el formato no es válido."""
try:
partes = str(valor).strip().split(':')
h = int(partes[0])
m = int(partes[1]) if len(partes) > 1 else 0
if not (0 <= h <= 24 and 0 <= m <= 59):
return None
return h + m / 60.0
except (ValueError, TypeError, IndexError):
return None
def agenda_bounds(env):
"""(inicio, fin) de la agenda en horas float.
Lee horario_apertura/horario_cierre de los ajustes; si faltan o son
incoherentes usa el horario de siempre (8:00-19:00), de modo que una
instancia sin configurar se comporta igual que hoy.
"""
s = get_settings(env)
inicio = _hhmm_to_float(s.get('horario_apertura'))
fin = _hhmm_to_float(s.get('horario_cierre'))
if inicio is None:
inicio = AGENDA_START_DEFAULT
if fin is None:
fin = AGENDA_END_DEFAULT
if fin <= inicio:
return AGENDA_START_DEFAULT, AGENDA_END_DEFAULT
return inicio, fin
def _es_url_privada(url):
"""True si el host es local/privado (no sirve para URLs de pago públicas)."""
try:
host = (urlparse(url).hostname or '').lower()
except ValueError:
return True
if not host or host in ('localhost', '127.0.0.1', '::1'):
return True
if host.startswith('10.') or host.startswith('192.168.'):
return True
if host.startswith('172.'):
try:
segundo = int(host.split('.')[1])
except (IndexError, ValueError):
return True
if 16 <= segundo <= 31:
return True
return False
def url_publica(env, fallback=''):
"""URL pública base de la instancia, sin barra final.
Orden: ajustes de la clínica -> brand -> `web.base.url` (solo si es público)
-> `fallback` (el valor que usaba el código antes). El último escalón
preserva el comportamiento actual en instancias sin configurar.
"""
url = (get_settings(env).get('url_publica') or '').strip()
if not url:
url = str(brand_dict(env).get('url_publica') or '').strip()
if not url:
base = env['ir.config_parameter'].sudo().get_param('web.base.url') or ''
if base and not _es_url_privada(base):
url = base
if not url:
url = fallback
return (url or '').rstrip('/')

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Catálogos semilla configurables (white-label).
noupdate="1": se crean una sola vez; los cambios que haga la clínica
(renombrar, desactivar, agregar) sobreviven a los upgrades.
Los `code` coinciden con los valores que ya estaban hardcodeados en
skeen.cita.branch y skeen.servicio.category, para no tocar los datos
históricos. -->
<data noupdate="1">
<!-- Sucursales -->
<record id="sucursal_rosarito" model="skeen.sucursal">
<field name="name">Rosarito</field>
<field name="code">rosarito</field>
<field name="sequence">10</field>
<field name="is_default" eval="True"/>
</record>
<record id="sucursal_tijuana" model="skeen.sucursal">
<field name="name">Tijuana</field>
<field name="code">tijuana</field>
<field name="sequence">20</field>
</record>
<!-- Categorías de servicio -->
<record id="service_category_consulta" model="skeen.service.category">
<field name="name">Consulta</field>
<field name="code">consulta</field>
<field name="sequence">10</field>
<field name="is_system" eval="True"/>
</record>
<record id="service_category_tratamiento" model="skeen.service.category">
<field name="name">Tratamiento</field>
<field name="code">tratamiento</field>
<field name="sequence">20</field>
<field name="is_system" eval="True"/>
</record>
<record id="service_category_procedimiento" model="skeen.service.category">
<field name="name">Procedimiento</field>
<field name="code">procedimiento</field>
<field name="sequence">30</field>
<field name="is_system" eval="True"/>
</record>
<record id="service_category_paquete" model="skeen.service.category">
<field name="name">Paquete</field>
<field name="code">paquete</field>
<field name="sequence">40</field>
<field name="is_system" eval="True"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,212 @@
# -*- coding: utf-8 -*-
"""Glosario configurable de la clínica (terminología white-label).
Solo lee/escribe el parámetro `skeen.glosario` (JSON). No hay modelo propio:
el pack base y los overrides se resuelven en memoria.
Estructura guardada:
{"pack": "clinica", "overrides": {"patient_singular": "Huésped"}}
Resolución:
terms = PACKS[pack] (o 'clinica' si el pack no existe) + overrides
El pack 'custom' es válido: parte del pack 'clinica' y aplica los overrides.
Así el endpoint siempre devuelve las 24 claves canónicas resueltas.
"""
import json
GLOSARIO_KEY = 'skeen.glosario'
DEFAULT_PACK = 'clinica'
CUSTOM_PACK = 'custom'
# Las 24 claves canónicas, en el orden del brief (contrato del frontend).
GLOSARIO_KEYS = (
'patient_singular', 'patient_plural',
'appointment_singular', 'appointment_plural',
'visit_singular', 'visit_plural',
'record_singular', 'record_plural',
'doctor_singular', 'doctor_plural',
'staff_singular', 'staff_plural',
'branch_singular', 'branch_plural',
'room_singular', 'room_plural',
'service_singular', 'service_plural',
'package_singular', 'package_plural',
'prescription_singular', 'prescription_plural',
'treatment_singular', 'treatment_plural',
)
# Nombre legible de cada pack para el selector de configuración.
PACK_LABELS = {
'clinica': 'Clínica',
'spa': 'Spa / Wellness',
'estetica': 'Estética',
'dental': 'Dental',
}
PACKS = {
'clinica': {
'patient_singular': 'Paciente',
'patient_plural': 'Pacientes',
'appointment_singular': 'Cita',
'appointment_plural': 'Citas',
'visit_singular': 'Visita',
'visit_plural': 'Visitas',
'record_singular': 'Expediente',
'record_plural': 'Expedientes',
'doctor_singular': 'Médico',
'doctor_plural': 'Médicos',
'staff_singular': 'Personal',
'staff_plural': 'Personal',
'branch_singular': 'Sucursal',
'branch_plural': 'Sucursales',
'room_singular': 'Cabina',
'room_plural': 'Cabinas',
'service_singular': 'Servicio',
'service_plural': 'Servicios',
'package_singular': 'Paquete',
'package_plural': 'Paquetes',
'prescription_singular': 'Receta',
'prescription_plural': 'Recetas',
'treatment_singular': 'Tratamiento',
'treatment_plural': 'Tratamientos',
},
'spa': {
'patient_singular': 'Cliente',
'patient_plural': 'Clientes',
'appointment_singular': 'Reserva',
'appointment_plural': 'Reservas',
'visit_singular': 'Sesión',
'visit_plural': 'Sesiones',
'record_singular': 'Ficha',
'record_plural': 'Fichas',
'doctor_singular': 'Terapeuta',
'doctor_plural': 'Terapeutas',
'staff_singular': 'Personal',
'staff_plural': 'Personal',
'branch_singular': 'Sucursal',
'branch_plural': 'Sucursales',
'room_singular': 'Sala',
'room_plural': 'Salas',
'service_singular': 'Servicio',
'service_plural': 'Servicios',
'package_singular': 'Paquete',
'package_plural': 'Paquetes',
'prescription_singular': 'Recomendación',
'prescription_plural': 'Recomendaciones',
'treatment_singular': 'Tratamiento',
'treatment_plural': 'Tratamientos',
},
'estetica': {
'patient_singular': 'Cliente',
'patient_plural': 'Clientes',
'appointment_singular': 'Cita',
'appointment_plural': 'Citas',
'visit_singular': 'Sesión',
'visit_plural': 'Sesiones',
'record_singular': 'Ficha',
'record_plural': 'Fichas',
'doctor_singular': 'Especialista',
'doctor_plural': 'Especialistas',
'staff_singular': 'Personal',
'staff_plural': 'Personal',
'branch_singular': 'Sucursal',
'branch_plural': 'Sucursales',
'room_singular': 'Cabina',
'room_plural': 'Cabinas',
'service_singular': 'Servicio',
'service_plural': 'Servicios',
'package_singular': 'Paquete',
'package_plural': 'Paquetes',
'prescription_singular': 'Receta',
'prescription_plural': 'Recetas',
'treatment_singular': 'Tratamiento',
'treatment_plural': 'Tratamientos',
},
'dental': {
'patient_singular': 'Paciente',
'patient_plural': 'Pacientes',
'appointment_singular': 'Cita',
'appointment_plural': 'Citas',
'visit_singular': 'Consulta',
'visit_plural': 'Consultas',
'record_singular': 'Expediente',
'record_plural': 'Expedientes',
'doctor_singular': 'Odontólogo',
'doctor_plural': 'Odontólogos',
'staff_singular': 'Personal',
'staff_plural': 'Personal',
'branch_singular': 'Sucursal',
'branch_plural': 'Sucursales',
'room_singular': 'Consultorio',
'room_plural': 'Consultorios',
'service_singular': 'Tratamiento',
'service_plural': 'Tratamientos',
'package_singular': 'Paquete',
'package_plural': 'Paquetes',
'prescription_singular': 'Receta',
'prescription_plural': 'Recetas',
'treatment_singular': 'Procedimiento',
'treatment_plural': 'Procedimientos',
},
}
VALID_PACKS = tuple(PACKS) + (CUSTOM_PACK,)
def pack_terms(pack):
"""Términos base de un pack (copia). 'custom' parte del pack por defecto."""
base = PACKS.get(pack) or PACKS[DEFAULT_PACK]
return dict(base)
def get_config(env):
"""Config vigente: {'pack': str, 'overrides': {clave: valor}}."""
raw = env['ir.config_parameter'].sudo().get_param(GLOSARIO_KEY)
pack = DEFAULT_PACK
overrides = {}
if raw:
try:
guardado = json.loads(raw)
except (ValueError, TypeError):
guardado = None
if isinstance(guardado, dict):
p = guardado.get('pack')
if isinstance(p, str) and p:
pack = p
o = guardado.get('overrides')
if isinstance(o, dict):
overrides = {k: str(v) for k, v in o.items()
if k in GLOSARIO_KEYS and v is not None and str(v).strip()}
return {'pack': pack, 'overrides': overrides}
def resolve(env):
"""(pack, terms): las 24 claves resueltas (pack + overrides)."""
cfg = get_config(env)
terms = pack_terms(cfg['pack'])
terms.update(cfg['overrides'])
return cfg['pack'], terms
def set_config(env, pack=None, overrides=None):
"""Guarda pack/overrides. None = conserva lo actual."""
actual = get_config(env)
nuevo_pack = actual['pack'] if pack is None else (str(pack).strip() or DEFAULT_PACK)
nuevos_overrides = actual['overrides']
if overrides is not None:
if not isinstance(overrides, dict):
raise ValueError('overrides debe ser un objeto')
nuevos_overrides = {k: str(v).strip() for k, v in overrides.items()
if k in GLOSARIO_KEYS and v is not None and str(v).strip()}
env['ir.config_parameter'].sudo().set_param(
GLOSARIO_KEY,
json.dumps({'pack': nuevo_pack, 'overrides': nuevos_overrides},
ensure_ascii=False))
return get_config(env)
def list_packs():
"""Packs disponibles con nombre legible."""
return [{'code': code, 'name': PACK_LABELS.get(code, code.capitalize())}
for code in VALID_PACKS]

View File

@@ -3,3 +3,5 @@
from . import cita from . import cita
from . import bloqueo from . import bloqueo
from . import lista_espera from . import lista_espera
from . import sucursal
from . import service_category

View File

@@ -4,6 +4,8 @@ from odoo import models, fields, api, _
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
from datetime import datetime, timedelta from datetime import datetime, timedelta
from ..clinic_settings import agenda_bounds
# Estados que liberan el horario: no ocupan agenda ni bloquean solapes. # Estados que liberan el horario: no ocupan agenda ni bloquean solapes.
# 'waitlist' (Lista de espera) es una cita tentativa sin hueco reservado. # 'waitlist' (Lista de espera) es una cita tentativa sin hueco reservado.
@@ -34,13 +36,12 @@ class SkeenServicio(models.Model):
package_sessions = fields.Integer(string='Sesiones en Paquete', default=0) package_sessions = fields.Integer(string='Sesiones en Paquete', default=0)
package_notes = fields.Char(string='Notas del Paquete') package_notes = fields.Char(string='Notas del Paquete')
# Categoría # Categoría (valores configurables en skeen.service.category; los códigos
category = fields.Selection([ # semilla 'consulta', 'tratamiento', 'procedimiento' y 'paquete' conservan
('consulta', 'Consulta'), # el comportamiento y los lookups actuales del backend)
('tratamiento', 'Tratamiento'), category = fields.Selection(
('procedimiento', 'Procedimiento'), selection='_get_category_selection',
('paquete', 'Paquete'), string='Categoría', default='tratamiento', required=True)
], string='Categoría', default='tratamiento', required=True)
# Visual / agrupación # Visual / agrupación
color = fields.Char(string='Color', default='#1abc9c') color = fields.Char(string='Color', default='#1abc9c')
@@ -61,6 +62,20 @@ class SkeenServicio(models.Model):
('unique_code', 'unique(code)', 'El código debe ser único!') ('unique_code', 'unique(code)', 'El código debe ser único!')
] ]
@api.model
def _get_category_selection(self):
"""Categorías configuradas. Incluye las inactivas para no invalidar
servicios históricos; si aún no hay datos usa las de siempre."""
categorias = self.env['skeen.service.category'].sudo().with_context(
active_test=False).search([])
seleccion = [(c.code, c.name) for c in categorias if c.code]
return seleccion or [
('consulta', 'Consulta'),
('tratamiento', 'Tratamiento'),
('procedimiento', 'Procedimiento'),
('paquete', 'Paquete'),
]
class SkeenServiceTag(models.Model): class SkeenServiceTag(models.Model):
_name = 'skeen.service.tag' _name = 'skeen.service.tag'
@@ -103,10 +118,12 @@ class SkeenCita(models.Model):
datetime_end = fields.Datetime(string='Fin', compute='_compute_datetime', store=True) datetime_end = fields.Datetime(string='Fin', compute='_compute_datetime', store=True)
# Ubicación y médico # Ubicación y médico
branch = fields.Selection([ # Sucursal: valores configurables en skeen.sucursal. Las semilla conservan
('rosarito', 'Rosarito'), # los códigos 'rosarito'/'tijuana' de las citas existentes.
('tijuana', 'Tijuana'), branch = fields.Selection(
], string='Sucursal', default='rosarito') selection='_get_branch_selection',
string='Sucursal',
default=lambda self: self.env['skeen.sucursal'].default_code())
doctor_id = fields.Many2one('hr.employee', string='Médico / Especialista') doctor_id = fields.Many2one('hr.employee', string='Médico / Especialista')
medium = fields.Selection([ medium = fields.Selection([
('videocall', 'Videollamada'), ('videocall', 'Videollamada'),
@@ -178,6 +195,15 @@ class SkeenCita(models.Model):
package_finished = fields.Boolean(string='Paquete Terminado', default=False) package_finished = fields.Boolean(string='Paquete Terminado', default=False)
package_finished_date = fields.Datetime(string='Fecha Término Paquete') package_finished_date = fields.Datetime(string='Fecha Término Paquete')
@api.model
def _get_branch_selection(self):
"""Sucursales configuradas. Incluye las inactivas para no invalidar
citas históricas; si aún no hay datos usa las de siempre."""
sucursales = self.env['skeen.sucursal'].sudo().with_context(
active_test=False).search([])
seleccion = [(s.code, s.name) for s in sucursales if s.code]
return seleccion or [('rosarito', 'Rosarito'), ('tijuana', 'Tijuana')]
@api.depends('date', 'time', 'servicio_id', 'duration') @api.depends('date', 'time', 'servicio_id', 'duration')
def _compute_datetime(self): def _compute_datetime(self):
for cita in self: for cita in self:
@@ -430,8 +456,8 @@ class SkeenCita(models.Model):
Con doctor_id, además excluye los horarios bloqueados de ese médico Con doctor_id, además excluye los horarios bloqueados de ese médico
(el usuario no debe poder elegir una hora bloqueada) y respeta su (el usuario no debe poder elegir una hora bloqueada) y respeta su
horario semanal cuando lo tiene configurado (sin horario configurado horario semanal cuando lo tiene configurado. Los límites de la jornada
el comportamiento es el de siempre: 8:00 a 19:00). salen de los ajustes de la clínica; sin configurar son 8:00 a 19:00.
`duration` (min) permite simular una duración distinta a la del `duration` (min) permite simular una duración distinta a la del
servicio, p. ej. cuando recepción la ajusta en el modal de la cita. servicio, p. ej. cuando recepción la ajusta en el modal de la cita.
@@ -449,9 +475,8 @@ class SkeenCita(models.Model):
('date', '=', date), ('date', '=', date),
] + ([('doctor_id', '=', doctor_id)] if doctor_id else [])) ] + ([('doctor_id', '=', doctor_id)] if doctor_id else []))
# Horario de la clínica: 8:00 AM a 7:00 PM # Horario de la clínica (configurable en los ajustes; por defecto 8:00-19:00)
start_time = 8.0 # 8:00 AM start_time, end_time = agenda_bounds(self.env)
end_time = 19.0 # 7:00 PM
# Horario del médico (si lo tiene configurado manda sobre el de la clínica) # Horario del médico (si lo tiene configurado manda sobre el de la clínica)
if doctor_id: if doctor_id:

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class SkeenServiceCategory(models.Model):
"""Categoría de servicio (consulta, tratamiento, procedimiento, paquete...).
Antes era un Selection fijo en skeen.servicio. Los `code` semilla
('consulta', 'tratamiento', 'procedimiento', 'paquete') son los valores que
el backend ya usa en sus búsquedas, por eso deben conservarse.
"""
_name = 'skeen.service.category'
_description = 'Categoría de servicio'
_order = 'sequence, name'
name = fields.Char(string='Nombre', required=True, translate=True)
code = fields.Char(string='Código', required=True, copy=False)
sequence = fields.Integer(string='Secuencia', default=10)
active = fields.Boolean(string='Activa', default=True)
is_system = fields.Boolean(
string='De sistema', default=False,
help='Las categorías de sistema no se pueden eliminar, solo renombrar.')
_sql_constraints = [
('unique_code', 'unique(code)', 'El código de la categoría debe ser único!')
]

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class SkeenSucursal(models.Model):
"""Sucursal de la clínica.
Antes las sucursales eran un Selection fijo ('rosarito', 'tijuana')
hardcodeado en skeen.cita. Ahora son datos: el `code` es el valor que se
guarda en las citas, por eso las sucursales semilla conservan los códigos
actuales y las citas históricas no se tocan.
"""
_name = 'skeen.sucursal'
_description = 'Sucursal de la clínica'
_order = 'sequence, name'
name = fields.Char(string='Nombre', required=True, translate=True)
code = fields.Char(string='Código', required=True, copy=False,
help='Identificador estable que se guarda en las citas.')
sequence = fields.Integer(string='Secuencia', default=10)
active = fields.Boolean(string='Activa', default=True)
is_default = fields.Boolean(string='Predeterminada', default=False)
address = fields.Char(string='Dirección')
phone = fields.Char(string='Teléfono')
notes = fields.Text(string='Notas')
_sql_constraints = [
('unique_code', 'unique(code)', 'El código de la sucursal debe ser único!')
]
@api.model
def default_code(self):
"""Código de la sucursal predeterminada (o la primera); 'rosarito' si no hay."""
sucursal = self.search([('is_default', '=', True)], limit=1)
if not sucursal:
sucursal = self.search([], limit=1)
return sucursal.code or 'rosarito'

View File

@@ -5,3 +5,5 @@ access_skeen_cita_user,Acceso a Citas SKEEN,model_skeen_cita,base.group_user,1,1
access_skeen_cita_bloqueo_user,Acceso a Bloqueos de Agenda SKEEN,model_skeen_cita_bloqueo,base.group_user,1,1,1,1 access_skeen_cita_bloqueo_user,Acceso a Bloqueos de Agenda SKEEN,model_skeen_cita_bloqueo,base.group_user,1,1,1,1
access_skeen_cita_state_log_user,Acceso a Bitácora de Estados SKEEN,model_skeen_cita_state_log,base.group_user,1,1,1,1 access_skeen_cita_state_log_user,Acceso a Bitácora de Estados SKEEN,model_skeen_cita_state_log,base.group_user,1,1,1,1
access_skeen_lista_espera_user,Acceso a Lista de Espera SKEEN,model_skeen_lista_espera,base.group_user,1,1,1,1 access_skeen_lista_espera_user,Acceso a Lista de Espera SKEEN,model_skeen_lista_espera,base.group_user,1,1,1,1
access_skeen_sucursal_user,Acceso a Sucursales SKEEN,model_skeen_sucursal,base.group_user,1,1,1,1
access_skeen_service_category_user,Acceso a Categorias de Servicio SKEEN,model_skeen_service_category,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_skeen_cita_bloqueo_user Acceso a Bloqueos de Agenda SKEEN model_skeen_cita_bloqueo base.group_user 1 1 1 1
6 access_skeen_cita_state_log_user Acceso a Bitácora de Estados SKEEN model_skeen_cita_state_log base.group_user 1 1 1 1
7 access_skeen_lista_espera_user Acceso a Lista de Espera SKEEN model_skeen_lista_espera base.group_user 1 1 1 1
8 access_skeen_sucursal_user Acceso a Sucursales SKEEN model_skeen_sucursal base.group_user 1 1 1 1
9 access_skeen_service_category_user Acceso a Categorias de Servicio SKEEN model_skeen_service_category base.group_user 1 1 1 1

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
{ {
'name': 'SKEEN Pagos', 'name': 'SKEEN Pagos',
'version': '1.0.2', 'version': '1.0.3',
'category': 'Healthcare', 'category': 'Healthcare',
'summary': 'Integración de pagos Stripe y MercadoPago para SKEEN', 'summary': 'Integración de pagos Stripe y MercadoPago para SKEEN',
'description': """ 'description': """

View File

@@ -2,6 +2,8 @@
from odoo import models, fields, api, _ from odoo import models, fields, api, _
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
from odoo.addons.skeen_citas.clinic_settings import get_settings as _clinic_settings
from odoo.addons.skeen_citas.clinic_settings import url_publica as _clinic_url_publica
import requests import requests
import json import json
@@ -62,6 +64,23 @@ class SkeenPago(models.Model):
vals['name'] = self.env['ir.sequence'].next_by_code('skeen.pago') or 'PAGO-001' vals['name'] = self.env['ir.sequence'].next_by_code('skeen.pago') or 'PAGO-001'
return super(SkeenPago, self).create(vals_list) return super(SkeenPago, self).create(vals_list)
def _payment_base(self, fallback):
"""URL pública base de la instancia para los enlaces de pago.
Sale de los ajustes de la clínica (url_publica); si la instancia no está
configurada, de `web.base.url` cuando es público y, en último caso, del
valor que ya usaba el código (`fallback`).
"""
return _clinic_url_publica(self.env, fallback=fallback)
def _product_name(self):
"""Nombre del producto en la pasarela: '<clínica> - <servicio>'."""
nombre = (_clinic_settings(self.env).get('nombre') or '').strip() or 'SKEEN Derma Experts'
servicio = ''
if self.cita_id and self.cita_id.servicio_id:
servicio = self.cita_id.servicio_id.name or ''
return '%s - %s' % (nombre, servicio) if servicio else nombre
def action_generate_stripe_link(self): def action_generate_stripe_link(self):
"""Generar link de pago Stripe""" """Generar link de pago Stripe"""
self.ensure_one() self.ensure_one()
@@ -70,6 +89,7 @@ class SkeenPago(models.Model):
raise ValidationError(_('Stripe no está configurado!')) raise ValidationError(_('Stripe no está configurado!'))
# Crear sesión de checkout # Crear sesión de checkout
base = self._payment_base('https://app.skeen.mx')
headers = { headers = {
'Authorization': f'Bearer {stripe_key}', 'Authorization': f'Bearer {stripe_key}',
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
@@ -77,12 +97,12 @@ class SkeenPago(models.Model):
data = { data = {
'payment_method_types[]': 'card', 'payment_method_types[]': 'card',
'line_items[0][price_data][currency]': 'mxn', 'line_items[0][price_data][currency]': 'mxn',
'line_items[0][price_data][product_data][name]': f'Servicio SKEEN - {self.cita_id.servicio_id.name}', 'line_items[0][price_data][product_data][name]': self._product_name(),
'line_items[0][price_data][unit_amount]': int(self.amount * 100), 'line_items[0][price_data][unit_amount]': int(self.amount * 100),
'line_items[0][quantity]': 1, 'line_items[0][quantity]': 1,
'mode': 'payment', 'mode': 'payment',
'success_url': f'https://app.skeen.mx/pagos/exitoso?ref={self.name}', 'success_url': f'{base}/pagos/exitoso?ref={self.name}',
'cancel_url': f'https://app.skeen.mx/pagos/cancelado?ref={self.name}', 'cancel_url': f'{base}/pagos/cancelado?ref={self.name}',
'metadata[payment_id]': str(self.id), 'metadata[payment_id]': str(self.id),
'metadata[patient_phone]': self.partner_id.phone or '', 'metadata[patient_phone]': self.partner_id.phone or '',
} }
@@ -111,22 +131,24 @@ class SkeenPago(models.Model):
if not mp_token: if not mp_token:
raise ValidationError(_('MercadoPago no está configurado!')) raise ValidationError(_('MercadoPago no está configurado!'))
# Crear preferencia de checkout
base = self._payment_base('https://app.skeen.mx')
headers = { headers = {
'Authorization': f'Bearer {mp_token}', 'Authorization': f'Bearer {mp_token}',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} }
data = { data = {
'items': [{ 'items': [{
'title': f'Servicio SKEEN - {self.cita_id.servicio_id.name}', 'title': self._product_name(),
'quantity': 1, 'quantity': 1,
'unit_price': int(self.amount), 'unit_price': int(self.amount),
}], }],
'back_urls': { 'back_urls': {
'success': f'https://app.skeen.mx/pagos/exitoso?ref={self.name}', 'success': f'{base}/pagos/exitoso?ref={self.name}',
'failure': f'https://app.skeen.mx/pagos/fallido?ref={self.name}', 'failure': f'{base}/pagos/fallido?ref={self.name}',
'pending': f'https://app.skeen.mx/pagos/pendiente?ref={self.name}', 'pending': f'{base}/pagos/pendiente?ref={self.name}',
}, },
'notification_url': 'https://skeen.mx/webhooks/mercadopago', 'notification_url': self._payment_base('https://skeen.mx') + '/webhooks/mercadopago',
'metadata': { 'metadata': {
'payment_id': str(self.id), 'payment_id': str(self.id),
'patient_phone': self.partner_id.phone or '', 'patient_phone': self.partner_id.phone or '',

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
{ {
'name': 'SKEEN WhatsApp', 'name': 'SKEEN WhatsApp',
'version': '1.0.0', 'version': '1.0.1',
'category': 'Healthcare', 'category': 'Healthcare',
'summary': 'Integración WhatsApp para SKEEN Derma Experts', 'summary': 'Integración WhatsApp para SKEEN Derma Experts',
'description': """ 'description': """

View File

@@ -12,6 +12,20 @@ from .auth import sign_token, current_user, require_role, forbid_roles, verify_t
from .auth import require_agenda, agenda_perm_error from .auth import require_agenda, agenda_perm_error
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
from ..roles import AGENDA_PERM_KEYS from ..roles import AGENDA_PERM_KEYS
from odoo.addons.skeen_citas.clinic_settings import (
agenda_bounds as clinic_agenda_bounds,
get_settings as get_clinic_settings,
set_settings as set_clinic_settings,
)
from odoo.addons.skeen_citas.glosario import (
GLOSARIO_KEYS as GLOSARIO_KEY_SET,
get_config as get_glosario_config,
list_packs as list_glosario_packs,
resolve as resolver_glosario,
set_config as set_glosario_config,
)
from odoo.addons.skeen_citas.catalogs import slugify as catalog_slugify
from odoo.addons.skeen_citas.catalogs import unique_slug as catalog_unique_slug
def json_response(data, status=200): def json_response(data, status=200):
@@ -93,20 +107,35 @@ def _patient_search_domain(search):
class SkeenFrontendController(http.Controller): class SkeenFrontendController(http.Controller):
"""Controladores API públicos para el Frontend React de SKEEN""" """Controladores API públicos para el Frontend React de SKEEN"""
# Horario de agenda de la clínica (hora de inicio permitida de una cita) # Horario de agenda de la clínica. Son solo el respaldo: el horario real
# sale de los ajustes de la clínica (clinic-settings). Sin configurar se
# usan estos valores, idénticos al comportamiento de siempre.
AGENDA_START = 8.0 # 8:00 AM AGENDA_START = 8.0 # 8:00 AM
AGENDA_END = 19.0 # 7:00 PM AGENDA_END = 19.0 # 7:00 PM
# Tope de seguridad de la exportación completa de pacientes (hoy ~7.2k) # Tope de seguridad de la exportación completa de pacientes (hoy ~7.2k)
EXPORT_MAX = 100000 EXPORT_MAX = 100000
def _agenda_bounds(self):
"""(inicio, fin) de la agenda en horas float, según los ajustes."""
try:
return clinic_agenda_bounds(request.env)
except Exception:
return self.AGENDA_START, self.AGENDA_END
def _agenda_horario_txt(self):
"""'8:00 a 19:00' para los mensajes de validación."""
inicio, fin = self._agenda_bounds()
return '%s a %s' % (self._fmt_horas(inicio), self._fmt_horas(fin))
def _valid_agenda_time(self, value): def _valid_agenda_time(self, value):
"""Valida hora de inicio dentro del horario de agenda (8:00-19:00).""" """Valida hora de inicio dentro del horario de agenda configurado."""
try: try:
t = float(value) t = float(value)
except (TypeError, ValueError): except (TypeError, ValueError):
return False return False
return self.AGENDA_START <= t < self.AGENDA_END inicio, fin = self._agenda_bounds()
return inicio <= t < fin
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Auditoría de configuración # Auditoría de configuración
@@ -633,7 +662,7 @@ class SkeenFrontendController(http.Controller):
first_time = data.get('time') first_time = data.get('time')
if first_time is not None and not self._valid_agenda_time(first_time): if first_time is not None and not self._valid_agenda_time(first_time):
return json_response( return json_response(
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400) {'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (%s)' % self._agenda_horario_txt()}, 400)
created = [] created = []
# Para paquetes, generamos citas separadas con fechas consecutivas # Para paquetes, generamos citas separadas con fechas consecutivas
current_date = datetime.strptime(first_date, '%Y-%m-%d').date() if first_date else date.today() current_date = datetime.strptime(first_date, '%Y-%m-%d').date() if first_date else date.today()
@@ -688,7 +717,7 @@ class SkeenFrontendController(http.Controller):
apt_time = data.get('time') apt_time = data.get('time')
if apt_time is None or not self._valid_agenda_time(apt_time): if apt_time is None or not self._valid_agenda_time(apt_time):
return json_response( return json_response(
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400) {'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (%s)' % self._agenda_horario_txt()}, 400)
Partner = request.env['res.partner'].sudo() Partner = request.env['res.partner'].sudo()
digits = re.sub(r'\D', '', phone) digits = re.sub(r'\D', '', phone)
@@ -793,7 +822,7 @@ class SkeenFrontendController(http.Controller):
vals = {k: data[k] for k in allowed if k in data} vals = {k: data[k] for k in allowed if k in data}
if 'time' in vals and not self._valid_agenda_time(vals['time']): if 'time' in vals and not self._valid_agenda_time(vals['time']):
return json_response( return json_response(
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400) {'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (%s)' % self._agenda_horario_txt()}, 400)
try: try:
cita.write(vals) cita.write(vals)
except ValidationError as e: except ValidationError as e:
@@ -1218,9 +1247,11 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'Horas inválidas'}, 400) return json_response({'status': 'error', 'message': 'Horas inválidas'}, 400)
if not all_day and time_to <= time_from: if not all_day and time_to <= time_from:
return json_response({'status': 'error', 'message': 'La hora final debe ser mayor a la inicial'}, 400) return json_response({'status': 'error', 'message': 'La hora final debe ser mayor a la inicial'}, 400)
if not all_day and (time_from < self.AGENDA_START - 1e-6 or time_to > self.AGENDA_END + 1e-6): if not all_day:
return json_response( agenda_ini, agenda_fin = self._agenda_bounds()
{'status': 'error', 'message': 'El rango de horas debe estar entre 8:00 y 19:00'}, 400) if time_from < agenda_ini - 1e-6 or time_to > agenda_fin + 1e-6:
return json_response(
{'status': 'error', 'message': 'El rango de horas debe estar entre %s' % self._agenda_horario_txt()}, 400)
motivo_tipo = data.get('motivo_tipo') or 'otro' motivo_tipo = data.get('motivo_tipo') or 'otro'
if motivo_tipo not in dict(request.env['skeen.cita.bloqueo']._fields['motivo_tipo'].selection): if motivo_tipo not in dict(request.env['skeen.cita.bloqueo']._fields['motivo_tipo'].selection):
@@ -6416,20 +6447,33 @@ class SkeenFrontendController(http.Controller):
logo_claro - logo para fondos oscuros (login SKEEN, sidebar) logo_claro - logo para fondos oscuros (login SKEEN, sidebar)
logo_oscuro - logo para fondos claros (tema HomeNest) logo_oscuro - logo para fondos claros (tema HomeNest)
pie - línea del pie (login / documentos impresos) pie - línea del pie (login / documentos impresos)
url_publica - URL pública de la instancia (opcional; la usan los
enlaces de pago y correos)
colores - {'primario': '#hex', 'acento': '#hex'} (opcional)
""" """
defaults = { defaults = {
'nombre': 'SKEEN Derma Experts', 'nombre': 'SKEEN Derma Experts',
'logo_claro': '/skeen-brand/logos/Logo%20Completo%20Blanco.png', 'logo_claro': '/skeen-brand/logos/Logo%20Completo%20Blanco.png',
'logo_oscuro': '/skeen-brand/logos/Logo%20Completo%20Negro.png', 'logo_oscuro': '/skeen-brand/logos/Logo%20Completo%20Negro.png',
'pie': 'SKEEN Derma Experts · Playas de Rosarito, B.C.', 'pie': 'SKEEN Derma Experts · Playas de Rosarito, B.C.',
'url_publica': '',
'colores': {'primario': '', 'acento': ''},
} }
raw = request.env['ir.config_parameter'].sudo().get_param(self.BRAND_KEY) raw = request.env['ir.config_parameter'].sudo().get_param(self.BRAND_KEY)
if raw: if raw:
try: try:
guardado = json.loads(raw) guardado = json.loads(raw)
if isinstance(guardado, dict): if isinstance(guardado, dict):
defaults.update({k: str(v) for k, v in guardado.items() for k in ('nombre', 'logo_claro', 'logo_oscuro', 'pie', 'url_publica'):
if k in defaults and v is not None and str(v).strip()}) v = guardado.get(k)
if v is not None and str(v).strip():
defaults[k] = str(v)
colores = guardado.get('colores')
if isinstance(colores, dict):
for c in ('primario', 'acento'):
v = colores.get(c)
if v is not None and str(v).strip():
defaults['colores'][c] = str(v)
except Exception: except Exception:
pass # JSON roto: se sirven los defaults pass # JSON roto: se sirven los defaults
return defaults return defaults
@@ -6458,8 +6502,29 @@ class SkeenFrontendController(http.Controller):
'message': 'brand debe ser un objeto'}, 400) 'message': 'brand debe ser un objeto'}, 400)
antes = self._brand_dict() antes = self._brand_dict()
limpio = {k: str(brand[k]).strip() for k in limpio = {k: str(brand[k]).strip() for k in
('nombre', 'logo_claro', 'logo_oscuro', 'pie') ('nombre', 'logo_claro', 'logo_oscuro', 'pie', 'url_publica')
if brand.get(k) is not None and str(brand.get(k)).strip()} if brand.get(k) is not None and str(brand.get(k)).strip()}
# Colores de marca (opcional). Formato hex básico; se ignora lo vacío.
colores = brand.get('colores')
if colores is not None:
if not isinstance(colores, dict):
return json_response({'status': 'error',
'message': 'colores debe ser un objeto'}, 400)
limpio_colores = {}
for clave in ('primario', 'acento'):
valor = colores.get(clave)
if valor is None:
continue
valor = str(valor).strip()
if not valor:
continue
if not re.match(r'^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$', valor):
return json_response({
'status': 'error',
'message': 'El color %s debe ser hex (#RRGGBB)' % clave}, 400)
limpio_colores[clave] = valor
if limpio_colores:
limpio['colores'] = limpio_colores
request.env['ir.config_parameter'].sudo().set_param( request.env['ir.config_parameter'].sudo().set_param(
self.BRAND_KEY, json.dumps(limpio, ensure_ascii=False)) self.BRAND_KEY, json.dumps(limpio, ensure_ascii=False))
nuevo = self._brand_dict() nuevo = self._brand_dict()
@@ -6474,6 +6539,464 @@ class SkeenFrontendController(http.Controller):
request.env.cr.rollback() request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500) return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Terminología de la clínica (glosario) — white-label
# ============================================================
def _puede_configurar_catalogo(self):
"""Sucursales, categorías y terminología: admin o gerencia."""
u = self._fu_user()
return bool(u and u.role in ('admin', 'gerencia'))
@http.route('/skeen/frontend/v1/glosario', type='http',
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_glosario(self, **kw):
"""Glosario vigente: siempre las 24 claves resueltas (pack + overrides)."""
try:
pack, terms = resolver_glosario(request.env)
return json_response({'status': 'success', 'pack': pack, 'terms': terms})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/glosario/packs', type='http',
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_glosario_packs(self, **kw):
"""Packs predefinidos con nombre legible, para el selector."""
try:
return json_response({'status': 'success', 'packs': list_glosario_packs()})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/glosario', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
def put_glosario(self, **kw):
"""Guarda pack y overrides. Los overrides pisan al pack.
Body: {"pack": "spa", "overrides": {"patient_singular": "Huésped"}}
Si se omite una de las dos claves, se conserva lo que ya había. Los
overrides enviados reemplazan a los anteriores (no se mezclan).
"""
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
data = _parse_json_body()
pack = data.get('pack')
overrides = data.get('overrides')
if pack is not None and str(pack).strip():
if str(pack).strip() not in [p['code'] for p in list_glosario_packs()]:
return json_response({
'status': 'error',
'message': 'Pack no válido: %s' % pack}, 400)
if overrides is not None and not isinstance(overrides, dict):
return json_response({'status': 'error',
'message': 'overrides debe ser un objeto'}, 400)
if isinstance(overrides, dict):
desconocidas = [k for k in overrides if k not in GLOSARIO_KEY_SET]
if desconocidas:
return json_response({
'status': 'error',
'message': 'Claves desconocidas: %s' % ', '.join(desconocidas)}, 400)
antes = get_glosario_config(request.env)
set_glosario_config(request.env, pack=pack, overrides=overrides)
pack_actual, terms = resolver_glosario(request.env)
self._auditar('clinica', 'glosario', 'terminologia',
old_value=json.dumps(antes, ensure_ascii=False)[:500],
new_value=json.dumps({'pack': pack_actual,
'overrides': get_glosario_config(request.env)['overrides']},
ensure_ascii=False)[:500],
entity_label='Terminología de la clínica (glosario)',
notes=(data.get('motivo') or 'actualizado').strip())
return json_response({'status': 'success', 'pack': pack_actual,
'terms': terms,
'message': 'Terminología actualizada'})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Ajustes de la clínica por instancia
# ============================================================
@http.route('/skeen/frontend/v1/clinic-settings', type='http',
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_clinic_settings_api(self, **kw):
"""Ajustes de la clínica (nombre, contacto, horario, moneda...)."""
try:
return json_response({'status': 'success',
'settings': get_clinic_settings(request.env)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/clinic-settings', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
def put_clinic_settings_api(self, **kw):
"""Guarda los ajustes mergeando sobre lo actual.
Acepta los campos al nivel raíz o dentro de {"settings": {...}}.
"""
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
data = _parse_json_body()
vals = data.get('settings') if isinstance(data.get('settings'), dict) else data
antes = get_clinic_settings(request.env)
nuevo = set_clinic_settings(request.env, vals)
self._auditar('clinica', 'clinic-settings', 'ajustes',
old_value=json.dumps(antes, ensure_ascii=False)[:500],
new_value=json.dumps(nuevo, ensure_ascii=False)[:500],
entity_label='Ajustes de la clínica',
notes=(data.get('motivo') or 'actualizado').strip())
return json_response({'status': 'success', 'settings': nuevo,
'message': 'Ajustes actualizados'})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Sucursales (configurables)
# ============================================================
def _sucursal_to_dict(self, s):
return {
'id': s.id,
'code': s.code,
'name': s.name,
'sequence': s.sequence,
'active': s.active,
'is_default': s.is_default,
'address': s.address or '',
'phone': s.phone or '',
'notes': s.notes or '',
}
def _marcar_sucursal_default(self, sucursal):
"""Como mucho una sucursal predeterminada."""
otras = request.env['skeen.sucursal'].sudo().with_context(
active_test=False).search(
[('is_default', '=', True), ('id', '!=', sucursal.id)])
if otras:
otras.write({'is_default': False})
@http.route('/skeen/frontend/v1/sucursales', type='http',
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_sucursales(self, **kw):
"""Sucursales. Por defecto solo activas; ?include_inactive=1 las trae todas."""
try:
incluir = kw.get('include_inactive') in ('1', 'true', 'True')
Sucursal = request.env['skeen.sucursal'].sudo()
if incluir:
Sucursal = Sucursal.with_context(active_test=False)
recs = Sucursal.search([], order='sequence, name')
return json_response({'status': 'success',
'sucursales': [self._sucursal_to_dict(s) for s in recs],
'total': len(recs)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/sucursales', type='http',
auth='none', methods=['POST'], csrf=False)
@require_role('gerencia')
def create_sucursal(self, **kw):
"""Crea una sucursal. Sin `code` se genera un slug del nombre."""
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
data = _parse_json_body()
name = (data.get('name') or '').strip()
if not name:
return json_response({'status': 'error',
'message': 'El nombre es obligatorio'}, 400)
Sucursal = request.env['skeen.sucursal'].sudo()
codigo = (data.get('code') or '').strip()
if codigo:
codigo = catalog_slugify(codigo) or codigo
if Sucursal.with_context(active_test=False).search_count([('code', '=', codigo)]):
return json_response({
'status': 'error',
'message': 'Ya existe una sucursal con el código %s' % codigo}, 409)
else:
codigo = catalog_unique_slug(Sucursal, name)
try:
sequence = int(data.get('sequence') or 10)
except (TypeError, ValueError):
return json_response({'status': 'error',
'message': 'La secuencia debe ser numérica'}, 400)
sucursal = Sucursal.create({
'name': name,
'code': codigo,
'sequence': sequence,
'active': bool(data.get('active', True)),
'is_default': bool(data.get('is_default', False)),
'address': data.get('address') or '',
'phone': data.get('phone') or '',
'notes': data.get('notes') or '',
})
if sucursal.is_default:
self._marcar_sucursal_default(sucursal)
self._auditar('catalogos', 'sucursal:%s' % sucursal.id, 'crear',
new_value=json.dumps(self._sucursal_to_dict(sucursal),
ensure_ascii=False)[:500],
entity_label='Sucursal %s' % sucursal.name,
notes=(data.get('motivo') or 'creada').strip())
return json_response({'status': 'success',
'sucursal': self._sucursal_to_dict(sucursal)}, 201)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/sucursales/<int:sucursal_id>', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
def update_sucursal(self, sucursal_id, **kw):
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
sucursal = request.env['skeen.sucursal'].sudo().browse(sucursal_id)
if not sucursal.exists():
return json_response({'status': 'error',
'message': 'Sucursal no encontrada'}, 404)
data = _parse_json_body()
vals = {}
if 'name' in data and str(data.get('name') or '').strip():
vals['name'] = str(data['name']).strip()
if 'code' in data:
codigo = catalog_slugify(data.get('code')) or str(data.get('code') or '').strip()
if codigo and codigo != sucursal.code:
dup = request.env['skeen.sucursal'].sudo().with_context(
active_test=False).search_count(
[('code', '=', codigo), ('id', '!=', sucursal.id)])
if dup:
return json_response({
'status': 'error',
'message': 'Ya existe una sucursal con el código %s' % codigo}, 409)
vals['code'] = codigo
for campo in ('address', 'phone', 'notes'):
if campo in data:
vals[campo] = data.get(campo) or ''
if 'sequence' in data:
try:
vals['sequence'] = int(data['sequence'])
except (TypeError, ValueError):
return json_response({'status': 'error',
'message': 'La secuencia debe ser numérica'}, 400)
if 'active' in data:
vals['active'] = bool(data['active'])
if 'is_default' in data:
vals['is_default'] = bool(data['is_default'])
if not vals:
return json_response({'status': 'error',
'message': 'Nada que actualizar'}, 400)
sucursal.write(vals)
if sucursal.is_default:
self._marcar_sucursal_default(sucursal)
self._auditar('catalogos', 'sucursal:%s' % sucursal.id, 'editar',
new_value=json.dumps(self._sucursal_to_dict(sucursal),
ensure_ascii=False)[:500],
entity_label='Sucursal %s' % sucursal.name,
notes=(data.get('motivo') or 'actualizada').strip())
return json_response({'status': 'success',
'sucursal': self._sucursal_to_dict(sucursal)})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/sucursales/<int:sucursal_id>', type='http',
auth='none', methods=['DELETE'], csrf=False)
@require_role('gerencia')
def delete_sucursal(self, sucursal_id, **kw):
"""Desactiva la sucursal (nunca se borra: las citas la referencian)."""
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
sucursal = request.env['skeen.sucursal'].sudo().browse(sucursal_id)
if not sucursal.exists():
return json_response({'status': 'error',
'message': 'Sucursal no encontrada'}, 404)
en_uso = request.env['skeen.cita'].sudo().search_count(
[('branch', '=', sucursal.code)])
sucursal.write({'active': False, 'is_default': False})
self._auditar('catalogos', 'sucursal:%s' % sucursal.id, 'desactivar',
old_value=json.dumps(self._sucursal_to_dict(sucursal),
ensure_ascii=False)[:500],
entity_label='Sucursal %s' % sucursal.name,
notes='desactivada (%d citas)' % en_uso)
if en_uso:
mensaje = ('La sucursal tiene %d cita(s) registradas y no se puede '
'eliminar; quedó desactivada.' % en_uso)
else:
mensaje = 'Sucursal desactivada.'
return json_response({'status': 'success',
'sucursal': self._sucursal_to_dict(sucursal),
'en_uso': en_uso, 'message': mensaje})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Categorías de servicio (configurables)
# ============================================================
def _service_category_to_dict(self, c):
return {
'id': c.id,
'code': c.code,
'name': c.name,
'sequence': c.sequence,
'active': c.active,
'is_system': c.is_system,
}
@http.route('/skeen/frontend/v1/service-categories', type='http',
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_service_categories(self, **kw):
"""Categorías de servicio. ?include_inactive=1 trae también las inactivas."""
try:
incluir = kw.get('include_inactive') in ('1', 'true', 'True')
Category = request.env['skeen.service.category'].sudo()
if incluir:
Category = Category.with_context(active_test=False)
recs = Category.search([], order='sequence, name')
return json_response({'status': 'success',
'categories': [self._service_category_to_dict(c) for c in recs],
'total': len(recs)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/service-categories', type='http',
auth='none', methods=['POST'], csrf=False)
@require_role('gerencia')
def create_service_category(self, **kw):
"""Crea una categoría (las creadas por API nunca son de sistema)."""
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
data = _parse_json_body()
name = (data.get('name') or '').strip()
if not name:
return json_response({'status': 'error',
'message': 'El nombre es obligatorio'}, 400)
Category = request.env['skeen.service.category'].sudo()
codigo = (data.get('code') or '').strip()
if codigo:
codigo = catalog_slugify(codigo) or codigo
if Category.with_context(active_test=False).search_count([('code', '=', codigo)]):
return json_response({
'status': 'error',
'message': 'Ya existe una categoría con el código %s' % codigo}, 409)
else:
codigo = catalog_unique_slug(Category, name)
try:
sequence = int(data.get('sequence') or 10)
except (TypeError, ValueError):
return json_response({'status': 'error',
'message': 'La secuencia debe ser numérica'}, 400)
categoria = Category.create({
'name': name,
'code': codigo,
'sequence': sequence,
'active': bool(data.get('active', True)),
'is_system': False,
})
self._auditar('catalogos', 'service-category:%s' % categoria.id, 'crear',
new_value=json.dumps(self._service_category_to_dict(categoria),
ensure_ascii=False)[:500],
entity_label='Categoría %s' % categoria.name,
notes=(data.get('motivo') or 'creada').strip())
return json_response({'status': 'success',
'category': self._service_category_to_dict(categoria)}, 201)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/service-categories/<int:category_id>', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
def update_service_category(self, category_id, **kw):
"""Edita una categoría. `is_system` no se puede cambiar."""
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
categoria = request.env['skeen.service.category'].sudo().browse(category_id)
if not categoria.exists():
return json_response({'status': 'error',
'message': 'Categoría no encontrada'}, 404)
data = _parse_json_body()
vals = {}
if 'name' in data and str(data.get('name') or '').strip():
vals['name'] = str(data['name']).strip()
if 'code' in data:
codigo = catalog_slugify(data.get('code')) or str(data.get('code') or '').strip()
if codigo and codigo != categoria.code:
dup = request.env['skeen.service.category'].sudo().with_context(
active_test=False).search_count(
[('code', '=', codigo), ('id', '!=', categoria.id)])
if dup:
return json_response({
'status': 'error',
'message': 'Ya existe una categoría con el código %s' % codigo}, 409)
vals['code'] = codigo
if 'sequence' in data:
try:
vals['sequence'] = int(data['sequence'])
except (TypeError, ValueError):
return json_response({'status': 'error',
'message': 'La secuencia debe ser numérica'}, 400)
if 'active' in data:
vals['active'] = bool(data['active'])
if not vals:
return json_response({'status': 'error',
'message': 'Nada que actualizar'}, 400)
categoria.write(vals)
self._auditar('catalogos', 'service-category:%s' % categoria.id, 'editar',
new_value=json.dumps(self._service_category_to_dict(categoria),
ensure_ascii=False)[:500],
entity_label='Categoría %s' % categoria.name,
notes=(data.get('motivo') or 'actualizada').strip())
return json_response({'status': 'success',
'category': self._service_category_to_dict(categoria)})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/service-categories/<int:category_id>', type='http',
auth='none', methods=['DELETE'], csrf=False)
@require_role('gerencia')
def delete_service_category(self, category_id, **kw):
"""Desactiva la categoría. Las de sistema no se pueden eliminar."""
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
categoria = request.env['skeen.service.category'].sudo().browse(category_id)
if not categoria.exists():
return json_response({'status': 'error',
'message': 'Categoría no encontrada'}, 404)
if categoria.is_system:
return json_response({
'status': 'error',
'message': ('"%s" es una categoría del sistema: no se puede '
'eliminar, solo renombrar.' % categoria.name)}, 400)
en_uso = request.env['skeen.servicio'].sudo().search_count(
[('category', '=', categoria.code)])
categoria.write({'active': False})
self._auditar('catalogos', 'service-category:%s' % categoria.id, 'desactivar',
old_value=json.dumps(self._service_category_to_dict(categoria),
ensure_ascii=False)[:500],
entity_label='Categoría %s' % categoria.name,
notes='desactivada (%d servicios)' % en_uso)
mensaje = 'Categoría desactivada.'
if en_uso:
mensaje = ('La categoría tiene %d servicio(s) y no se puede eliminar; '
'quedó desactivada.' % en_uso)
return json_response({'status': 'success',
'category': self._service_category_to_dict(categoria),
'en_uso': en_uso, 'message': mensaje})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================ # ============================================================
# Diseño de tickets (Configuración › Tickets) # Diseño de tickets (Configuración › Tickets)
# ============================================================ # ============================================================