feat: Add major features - Mejoras 5-10

- Mejora 5: Órdenes de Compra integration in obra detail
- Mejora 6: Portal de Cliente with JWT auth for clients
- Mejora 7: Diagrama de Gantt for project visualization
- Mejora 8: Push Notifications with service worker
- Mejora 9: Activity Log system with templates
- Mejora 10: PWA support with offline capabilities

New features include:
- Fotos gallery with upload/delete
- Bitácora de obra with daily logs
- PDF export for reports, gastos, presupuestos
- Control de asistencia for employees
- Client portal with granular permissions
- Gantt chart with task visualization
- Push notification system
- Activity timeline component
- PWA manifest, icons, and install prompt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mexus
2026-01-19 03:09:38 +00:00
parent 86bfbd2039
commit a08e7057e8
69 changed files with 12435 additions and 26 deletions

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

36
public/icons/README.md Normal file
View File

@@ -0,0 +1,36 @@
# PWA Icons Generation Instructions
The placeholder icons in this directory should be replaced with properly generated icons.
## Option 1: Use an online tool
1. Go to https://realfavicongenerator.net/
2. Upload the icon.svg file from this directory
3. Download the generated icons
4. Replace the placeholder PNGs
## Option 2: Use sharp (Node.js)
If you have libvips installed, you can use the generate-icons.js script:
```bash
npm install sharp --save-dev
node scripts/generate-icons.js
```
## Option 3: Use ImageMagick
If you have ImageMagick installed:
```bash
for size in 72 96 128 144 152 192 384 512; do
convert icon.svg -resize ${size}x${size} icon-${size}x${size}.png
done
```
## Required icon sizes:
- 72x72
- 96x96
- 128x128
- 144x144
- 152x152
- 192x192
- 384x384
- 512x512
- 180x180 (apple-touch-icon.png)
- 32x32 (favicon.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

BIN
public/icons/icon-72x72.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

BIN
public/icons/icon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

38
public/icons/icon.svg Normal file
View File

@@ -0,0 +1,38 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background -->
<rect width="512" height="512" rx="64" fill="url(#grad1)"/>
<!-- Building icon -->
<g fill="#ffffff">
<!-- Main building -->
<rect x="156" y="180" width="200" height="252" rx="8"/>
<!-- Windows row 1 -->
<rect x="180" y="210" width="40" height="35" rx="4" fill="#2563eb"/>
<rect x="236" y="210" width="40" height="35" rx="4" fill="#2563eb"/>
<rect x="292" y="210" width="40" height="35" rx="4" fill="#2563eb"/>
<!-- Windows row 2 -->
<rect x="180" y="265" width="40" height="35" rx="4" fill="#2563eb"/>
<rect x="236" y="265" width="40" height="35" rx="4" fill="#2563eb"/>
<rect x="292" y="265" width="40" height="35" rx="4" fill="#2563eb"/>
<!-- Windows row 3 -->
<rect x="180" y="320" width="40" height="35" rx="4" fill="#2563eb"/>
<rect x="236" y="320" width="40" height="35" rx="4" fill="#2563eb"/>
<rect x="292" y="320" width="40" height="35" rx="4" fill="#2563eb"/>
<!-- Door -->
<rect x="226" y="375" width="60" height="57" rx="4" fill="#2563eb"/>
<!-- Crane -->
<rect x="356" y="100" width="12" height="200" fill="#ffffff"/>
<rect x="280" y="100" width="100" height="12" fill="#ffffff"/>
<rect x="280" y="100" width="12" height="60" fill="#ffffff"/>
<!-- Crane hook -->
<line x1="286" y1="160" x2="286" y2="200" stroke="#ffffff" stroke-width="4"/>
<rect x="276" y="200" width="20" height="15" rx="2" fill="#ffffff"/>
<!-- Letter M -->
<text x="256" y="90" font-family="Arial, sans-serif" font-size="48" font-weight="bold" fill="#ffffff" text-anchor="middle">M</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

103
public/manifest.json Normal file
View File

@@ -0,0 +1,103 @@
{
"name": "Mexus - Gestión de Obras",
"short_name": "Mexus",
"description": "Sistema de gestión de obras de construcción",
"start_url": "/dashboard",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2563eb",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
],
"categories": ["business", "productivity"],
"screenshots": [
{
"src": "/screenshots/dashboard.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "Dashboard principal"
},
{
"src": "/screenshots/obras.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "Gestión de obras"
}
],
"shortcuts": [
{
"name": "Dashboard",
"short_name": "Dashboard",
"description": "Ver dashboard principal",
"url": "/dashboard",
"icons": [{ "src": "/icons/icon-96x96.png", "sizes": "96x96" }]
},
{
"name": "Obras",
"short_name": "Obras",
"description": "Ver lista de obras",
"url": "/obras",
"icons": [{ "src": "/icons/icon-96x96.png", "sizes": "96x96" }]
},
{
"name": "Nueva Obra",
"short_name": "Nueva",
"description": "Crear nueva obra",
"url": "/obras/nueva",
"icons": [{ "src": "/icons/icon-96x96.png", "sizes": "96x96" }]
}
],
"related_applications": [],
"prefer_related_applications": false
}

