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:
224
docs/nueva-instancia.md
Normal file
224
docs/nueva-instancia.md
Normal 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).
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
221
frontend/src/components/CategoriasPanel.tsx
Normal file
221
frontend/src/components/CategoriasPanel.tsx
Normal 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;
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
251
frontend/src/components/SucursalesPanel.tsx
Normal file
251
frontend/src/components/SucursalesPanel.tsx
Normal 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;
|
||||||
235
frontend/src/components/TerminologiaPanel.tsx
Normal file
235
frontend/src/components/TerminologiaPanel.tsx
Normal 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;
|
||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
67
frontend/src/lib/brand-colors.ts
Normal file
67
frontend/src/lib/brand-colors.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
174
frontend/src/lib/glosario.tsx
Normal file
174
frontend/src/lib/glosario.tsx
Normal 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) })),
|
||||||
|
}));
|
||||||
@@ -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;
|
||||||
|
|||||||
87
frontend/src/lib/service-categories.tsx
Normal file
87
frontend/src/lib/service-categories.tsx
Normal 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;
|
||||||
|
};
|
||||||
86
frontend/src/lib/sucursales.tsx
Normal file
86
frontend/src/lib/sucursales.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
{[
|
{[
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
51
infra/provision/README.md
Normal 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
89
infra/provision/demo_limpiar.py
Executable 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)
|
||||||
445
infra/provision/postinstall_clinica.py
Executable file
445
infra/provision/postinstall_clinica.py
Executable 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)
|
||||||
582
infra/provision/provision_clinica.py
Executable file
582
infra/provision/provision_clinica.py
Executable 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)
|
||||||
39
infra/provision/templates/nginx-vhost.tmpl
Normal file
39
infra/provision/templates/nginx-vhost.tmpl
Normal 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}
|
||||||
|
}
|
||||||
44
infra/provision/templates/odoo.conf.tmpl
Normal file
44
infra/provision/templates/odoo.conf.tmpl
Normal 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
|
||||||
19
infra/provision/templates/service.tmpl
Normal file
19
infra/provision/templates/service.tmpl
Normal 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
|
||||||
@@ -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',
|
||||||
|
|||||||
38
odoo-addons/skeen_citas/catalogs.py
Normal file
38
odoo-addons/skeen_citas/catalogs.py
Normal 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
|
||||||
166
odoo-addons/skeen_citas/clinic_settings.py
Normal file
166
odoo-addons/skeen_citas/clinic_settings.py
Normal 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('/')
|
||||||
49
odoo-addons/skeen_citas/data/catalogos.xml
Normal file
49
odoo-addons/skeen_citas/data/catalogos.xml
Normal 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>
|
||||||
212
odoo-addons/skeen_citas/glosario.py
Normal file
212
odoo-addons/skeen_citas/glosario.py
Normal 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]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
27
odoo-addons/skeen_citas/models/service_category.py
Normal file
27
odoo-addons/skeen_citas/models/service_category.py
Normal 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!')
|
||||||
|
]
|
||||||
38
odoo-addons/skeen_citas/models/sucursal.py
Normal file
38
odoo-addons/skeen_citas/models/sucursal.py
Normal 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'
|
||||||
@@ -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,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': """
|
||||||
|
|||||||
@@ -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 '',
|
||||||
|
|||||||
@@ -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': """
|
||||||
|
|||||||
@@ -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)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user