fase 3.4: PWA instalable (manifest e iconos, service worker propio con app shell precacheado y cache versionada por build, API nunca cacheada, fallback offline, aviso de instalacion con guia iOS y banner sin conexion) + branding: perfiles de ticket por instancia (defaults neutros, sin filtrar datos de SKEEN) y Dashboard sin nombre hardcodeado
BIN
frontend/public/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/icons/icon-192-maskable.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/public/icons/icon-32.png
Normal file
|
After Width: | Height: | Size: 662 B |
BIN
frontend/public/icons/icon-512-maskable.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
4
frontend/public/icons/icon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="512" height="512">
|
||||
<rect x="0" y="0" width="48" height="48" rx="10" fill="#1f3a4d"/>
|
||||
<path d="M24 13v22M13 24h22" stroke="#ffffff" stroke-width="8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 251 B |
45
frontend/public/manifest.webmanifest
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
84
frontend/public/offline.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1f3a4d" />
|
||||
<title>Sin conexión · Panel Clínico</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f8f6f4;
|
||||
--surface: #ffffff;
|
||||
--border: #e5e0dc;
|
||||
--text: #2d2d2d;
|
||||
--muted: #6b6b6b;
|
||||
--heading: #1a1a1a;
|
||||
--accent: #1f3a4d;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 32px 28px;
|
||||
}
|
||||
.mark {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 18px;
|
||||
display: block;
|
||||
border-radius: 14px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
color: var(--heading);
|
||||
}
|
||||
p {
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
}
|
||||
button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 11px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
button:active { opacity: 0.9; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<img class="mark" src="/icons/icon.svg" alt="" width="56" height="56" />
|
||||
<h1>Sin conexión</h1>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<button type="button" onclick="location.reload()">Reintentar</button>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
192
frontend/public/sw.js
Normal file
@@ -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.
|
||||
});
|
||||