diff --git a/frontend/index.html b/frontend/index.html index 8a04141..73794a9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,21 @@ - + + + + + + + + + - SKEEN Derma Experts + Panel Clínico
diff --git a/frontend/public/icons/apple-touch-icon.png b/frontend/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..da4b6b5 Binary files /dev/null and b/frontend/public/icons/apple-touch-icon.png differ diff --git a/frontend/public/icons/icon-192-maskable.png b/frontend/public/icons/icon-192-maskable.png new file mode 100644 index 0000000..15d1220 Binary files /dev/null and b/frontend/public/icons/icon-192-maskable.png differ diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png new file mode 100644 index 0000000..f534522 Binary files /dev/null and b/frontend/public/icons/icon-192.png differ diff --git a/frontend/public/icons/icon-32.png b/frontend/public/icons/icon-32.png new file mode 100644 index 0000000..1c2fb0f Binary files /dev/null and b/frontend/public/icons/icon-32.png differ diff --git a/frontend/public/icons/icon-512-maskable.png b/frontend/public/icons/icon-512-maskable.png new file mode 100644 index 0000000..c3d9f7f Binary files /dev/null and b/frontend/public/icons/icon-512-maskable.png differ diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png new file mode 100644 index 0000000..8dcd5a4 Binary files /dev/null and b/frontend/public/icons/icon-512.png differ diff --git a/frontend/public/icons/icon.svg b/frontend/public/icons/icon.svg new file mode 100644 index 0000000..3373425 --- /dev/null +++ b/frontend/public/icons/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..9d153a2 --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,45 @@ +{ + "name": "Panel Clínico", + "short_name": "Clínico", + "description": "Gestión clínica: agenda, pacientes, ventas, monedero y reportes.", + "lang": "es", + "dir": "ltr", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#F8F6F4", + "theme_color": "#1f3a4d", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-192-maskable.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icons/icon-512-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/frontend/public/offline.html b/frontend/public/offline.html new file mode 100644 index 0000000..c465211 --- /dev/null +++ b/frontend/public/offline.html @@ -0,0 +1,84 @@ + + + + + + + Sin conexión · Panel Clínico + + + + +
+ +

Sin conexión

+

+ No hay conexión a internet en este momento. Revisa tu red e intenta de nuevo; + la aplicación seguirá trabajando en cuanto vuelvas a estar en línea. +

+ +
+ + diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..63281a9 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,192 @@ +/* ===================================================================== + Service Worker del Panel Clínico (SKEEN) + --------------------------------------------------------------------- + Sin dependencias ni Workbox. Estrategia: + + - App shell (HTML, manifest, iconos, JS/CSS con hash): precache en + install, versionado por build. + - Navegación (HTML): network-first. Un deploy nuevo se ve al primer + refresh (nginx sirve index.html con Cache-Control: no-cache) y, si + no hay red, se devuelve la última copia del shell o /offline.html. + - Assets con hash (/assets/*): cache-first (son inmutables). + - Otras GET del mismo origen (logos de marca, svg): stale-while-revalidate. + - API (/api/*), peticiones con Authorization y cualquier método distinto + de GET: NUNCA se cachean. Se dejan pasar tal cual al navegador. + + La versión de caché y la lista de assets se inyectan en el build + (vite.config.ts); si no se inyectan, el SW sigue siendo funcional. + ===================================================================== */ + +var VERSION = '__BUILD_VERSION__'; + +// Lista de assets con hash generados por el build (JSON inyectado). +var PRECACHE_ASSETS = []; +try { PRECACHE_ASSETS = JSON.parse('__PRECACHE_ASSETS__'); } catch (e) { PRECACHE_ASSETS = []; } + +var PRECACHE = 'skeen-precache-' + VERSION; +var RUNTIME = 'skeen-runtime-' + VERSION; +var CACHE_PREFIX = 'skeen-'; + +var PRECACHE_URLS = [ + '/', + '/index.html', + '/offline.html', + '/manifest.webmanifest', + '/favicon.svg', + '/icons/icon.svg', + '/icons/icon-32.png', + '/icons/icon-192.png', + '/icons/icon-512.png', + '/icons/icon-192-maskable.png', + '/icons/icon-512-maskable.png', + '/icons/apple-touch-icon.png', + // Brand neutro por defecto: permite pintar el login sin red. + '/brand/favicon.svg', + '/brand/logo-blanco.svg', + '/brand/logo-negro.svg', +].concat(PRECACHE_ASSETS); + +// -------------------------------------------------------------------- +// install / activate +// -------------------------------------------------------------------- +self.addEventListener('install', function (event) { + event.waitUntil( + caches.open(PRECACHE).then(function (cache) { + // add() individual: un recurso que falle no aborta toda la instalación. + return Promise.all( + PRECACHE_URLS.map(function (url) { + return cache.add(new Request(url, { cache: 'reload' })).catch(function () { /* ignore */ }); + }) + ); + }).then(function () { return self.skipWaiting(); }) + ); +}); + +self.addEventListener('activate', function (event) { + event.waitUntil( + caches.keys().then(function (keys) { + return Promise.all( + keys.map(function (key) { + // Borra cachés de builds anteriores (mismo prefijo, otra versión). + if (key.indexOf(CACHE_PREFIX) === 0 && key !== PRECACHE && key !== RUNTIME) { + return caches.delete(key); + } + return undefined; + }) + ); + }).then(function () { return self.clients.claim(); }) + ); +}); + +// Permite forzar la activación desde la página si se desea. +self.addEventListener('message', function (event) { + if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting(); +}); + +// -------------------------------------------------------------------- +// Estrategias +// -------------------------------------------------------------------- +function isSameOrigin(url) { + return url.origin === self.location.origin; +} + +function isApiRequest(url) { + return url.pathname.indexOf('/api/') === 0; +} + +function isHashedAsset(url) { + return isSameOrigin(url) && url.pathname.indexOf('/assets/') === 0; +} + +async function networkFirstNavigation(request) { + try { + var fresh = await fetch(request); + var type = fresh.headers.get('content-type') || ''; + if (fresh && fresh.ok && type.indexOf('text/html') !== -1) { + var cache = await caches.open(PRECACHE); + // Se guarda una única copia del shell bajo /index.html. + cache.put('/index.html', fresh.clone()).catch(function () { /* ignore */ }); + } + return fresh; + } catch (e) { + var shell = await caches.open(PRECACHE); + var cached = (await shell.match('/index.html')) || (await shell.match('/')); + if (cached) return cached; + var offline = await shell.match('/offline.html'); + if (offline) return offline; + return new Response('Sin conexión', { + status: 503, + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }); + } +} + +async function cacheFirst(request, cacheName) { + var cache = await caches.open(cacheName); + var hit = await cache.match(request); + if (hit) return hit; + var res = await fetch(request); + if (res && res.ok && res.type === 'basic') { + cache.put(request, res.clone()).catch(function () { /* ignore */ }); + } + return res; +} + +async function staleWhileRevalidate(request, cacheName) { + var cache = await caches.open(cacheName); + var hit = await cache.match(request); + var network = fetch(request).then(function (res) { + if (res && res.ok && res.type === 'basic') { + cache.put(request, res.clone()).catch(function () { /* ignore */ }); + } + return res; + }).catch(function () { return undefined; }); + if (hit) return hit; + var res = await network; + if (res) return res; + return new Response('', { status: 504, statusText: 'Sin conexión' }); +} + +// -------------------------------------------------------------------- +// fetch +// -------------------------------------------------------------------- +self.addEventListener('fetch', function (event) { + var request = event.request; + var url; + + try { + url = new URL(request.url); + } catch (e) { + return; + } + + // 1) Nunca interceptar lo que no debe cachearse: + // - métodos distintos de GET + // - peticiones con Authorization (JWT / datos clínicos) + // - API del backend (/api/odoo/... y /api/v1/...) + // - backend Odoo directo (/web/) + if (request.method !== 'GET') return; + if (request.headers.get('authorization')) return; + if (isApiRequest(url)) return; + if (url.pathname.indexOf('/web/') === 0) return; + + // 2) Navegación HTML: network-first con fallback al shell cacheado. + if (request.mode === 'navigate') { + event.respondWith(networkFirstNavigation(request)); + return; + } + + // 3) Assets con hash: cache-first (inmutables). + if (isHashedAsset(url)) { + event.respondWith(cacheFirst(request, RUNTIME)); + return; + } + + // 4) Otras GET del mismo origen (logos, svg): SWR. + if (isSameOrigin(url)) { + event.respondWith(staleWhileRevalidate(request, RUNTIME)); + return; + } + + // 5) Cross-origin (p. ej. Google Fonts): sin interceptar. +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f02c100..a0d1ed3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { BrowserRouter as Router, Routes, Route, Navigate, useSearchParams } fro import { MENU_ITEMS } from './lib/menu-items'; import { ToastProvider, Button } from './components/ui'; import ErrorBoundary from './components/ErrorBoundary'; +import { PwaInstallPrompt, OfflineBanner } from './components/PwaNotices'; import { AuthProvider, useAuth } from './lib/auth'; import { GlosarioProvider } from './lib/glosario'; import { SucursalesProvider } from './lib/sucursales'; @@ -167,6 +168,8 @@ const App: FC = () => { + + diff --git a/frontend/src/components/PwaNotices.tsx b/frontend/src/components/PwaNotices.tsx new file mode 100644 index 0000000..bc6d088 --- /dev/null +++ b/frontend/src/components/PwaNotices.tsx @@ -0,0 +1,148 @@ +import type { FC } from 'react'; +import { useEffect, useState } from 'react'; +import { Download, Share, WifiOff, X } from 'lucide-react'; +import { Button } from './ui'; + +// Avisos de PWA: invitación a instalar (Android/desktop vía +// `beforeinstallprompt`, iOS con guía manual) y banner de sin conexión. +// Son discretos, descartables y no bloquean la operación. + +const DISMISS_KEY = 'skeen_pwa_install_dismissed'; + +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; +} + +const estaInstalada = (): boolean => { + if (typeof window === 'undefined') return false; + const standalone = window.matchMedia?.('(display-mode: standalone)').matches; + const iosStandalone = (window.navigator as Navigator & { standalone?: boolean }).standalone === true; + return Boolean(standalone || iosStandalone); +}; + +const esIOS = (): boolean => + typeof navigator !== 'undefined' && /iphone|ipad|ipod/i.test(navigator.userAgent); + +export const PwaInstallPrompt: FC = () => { + const [deferred, setDeferred] = useState(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (estaInstalada()) return; + try { + if (localStorage.getItem(DISMISS_KEY) === '1') return; + } catch { + return; + } + + const onBeforeInstall = (e: Event) => { + e.preventDefault(); + // Si ya se descartó (incluso en esta misma sesión), no volver a insistir. + try { if (localStorage.getItem(DISMISS_KEY) === '1') return; } catch { /* ignore */ } + setDeferred(e as BeforeInstallPromptEvent); + setVisible(true); + }; + const onInstalled = () => { + setVisible(false); + try { localStorage.setItem(DISMISS_KEY, '1'); } catch { /* ignore */ } + }; + window.addEventListener('beforeinstallprompt', onBeforeInstall); + window.addEventListener('appinstalled', onInstalled); + + // Safari/iOS no dispara `beforeinstallprompt`: se muestra la guía manual. + let timer: number | undefined; + if (esIOS() && !('onbeforeinstallprompt' in window)) { + timer = window.setTimeout(() => setVisible(true), 6000); + } + + return () => { + window.removeEventListener('beforeinstallprompt', onBeforeInstall); + window.removeEventListener('appinstalled', onInstalled); + if (timer) window.clearTimeout(timer); + }; + }, []); + + const descartar = () => { + setVisible(false); + try { localStorage.setItem(DISMISS_KEY, '1'); } catch { /* ignore */ } + }; + + const instalar = async () => { + if (!deferred) return; + try { + await deferred.prompt(); + await deferred.userChoice; + } catch { + /* ignore */ + } + setDeferred(null); + descartar(); + }; + + if (!visible) return null; + + return ( +
+
+
+ +
+
+

Instalar la app

+ {deferred ? ( +

+ Accede más rápido desde tu pantalla de inicio. +

+ ) : ( +

+ Toca Compartir y luego + «Añadir a pantalla de inicio». +

+ )} + {deferred && ( +
+ +
+ )} +
+ +
+
+ ); +}; + +export const OfflineBanner: FC = () => { + const [online, setOnline] = useState(() => (typeof navigator === 'undefined' ? true : navigator.onLine)); + + useEffect(() => { + const on = () => setOnline(true); + const off = () => setOnline(false); + window.addEventListener('online', on); + window.addEventListener('offline', off); + return () => { + window.removeEventListener('online', on); + window.removeEventListener('offline', off); + }; + }, []); + + if (online) return null; + + return ( +
+
+ + Sin conexión — los cambios se guardarán al recuperar la red +
+
+ ); +}; + +export default PwaInstallPrompt; diff --git a/frontend/src/lib/publicBooking.ts b/frontend/src/lib/publicBooking.ts index bf80128..693ff32 100644 --- a/frontend/src/lib/publicBooking.ts +++ b/frontend/src/lib/publicBooking.ts @@ -83,11 +83,11 @@ export const construirIcs = (opts: { return [ 'BEGIN:VCALENDAR', 'VERSION:2.0', - 'PRODID:-//SKEEN//Reservas en linea//ES', + 'PRODID:-//Panel Clinico//Reservas en linea//ES', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'BEGIN:VEVENT', - `UID:${esc(opts.folio)}@skeen`, + `UID:${esc(opts.folio)}@panel-clinico`, `DTSTAMP:${fmt(new Date())}`, `DTSTART:${fmt(inicio)}`, `DTEND:${fmt(fin)}`, diff --git a/frontend/src/lib/ticketConfig.ts b/frontend/src/lib/ticketConfig.ts index 3d256de..6d628f9 100644 --- a/frontend/src/lib/ticketConfig.ts +++ b/frontend/src/lib/ticketConfig.ts @@ -89,11 +89,14 @@ export const FALLBACK_CONFIG: TicketConfig = { }, }, clinica: { - nombre: 'SKEEN Derma Experts', - direccion: 'Playas de Rosarito, Baja California', - telefono: '+52 (661) 100-2172', - email: 'hola@skeen.mx', - sitio: 'https://skeen.mx', + // Neutro a propósito: cada clínica configura su perfil en + // Configuración › Tickets. Si `nombre` queda vacío se usa el nombre del + // brand. Antes traía los datos de SKEEN y se filtraban a otras instancias. + nombre: '', + direccion: '', + telefono: '', + email: '', + sitio: '', rfc: '', sucursal: '', tiene_logo: false, diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f48c8e7..43a2cfe 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,6 +4,18 @@ import './index.css' import App from './App.tsx' import { ThemeProvider } from './lib/theme.tsx' +// PWA: registra el service worker solo en producción (en dev rompería el HMR) +// y nunca deja que un fallo del SW rompa el arranque de la app. +if (import.meta.env.PROD && 'serviceWorker' in navigator) { + try { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js').catch(() => { /* PWA opcional */ }) + }) + } catch { + /* PWA opcional */ + } +} + createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 4faf0f5..4945cdf 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -467,7 +467,7 @@ const Dashboard: FC = () => {
-

SKEEN Derma Experts

+

{brand?.nombre || 'Panel Clínico'}

Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con confianza.

diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5831268..2d2c470 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,10 +1,63 @@ -import { defineConfig } from 'vite' +import { defineConfig, type Plugin, type ResolvedConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' + +// PWA: inyecta en dist/sw.js (1) una versión de caché única por build y +// (2) la lista de assets con hash a precachear. La versión es un hash del +// index.html, el manifest y los nombres de los assets, así que cambia +// exactamente cuando cambia el bundle (y el SW descarta las cachés viejas +// en `activate`). No toca el pipeline `tsc -b && vite build`. +function pwaServiceWorker(): Plugin { + let outDir = 'dist' + return { + name: 'skeen-pwa-service-worker', + apply: 'build', + configResolved(config: ResolvedConfig) { + outDir = config.build.outDir + }, + closeBundle() { + const dir = resolve(process.cwd(), outDir) + const swPath = resolve(dir, 'sw.js') + if (!existsSync(swPath)) return + + const listAssets = (sub: string): string[] => { + const abs = resolve(dir, sub) + if (!existsSync(abs)) return [] + return readdirSync(abs) + .filter((f) => /\.(js|css|woff2?|png|jpe?g|svg|webp|ico|json)$/i.test(f)) + .map((f) => `/${sub}/${f}`) + .sort() + } + // Solo /assets (hash): los iconos ya van listados explícitamente en sw.js. + const assets = listAssets('assets') + + const read = (file: string): Buffer => + existsSync(resolve(dir, file)) ? readFileSync(resolve(dir, file)) : Buffer.from('') + const version = createHash('sha256') + .update(read('index.html')) + .update(read('manifest.webmanifest')) + .update(assets.join('|')) + .digest('hex') + .slice(0, 12) + + let sw = readFileSync(swPath, 'utf8') + sw = sw.replace('__BUILD_VERSION__', version) + // JSON dentro de un literal de una sola comilla: se quitan comillas + // simples por seguridad (los nombres de Vite no las contienen). + sw = sw.replace('__PRECACHE_ASSETS__', JSON.stringify(assets).replace(/'/g, '')) + writeFileSync(swPath, sw) + + this.warn(`PWA service worker: versión de caché ${version}, ${assets.length} assets precacheados`) + }, + } +} // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [react(), tailwindcss(), pwaServiceWorker()], server: { port: 5173, proxy: { diff --git a/odoo-addons/skeen_whatsapp/models/ticket_config.py b/odoo-addons/skeen_whatsapp/models/ticket_config.py index 3c5131c..b91a875 100644 --- a/odoo-addons/skeen_whatsapp/models/ticket_config.py +++ b/odoo-addons/skeen_whatsapp/models/ticket_config.py @@ -61,12 +61,17 @@ DEFAULT_TICKET_LAYOUT = { # cada equipo imprimía una dirección o un teléfono distinto. TICKET_CLINICA_KEY = 'skeen.ticket_clinica' +# Defaults NEUTROS a propósito: cada instancia configura su propio perfil en +# Configuración › Tickets. Si el nombre queda vacío, el frontend usa el nombre +# del brand de la clínica. Antes estos defaults traían los datos de SKEEN, que +# se filtraban a cualquier instancia nueva (dirección, teléfono y correo ajenos +# impresos en el ticket). DEFAULT_CLINICA = { - 'nombre': 'SKEEN Derma Experts', - 'direccion': 'Playas de Rosarito, Baja California', - 'telefono': '+52 (661) 100-2172', - 'email': 'hola@skeen.mx', - 'sitio': 'https://skeen.mx', + 'nombre': '', + 'direccion': '', + 'telefono': '', + 'email': '', + 'sitio': '', 'rfc': '', 'sucursal': '', 'tiene_logo': False,