diff --git a/docs/nueva-instancia.md b/docs/nueva-instancia.md new file mode 100644 index 0000000..1f0f544 --- /dev/null +++ b/docs/nueva-instancia.md @@ -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` | `_odoo` | Nombre de la base de datos. | +| `--db-role` | `` | 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://: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/-credenciales-.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-.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 "` (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 `. +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": ""}` (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://`, 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": "", + "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/-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 +`/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** (``) 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- # active (running) +tail -50 /tmp/odoo-.log +curl -s http://127.0.0.1:/web/health +curl -s http://127.0.0.1:/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). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2f6f04e..0cc9083 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( + + + @@ -160,6 +166,9 @@ const App: FC = () => { + + + ); diff --git a/frontend/src/components/CategoriasPanel.tsx b/frontend/src/components/CategoriasPanel.tsx new file mode 100644 index 0000000..9d5058f --- /dev/null +++ b/frontend/src/components/CategoriasPanel.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [form, setForm] = useState(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 ( + <> +
+
+

Categorías de servicios

+

+ Ordenan y filtran el catálogo de servicios. Las del sistema se pueden renombrar, pero no eliminar. +

+
+ +
+ + {loading ? ( + + ) : categories.length === 0 ? ( + } + /> + ) : ( +
+ + + + + + + + + + + + {categories.map((c) => ( + + + + + + + + ))} + +
NombreCódigoOrdenEstadoAcciones
+ + {c.name} + {c.is_system && ( + + Sistema + + )} + + {c.code}{c.sequence} + + {c.active ? 'Activa' : 'Inactiva'} + + +
+ + +
+
+
+ )} + + setModalOpen(false)} + title={editing ? 'Editar categoría' : 'Nueva categoría'} + maxWidth="md" + footer={ + <> + + + + } + > +
+ setForm({ ...form, name: e.target.value })} /> +
+ setForm({ ...form, code: e.target.value })} + placeholder="Se genera del nombre si se deja vacío" + /> + setForm({ ...form, sequence: e.target.value })} + /> +
+
+
+ + ); +}; + +export default CategoriasPanel; diff --git a/frontend/src/components/ClasicoLayout.tsx b/frontend/src/components/ClasicoLayout.tsx index 5d2e150..98460d4 100644 --- a/frontend/src/components/ClasicoLayout.tsx +++ b/frontend/src/components/ClasicoLayout.tsx @@ -148,7 +148,7 @@ const menuLeaves = menuTree.flatMap((e) => export const ClasicoLayout: FC = ({ 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 = ({ children, title: _title, subtit style={{ backgroundColor: 'var(--header-bg)' }} >
- - SKEEN® + + {brand.nombre}
@@ -530,7 +530,7 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit > - SKEEN® + {brand.nombre}
diff --git a/frontend/src/components/HomeNestLayout.tsx b/frontend/src/components/HomeNestLayout.tsx index d9161ff..04cdf54 100644 --- a/frontend/src/components/HomeNestLayout.tsx +++ b/frontend/src/components/HomeNestLayout.tsx @@ -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 = ({ 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(['Clínica']); const chatUnread = useChatUnread(); @@ -122,7 +129,7 @@ export const HomeNestLayout: FC = ({ 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 = ({ 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 = ({ 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') diff --git a/frontend/src/components/RolesPanel.tsx b/frontend/src/components/RolesPanel.tsx index 346f91e..059e4b9 100644 --- a/frontend/src/components/RolesPanel.tsx +++ b/frontend/src/components/RolesPanel.tsx @@ -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; const RolesPanel: FC = () => { + const { term } = useGlosario(); const [matrix, setMatrix] = useState(null); const [saved, setSaved] = useState(null); const [loading, setLoading] = useState(true); @@ -101,7 +103,7 @@ const RolesPanel: FC = () => { {MENU_ITEMS.map((item) => ( - {item.label} + {menuItemLabel(item, term)} {ROLES.map((role) => { const locked = item.href === '/configuracion' && role === 'admin'; diff --git a/frontend/src/components/SkeenLayout.tsx b/frontend/src/components/SkeenLayout.tsx index 50e211a..35362db 100644 --- a/frontend/src/components/SkeenLayout.tsx +++ b/frontend/src/components/SkeenLayout.tsx @@ -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 = ({ 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 = ({ 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') diff --git a/frontend/src/components/SucursalesPanel.tsx b/frontend/src/components/SucursalesPanel.tsx new file mode 100644 index 0000000..4b6df0d --- /dev/null +++ b/frontend/src/components/SucursalesPanel.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [form, setForm] = useState(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 ( + <> +
+
+

Sucursales

+

+ Se usan al agendar citas y para etiquetar el lugar de atención. Una sucursal se desactiva, nunca se borra. +

+
+ +
+ + {loading ? ( + + ) : sucursales.length === 0 ? ( + } + /> + ) : ( +
+ + + + + + + + + + + + {sucursales.map((s) => ( + + + + + + + + ))} + +
NombreCódigoOrdenEstadoAcciones
+ + {s.name} + {s.is_default && ( + + Predeterminada + + )} + + {s.code}{s.sequence} + + {s.active ? 'Activa' : 'Inactiva'} + + +
+ + +
+
+
+ )} + + setModalOpen(false)} + title={editing ? 'Editar sucursal' : 'Nueva sucursal'} + maxWidth="lg" + footer={ + <> + + + + } + > +
+
+ setForm({ ...form, name: e.target.value })} /> + setForm({ ...form, code: e.target.value })} + placeholder="Se genera del nombre si se deja vacío" + /> +
+
+ setForm({ ...form, sequence: e.target.value })} + /> + setForm({ ...form, phone: e.target.value })} /> +
+ setForm({ ...form, address: e.target.value })} /> +