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 ErrorBoundary from './components/ErrorBoundary';
|
||||
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 { PatientDrawerProvider } from './components/PatientDrawer';
|
||||
import ChatNotifications from './lib/chat-notifications';
|
||||
@@ -122,6 +125,9 @@ const App: FC = () => {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>
|
||||
<GlosarioProvider>
|
||||
<SucursalesProvider>
|
||||
<ServiceCategoriesProvider>
|
||||
<QuickLinksProvider>
|
||||
<Router>
|
||||
<ToastProvider />
|
||||
@@ -160,6 +166,9 @@ const App: FC = () => {
|
||||
</PatientDrawerProvider>
|
||||
</Router>
|
||||
</QuickLinksProvider>
|
||||
</ServiceCategoriesProvider>
|
||||
</SucursalesProvider>
|
||||
</GlosarioProvider>
|
||||
</AuthProvider>
|
||||
</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 }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, canSee, hasRole, logout } = useAuth();
|
||||
const { user, canSee, hasRole, logout, brand } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const chatUnread = useChatUnread();
|
||||
|
||||
@@ -515,8 +515,8 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
||||
style={{ backgroundColor: 'var(--header-bg)' }}
|
||||
>
|
||||
<div className="hidden lg:flex w-[230px] shrink-0 items-center px-5">
|
||||
<span className="text-[26px] font-bold tracking-tight" style={{ color: '#333333' }}>
|
||||
SKEEN<sup className="text-xs font-semibold">®</sup>
|
||||
<span className="text-[26px] font-bold tracking-tight truncate" style={{ color: '#333333' }}>
|
||||
{brand.nombre}
|
||||
</span>
|
||||
</div>
|
||||
<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} />
|
||||
</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 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 NotificationBell from './NotificationBell';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useGlosario, traducirMenu, traducirEtiqueta } from '../lib/glosario';
|
||||
import { useChatUnread } from '../lib/chat';
|
||||
import { useQuickLinks } from '../lib/quicklinks';
|
||||
import { odooApi } from '../services/odoo';
|
||||
@@ -53,14 +54,14 @@ interface LayoutProps {
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const topNavItems = [
|
||||
const TOP_NAV_ITEMS = [
|
||||
{ label: 'Dashboard', icon: LayoutDashboard, href: '/' },
|
||||
{ label: 'Agenda', icon: Calendar, href: '/agenda' },
|
||||
{ label: 'Pacientes', icon: Users, href: '/pacientes' },
|
||||
{ label: 'Ventas', icon: ShoppingCart, href: '/ventas' },
|
||||
];
|
||||
|
||||
const menuSections = [
|
||||
const MENU_SECTIONS = [
|
||||
{
|
||||
label: 'Clínica',
|
||||
items: [
|
||||
@@ -113,6 +114,12 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
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 [expanded, setExpanded] = useState<string[]>(['Clínica']);
|
||||
const chatUnread = useChatUnread();
|
||||
@@ -122,7 +129,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
menuSections
|
||||
.map((s) => ({ ...s, items: s.items.filter((it) => canSee(it.href)) }))
|
||||
.filter((s) => s.items.length > 0),
|
||||
[canSee, user]
|
||||
[canSee, user, menuSections]
|
||||
);
|
||||
|
||||
// Accesos rápidos personalizables (localStorage por usuario)
|
||||
@@ -135,7 +142,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
seen.add(it.href);
|
||||
return true;
|
||||
});
|
||||
}, [canSee]);
|
||||
}, [canSee, topNavItems, menuSections]);
|
||||
const quickItems = useMemo(
|
||||
() =>
|
||||
quick.links
|
||||
@@ -148,7 +155,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
return null;
|
||||
})
|
||||
.filter((it): it is (typeof menuSections)[number]['items'][number] => it !== null),
|
||||
[quick.links, canSee]
|
||||
[quick.links, canSee, menuSections]
|
||||
);
|
||||
|
||||
const initials = (user?.name || 'US')
|
||||
|
||||
@@ -4,13 +4,15 @@ import { Save, Info, Lock } from 'lucide-react';
|
||||
import { Button, Skeleton, toast } from './ui';
|
||||
import { odooApi, type FrontendRole } from '../services/odoo';
|
||||
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[];
|
||||
|
||||
type Matrix = Record<string, FrontendRole[]>;
|
||||
|
||||
const RolesPanel: FC = () => {
|
||||
const { term } = useGlosario();
|
||||
const [matrix, setMatrix] = useState<Matrix | null>(null);
|
||||
const [saved, setSaved] = useState<Matrix | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -101,7 +103,7 @@ const RolesPanel: FC = () => {
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<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">
|
||||
{item.label}
|
||||
{menuItemLabel(item, term)}
|
||||
</td>
|
||||
{ROLES.map((role) => {
|
||||
const locked = item.href === '/configuracion' && role === 'admin';
|
||||
|
||||
@@ -45,6 +45,7 @@ import GlobalSearch from './GlobalSearch';
|
||||
import ThemePicker from './ThemePicker';
|
||||
import NotificationBell from './NotificationBell';
|
||||
import { useAuth, ROLE_LABELS } from '../lib/auth';
|
||||
import { useGlosario, traducirMenu } from '../lib/glosario';
|
||||
import { useChatUnread } from '../lib/chat';
|
||||
import { useQuickLinks } from '../lib/quicklinks';
|
||||
import { odooApi } from '../services/odoo';
|
||||
@@ -121,6 +122,8 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, canSee, hasRole, logout, brand } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const menuGroupsT = useMemo(() => traducirMenu(menuGroups, term), [term]);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const chatUnread = useChatUnread();
|
||||
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas
|
||||
@@ -139,31 +142,31 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
|
||||
const visibleGroups = useMemo(
|
||||
() =>
|
||||
menuGroups
|
||||
menuGroupsT
|
||||
.map((g) => ({ ...g, items: g.items.filter((it) => canSee(it.href)) }))
|
||||
.filter((g) => g.items.length > 0),
|
||||
[canSee, user]
|
||||
[canSee, user, menuGroupsT]
|
||||
);
|
||||
|
||||
// Accesos rápidos personalizables (localStorage por usuario)
|
||||
const quick = useQuickLinks();
|
||||
const dockItems = useMemo(
|
||||
() => menuGroups.flatMap((g) => g.items).filter((it) => canSee(it.href)),
|
||||
[canSee]
|
||||
() => menuGroupsT.flatMap((g) => g.items).filter((it) => canSee(it.href)),
|
||||
[canSee, menuGroupsT]
|
||||
);
|
||||
const quickItems = useMemo(
|
||||
() =>
|
||||
quick.links
|
||||
.filter((href) => canSee(href))
|
||||
.map((href) => {
|
||||
for (const g of menuGroups) {
|
||||
for (const g of menuGroupsT) {
|
||||
const found = g.items.find((it) => it.href === href);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((it): it is (typeof menuGroups)[number]['items'][number] => it !== null),
|
||||
[quick.links, canSee]
|
||||
[quick.links, canSee, menuGroupsT]
|
||||
);
|
||||
|
||||
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';
|
||||
|
||||
const THEME_LABELS: Record<Theme, string> = {
|
||||
skeen: 'SKEEN',
|
||||
skeen: 'Original',
|
||||
homenest: 'HomeNest',
|
||||
clasico: 'Clásico',
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '../lib/ticketConfig';
|
||||
import { odooApi } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useSucursales } from '../lib/sucursales';
|
||||
|
||||
// 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 = () => {
|
||||
const config = useTicketConfig();
|
||||
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;
|
||||
// gerencia puede editar y guardar su propio diseño.
|
||||
const esAdmin = user?.role === 'admin';
|
||||
@@ -504,7 +508,7 @@ export const TicketDesigner: FC = () => {
|
||||
<div className="xl:sticky xl:top-4">
|
||||
<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)]">
|
||||
<TicketDoc data={MUESTRA} config={previewConfig} preview />
|
||||
<TicketDoc data={{ ...MUESTRA, sucursal: sucursalMuestra }} config={previewConfig} preview />
|
||||
</div>
|
||||
<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
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
} from './ui';
|
||||
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, AGENDA_PERM_ITEMS } from '../services/odoo';
|
||||
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 }[] = (
|
||||
Object.keys(ROLE_LABELS) as FrontendRole[]
|
||||
@@ -35,6 +36,7 @@ const roleBadge = (role: FrontendRole) => {
|
||||
|
||||
const UsuariosPanel: FC = () => {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const [users, setUsers] = useState<FrontendUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -382,7 +384,7 @@ const UsuariosPanel: FC = () => {
|
||||
onChange={() => togglePerm(item.href)}
|
||||
className="rounded border-theme-border-strong"
|
||||
/>
|
||||
{item.label}
|
||||
{menuItemLabel(item, term)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, ChevronLeft, ChevronRight, History, UserCheck, UserX, XCircle } from 'lucide-react';
|
||||
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) */
|
||||
export const CHECKLIST_STEPS = [
|
||||
@@ -37,17 +38,22 @@ interface Props {
|
||||
* siguen sincronizadas con phone_confirmed / arrived_at desde el backend.
|
||||
*/
|
||||
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 idx = stepIndex(apt.state);
|
||||
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]);
|
||||
|
||||
const irA = (state: string) => {
|
||||
if (state === apt.state) return;
|
||||
const destino = stepIndex(state);
|
||||
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);
|
||||
};
|
||||
@@ -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>}
|
||||
{step.label}
|
||||
{etiquetaPaso(step)}
|
||||
</button>
|
||||
</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"
|
||||
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} />
|
||||
</button>
|
||||
<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 PatientCombobox from '../PatientCombobox';
|
||||
import { odooApi, type Doctor, type ListaEspera, type Patient, type Service } from '../../services/odoo';
|
||||
import { useGlosario } from '../../lib/glosario';
|
||||
|
||||
const PRIORIDADES = [
|
||||
{ value: 'alta', label: 'Alta' },
|
||||
@@ -50,6 +51,9 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
|
||||
entries, loading, loadingAgendaId, onAgendar, onQuitar, onAdd, onRefresh, puedeGestionar = true, onChangeSala,
|
||||
}) => {
|
||||
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.
|
||||
const [salaFiltro, setSalaFiltro] = useState<'todas' | 'consultoria' | 'cabina'>('todas');
|
||||
const visibles = salaFiltro === 'todas' ? entries : entries.filter((e) => (e.sala || '') === salaFiltro);
|
||||
@@ -72,7 +76,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
|
||||
</button>
|
||||
{/* 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">
|
||||
{([{ key: 'todas', label: 'Todas' }, ...SALAS] as const).map((s) => (
|
||||
{[{ key: 'todas', label: 'Todas' }, ...salas].map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
type="button"
|
||||
@@ -110,7 +114,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
|
||||
? 'Cargando...'
|
||||
: salaFiltro === 'todas'
|
||||
? '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>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -119,7 +123,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
|
||||
e.phone,
|
||||
`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 (
|
||||
<div
|
||||
key={e.id}
|
||||
@@ -145,7 +149,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
|
||||
salaActual ? salaActual.chip : 'bg-theme-bg text-theme-muted border-theme-border'
|
||||
} ${puedeGestionar ? 'hover:brightness-95 cursor-pointer' : 'cursor-default'}`}
|
||||
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'}`}
|
||||
>
|
||||
{salaActual?.label || 'Sin sala'}
|
||||
@@ -224,6 +228,7 @@ interface ModalProps {
|
||||
export const ListaEsperaModal: FC<ModalProps> = ({
|
||||
isOpen, onClose, doctors, services, patient, defaultDoctorId, defaultServiceId, isMedico, onCreated,
|
||||
}) => {
|
||||
const { term } = useGlosario();
|
||||
const [patientId, setPatientId] = useState('');
|
||||
const [patientName, setPatientName] = useState('');
|
||||
const [doctorId, setDoctorId] = useState(defaultDoctorId || '');
|
||||
@@ -350,7 +355,7 @@ export const ListaEsperaModal: FC<ModalProps> = ({
|
||||
options={[
|
||||
{ value: '', label: 'Sin asignar' },
|
||||
{ value: 'consultoria', label: 'Consultoría' },
|
||||
{ value: 'cabina', label: 'Cabina' },
|
||||
{ value: 'cabina', label: term('room_singular') },
|
||||
]}
|
||||
value={sala}
|
||||
onChange={(e) => setSala(e.target.value)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
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 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 () => {
|
||||
try {
|
||||
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)
|
||||
export const MENU_ITEMS: { label: string; href: string }[] = [
|
||||
import type { GlossaryKey } from '../services/odoo';
|
||||
|
||||
// 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: 'Agenda', href: '/agenda' },
|
||||
{ label: 'Pacientes', href: '/pacientes' },
|
||||
{ label: 'Visitas', href: '/visitas' },
|
||||
{ label: 'Médicos', href: '/medicos' },
|
||||
{ label: 'Servicios', href: '/servicios' },
|
||||
{ label: 'Pacientes', href: '/pacientes', termKey: 'patient_plural' },
|
||||
{ label: 'Visitas', href: '/visitas', termKey: 'visit_plural' },
|
||||
{ label: 'Médicos', href: '/medicos', termKey: 'doctor_plural' },
|
||||
{ label: 'Servicios', href: '/servicios', termKey: 'service_plural' },
|
||||
{ label: 'Punto de Venta', href: '/pos' },
|
||||
{ label: 'Ventas', href: '/ventas' },
|
||||
{ label: 'Pagos', href: '/pagos' },
|
||||
@@ -16,7 +28,7 @@ export const MENU_ITEMS: { label: string; href: string }[] = [
|
||||
{ label: 'Comisiones', href: '/reportes?tab=comisiones' },
|
||||
{ label: 'Adeudos', href: '/reportes?tab=adeudos' },
|
||||
{ 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: 'Concentrado', href: '/reportes?tab=concentrado' },
|
||||
{ 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: '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,
|
||||
} from '../components/ui';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { odooApi, type Appointment, type AppointmentStateLog, type Patient, type Service, type Doctor, type Bloqueo, type ListaEspera, type PatientWallet, type ReceiptData, type AgendaPerm, AGENDA_PERM_ITEMS } from '../services/odoo';
|
||||
import { odooApi, type Appointment, type AppointmentStateLog, type Patient, type Service, type Doctor, type Bloqueo, type ListaEspera, type PatientWallet, type ReceiptData, type AgendaPerm, type GlossaryKey, AGENDA_PERM_ITEMS } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
import { useSucursales } from '../lib/sucursales';
|
||||
import { todayLocal } from '../lib/utils';
|
||||
import { useAutoRefresh, AutoRefreshBadge } from '../lib/autoRefresh';
|
||||
|
||||
@@ -55,6 +57,10 @@ export const APT_STATE_LABELS: Record<string, string> = {
|
||||
waitlist: 'Lista de espera',
|
||||
};
|
||||
|
||||
/** Etiqueta de estado de cita traducida ('En cabina' sigue el término de sala). */
|
||||
export const aptStateLabel = (state: string, term: (key: GlossaryKey) => string): string =>
|
||||
state === 'arrived' ? `En ${term('room_singular').toLowerCase()}` : (APT_STATE_LABELS[state] || state);
|
||||
|
||||
const stateOptions = [
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
{ value: 'pending', label: 'Agendada' },
|
||||
@@ -140,6 +146,8 @@ const Agenda: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { user, canAgenda } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const { sucursales, defaultCode } = useSucursales();
|
||||
const { openPatient } = usePatientDrawer();
|
||||
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor).
|
||||
// Cosmetología ve la agenda completa (es personal clínico, sin la privacidad
|
||||
@@ -253,6 +261,7 @@ const Agenda: FC = () => {
|
||||
notes: '',
|
||||
sessions: 1,
|
||||
duration: '',
|
||||
branch: '',
|
||||
});
|
||||
const quickToggleClass = editingId
|
||||
? 'hidden'
|
||||
@@ -272,6 +281,7 @@ const Agenda: FC = () => {
|
||||
time: '',
|
||||
notes: '',
|
||||
duration: '',
|
||||
branch: '',
|
||||
});
|
||||
const [pkgUpdating, setPkgUpdating] = useState<number | null>(null);
|
||||
const [expressOpen, setExpressOpen] = useState(false);
|
||||
@@ -943,7 +953,7 @@ const Agenda: FC = () => {
|
||||
const cambiarSala = async (entry: ListaEspera, sala: string) => {
|
||||
try {
|
||||
await odooApi.updateListaEspera(entry.id, { sala });
|
||||
const etiqueta = sala === 'consultoria' ? 'Consultoría' : sala === 'cabina' ? 'Cabina' : 'Sin sala';
|
||||
const etiqueta = sala === 'consultoria' ? 'Consultoría' : sala === 'cabina' ? term('room_singular') : 'Sin sala';
|
||||
toast.success(`${entry.patient}: ${etiqueta}`);
|
||||
await recargarListaEspera();
|
||||
} catch (err) {
|
||||
@@ -1249,7 +1259,7 @@ const Agenda: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const aptStateLabels = APT_STATE_LABELS;
|
||||
const aptStateLabels = (state: string) => aptStateLabel(state, term);
|
||||
|
||||
const handlePhoneConfirm = async (apt: Appointment, confirmed: boolean) => {
|
||||
try {
|
||||
@@ -1413,6 +1423,7 @@ const Agenda: FC = () => {
|
||||
notes: form.notes,
|
||||
sessions,
|
||||
duration: form.duration ? parseInt(form.duration, 10) : 0,
|
||||
branch: form.branch || defaultCode || undefined,
|
||||
} as never);
|
||||
toast.success(sessions > 1 ? `Paquete creado: ${sessions} citas` : 'Cita creada');
|
||||
// Si la cita salió de la lista de espera, se marca como agendada
|
||||
@@ -1427,6 +1438,7 @@ const Agenda: FC = () => {
|
||||
notes: '',
|
||||
sessions: 1,
|
||||
duration: '',
|
||||
branch: '',
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -1453,6 +1465,7 @@ const Agenda: FC = () => {
|
||||
notes: apt.notes || '',
|
||||
sessions: 1,
|
||||
duration: String(apt.duration || apt.service_duration || DEFAULT_DURATION),
|
||||
branch: apt.branch || '',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
};
|
||||
@@ -1473,6 +1486,7 @@ const Agenda: FC = () => {
|
||||
time: parseTimeToFloat(form.time) ?? 0,
|
||||
notes: form.notes,
|
||||
duration: form.duration ? parseInt(form.duration, 10) : 0,
|
||||
branch: form.branch || defaultCode || undefined,
|
||||
} as never);
|
||||
toast.success('Cita actualizada');
|
||||
setCreateOpen(false);
|
||||
@@ -1492,7 +1506,7 @@ const Agenda: FC = () => {
|
||||
setQuickMode(false);
|
||||
setEditingId(null);
|
||||
setPickedPatient(null);
|
||||
setForm((f) => ({ ...f, patient_id: '', duration: '', ...(medicoEmployeeId ? { doctor_id: String(medicoEmployeeId) } : {}) }));
|
||||
setForm((f) => ({ ...f, patient_id: '', duration: '', branch: '', ...(medicoEmployeeId ? { doctor_id: String(medicoEmployeeId) } : {}) }));
|
||||
setQuickForm({
|
||||
name: '',
|
||||
phone: '',
|
||||
@@ -1501,6 +1515,7 @@ const Agenda: FC = () => {
|
||||
time: '',
|
||||
notes: '',
|
||||
duration: '',
|
||||
branch: '',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
};
|
||||
@@ -1519,7 +1534,7 @@ const Agenda: FC = () => {
|
||||
? String(medicoEmployeeId)
|
||||
: (opts?.doctorId != null ? String(opts.doctorId) : doctorFilter);
|
||||
const t = opts?.time ?? '';
|
||||
setForm((f) => ({ ...f, patient_id: '', date: d, time: t, doctor_id: docId }));
|
||||
setForm((f) => ({ ...f, patient_id: '', date: d, time: t, doctor_id: docId, branch: '' }));
|
||||
setQuickForm({
|
||||
name: '',
|
||||
phone: '',
|
||||
@@ -1528,6 +1543,7 @@ const Agenda: FC = () => {
|
||||
time: t,
|
||||
notes: '',
|
||||
duration: '',
|
||||
branch: '',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
};
|
||||
@@ -1564,6 +1580,7 @@ const Agenda: FC = () => {
|
||||
doctor_id: quickForm.doctor_id ? parseInt(quickForm.doctor_id, 10) : undefined,
|
||||
notes: quickForm.notes || undefined,
|
||||
duration: quickForm.duration ? parseInt(quickForm.duration, 10) : undefined,
|
||||
branch: quickForm.branch || defaultCode || undefined,
|
||||
});
|
||||
if (res.patient_created) {
|
||||
toast.success('Paciente y cita rápida creados');
|
||||
@@ -1764,7 +1781,7 @@ const Agenda: FC = () => {
|
||||
{puedeAgendar && (
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nueva cita
|
||||
Nueva {term('appointment_singular').toLowerCase()}
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
@@ -1822,7 +1839,7 @@ const Agenda: FC = () => {
|
||||
</div>
|
||||
<Select
|
||||
label="Estado"
|
||||
options={stateOptions}
|
||||
options={stateOptions.map((o) => (o.value === 'arrived' ? { ...o, label: `En ${term('room_singular').toLowerCase()}` } : o))}
|
||||
value={stateFilter}
|
||||
onChange={(e) => setStateFilter(e.target.value)}
|
||||
className="sm:max-w-[180px]"
|
||||
@@ -2485,7 +2502,7 @@ const Agenda: FC = () => {
|
||||
<EmptyState
|
||||
title="Sin citas"
|
||||
subtitle="No hay citas para los filtros seleccionados."
|
||||
actionLabel="Nueva cita"
|
||||
actionLabel={`Nueva ${term('appointment_singular').toLowerCase()}`}
|
||||
onAction={openCreate}
|
||||
/>
|
||||
) : (
|
||||
@@ -2553,7 +2570,7 @@ const Agenda: FC = () => {
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{apt.doctor || '-'}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={badgeForAppointmentState(apt.state)}>{APT_STATE_LABELS[apt.state] || apt.state}</Badge>
|
||||
<Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term)}</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<button
|
||||
@@ -2586,7 +2603,7 @@ const Agenda: FC = () => {
|
||||
rows={[
|
||||
{ label: 'Hora', value: apt.time },
|
||||
{ label: 'Doctor', value: apt.doctor || '-' },
|
||||
{ label: 'Estado', value: <Badge variant={badgeForAppointmentState(apt.state)}>{APT_STATE_LABELS[apt.state] || apt.state}</Badge> },
|
||||
{ label: 'Estado', value: <Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term)}</Badge> },
|
||||
{ label: 'Confirmación', value: <span title={confirmTitle(apt)}>{confirmIndicator(apt, 14)}</span> },
|
||||
{ label: 'Pago', value: apt.payment_state },
|
||||
]}
|
||||
@@ -2672,7 +2689,7 @@ const Agenda: FC = () => {
|
||||
<Modal
|
||||
isOpen={createOpen}
|
||||
onClose={() => cerrarModalCita()}
|
||||
title={editingId ? 'Editar cita' : 'Nueva cita'}
|
||||
title={editingId ? `Editar ${term('appointment_singular').toLowerCase()}` : `Nueva ${term('appointment_singular').toLowerCase()}`}
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
@@ -2717,6 +2734,12 @@ const Agenda: FC = () => {
|
||||
onChange={(e) => setQuickForm({ ...quickForm, doctor_id: e.target.value })}
|
||||
disabled={isMedico}
|
||||
/>
|
||||
<Select
|
||||
label={term('branch_singular')}
|
||||
options={sucursales.map((s) => ({ value: s.code, label: s.name }))}
|
||||
value={quickForm.branch || defaultCode}
|
||||
onChange={(e) => setQuickForm({ ...quickForm, branch: e.target.value })}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Fecha *"
|
||||
@@ -2816,6 +2839,12 @@ const Agenda: FC = () => {
|
||||
onChange={(e) => setForm({ ...form, doctor_id: e.target.value })}
|
||||
disabled={isMedico}
|
||||
/>
|
||||
<Select
|
||||
label={term('branch_singular')}
|
||||
options={sucursales.map((s) => ({ value: s.code, label: s.name }))}
|
||||
value={form.branch || defaultCode}
|
||||
onChange={(e) => setForm({ ...form, branch: e.target.value })}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Fecha *"
|
||||
@@ -2977,7 +3006,7 @@ const Agenda: FC = () => {
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={badgeForAppointmentState(detailApt.state)}>
|
||||
{aptStateLabels[detailApt.state] || detailApt.state}
|
||||
{aptStateLabels(detailApt.state)}
|
||||
</Badge>
|
||||
{detailApt.is_first_visit && (
|
||||
<Badge variant="primary">Primera vez</Badge>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft, Percent, Check, Calculator } from 'lucide-react';
|
||||
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft, Percent, Check, Calculator, Palette } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import UsuariosPanel from '../components/UsuariosPanel';
|
||||
import RolesPanel from '../components/RolesPanel';
|
||||
@@ -10,12 +10,18 @@ import CatalogoPanel from '../components/CatalogoPanel';
|
||||
import AuditoriaPanel from '../components/AuditoriaPanel';
|
||||
import TicketDesigner from '../components/TicketDesigner';
|
||||
import ImpactoModal from '../components/ImpactoModal';
|
||||
import SucursalesPanel from '../components/SucursalesPanel';
|
||||
import CategoriasPanel from '../components/CategoriasPanel';
|
||||
import TerminologiaPanel from '../components/TerminologiaPanel';
|
||||
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
||||
import { odooApi, type Doctor, BRAND_DEFAULT } from '../services/odoo';
|
||||
import { odooApi, type Doctor, type ClinicSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'clinica', label: 'Clínica' },
|
||||
{ key: 'sucursales', label: 'Sucursales' },
|
||||
{ key: 'categorias', label: 'Categorías' },
|
||||
{ key: 'terminologia', label: 'Terminología' },
|
||||
{ key: 'usuarios', label: 'Usuarios' },
|
||||
{ key: 'roles', label: 'Roles' },
|
||||
{ key: 'recetas', label: 'Recetas' },
|
||||
@@ -24,38 +30,25 @@ const TABS = [
|
||||
{ key: 'auditoria', label: 'Auditoría' },
|
||||
] as const;
|
||||
|
||||
// Secciones que ve gerencia (admin del sistema ve todas).
|
||||
const TABS_GERENCIA = ['sucursales', 'categorias', 'terminologia', 'usuarios', 'roles', 'tickets'];
|
||||
|
||||
const SUBCATALOGOS = [
|
||||
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
||||
{ key: 'procedimientos', label: 'Procedimientos' },
|
||||
] as const;
|
||||
|
||||
interface ClinicSettings {
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
website: string;
|
||||
}
|
||||
|
||||
const defaultSettings: ClinicSettings = {
|
||||
name: 'SKEEN Derma Experts',
|
||||
phone: '+52 (661) 100-2172',
|
||||
email: 'hola@skeen.mx',
|
||||
address: 'Playas de Rosarito, Baja California',
|
||||
website: 'https://skeen.mx',
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'skeen_clinic_settings';
|
||||
// Clave histórica del navegador: antes los ajustes de la clínica vivían solo en
|
||||
// localStorage. Se migran al backend una vez y deja de usarse.
|
||||
const LEGACY_STORAGE_KEY = 'skeen_clinic_settings';
|
||||
|
||||
const Configuracion: FC = () => {
|
||||
const { user, brand, setBrand } = useAuth();
|
||||
// Gerencia gestiona usuarios y roles; la sección "Clínica" (tipo de cambio y
|
||||
// comisiones) queda para administración del sistema.
|
||||
// Gerencia gestiona catálogos, usuarios y roles; la sección "Clínica" (datos
|
||||
// de contacto, tipo de cambio y comisiones) queda para administración del sistema.
|
||||
const esAdmin = user?.role === 'admin';
|
||||
const tabsVisibles = useMemo(
|
||||
() => (esAdmin
|
||||
? TABS
|
||||
: TABS.filter((t) => t.key === 'usuarios' || t.key === 'roles' || t.key === 'tickets')),
|
||||
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key))),
|
||||
[esAdmin],
|
||||
);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -66,31 +59,60 @@ const Configuracion: FC = () => {
|
||||
const sub = searchParams.get('sub');
|
||||
return sub === 'procedimientos' ? 'procedimientos' : 'diagnosticos';
|
||||
});
|
||||
const [settings, setSettings] = useState<ClinicSettings>(() => ({
|
||||
...defaultSettings,
|
||||
// El nombre default sigue al brand (white-label); teléfono/dirección
|
||||
// quedan en localStorage o los valores históricos de la clínica.
|
||||
name: brand.nombre,
|
||||
}));
|
||||
const [settings, setSettings] = useState<ClinicSettings>(CLINIC_SETTINGS_DEFAULT);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Ajustes de la clínica desde el backend. Migración de una sola vez: si este
|
||||
// navegador tiene los datos viejos en localStorage y el backend sigue con los
|
||||
// defaults (contacto vacío), se precargan y se suben; después se deja de usar
|
||||
// localStorage como fuente de verdad.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
odooApi.getClinicSettings()
|
||||
.then(async (res) => {
|
||||
if (!alive || res.status !== 'success') return;
|
||||
let next = res.settings;
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) setSettings(JSON.parse(saved));
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
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 {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
const res = await odooApi.putClinicSettings({ ...settings, motivo: 'Ajustes desde Configuración' });
|
||||
if (res.status === 'success') setSettings(res.settings);
|
||||
toast.success('Configuración guardada');
|
||||
} catch {
|
||||
toast.error('Error al guardar configuración');
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al guardar configuración');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -104,7 +126,7 @@ const Configuracion: FC = () => {
|
||||
const [tcSaving, setTcSaving] = useState(false);
|
||||
// ---- Identidad de la clínica (brand white-label) ----
|
||||
const [brandForm, setBrandForm] = useState({
|
||||
nombre: '', pie: '', logo_claro: '', logo_oscuro: '',
|
||||
nombre: '', pie: '', logo_claro: '', logo_oscuro: '', url_publica: '', primario: '', acento: '',
|
||||
});
|
||||
const [brandSaving, setBrandSaving] = useState(false);
|
||||
const brandCargadoRef = useRef(false);
|
||||
@@ -116,6 +138,9 @@ const Configuracion: FC = () => {
|
||||
pie: brand.pie === BRAND_DEFAULT.pie ? '' : brand.pie,
|
||||
logo_claro: brand.logo_claro === BRAND_DEFAULT.logo_claro ? '' : brand.logo_claro,
|
||||
logo_oscuro: brand.logo_oscuro === BRAND_DEFAULT.logo_oscuro ? '' : brand.logo_oscuro,
|
||||
url_publica: brand.url_publica || '',
|
||||
primario: brand.colores?.primario || '',
|
||||
acento: brand.colores?.acento || '',
|
||||
});
|
||||
}, [brand]);
|
||||
|
||||
@@ -128,13 +153,16 @@ const Configuracion: FC = () => {
|
||||
pie: brandForm.pie.trim() || BRAND_DEFAULT.pie,
|
||||
logo_claro: brandForm.logo_claro.trim() || BRAND_DEFAULT.logo_claro,
|
||||
logo_oscuro: brandForm.logo_oscuro.trim() || BRAND_DEFAULT.logo_oscuro,
|
||||
url_publica: brandForm.url_publica.trim(),
|
||||
colores: { primario: brandForm.primario.trim(), acento: brandForm.acento.trim() },
|
||||
},
|
||||
motivo: 'Identidad desde Configuración',
|
||||
});
|
||||
setBrand(res.brand);
|
||||
toast.success('Identidad de la clínica actualizada (se ve al recargar en los demás equipos)');
|
||||
} catch {
|
||||
toast.error('No se pudo guardar la identidad de la clínica');
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo guardar la identidad de la clínica');
|
||||
} finally {
|
||||
setBrandSaving(false);
|
||||
}
|
||||
@@ -298,6 +326,24 @@ const Configuracion: FC = () => {
|
||||
<CatalogoPanel kind={subcatalogo as 'diagnosticos' | 'procedimientos'} titulo={subcatalogo === 'diagnosticos' ? 'Diagnósticos' : 'Procedimientos'} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'sucursales' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<SucursalesPanel />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'categorias' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<CategoriasPanel />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'terminologia' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<TerminologiaPanel />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
<Card>
|
||||
@@ -305,7 +351,7 @@ const Configuracion: FC = () => {
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-4">Información de la clínica</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<a
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(settings.address)}`}
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(settings.direccion)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
|
||||
@@ -315,12 +361,12 @@ const Configuracion: FC = () => {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-theme-heading">Dirección</p>
|
||||
<p className="text-sm text-theme-muted truncate">{settings.address}</p>
|
||||
<p className="text-sm text-theme-muted truncate">{settings.direccion}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={`tel:${settings.phone}`}
|
||||
href={`tel:${settings.telefono}`}
|
||||
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-theme-surface flex items-center justify-center text-theme-muted shadow-sm shrink-0">
|
||||
@@ -328,7 +374,7 @@ const Configuracion: FC = () => {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-theme-heading">Teléfono</p>
|
||||
<p className="text-sm text-theme-muted truncate">{settings.phone}</p>
|
||||
<p className="text-sm text-theme-muted truncate">{settings.telefono}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -346,7 +392,7 @@ const Configuracion: FC = () => {
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={settings.website}
|
||||
href={settings.web}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
|
||||
@@ -356,7 +402,7 @@ const Configuracion: FC = () => {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-theme-heading">Sitio web</p>
|
||||
<p className="text-sm text-theme-muted truncate">{settings.website.replace(/^https?:\/\//, '')}</p>
|
||||
<p className="text-sm text-theme-muted truncate">{(settings.web || '').replace(/^https?:\/\//, '')}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -375,30 +421,78 @@ const Configuracion: FC = () => {
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Nombre de la clínica"
|
||||
value={settings.name}
|
||||
onChange={(e) => update('name', e.target.value)}
|
||||
value={settings.nombre}
|
||||
onChange={(e) => update('nombre', e.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Teléfono"
|
||||
value={settings.phone}
|
||||
onChange={(e) => update('phone', e.target.value)}
|
||||
value={settings.telefono}
|
||||
onChange={(e) => update('telefono', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="RFC"
|
||||
value={settings.rfc}
|
||||
onChange={(e) => update('rfc', e.target.value)}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={settings.email}
|
||||
onChange={(e) => update('email', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Sitio web"
|
||||
value={settings.web}
|
||||
onChange={(e) => update('web', e.target.value)}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Dirección"
|
||||
value={settings.address}
|
||||
onChange={(e) => update('address', e.target.value)}
|
||||
value={settings.direccion}
|
||||
onChange={(e) => update('direccion', e.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Horario de apertura"
|
||||
type="time"
|
||||
value={settings.horario_apertura}
|
||||
onChange={(e) => update('horario_apertura', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Sitio web"
|
||||
value={settings.website}
|
||||
onChange={(e) => update('website', e.target.value)}
|
||||
label="Horario de cierre"
|
||||
type="time"
|
||||
value={settings.horario_cierre}
|
||||
onChange={(e) => update('horario_cierre', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Zona horaria"
|
||||
value={settings.zona_horaria}
|
||||
onChange={(e) => update('zona_horaria', e.target.value)}
|
||||
placeholder="America/Tijuana"
|
||||
/>
|
||||
<Input
|
||||
label="URL pública"
|
||||
value={settings.url_publica}
|
||||
onChange={(e) => update('url_publica', e.target.value)}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Nota del ticket"
|
||||
value={settings.nota_ticket}
|
||||
onChange={(e) => update('nota_ticket', e.target.value)}
|
||||
placeholder="Se imprime al pie del ticket"
|
||||
/>
|
||||
<p className="text-xs text-theme-muted">
|
||||
Moneda base: <span className="font-medium text-theme-heading">{settings.moneda_base || 'MXN'}</span>
|
||||
</p>
|
||||
<div className="pt-2">
|
||||
<Button onClick={save}>
|
||||
<Save size={16} className="mr-2" />
|
||||
@@ -445,7 +539,53 @@ const Configuracion: FC = () => {
|
||||
onChange={(e) => setBrandForm((f) => ({ ...f, logo_oscuro: e.target.value }))}
|
||||
placeholder={BRAND_DEFAULT.logo_oscuro}
|
||||
/>
|
||||
<Input
|
||||
label="URL pública de la instancia"
|
||||
value={brandForm.url_publica}
|
||||
onChange={(e) => setBrandForm((f) => ({ ...f, url_publica: e.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Colores de marca: vacíos = se conserva el color del tema activo. */}
|
||||
<div className="mt-5">
|
||||
<p className="text-sm font-medium text-theme-heading mb-1 flex items-center">
|
||||
<Palette size={16} className="mr-2" /> Colores de marca
|
||||
</p>
|
||||
<p className="text-xs text-theme-muted mb-3">
|
||||
Si se definen, pisan el color del tema en los tres temas. Dejarlos vacíos conserva el color original.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{([
|
||||
{ key: 'primario' as const, label: 'Color primario' },
|
||||
{ key: 'acento' as const, label: 'Color de acento' },
|
||||
]).map(({ key, label }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-xs font-medium text-theme-muted mb-1.5">{label}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={brandForm[key] || (key === 'primario' ? '#eda588' : '#c7bcdd')}
|
||||
onChange={(e) => setBrandForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||
className="h-10 w-12 shrink-0 rounded-lg border border-theme-border-strong bg-theme-surface cursor-pointer"
|
||||
aria-label={label}
|
||||
/>
|
||||
<Input
|
||||
value={brandForm[key]}
|
||||
onChange={(e) => setBrandForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||
placeholder="Vacío = color del tema"
|
||||
/>
|
||||
{brandForm[key] && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setBrandForm((f) => ({ ...f, [key]: '' }))} title="Usar el color del tema">
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
{(brandForm.logo_claro || brandForm.logo_oscuro) && (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -51,8 +51,9 @@ import { odooApi, type DashboardStats, type Appointment, type Service, type Char
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
import { useTheme } from '../lib/theme';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
import { waLink } from '../lib/whatsapp';
|
||||
import { APT_STATE_LABELS } from './Agenda';
|
||||
import { aptStateLabel } from './Agenda';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
@@ -161,6 +162,7 @@ const TodayTile: FC<TodayTileProps> = ({ label, icon, onClick, children, classNa
|
||||
const Dashboard: FC = () => {
|
||||
const { theme } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const navigate = useNavigate();
|
||||
const isHomeNest = theme === 'homenest';
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
@@ -436,7 +438,7 @@ const Dashboard: FC = () => {
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 shrink-0">
|
||||
<Button variant="primary" size="md" onClick={() => navigate('/agenda')}>
|
||||
<Calendar size={16} className="mr-2" /> Nueva cita
|
||||
<Calendar size={16} className="mr-2" /> Nueva {term('appointment_singular').toLowerCase()}
|
||||
</Button>
|
||||
<Button variant="outline" size="md" onClick={() => navigate('/reportes')}>
|
||||
<TrendingUp size={16} className="mr-2" /> Ver reportes
|
||||
@@ -706,7 +708,7 @@ const Dashboard: FC = () => {
|
||||
<p className="text-sm font-medium text-theme-heading truncate">{apt.patient}</p>
|
||||
<p className="text-xs text-theme-muted truncate">{apt.service} • {apt.time}</p>
|
||||
</div>
|
||||
<Badge variant={badgeForAppointmentState(apt.state)}>{APT_STATE_LABELS[apt.state] || apt.state}</Badge>
|
||||
<Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term)}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Mail, Phone, Stethoscope, Briefcase, Save, Percent, Eye, CalendarDays,
|
||||
import Layout from '../components/Layout';
|
||||
import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, Modal, Select, toast } from '../components/ui';
|
||||
import { odooApi, type Doctor, type DoctorHistory, type HorarioDia } from '../services/odoo';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
|
||||
const DIAS = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'];
|
||||
|
||||
@@ -130,6 +131,7 @@ const StatBadges: FC<{ d: Doctor }> = ({ d }) => (
|
||||
);
|
||||
|
||||
const Medicos: FC = () => {
|
||||
const { term } = useGlosario();
|
||||
const [employees, setEmployees] = useState<Doctor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -326,8 +328,8 @@ const Medicos: FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Médicos" subtitle="Equipo médico">
|
||||
<PageHeader title="Médicos" subtitle="Especialistas de la clínica">
|
||||
<Layout title={term('doctor_plural')} subtitle="Equipo médico">
|
||||
<PageHeader title={term('doctor_plural')} subtitle="Especialistas de la clínica">
|
||||
{countBaja > 0 && (
|
||||
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
|
||||
<input
|
||||
|
||||
@@ -32,6 +32,8 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import { odooApi, type Patient, type Appointment, type Sale, type Visita, type Doctor, type EstadoCuenta, type PatientWallet, type ReceiptData } from '../services/odoo';
|
||||
import { exportToExcel } from '../lib/exporter';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
import { useSucursales } from '../lib/sucursales';
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||
@@ -100,7 +102,6 @@ const visitaStateLabels: Record<string, string> = {
|
||||
const paymentStateLabels: Record<string, string> = {
|
||||
not_paid: 'No pagado', partial: 'Pago parcial', paid: 'Pagado',
|
||||
};
|
||||
const branchLabels: Record<string, string> = { rosarito: 'Rosarito', tijuana: 'Tijuana' };
|
||||
const mediumLabels: Record<string, string> = { onsite: 'Presencial', videocall: 'Videollamada' };
|
||||
|
||||
const completionBadgeClasses = (pct?: number) => {
|
||||
@@ -122,6 +123,8 @@ const CRITICAL_ALERTS: { key: keyof Patient; label: string }[] = [
|
||||
|
||||
const Pacientes: FC = () => {
|
||||
const { brand } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const { labelFor: branchLabel } = useSucursales();
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -429,7 +432,7 @@ const Pacientes: FC = () => {
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Teléfono</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Edad</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Última visita</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Visitas</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">{term('visit_plural')}</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Puntos</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
|
||||
</tr>
|
||||
@@ -520,15 +523,15 @@ const Pacientes: FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Pacientes" subtitle="Directorio de pacientes">
|
||||
<PageHeader title="Pacientes" subtitle="Busca, crea y gestiona pacientes">
|
||||
<Layout title={term('patient_plural')} subtitle="Directorio de pacientes">
|
||||
<PageHeader title={term('patient_plural')} subtitle="Busca, crea y gestiona pacientes">
|
||||
<Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Exportar CSV
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo paciente
|
||||
Nuevo {term('patient_singular').toLowerCase()}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
@@ -611,7 +614,7 @@ const Pacientes: FC = () => {
|
||||
<EmptyState
|
||||
title="Sin pacientes"
|
||||
subtitle="No se encontraron pacientes."
|
||||
actionLabel="Nuevo paciente"
|
||||
actionLabel={`Nuevo ${term('patient_singular').toLowerCase()}`}
|
||||
onAction={openCreate}
|
||||
/>
|
||||
) : (
|
||||
@@ -717,7 +720,7 @@ const Pacientes: FC = () => {
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={editing ? 'Editar paciente' : 'Nuevo paciente'}
|
||||
title={editing ? `Editar ${term('patient_singular').toLowerCase()}` : `Nuevo ${term('patient_singular').toLowerCase()}`}
|
||||
maxWidth="2xl"
|
||||
footer={
|
||||
<>
|
||||
@@ -1263,12 +1266,12 @@ const Pacientes: FC = () => {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<InfoRow label="Referencia" value={entryDetail.item.reference} icon={FileText} />
|
||||
<InfoRow label="Fecha y hora" value={`${entryDetail.item.date} · ${entryDetail.item.time}`} icon={Calendar} />
|
||||
<InfoRow label="Servicio" value={entryDetail.item.service} icon={Stethoscope} />
|
||||
<InfoRow label="Médico" value={entryDetail.item.doctor || 'Sin asignar'} icon={User} />
|
||||
<InfoRow label={term('service_singular')} value={entryDetail.item.service} icon={Stethoscope} />
|
||||
<InfoRow label={term('doctor_singular')} value={entryDetail.item.doctor || 'Sin asignar'} icon={User} />
|
||||
<InfoRow label="Duración" value={entryDetail.item.duration ? `${entryDetail.item.duration} min` : '-'} />
|
||||
<InfoRow label="Precio" value={`$${entryDetail.item.price}`} />
|
||||
<InfoRow label="Monto pagado" value={`$${entryDetail.item.amount_paid ?? 0}`} />
|
||||
<InfoRow label="Sucursal" value={branchLabels[entryDetail.item.branch || ''] || entryDetail.item.branch || '-'} icon={MapPin} />
|
||||
<InfoRow label="Sucursal" value={branchLabel(entryDetail.item.branch) || '-'} icon={MapPin} />
|
||||
<InfoRow label="Medio" value={mediumLabels[entryDetail.item.medium || ''] || entryDetail.item.medium || '-'} />
|
||||
{entryDetail.item.package_finished && (
|
||||
<InfoRow label="Paquete terminado" value={entryDetail.item.package_finished_date || 'Sí'} />
|
||||
@@ -1351,8 +1354,8 @@ const Pacientes: FC = () => {
|
||||
<InfoRow label="Folio" value={entryDetail.item.name} icon={FileText} />
|
||||
<InfoRow label="Inicio" value={entryDetail.item.date_start || '-'} icon={Calendar} />
|
||||
<InfoRow label="Fin" value={entryDetail.item.date_end || '-'} icon={Calendar} />
|
||||
<InfoRow label="Servicio" value={entryDetail.item.servicio || '-'} icon={Stethoscope} />
|
||||
<InfoRow label="Médico" value={entryDetail.item.doctor || 'Sin asignar'} icon={User} />
|
||||
<InfoRow label={term('service_singular')} value={entryDetail.item.servicio || '-'} icon={Stethoscope} />
|
||||
<InfoRow label={term('doctor_singular')} value={entryDetail.item.doctor || 'Sin asignar'} icon={User} />
|
||||
<InfoRow label="Cosmetóloga" value={entryDetail.item.cosmetologa || 'Sin asignar'} icon={User} />
|
||||
</div>
|
||||
{[
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { odooApi } from '../services/odoo';
|
||||
import type { Paquete, PaqueteCompra, PaqueteConsumo, Service } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||
@@ -51,6 +52,7 @@ const emptyForm: FormPaquete = {
|
||||
};
|
||||
|
||||
const Paquetes: FC = () => {
|
||||
const { term } = useGlosario();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = searchParams.get('tab') === 'saldos' ? 'saldos' : 'catalogo';
|
||||
const setTab = (key: string) =>
|
||||
@@ -327,9 +329,9 @@ const Paquetes: FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Paquetes" subtitle="Paquetes de sesiones">
|
||||
<Layout title={term('package_plural')} subtitle="Paquetes de sesiones">
|
||||
<PageHeader
|
||||
title="Paquetes de sesiones"
|
||||
title={`${term('package_plural')} de sesiones`}
|
||||
subtitle="Combos de sesiones a precio cerrado y el saldo que lleva cada paciente"
|
||||
>
|
||||
<Button variant="outline" onClick={() => (tab === 'catalogo' ? loadCatalogo() : loadSaldos())}>
|
||||
@@ -337,7 +339,7 @@ const Paquetes: FC = () => {
|
||||
</Button>
|
||||
{tab === 'catalogo' && esAdmin && (
|
||||
<Button onClick={abrirNuevo}>
|
||||
<Plus size={16} className="mr-2" /> Nuevo paquete
|
||||
<Plus size={16} className="mr-2" /> Nuevo {term('package_singular').toLowerCase()}
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
@@ -607,7 +609,7 @@ const Paquetes: FC = () => {
|
||||
<Modal
|
||||
isOpen={formOpen}
|
||||
onClose={() => setFormOpen(false)}
|
||||
title={form.id ? 'Editar paquete' : 'Nuevo paquete de sesiones'}
|
||||
title={form.id ? `Editar ${term('package_singular').toLowerCase()}` : `Nuevo ${term('package_singular').toLowerCase()} de sesiones`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Star, Wallet, Edit2, Plus, Clock, Tag, Package, Search, Copy, ChevronUp, ChevronDown, ArrowUpToLine } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
@@ -16,23 +16,8 @@ import {
|
||||
} from '../components/ui';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { odooApi, type Service } from '../services/odoo';
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: '', label: 'Todas las categorías' },
|
||||
{ value: 'consulta', label: 'Consulta' },
|
||||
{ value: 'tratamiento', label: 'Tratamiento' },
|
||||
{ value: 'procedimiento', label: 'Procedimiento' },
|
||||
{ value: 'paquete', label: 'Paquete' },
|
||||
];
|
||||
|
||||
const categoryFormOptions = categoryOptions.filter((o) => o.value !== '');
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
consulta: 'Consulta',
|
||||
tratamiento: 'Tratamiento',
|
||||
procedimiento: 'Procedimiento',
|
||||
paquete: 'Paquete',
|
||||
};
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
import { useServiceCategories } from '../lib/service-categories';
|
||||
|
||||
interface ServiceForm {
|
||||
name: string;
|
||||
@@ -63,6 +48,16 @@ const emptyForm: ServiceForm = {
|
||||
};
|
||||
|
||||
const Servicios: FC = () => {
|
||||
const { term } = useGlosario();
|
||||
const { categories, labelFor } = useServiceCategories();
|
||||
const categoryOptions = useMemo(
|
||||
() => [{ value: '', label: 'Todas las categorías' }, ...categories.map((c) => ({ value: c.code, label: c.name }))],
|
||||
[categories],
|
||||
);
|
||||
const categoryFormOptions = useMemo(
|
||||
() => categories.map((c) => ({ value: c.code, label: c.name })),
|
||||
[categories],
|
||||
);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -289,11 +284,11 @@ const Servicios: FC = () => {
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
return (
|
||||
<Layout title="Servicios" subtitle="Catálogo de tratamientos">
|
||||
<PageHeader title="Servicios" subtitle="Gestiona precios, paquetes y favoritos">
|
||||
<Layout title={term('service_plural')} subtitle="Catálogo de tratamientos">
|
||||
<PageHeader title={term('service_plural')} subtitle="Gestiona precios, paquetes y favoritos">
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo servicio
|
||||
Nuevo {term('service_singular').toLowerCase()}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
@@ -325,7 +320,7 @@ const Servicios: FC = () => {
|
||||
<EmptyState
|
||||
title="Sin servicios"
|
||||
subtitle={search || categoryFilter ? 'No hay servicios que coincidan con los filtros.' : 'No hay servicios registrados.'}
|
||||
actionLabel="Nuevo servicio"
|
||||
actionLabel={`Nuevo ${term('service_singular').toLowerCase()}`}
|
||||
onAction={openCreate}
|
||||
/>
|
||||
) : (
|
||||
@@ -371,7 +366,7 @@ const Servicios: FC = () => {
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-theme-muted mb-4">
|
||||
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
|
||||
<Tag size={12} className="mr-1" />
|
||||
{categoryLabels[s.category] || s.category || 'Sin categoría'}
|
||||
{labelFor(s.category) || 'Sin categoría'}
|
||||
</span>
|
||||
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
|
||||
<Clock size={12} className="mr-1" />
|
||||
@@ -468,7 +463,7 @@ const Servicios: FC = () => {
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => { setModalOpen(false); setEditing(null); setDuplicando(false); }}
|
||||
title={editing ? `Editar: ${editing.name}` : duplicando ? 'Duplicar servicio' : 'Nuevo servicio'}
|
||||
title={editing ? `Editar: ${editing.name}` : duplicando ? `Duplicar ${term('service_singular').toLowerCase()}` : `Nuevo ${term('service_singular').toLowerCase()}`}
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
@@ -484,7 +479,7 @@ const Servicios: FC = () => {
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => { setModalOpen(false); setEditing(null); setDuplicando(false); }}>Cancelar</Button>
|
||||
<Button onClick={save} loading={submitting}>{editing ? 'Guardar cambios' : duplicando ? 'Crear copia' : 'Crear servicio'}</Button>
|
||||
<Button onClick={save} loading={submitting}>{editing ? 'Guardar cambios' : duplicando ? 'Crear copia' : `Crear ${term('service_singular').toLowerCase()}`}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { odooApi, type Visita, type VisitaAdjunto, type Doctor, type InventoryItem } from '../services/odoo';
|
||||
import type { BadgeVariant } from '../components/ui/Badge';
|
||||
import { todayLocal } from '../lib/utils';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
|
||||
const visitaStateLabels: Record<string, string> = {
|
||||
en_curso: 'En curso',
|
||||
@@ -48,6 +49,7 @@ const MAX_ADJUNTO_BYTES = 10 * 1024 * 1024;
|
||||
const hoy = () => todayLocal();
|
||||
|
||||
const Visitas: FC = () => {
|
||||
const { term } = useGlosario();
|
||||
const [visitas, setVisitas] = useState<Visita[]>([]);
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -294,8 +296,8 @@ const Visitas: FC = () => {
|
||||
const hora = (v: Visita) => (v.date_start ? v.date_start.split(' ')[1] || '' : '-');
|
||||
|
||||
return (
|
||||
<Layout title="Visitas" subtitle="Registro de visitas clínicas">
|
||||
<PageHeader title="Visitas" subtitle="Consulta y completa las visitas clínicas del día" />
|
||||
<Layout title={term('visit_plural')} subtitle="Registro de visitas clínicas">
|
||||
<PageHeader title={term('visit_plural')} subtitle="Consulta y completa las visitas clínicas del día" />
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
@@ -325,9 +327,9 @@ const Visitas: FC = () => {
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Hora</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Servicio</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Médico</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">{term('patient_singular')}</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">{term('service_singular')}</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">{term('doctor_singular')}</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden lg:table-cell">Motivo</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
|
||||
</tr>
|
||||
|
||||
@@ -905,6 +905,9 @@ export interface ClinicBrand {
|
||||
logo_claro: string; // fondos oscuros (login SKEEN, sidebar)
|
||||
logo_oscuro: string; // fondos claros (tema HomeNest)
|
||||
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 = {
|
||||
@@ -914,6 +917,80 @@ export const BRAND_DEFAULT: ClinicBrand = {
|
||||
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 {
|
||||
id: number;
|
||||
login: string;
|
||||
@@ -1397,6 +1474,79 @@ export const odooApi = {
|
||||
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 }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/roles/permissions`, { matrix });
|
||||
return data;
|
||||
@@ -1430,7 +1580,7 @@ export const odooApi = {
|
||||
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);
|
||||
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 -*-
|
||||
{
|
||||
'name': 'SKEEN Citas',
|
||||
'version': '1.0.1',
|
||||
'version': '1.1.0',
|
||||
'category': 'Healthcare',
|
||||
'summary': 'Agendamiento de citas para SKEEN Derma Experts',
|
||||
'description': """
|
||||
@@ -14,6 +14,7 @@
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/sequences.xml',
|
||||
'data/catalogos.xml',
|
||||
# Menú padre primero: las vistas de abajo cuelgan de él.
|
||||
'views/menus.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 bloqueo
|
||||
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 datetime import datetime, timedelta
|
||||
|
||||
from ..clinic_settings import agenda_bounds
|
||||
|
||||
|
||||
# Estados que liberan el horario: no ocupan agenda ni bloquean solapes.
|
||||
# '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_notes = fields.Char(string='Notas del Paquete')
|
||||
|
||||
# Categoría
|
||||
category = fields.Selection([
|
||||
('consulta', 'Consulta'),
|
||||
('tratamiento', 'Tratamiento'),
|
||||
('procedimiento', 'Procedimiento'),
|
||||
('paquete', 'Paquete'),
|
||||
], string='Categoría', default='tratamiento', required=True)
|
||||
# Categoría (valores configurables en skeen.service.category; los códigos
|
||||
# semilla 'consulta', 'tratamiento', 'procedimiento' y 'paquete' conservan
|
||||
# el comportamiento y los lookups actuales del backend)
|
||||
category = fields.Selection(
|
||||
selection='_get_category_selection',
|
||||
string='Categoría', default='tratamiento', required=True)
|
||||
|
||||
# Visual / agrupación
|
||||
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!')
|
||||
]
|
||||
|
||||
@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):
|
||||
_name = 'skeen.service.tag'
|
||||
@@ -103,10 +118,12 @@ class SkeenCita(models.Model):
|
||||
datetime_end = fields.Datetime(string='Fin', compute='_compute_datetime', store=True)
|
||||
|
||||
# Ubicación y médico
|
||||
branch = fields.Selection([
|
||||
('rosarito', 'Rosarito'),
|
||||
('tijuana', 'Tijuana'),
|
||||
], string='Sucursal', default='rosarito')
|
||||
# Sucursal: valores configurables en skeen.sucursal. Las semilla conservan
|
||||
# los códigos 'rosarito'/'tijuana' de las citas existentes.
|
||||
branch = fields.Selection(
|
||||
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')
|
||||
medium = fields.Selection([
|
||||
('videocall', 'Videollamada'),
|
||||
@@ -178,6 +195,15 @@ class SkeenCita(models.Model):
|
||||
package_finished = fields.Boolean(string='Paquete Terminado', default=False)
|
||||
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')
|
||||
def _compute_datetime(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
|
||||
(el usuario no debe poder elegir una hora bloqueada) y respeta su
|
||||
horario semanal cuando lo tiene configurado (sin horario configurado
|
||||
el comportamiento es el de siempre: 8:00 a 19:00).
|
||||
horario semanal cuando lo tiene configurado. Los límites de la jornada
|
||||
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
|
||||
servicio, p. ej. cuando recepción la ajusta en el modal de la cita.
|
||||
@@ -449,9 +475,8 @@ class SkeenCita(models.Model):
|
||||
('date', '=', date),
|
||||
] + ([('doctor_id', '=', doctor_id)] if doctor_id else []))
|
||||
|
||||
# Horario de la clínica: 8:00 AM a 7:00 PM
|
||||
start_time = 8.0 # 8:00 AM
|
||||
end_time = 19.0 # 7:00 PM
|
||||
# Horario de la clínica (configurable en los ajustes; por defecto 8:00-19:00)
|
||||
start_time, end_time = agenda_bounds(self.env)
|
||||
|
||||
# Horario del médico (si lo tiene configurado manda sobre el de la clínica)
|
||||
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_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_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 -*-
|
||||
{
|
||||
'name': 'SKEEN Pagos',
|
||||
'version': '1.0.2',
|
||||
'version': '1.0.3',
|
||||
'category': 'Healthcare',
|
||||
'summary': 'Integración de pagos Stripe y MercadoPago para SKEEN',
|
||||
'description': """
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
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 json
|
||||
|
||||
@@ -62,6 +64,23 @@ class SkeenPago(models.Model):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('skeen.pago') or 'PAGO-001'
|
||||
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):
|
||||
"""Generar link de pago Stripe"""
|
||||
self.ensure_one()
|
||||
@@ -70,6 +89,7 @@ class SkeenPago(models.Model):
|
||||
raise ValidationError(_('Stripe no está configurado!'))
|
||||
|
||||
# Crear sesión de checkout
|
||||
base = self._payment_base('https://app.skeen.mx')
|
||||
headers = {
|
||||
'Authorization': f'Bearer {stripe_key}',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -77,12 +97,12 @@ class SkeenPago(models.Model):
|
||||
data = {
|
||||
'payment_method_types[]': 'card',
|
||||
'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][quantity]': 1,
|
||||
'mode': 'payment',
|
||||
'success_url': f'https://app.skeen.mx/pagos/exitoso?ref={self.name}',
|
||||
'cancel_url': f'https://app.skeen.mx/pagos/cancelado?ref={self.name}',
|
||||
'success_url': f'{base}/pagos/exitoso?ref={self.name}',
|
||||
'cancel_url': f'{base}/pagos/cancelado?ref={self.name}',
|
||||
'metadata[payment_id]': str(self.id),
|
||||
'metadata[patient_phone]': self.partner_id.phone or '',
|
||||
}
|
||||
@@ -111,22 +131,24 @@ class SkeenPago(models.Model):
|
||||
if not mp_token:
|
||||
raise ValidationError(_('MercadoPago no está configurado!'))
|
||||
|
||||
# Crear preferencia de checkout
|
||||
base = self._payment_base('https://app.skeen.mx')
|
||||
headers = {
|
||||
'Authorization': f'Bearer {mp_token}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
data = {
|
||||
'items': [{
|
||||
'title': f'Servicio SKEEN - {self.cita_id.servicio_id.name}',
|
||||
'title': self._product_name(),
|
||||
'quantity': 1,
|
||||
'unit_price': int(self.amount),
|
||||
}],
|
||||
'back_urls': {
|
||||
'success': f'https://app.skeen.mx/pagos/exitoso?ref={self.name}',
|
||||
'failure': f'https://app.skeen.mx/pagos/fallido?ref={self.name}',
|
||||
'pending': f'https://app.skeen.mx/pagos/pendiente?ref={self.name}',
|
||||
'success': f'{base}/pagos/exitoso?ref={self.name}',
|
||||
'failure': f'{base}/pagos/fallido?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': {
|
||||
'payment_id': str(self.id),
|
||||
'patient_phone': self.partner_id.phone or '',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'SKEEN WhatsApp',
|
||||
'version': '1.0.0',
|
||||
'version': '1.0.1',
|
||||
'category': 'Healthcare',
|
||||
'summary': 'Integración WhatsApp para SKEEN Derma Experts',
|
||||
'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 ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
|
||||
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):
|
||||
@@ -93,20 +107,35 @@ def _patient_search_domain(search):
|
||||
class SkeenFrontendController(http.Controller):
|
||||
"""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_END = 19.0 # 7:00 PM
|
||||
|
||||
# Tope de seguridad de la exportación completa de pacientes (hoy ~7.2k)
|
||||
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):
|
||||
"""Valida hora de inicio dentro del horario de agenda (8:00-19:00)."""
|
||||
"""Valida hora de inicio dentro del horario de agenda configurado."""
|
||||
try:
|
||||
t = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return self.AGENDA_START <= t < self.AGENDA_END
|
||||
inicio, fin = self._agenda_bounds()
|
||||
return inicio <= t < fin
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auditoría de configuración
|
||||
@@ -633,7 +662,7 @@ class SkeenFrontendController(http.Controller):
|
||||
first_time = data.get('time')
|
||||
if first_time is not None and not self._valid_agenda_time(first_time):
|
||||
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 = []
|
||||
# Para paquetes, generamos citas separadas con fechas consecutivas
|
||||
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')
|
||||
if apt_time is None or not self._valid_agenda_time(apt_time):
|
||||
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()
|
||||
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}
|
||||
if 'time' in vals and not self._valid_agenda_time(vals['time']):
|
||||
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:
|
||||
cita.write(vals)
|
||||
except ValidationError as e:
|
||||
@@ -1218,9 +1247,11 @@ class SkeenFrontendController(http.Controller):
|
||||
return json_response({'status': 'error', 'message': 'Horas inválidas'}, 400)
|
||||
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)
|
||||
if not all_day and (time_from < self.AGENDA_START - 1e-6 or time_to > self.AGENDA_END + 1e-6):
|
||||
if not all_day:
|
||||
agenda_ini, agenda_fin = self._agenda_bounds()
|
||||
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 8:00 y 19:00'}, 400)
|
||||
{'status': 'error', 'message': 'El rango de horas debe estar entre %s' % self._agenda_horario_txt()}, 400)
|
||||
|
||||
motivo_tipo = data.get('motivo_tipo') or 'otro'
|
||||
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_oscuro - logo para fondos claros (tema HomeNest)
|
||||
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 = {
|
||||
'nombre': 'SKEEN Derma Experts',
|
||||
'logo_claro': '/skeen-brand/logos/Logo%20Completo%20Blanco.png',
|
||||
'logo_oscuro': '/skeen-brand/logos/Logo%20Completo%20Negro.png',
|
||||
'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)
|
||||
if raw:
|
||||
try:
|
||||
guardado = json.loads(raw)
|
||||
if isinstance(guardado, dict):
|
||||
defaults.update({k: str(v) for k, v in guardado.items()
|
||||
if k in defaults and v is not None and str(v).strip()})
|
||||
for k in ('nombre', 'logo_claro', 'logo_oscuro', 'pie', 'url_publica'):
|
||||
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:
|
||||
pass # JSON roto: se sirven los defaults
|
||||
return defaults
|
||||
@@ -6458,8 +6502,29 @@ class SkeenFrontendController(http.Controller):
|
||||
'message': 'brand debe ser un objeto'}, 400)
|
||||
antes = self._brand_dict()
|
||||
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()}
|
||||
# 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(
|
||||
self.BRAND_KEY, json.dumps(limpio, ensure_ascii=False))
|
||||
nuevo = self._brand_dict()
|
||||
@@ -6474,6 +6539,464 @@ class SkeenFrontendController(http.Controller):
|
||||
request.env.cr.rollback()
|
||||
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)
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user