183
public/sw.js Normal file
View File

@@ -0,0 +1,183 @@
// Service Worker para PWA y Notificaciones Push
// Mexus App - Construction Management System
const CACHE_NAME = 'mexus-app-v1';
const STATIC_CACHE = 'mexus-static-v1';
const DYNAMIC_CACHE = 'mexus-dynamic-v1';
// Recursos estáticos para cachear
const STATIC_ASSETS = [
'/',
'/manifest.json',
'/icons/icon-192x192.png',
'/icons/icon-512x512.png',
'/apple-touch-icon.png',
'/favicon.png',
];
// Instalación del Service Worker
self.addEventListener('install', (event) => {
console.log('Service Worker instalado');
event.waitUntil(
caches.open(STATIC_CACHE)
.then((cache) => {
console.log('Cacheando recursos estáticos');
return cache.addAll(STATIC_ASSETS);
})
.then(() => self.skipWaiting())
);
});
// Activación del Service Worker
self.addEventListener('activate', (event) => {
console.log('Service Worker activado');
event.waitUntil(
Promise.all([
// Limpiar caches antiguos
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== STATIC_CACHE && name !== DYNAMIC_CACHE)
.map((name) => caches.delete(name))
);
}),
clients.claim(),
])
);
});
// Estrategia de fetch: Network first, fallback to cache
self.addEventListener('fetch', (event) => {
// Skip non-GET requests
if (event.request.method !== 'GET') return;
// Skip API requests (siempre online para datos frescos)
if (event.request.url.includes('/api/')) return;
// Skip chrome-extension and other non-http(s) requests
if (!event.request.url.startsWith('http')) return;
event.respondWith(
fetch(event.request)
.then((response) => {
// Clone response para guardarlo en cache
const responseClone = response.clone();
// Solo cachear respuestas exitosas
if (response.status === 200) {
caches.open(DYNAMIC_CACHE).then((cache) => {
cache.put(event.request, responseClone);
});
}
return response;
})
.catch(() => {
// Si falla la red, buscar en cache
return caches.match(event.request).then((response) => {
if (response) {
return response;
}
// Si es una página, mostrar página offline
if (event.request.headers.get('accept')?.includes('text/html')) {
return caches.match('/');
}
return new Response('Offline', { status: 503 });
});
})
);
});
// Recibir notificaciones push
self.addEventListener('push', (event) => {
console.log('Push recibido:', event);
let data = {
title: 'Mexus App',
body: 'Nueva notificación',
icon: '/icon-192x192.png',
badge: '/badge-72x72.png',
url: '/',
};
try {
if (event.data) {
data = { ...data, ...event.data.json() };
}
} catch (e) {
console.error('Error parsing push data:', e);
}
const options = {
body: data.body,
icon: data.icon || '/icon-192x192.png',
badge: data.badge || '/badge-72x72.png',
vibrate: [100, 50, 100],
data: {
url: data.url || '/',
dateOfArrival: Date.now(),
},
actions: [
{
action: 'open',
title: 'Abrir',
icon: '/icons/checkmark.png',
},
{
action: 'close',
title: 'Cerrar',
icon: '/icons/xmark.png',
},
],
tag: data.tag || 'default',
renotify: true,
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
// Clic en notificación
self.addEventListener('notificationclick', (event) => {
console.log('Notificación clickeada:', event);
event.notification.close();
if (event.action === 'close') {
return;
}
const url = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Si ya hay una ventana abierta, enfocarla y navegar
for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
client.focus();
return client.navigate(url);
}
}
// Si no hay ventana, abrir una nueva
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
});
// Cerrar notificación
self.addEventListener('notificationclose', (event) => {
console.log('Notificación cerrada:', event);
});
// Sincronización en background (para futuras mejoras)
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-notifications') {
console.log('Sync de notificaciones');
}
});

0
public/uploads/.gitkeep Normal file
View File