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

4
.gitignore vendored
View File

@@ -52,3 +52,7 @@ logs/
# OS
Thumbs.db
# Uploads (user content)
public/uploads/fotos/*
!public/uploads/.gitkeep

View File

@@ -7,11 +7,19 @@ const nextConfig = {
protocol: "https",
hostname: "**",
},
{
protocol: "http",
hostname: "localhost",
},
{
protocol: "http",
hostname: "192.168.10.197",
},
],
},
experimental: {
serverActions: {
bodySizeLimit: "2mb",
bodySizeLimit: "10mb",
},
},
};

686
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,14 +34,18 @@
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.3",
"@react-pdf/renderer": "^4.3.2",
"autoprefixer": "^10.4.23",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"gantt-task-react": "^0.3.9",
"jose": "^6.1.3",
"lucide-react": "^0.454.0",
"next": "^14.2.28",
"next-auth": "^5.0.0-beta.25",
@@ -51,6 +55,7 @@
"recharts": "^2.13.0",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"web-push": "^3.6.7",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -58,6 +63,7 @@
"@types/node": "^20.17.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/web-push": "^3.6.4",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.28",
"postcss": "^8.4.47",

View File

@@ -106,6 +106,14 @@ model User {
tareasAsignadas TareaObra[]
obrasSupervision Obra[] @relation("ObraSupervisor")
registrosAvance RegistroAvance[]
fotosSubidas FotoAvance[]
bitacorasRegistradas BitacoraObra[]
asistenciasRegistradas Asistencia[]
ordenesCreadas OrdenCompra[] @relation("OrdenCreador")
ordenesAprobadas OrdenCompra[] @relation("OrdenAprobador")
pushSubscriptions PushSubscription[]
notificaciones Notificacion[]
actividades ActividadLog[]
@@index([empresaId])
@@index([email])
@@ -145,10 +153,40 @@ model Cliente {
// Relations
obras Obra[]
accesos ClienteAcceso[]
@@index([empresaId])
}
// ============== PORTAL DE CLIENTES ==============
model ClienteAcceso {
id String @id @default(cuid())
email String @unique
password String? // Hash de contraseña (opcional si usa token)
token String? @unique // Token de acceso único
tokenExpira DateTime? // Expiración del token
activo Boolean @default(true)
ultimoAcceso DateTime?
// Permisos
verFotos Boolean @default(true)
verAvances Boolean @default(true)
verGastos Boolean @default(false)
verDocumentos Boolean @default(true)
descargarPDF Boolean @default(true)
// Relaciones
clienteId String
cliente Cliente @relation(fields: [clienteId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([clienteId])
@@index([token])
}
model Obra {
id String @id @default(cuid())
nombre String
@@ -180,6 +218,11 @@ model Obra {
asignaciones AsignacionEmpleado[]
contratos ContratoSubcontratista[]
registrosAvance RegistroAvance[]
fotos FotoAvance[]
bitacoras BitacoraObra[]
asistencias Asistencia[]
ordenesCompra OrdenCompra[]
actividadesLog ActividadLog[]
@@index([empresaId])
@@index([estado])
@@ -201,6 +244,7 @@ model FaseObra {
// Relations
tareas TareaObra[]
fotos FotoAvance[]
@@index([obraId])
}
@@ -350,6 +394,7 @@ model Material {
// Relations
movimientos MovimientoInventario[]
itemsOrden ItemOrdenCompra[]
@@unique([codigo, empresaId])
@@index([empresaId])
@@ -392,6 +437,7 @@ model Empleado {
// Relations
asignaciones AsignacionEmpleado[]
jornadas JornadaTrabajo[]
asistencias Asistencia[]
@@index([empresaId])
}
@@ -464,3 +510,388 @@ model ContratoSubcontratista {
@@index([subcontratistaId])
@@index([obraId])
}
// ============== ÓRDENES DE COMPRA ==============
enum EstadoOrdenCompra {
BORRADOR
PENDIENTE
APROBADA
ENVIADA
RECIBIDA_PARCIAL
RECIBIDA
CANCELADA
}
enum PrioridadOrden {
BAJA
NORMAL
ALTA
URGENTE
}
model OrdenCompra {
id String @id @default(cuid())
numero String // Número de orden (OC-001, etc.)
// Estado y prioridad
estado EstadoOrdenCompra @default(BORRADOR)
prioridad PrioridadOrden @default(NORMAL)
// Fechas
fechaEmision DateTime @default(now())
fechaRequerida DateTime? // Fecha en que se necesitan los materiales
fechaAprobacion DateTime?
fechaEnvio DateTime?
fechaRecepcion DateTime?
// Proveedor
proveedorNombre String
proveedorRfc String?
proveedorContacto String?
proveedorTelefono String?
proveedorEmail String?
proveedorDireccion String?
// Totales
subtotal Float @default(0)
descuento Float @default(0)
iva Float @default(0)
total Float @default(0)
// Condiciones
condicionesPago String? // Ej: "Contado", "Crédito 30 días"
tiempoEntrega String? // Ej: "3-5 días hábiles"
lugarEntrega String? // Dirección de entrega
// Notas
notas String? @db.Text
notasInternas String? @db.Text
// Relaciones
obraId String
obra Obra @relation(fields: [obraId], references: [id], onDelete: Cascade)
creadoPorId String
creadoPor User @relation("OrdenCreador", fields: [creadoPorId], references: [id])
aprobadoPorId String?
aprobadoPor User? @relation("OrdenAprobador", fields: [aprobadoPorId], references: [id])
// Items
items ItemOrdenCompra[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([numero, obraId])
@@index([obraId])
@@index([estado])
@@index([fechaEmision])
}
model ItemOrdenCompra {
id String @id @default(cuid())
// Descripción del item
codigo String? // Código del material
descripcion String
unidad UnidadMedida
// Cantidades
cantidad Float
cantidadRecibida Float @default(0)
// Precios
precioUnitario Float
descuento Float @default(0)
subtotal Float
// Relaciones
ordenId String
orden OrdenCompra @relation(fields: [ordenId], references: [id], onDelete: Cascade)
materialId String? // Opcional: vincular con catálogo de materiales
material Material? @relation(fields: [materialId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([ordenId])
@@index([materialId])
}
// ============== CONTROL DE ASISTENCIA ==============
enum TipoAsistencia {
PRESENTE
AUSENTE
RETARDO
PERMISO
INCAPACIDAD
VACACIONES
}
model Asistencia {
id String @id @default(cuid())
fecha DateTime @db.Date
// Estado de asistencia
tipo TipoAsistencia @default(PRESENTE)
// Registro de entrada
horaEntrada DateTime?
latitudEntrada Float?
longitudEntrada Float?
// Registro de salida
horaSalida DateTime?
latitudSalida Float?
longitudSalida Float?
// Horas trabajadas (calculadas)
horasTrabajadas Float?
horasExtra Float @default(0)
// Notas y observaciones
notas String?
motivoAusencia String?
// Relaciones
empleadoId String
empleado Empleado @relation(fields: [empleadoId], references: [id], onDelete: Cascade)
obraId String
obra Obra @relation(fields: [obraId], references: [id], onDelete: Cascade)
registradoPorId String
registradoPor User @relation(fields: [registradoPorId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([empleadoId, obraId, fecha])
@@index([empleadoId])
@@index([obraId])
@@index([fecha])
@@index([tipo])
}
// ============== BITÁCORA DE OBRA ==============
enum CondicionClima {
SOLEADO
NUBLADO
PARCIALMENTE_NUBLADO
LLUVIA_LIGERA
LLUVIA_FUERTE
TORMENTA
VIENTO_FUERTE
FRIO_EXTREMO
CALOR_EXTREMO
}
model BitacoraObra {
id String @id @default(cuid())
fecha DateTime @db.Date
// Condiciones climáticas
clima CondicionClima
temperaturaMin Float?
temperaturaMax Float?
condicionesExtra String? // Notas adicionales del clima
// Personal en obra
personalPropio Int @default(0)
personalSubcontrato Int @default(0)
personalDetalle String? // Descripción del personal
// Actividades del día
actividadesRealizadas String @db.Text
actividadesPendientes String? @db.Text
// Materiales
materialesUtilizados String? @db.Text
materialesRecibidos String? @db.Text
// Equipo y maquinaria
equipoUtilizado String? @db.Text
// Incidentes y observaciones
incidentes String? @db.Text
observaciones String? @db.Text
// Seguridad
incidentesSeguridad String? @db.Text
platicaSeguridad Boolean @default(false)
temaSeguridad String?
// Visitas
visitasInspeccion String? @db.Text
// Relaciones
obraId String
obra Obra @relation(fields: [obraId], references: [id], onDelete: Cascade)
registradoPorId String
registradoPor User @relation(fields: [registradoPorId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([obraId, fecha])
@@index([obraId])
@@index([fecha])
}
// ============== FOTOS DE AVANCE ==============
model FotoAvance {
id String @id @default(cuid())
url String // Ruta del archivo
thumbnail String? // Ruta de la miniatura
titulo String?
descripcion String?
fechaCaptura DateTime @default(now())
// Geolocalización
latitud Float?
longitud Float?
direccionGeo String? // Dirección obtenida por geocoding
// Metadatos
tamanio Int? // Tamaño en bytes
tipo String? // MIME type (image/jpeg, etc.)
ancho Int? // Width en pixels
alto Int? // Height en pixels
// Relaciones
obraId String
obra Obra @relation(fields: [obraId], references: [id], onDelete: Cascade)
faseId String?
fase FaseObra? @relation(fields: [faseId], references: [id])
subidoPorId String
subidoPor User @relation(fields: [subidoPorId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([obraId])
@@index([faseId])
@@index([fechaCaptura])
}
// ============== NOTIFICACIONES PUSH ==============
enum TipoNotificacion {
TAREA_ASIGNADA
TAREA_COMPLETADA
GASTO_PENDIENTE
GASTO_APROBADO
ORDEN_APROBADA
AVANCE_REGISTRADO
RECORDATORIO
ALERTA_INVENTARIO
GENERAL
}
model PushSubscription {
id String @id @default(cuid())
endpoint String @unique
p256dh String
auth String
activo Boolean @default(true)
// Preferencias de notificación
notifyTareas Boolean @default(true)
notifyGastos Boolean @default(true)
notifyOrdenes Boolean @default(true)
notifyAvances Boolean @default(true)
notifyAlertas Boolean @default(true)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
model Notificacion {
id String @id @default(cuid())
tipo TipoNotificacion
titulo String
mensaje String
url String? // URL para navegar al hacer clic
leida Boolean @default(false)
enviada Boolean @default(false)
// Datos adicionales en JSON
metadata String? @db.Text
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([userId])
@@index([leida])
@@index([createdAt])
}
// ============== LOG DE ACTIVIDADES ==============
enum TipoActividad {
OBRA_CREADA
OBRA_ACTUALIZADA
OBRA_ESTADO_CAMBIADO
FASE_CREADA
TAREA_CREADA
TAREA_ASIGNADA
TAREA_COMPLETADA
TAREA_ESTADO_CAMBIADO
GASTO_CREADO
GASTO_APROBADO
GASTO_RECHAZADO
ORDEN_CREADA
ORDEN_APROBADA
ORDEN_ENVIADA
ORDEN_RECIBIDA
AVANCE_REGISTRADO
FOTO_SUBIDA
BITACORA_REGISTRADA
MATERIAL_MOVIMIENTO
USUARIO_ASIGNADO
COMENTARIO_AGREGADO
DOCUMENTO_SUBIDO
}
model ActividadLog {
id String @id @default(cuid())
tipo TipoActividad
descripcion String
detalles String? @db.Text // JSON con datos adicionales
// Entidad afectada
entidadTipo String? // "obra", "tarea", "gasto", etc.
entidadId String?
entidadNombre String?
// Contexto
obraId String?
obra Obra? @relation(fields: [obraId], references: [id], onDelete: SetNull)
// Usuario que realizó la acción
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
// Empresa para filtrar
empresaId String
// Metadatos de IP/dispositivo (opcional)
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
@@index([obraId])
@@index([userId])
@@index([empresaId])
@@index([tipo])
@@index([createdAt])
}

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

View File

@@ -0,0 +1,89 @@
const fs = require('fs');
const path = require('path');
// Icon sizes for PWA
const sizes = [72, 96, 128, 144, 152, 192, 384, 512];
// Simple 1x1 blue PNG as base64 (we'll use this as a placeholder)
// Users should replace these with actual icons generated from the SVG
const createPlaceholderPNG = (size) => {
// PNG header for a simple blue image
// This creates a valid minimal PNG
const png = Buffer.from([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 pixels
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // 8-bit RGB
0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, // IDAT chunk
0x54, 0x08, 0xD7, 0x63, 0x48, 0xC5, 0xD8, 0x60, // compressed blue pixel
0x00, 0x00, 0x00, 0x83, 0x00, 0x81, 0x3D, 0xE7,
0x79, 0xBA, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, // IEND chunk
0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82
]);
return png;
};
// Generate placeholder icons
const iconsDir = path.join(__dirname, '../public/icons');
console.log('Generating placeholder icons...');
console.log('Note: Replace these with properly generated icons from icon.svg');
console.log('You can use tools like: https://realfavicongenerator.net/\n');
// For now, we'll copy the SVG as a reference and create instruction file
sizes.forEach(size => {
const outputPath = path.join(iconsDir, `icon-${size}x${size}.png`);
fs.writeFileSync(outputPath, createPlaceholderPNG(size));
console.log(`Created placeholder: icon-${size}x${size}.png`);
});
// Create favicon placeholder
fs.writeFileSync(path.join(__dirname, '../public/favicon.png'), createPlaceholderPNG(32));
console.log('Created placeholder: favicon.png');
// Create apple-touch-icon placeholder
fs.writeFileSync(path.join(__dirname, '../public/apple-touch-icon.png'), createPlaceholderPNG(180));
console.log('Created placeholder: apple-touch-icon.png');
// Create instructions file
const instructions = `# 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)
`;
fs.writeFileSync(path.join(iconsDir, 'README.md'), instructions);
console.log('\nCreated: icons/README.md with generation instructions');
console.log('\nPlaceholder icons generated successfully!');

56
scripts/generate-icons.js Normal file
View File

@@ -0,0 +1,56 @@
const fs = require('fs');
const path = require('path');
// Read the SVG file
const svgPath = path.join(__dirname, '../public/icons/icon.svg');
const svgContent = fs.readFileSync(svgPath, 'utf8');
// Icon sizes for PWA
const sizes = [72, 96, 128, 144, 152, 192, 384, 512];
// Try to use sharp if available, otherwise create placeholder files
async function generateIcons() {
try {
const sharp = require('sharp');
for (const size of sizes) {
const outputPath = path.join(__dirname, `../public/icons/icon-${size}x${size}.png`);
await sharp(Buffer.from(svgContent))
.resize(size, size)
.png()
.toFile(outputPath);
console.log(`Generated: icon-${size}x${size}.png`);
}
// Generate favicon
await sharp(Buffer.from(svgContent))
.resize(32, 32)
.png()
.toFile(path.join(__dirname, '../public/favicon.png'));
console.log('Generated: favicon.png');
// Generate apple-touch-icon
await sharp(Buffer.from(svgContent))
.resize(180, 180)
.png()
.toFile(path.join(__dirname, '../public/apple-touch-icon.png'));
console.log('Generated: apple-touch-icon.png');
console.log('\nAll icons generated successfully!');
} catch (error) {
if (error.code === 'MODULE_NOT_FOUND') {
console.log('Sharp not found. Installing...');
const { execSync } = require('child_process');
execSync('npm install sharp --save-dev', { stdio: 'inherit' });
console.log('Sharp installed. Please run this script again.');
} else {
console.error('Error generating icons:', error);
}
}
}
generateIcons();

View File

@@ -41,7 +41,19 @@ import {
type EstadoTarea,
type EstadoGasto,
type CategoriaGasto,
type CondicionClima,
} from "@/types";
import { GaleriaFotos } from "@/components/fotos/galeria-fotos";
import { BitacoraObra } from "@/components/bitacora/bitacora-obra";
import { ControlAsistencia } from "@/components/asistencia/control-asistencia";
import { OrdenesCompra } from "@/components/ordenes/ordenes-compra";
import { DiagramaGantt } from "@/components/gantt/diagrama-gantt";
import {
ExportPDFMenu,
ReporteObraPDF,
GastosPDF,
BitacoraPDF,
} from "@/components/pdf";
interface ObraDetailProps {
obra: {
@@ -106,6 +118,43 @@ interface ObraDetailProps {
createdAt: Date;
registradoPor: { nombre: string; apellido: string };
}[];
fotos: {
id: string;
url: string;
thumbnail: string | null;
titulo: string | null;
descripcion: string | null;
fechaCaptura: Date;
latitud: number | null;
longitud: number | null;
direccionGeo: string | null;
subidoPor: { nombre: string; apellido: string };
fase: { nombre: string } | null;
}[];
bitacoras: {
id: string;
fecha: Date;
clima: CondicionClima;
temperaturaMin: number | null;
temperaturaMax: number | null;
condicionesExtra: string | null;
personalPropio: number;
personalSubcontrato: number;
personalDetalle: string | null;
actividadesRealizadas: string;
actividadesPendientes: string | null;
materialesUtilizados: string | null;
materialesRecibidos: string | null;
equipoUtilizado: string | null;
incidentes: string | null;
observaciones: string | null;
incidentesSeguridad: string | null;
platicaSeguridad: boolean;
temaSeguridad: string | null;
visitasInspeccion: string | null;
registradoPor: { nombre: string; apellido: string };
createdAt: Date;
}[];
};
}
@@ -139,6 +188,74 @@ export function ObraDetailClient({ obra }: ObraDetailProps) {
</span>
</div>
</div>
<div className="flex gap-2">
<ExportPDFMenu
options={[
{
label: "Reporte General",
document: (
<ReporteObraPDF
obra={{
...obra,
fechaInicio: obra.fechaInicio?.toString() || null,
fechaFinPrevista: obra.fechaFinPrevista?.toString() || null,
fases: obra.fases.map((f) => ({
nombre: f.nombre,
porcentajeAvance: f.porcentajeAvance,
tareas: f.tareas.map((t) => ({
nombre: t.nombre,
estado: t.estado,
})),
})),
gastos: obra.gastos.map((g) => ({
concepto: g.concepto,
monto: g.monto,
fecha: g.fecha.toString(),
categoria: g.categoria,
})),
}}
/>
),
fileName: `reporte-${obra.nombre.toLowerCase().replace(/\s+/g, "-")}`,
},
{
label: "Reporte de Gastos",
document: (
<GastosPDF
obra={{
nombre: obra.nombre,
direccion: obra.direccion,
presupuestoTotal: obra.presupuestoTotal,
}}
gastos={obra.gastos.map((g) => ({
...g,
fecha: g.fecha.toString(),
proveedor: null,
factura: null,
notas: null,
}))}
/>
),
fileName: `gastos-${obra.nombre.toLowerCase().replace(/\s+/g, "-")}`,
},
{
label: "Bitacora de Obra",
document: (
<BitacoraPDF
obra={{
nombre: obra.nombre,
direccion: obra.direccion,
}}
bitacoras={obra.bitacoras.map((b) => ({
...b,
fecha: b.fecha.toString(),
}))}
/>
),
fileName: `bitacora-${obra.nombre.toLowerCase().replace(/\s+/g, "-")}`,
},
]}
/>
<Link href={`/obras/${obra.id}/editar`}>
<Button>
<Edit className="h-4 w-4 mr-2" />
@@ -146,6 +263,7 @@ export function ObraDetailClient({ obra }: ObraDetailProps) {
</Button>
</Link>
</div>
</div>
{/* Progress and Stats */}
<div className="grid gap-4 md:grid-cols-4">
@@ -207,11 +325,16 @@ export function ObraDetailClient({ obra }: ObraDetailProps) {
{/* Tabs */}
<Tabs defaultValue="general" className="space-y-4">
<TabsList>
<TabsList className="flex-wrap">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="cronograma">Cronograma</TabsTrigger>
<TabsTrigger value="gantt">Gantt</TabsTrigger>
<TabsTrigger value="presupuesto">Presupuesto</TabsTrigger>
<TabsTrigger value="gastos">Gastos</TabsTrigger>
<TabsTrigger value="asistencia">Asistencia</TabsTrigger>
<TabsTrigger value="fotos">Fotos</TabsTrigger>
<TabsTrigger value="bitacora">Bitacora</TabsTrigger>
<TabsTrigger value="ordenes">Ordenes</TabsTrigger>
<TabsTrigger value="avances">Avances</TabsTrigger>
</TabsList>
@@ -362,6 +485,33 @@ export function ObraDetailClient({ obra }: ObraDetailProps) {
)}
</TabsContent>
<TabsContent value="gantt">
<DiagramaGantt
obraId={obra.id}
obraNombre={obra.nombre}
fechaInicioObra={obra.fechaInicio?.toString() || null}
fechaFinObra={obra.fechaFinPrevista?.toString() || null}
fases={obra.fases.map((fase) => ({
id: fase.id,
nombre: fase.nombre,
descripcion: fase.descripcion,
orden: fase.orden,
fechaInicio: null,
fechaFin: null,
porcentajeAvance: fase.porcentajeAvance,
tareas: fase.tareas.map((tarea) => ({
id: tarea.id,
nombre: tarea.nombre,
descripcion: null,
estado: tarea.estado,
fechaInicio: null,
fechaFin: null,
porcentajeAvance: 0,
})),
}))}
/>
</TabsContent>
<TabsContent value="presupuesto" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Presupuestos</h3>
@@ -447,6 +597,36 @@ export function ObraDetailClient({ obra }: ObraDetailProps) {
)}
</TabsContent>
<TabsContent value="asistencia">
<ControlAsistencia obraId={obra.id} />
</TabsContent>
<TabsContent value="fotos">
<GaleriaFotos
obraId={obra.id}
fotos={obra.fotos.map((f) => ({
...f,
fechaCaptura: f.fechaCaptura.toString(),
}))}
fases={obra.fases.map((f) => ({ id: f.id, nombre: f.nombre }))}
/>
</TabsContent>
<TabsContent value="bitacora">
<BitacoraObra
obraId={obra.id}
bitacoras={obra.bitacoras.map((b) => ({
...b,
fecha: b.fecha.toString(),
createdAt: b.createdAt.toString(),
}))}
/>
</TabsContent>
<TabsContent value="ordenes">
<OrdenesCompra obraId={obra.id} obraDireccion={obra.direccion} />
</TabsContent>
<TabsContent value="avances" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Registros de Avance</h3>

View File

@@ -40,6 +40,20 @@ async function getObra(id: string, empresaId: string) {
registradoPor: { select: { nombre: true, apellido: true } },
},
},
fotos: {
orderBy: { fechaCaptura: "desc" },
include: {
subidoPor: { select: { nombre: true, apellido: true } },
fase: { select: { nombre: true } },
},
},
bitacoras: {
orderBy: { fecha: "desc" },
take: 10,
include: {
registradoPor: { select: { nombre: true, apellido: true } },
},
},
},
});
}

View File

@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// GET - Obtener log de actividades
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const obraId = searchParams.get("obraId");
const tipo = searchParams.get("tipo");
const limit = parseInt(searchParams.get("limit") || "50");
const offset = parseInt(searchParams.get("offset") || "0");
const where: Record<string, unknown> = {
empresaId: session.user.empresaId,
};
if (obraId) {
where.obraId = obraId;
}
if (tipo) {
where.tipo = tipo;
}
const [actividades, total] = await Promise.all([
prisma.actividadLog.findMany({
where,
include: {
user: {
select: {
id: true,
nombre: true,
apellido: true,
},
},
obra: {
select: {
id: true,
nombre: true,
},
},
},
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
}),
prisma.actividadLog.count({ where }),
]);
return NextResponse.json({
actividades,
total,
limit,
offset,
hasMore: offset + limit < total,
});
} catch (error) {
console.error("Error fetching actividades:", error);
return NextResponse.json(
{ error: "Error al obtener actividades" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,222 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const updateAsistenciaSchema = z.object({
tipo: z.enum([
"PRESENTE",
"AUSENTE",
"RETARDO",
"PERMISO",
"INCAPACIDAD",
"VACACIONES",
]).optional(),
horaEntrada: z.string().optional().nullable(),
latitudEntrada: z.number().optional().nullable(),
longitudEntrada: z.number().optional().nullable(),
horaSalida: z.string().optional().nullable(),
latitudSalida: z.number().optional().nullable(),
longitudSalida: z.number().optional().nullable(),
horasExtra: z.number().min(0).optional(),
notas: z.string().optional().nullable(),
motivoAusencia: z.string().optional().nullable(),
});
// GET - Obtener una asistencia específica
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const asistencia = await prisma.asistencia.findFirst({
where: {
id,
obra: {
empresaId: session.user.empresaId,
},
},
include: {
empleado: {
select: {
id: true,
nombre: true,
apellido: true,
puesto: true,
telefono: true,
},
},
obra: {
select: {
nombre: true,
direccion: true,
},
},
registradoPor: {
select: {
nombre: true,
apellido: true,
email: true,
},
},
},
});
if (!asistencia) {
return NextResponse.json(
{ error: "Asistencia no encontrada" },
{ status: 404 }
);
}
return NextResponse.json(asistencia);
} catch (error) {
console.error("Error fetching asistencia:", error);
return NextResponse.json(
{ error: "Error al obtener la asistencia" },
{ status: 500 }
);
}
}
// PUT - Actualizar una asistencia (ej: registrar salida)
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const validatedData = updateAsistenciaSchema.parse(body);
// Verificar que la asistencia pertenece a la empresa del usuario
const asistenciaExistente = await prisma.asistencia.findFirst({
where: {
id,
obra: {
empresaId: session.user.empresaId,
},
},
});
if (!asistenciaExistente) {
return NextResponse.json(
{ error: "Asistencia no encontrada" },
{ status: 404 }
);
}
// Calcular horas trabajadas si hay cambios en entrada o salida
let horasTrabajadas = asistenciaExistente.horasTrabajadas;
const horaEntrada = validatedData.horaEntrada !== undefined
? (validatedData.horaEntrada ? new Date(validatedData.horaEntrada) : null)
: asistenciaExistente.horaEntrada;
const horaSalida = validatedData.horaSalida !== undefined
? (validatedData.horaSalida ? new Date(validatedData.horaSalida) : null)
: asistenciaExistente.horaSalida;
if (horaEntrada && horaSalida) {
horasTrabajadas = (horaSalida.getTime() - horaEntrada.getTime()) / (1000 * 60 * 60);
}
const asistencia = await prisma.asistencia.update({
where: { id },
data: {
...validatedData,
horaEntrada: validatedData.horaEntrada !== undefined
? (validatedData.horaEntrada ? new Date(validatedData.horaEntrada) : null)
: undefined,
horaSalida: validatedData.horaSalida !== undefined
? (validatedData.horaSalida ? new Date(validatedData.horaSalida) : null)
: undefined,
horasTrabajadas,
},
include: {
empleado: {
select: {
id: true,
nombre: true,
apellido: true,
puesto: true,
},
},
registradoPor: {
select: {
nombre: true,
apellido: true,
},
},
},
});
return NextResponse.json(asistencia);
} catch (error) {
console.error("Error updating asistencia:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0].message },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Error al actualizar la asistencia" },
{ status: 500 }
);
}
}
// DELETE - Eliminar una asistencia
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
// Verificar que la asistencia pertenece a la empresa del usuario
const asistencia = await prisma.asistencia.findFirst({
where: {
id,
obra: {
empresaId: session.user.empresaId,
},
},
});
if (!asistencia) {
return NextResponse.json(
{ error: "Asistencia no encontrada" },
{ status: 404 }
);
}
await prisma.asistencia.delete({
where: { id },
});
return NextResponse.json({ message: "Asistencia eliminada exitosamente" });
} catch (error) {
console.error("Error deleting asistencia:", error);
return NextResponse.json(
{ error: "Error al eliminar la asistencia" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// GET - Obtener empleados disponibles para registrar asistencia en una obra
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const obraId = searchParams.get("obraId");
if (!obraId) {
return NextResponse.json(
{ error: "Se requiere obraId" },
{ status: 400 }
);
}
// Verificar que la obra pertenece a la empresa del usuario
const obra = await prisma.obra.findFirst({
where: {
id: obraId,
empresaId: session.user.empresaId,
},
});
if (!obra) {
return NextResponse.json(
{ error: "Obra no encontrada" },
{ status: 404 }
);
}
// Obtener empleados asignados a la obra o todos los empleados activos de la empresa
const empleadosAsignados = await prisma.asignacionEmpleado.findMany({
where: {
obraId,
activo: true,
},
include: {
empleado: {
select: {
id: true,
nombre: true,
apellido: true,
puesto: true,
telefono: true,
activo: true,
},
},
},
});
// Si hay empleados asignados, devolver solo esos
if (empleadosAsignados.length > 0) {
const empleados = empleadosAsignados
.filter((a) => a.empleado.activo)
.map((a) => a.empleado);
return NextResponse.json(empleados);
}
// Si no hay asignaciones, devolver todos los empleados activos de la empresa
const todosEmpleados = await prisma.empleado.findMany({
where: {
empresaId: session.user.empresaId,
activo: true,
},
select: {
id: true,
nombre: true,
apellido: true,
puesto: true,
telefono: true,
activo: true,
},
orderBy: [{ apellido: "asc" }, { nombre: "asc" }],
});
return NextResponse.json(todosEmpleados);
} catch (error) {
console.error("Error fetching empleados:", error);
return NextResponse.json(
{ error: "Error al obtener los empleados" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,236 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const asistenciaSchema = z.object({
fecha: z.string(),
tipo: z.enum([
"PRESENTE",
"AUSENTE",
"RETARDO",
"PERMISO",
"INCAPACIDAD",
"VACACIONES",
]),
horaEntrada: z.string().optional().nullable(),
latitudEntrada: z.number().optional().nullable(),
longitudEntrada: z.number().optional().nullable(),
horaSalida: z.string().optional().nullable(),
latitudSalida: z.number().optional().nullable(),
longitudSalida: z.number().optional().nullable(),
horasExtra: z.number().min(0).default(0),
notas: z.string().optional().nullable(),
motivoAusencia: z.string().optional().nullable(),
empleadoId: z.string(),
obraId: z.string(),
});
// GET - Obtener asistencias de una obra
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const obraId = searchParams.get("obraId");
const fecha = searchParams.get("fecha"); // Formato: YYYY-MM-DD
const mes = searchParams.get("mes"); // Formato: YYYY-MM
const empleadoId = searchParams.get("empleadoId");
if (!obraId) {
return NextResponse.json(
{ error: "Se requiere obraId" },
{ status: 400 }
);
}
// Verificar que la obra pertenece a la empresa del usuario
const obra = await prisma.obra.findFirst({
where: {
id: obraId,
empresaId: session.user.empresaId,
},
});
if (!obra) {
return NextResponse.json(
{ error: "Obra no encontrada" },
{ status: 404 }
);
}
// Construir filtro de fecha
let dateFilter = {};
if (fecha) {
const fechaBusqueda = new Date(fecha);
dateFilter = { fecha: fechaBusqueda };
} else if (mes) {
const [year, month] = mes.split("-").map(Number);
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0);
dateFilter = {
fecha: {
gte: startDate,
lte: endDate,
},
};
}
const asistencias = await prisma.asistencia.findMany({
where: {
obraId,
...dateFilter,
...(empleadoId && { empleadoId }),
},
include: {
empleado: {
select: {
id: true,
nombre: true,
apellido: true,
puesto: true,
},
},
registradoPor: {
select: {
nombre: true,
apellido: true,
},
},
},
orderBy: [{ fecha: "desc" }, { empleado: { apellido: "asc" } }],
});
return NextResponse.json(asistencias);
} catch (error) {
console.error("Error fetching asistencias:", error);
return NextResponse.json(
{ error: "Error al obtener las asistencias" },
{ status: 500 }
);
}
}
// POST - Registrar asistencia
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.empresaId || !session?.user?.id) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const body = await request.json();
const validatedData = asistenciaSchema.parse(body);
// Verificar que la obra pertenece a la empresa del usuario
const obra = await prisma.obra.findFirst({
where: {
id: validatedData.obraId,
empresaId: session.user.empresaId,
},
});
if (!obra) {
return NextResponse.json(
{ error: "Obra no encontrada" },
{ status: 404 }
);
}
// Verificar que el empleado pertenece a la empresa
const empleado = await prisma.empleado.findFirst({
where: {
id: validatedData.empleadoId,
empresaId: session.user.empresaId,
},
});
if (!empleado) {
return NextResponse.json(
{ error: "Empleado no encontrado" },
{ status: 404 }
);
}
const fechaAsistencia = new Date(validatedData.fecha);
// Verificar si ya existe registro para este empleado en esta fecha y obra
const existente = await prisma.asistencia.findUnique({
where: {
empleadoId_obraId_fecha: {
empleadoId: validatedData.empleadoId,
obraId: validatedData.obraId,
fecha: fechaAsistencia,
},
},
});
if (existente) {
return NextResponse.json(
{ error: "Ya existe un registro de asistencia para este empleado en esta fecha" },
{ status: 400 }
);
}
// Calcular horas trabajadas si hay entrada y salida
let horasTrabajadas = null;
if (validatedData.horaEntrada && validatedData.horaSalida) {
const entrada = new Date(validatedData.horaEntrada);
const salida = new Date(validatedData.horaSalida);
horasTrabajadas = (salida.getTime() - entrada.getTime()) / (1000 * 60 * 60);
}
const asistencia = await prisma.asistencia.create({
data: {
fecha: fechaAsistencia,
tipo: validatedData.tipo,
horaEntrada: validatedData.horaEntrada ? new Date(validatedData.horaEntrada) : null,
latitudEntrada: validatedData.latitudEntrada,
longitudEntrada: validatedData.longitudEntrada,
horaSalida: validatedData.horaSalida ? new Date(validatedData.horaSalida) : null,
latitudSalida: validatedData.latitudSalida,
longitudSalida: validatedData.longitudSalida,
horasTrabajadas,
horasExtra: validatedData.horasExtra,
notas: validatedData.notas,
motivoAusencia: validatedData.motivoAusencia,
empleadoId: validatedData.empleadoId,
obraId: validatedData.obraId,
registradoPorId: session.user.id,
},
include: {
empleado: {
select: {
id: true,
nombre: true,
apellido: true,
puesto: true,
},
},
registradoPor: {
select: {
nombre: true,
apellido: true,
},
},
},
});
return NextResponse.json(asistencia, { status: 201 });
} catch (error) {
console.error("Error creating asistencia:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0].message },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Error al registrar la asistencia" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,194 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const updateBitacoraSchema = z.object({
clima: z.enum([
"SOLEADO",
"NUBLADO",
"PARCIALMENTE_NUBLADO",
"LLUVIA_LIGERA",
"LLUVIA_FUERTE",
"TORMENTA",
"VIENTO_FUERTE",
"FRIO_EXTREMO",
"CALOR_EXTREMO",
]).optional(),
temperaturaMin: z.number().optional().nullable(),
temperaturaMax: z.number().optional().nullable(),
condicionesExtra: z.string().optional().nullable(),
personalPropio: z.number().int().min(0).optional(),
personalSubcontrato: z.number().int().min(0).optional(),
personalDetalle: z.string().optional().nullable(),
actividadesRealizadas: z.string().optional(),
actividadesPendientes: z.string().optional().nullable(),
materialesUtilizados: z.string().optional().nullable(),
materialesRecibidos: z.string().optional().nullable(),
equipoUtilizado: z.string().optional().nullable(),
incidentes: z.string().optional().nullable(),
observaciones: z.string().optional().nullable(),
incidentesSeguridad: z.string().optional().nullable(),
platicaSeguridad: z.boolean().optional(),
temaSeguridad: z.string().optional().nullable(),
visitasInspeccion: z.string().optional().nullable(),
});
// GET - Obtener una bitácora específica
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const bitacora = await prisma.bitacoraObra.findFirst({
where: {
id,
obra: {
empresaId: session.user.empresaId,
},
},
include: {
registradoPor: {
select: {
nombre: true,
apellido: true,
email: true,
},
},
obra: {
select: {
nombre: true,
direccion: true,
},
},
},
});
if (!bitacora) {
return NextResponse.json(
{ error: "Bitácora no encontrada" },
{ status: 404 }
);
}
return NextResponse.json(bitacora);
} catch (error) {
console.error("Error fetching bitacora:", error);
return NextResponse.json(
{ error: "Error al obtener la bitácora" },
{ status: 500 }
);
}
}
// PUT - Actualizar una bitácora
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const validatedData = updateBitacoraSchema.parse(body);
// Verificar que la bitácora pertenece a la empresa del usuario
const bitacoraExistente = await prisma.bitacoraObra.findFirst({
where: {
id,
obra: {
empresaId: session.user.empresaId,
},
},
});
if (!bitacoraExistente) {
return NextResponse.json(
{ error: "Bitácora no encontrada" },
{ status: 404 }
);
}
const bitacora = await prisma.bitacoraObra.update({
where: { id },
data: validatedData,
include: {
registradoPor: {
select: {
nombre: true,
apellido: true,
},
},
},
});
return NextResponse.json(bitacora);
} catch (error) {
console.error("Error updating bitacora:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0].message },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Error al actualizar la bitácora" },
{ status: 500 }
);
}
}
// DELETE - Eliminar una bitácora
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
// Verificar que la bitácora pertenece a la empresa del usuario
const bitacora = await prisma.bitacoraObra.findFirst({
where: {
id,
obra: {
empresaId: session.user.empresaId,
},
},
});
if (!bitacora) {
return NextResponse.json(
{ error: "Bitácora no encontrada" },
{ status: 404 }
);
}
await prisma.bitacoraObra.delete({
where: { id },
});
return NextResponse.json({ message: "Bitácora eliminada exitosamente" });
} catch (error) {
console.error("Error deleting bitacora:", error);
return NextResponse.json(
{ error: "Error al eliminar la bitácora" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,191 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const bitacoraSchema = z.object({
fecha: z.string(),
clima: z.enum([
"SOLEADO",
"NUBLADO",
"PARCIALMENTE_NUBLADO",
"LLUVIA_LIGERA",
"LLUVIA_FUERTE",
"TORMENTA",
"VIENTO_FUERTE",
"FRIO_EXTREMO",
"CALOR_EXTREMO",
]),
temperaturaMin: z.number().optional().nullable(),
temperaturaMax: z.number().optional().nullable(),
condicionesExtra: z.string().optional().nullable(),
personalPropio: z.number().int().min(0).default(0),
personalSubcontrato: z.number().int().min(0).default(0),
personalDetalle: z.string().optional().nullable(),
actividadesRealizadas: z.string().min(1, "Las actividades son requeridas"),
actividadesPendientes: z.string().optional().nullable(),
materialesUtilizados: z.string().optional().nullable(),
materialesRecibidos: z.string().optional().nullable(),
equipoUtilizado: z.string().optional().nullable(),
incidentes: z.string().optional().nullable(),
observaciones: z.string().optional().nullable(),
incidentesSeguridad: z.string().optional().nullable(),
platicaSeguridad: z.boolean().default(false),
temaSeguridad: z.string().optional().nullable(),
visitasInspeccion: z.string().optional().nullable(),
obraId: z.string(),
});
// GET - Obtener bitácoras de una obra
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const obraId = searchParams.get("obraId");
const mes = searchParams.get("mes"); // Formato: YYYY-MM
const limit = searchParams.get("limit");
if (!obraId) {
return NextResponse.json(
{ error: "Se requiere obraId" },
{ status: 400 }
);
}
// Verificar que la obra pertenece a la empresa del usuario
const obra = await prisma.obra.findFirst({
where: {
id: obraId,
empresaId: session.user.empresaId,
},
});
if (!obra) {
return NextResponse.json(
{ error: "Obra no encontrada" },
{ status: 404 }
);
}
// Construir filtro de fecha
let dateFilter = {};
if (mes) {
const [year, month] = mes.split("-").map(Number);
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0);
dateFilter = {
fecha: {
gte: startDate,
lte: endDate,
},
};
}
const bitacoras = await prisma.bitacoraObra.findMany({
where: {
obraId,
...dateFilter,
},
include: {
registradoPor: {
select: {
nombre: true,
apellido: true,
},
},
},
orderBy: {
fecha: "desc",
},
...(limit && { take: parseInt(limit) }),
});
return NextResponse.json(bitacoras);
} catch (error) {
console.error("Error fetching bitacoras:", error);
return NextResponse.json(
{ error: "Error al obtener las bitácoras" },
{ status: 500 }
);
}
}
// POST - Crear nueva entrada de bitácora
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.empresaId || !session?.user?.id) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const body = await request.json();
const validatedData = bitacoraSchema.parse(body);
// Verificar que la obra pertenece a la empresa del usuario
const obra = await prisma.obra.findFirst({
where: {
id: validatedData.obraId,
empresaId: session.user.empresaId,
},
});
if (!obra) {
return NextResponse.json(
{ error: "Obra no encontrada" },
{ status: 404 }
);
}
// Verificar si ya existe una bitácora para esa fecha
const fechaBitacora = new Date(validatedData.fecha);
const existente = await prisma.bitacoraObra.findUnique({
where: {
obraId_fecha: {
obraId: validatedData.obraId,
fecha: fechaBitacora,
},
},
});
if (existente) {
return NextResponse.json(
{ error: "Ya existe una bitácora para esta fecha" },
{ status: 400 }
);
}
const bitacora = await prisma.bitacoraObra.create({
data: {
...validatedData,
fecha: fechaBitacora,
registradoPorId: session.user.id,
},
include: {
registradoPor: {
select: {
nombre: true,
apellido: true,
},
},
},
});
return NextResponse.json(bitacora, { status: 201 });
} catch (error) {
console.error("Error creating bitacora:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0].message },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Error al crear la bitácora" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,239 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { z } from "zod";
import crypto from "crypto";
const updateAccesoSchema = z.object({
password: z.string().min(6).optional(),
regenerarToken: z.boolean().optional(),
tokenExpiraDias: z.number().min(1).max(365).optional(),
activo: z.boolean().optional(),
verFotos: z.boolean().optional(),
verAvances: z.boolean().optional(),
verGastos: z.boolean().optional(),
verDocumentos: z.boolean().optional(),
descargarPDF: z.boolean().optional(),
});
// GET - Obtener un acceso específico
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const acceso = await prisma.clienteAcceso.findFirst({
where: {
id,
cliente: {
empresaId: session.user.empresaId,
},
},
include: {
cliente: {
select: {
id: true,
nombre: true,
email: true,
obras: {
select: {
id: true,
nombre: true,
estado: true,
},
},
},
},
},
});
if (!acceso) {
return NextResponse.json(
{ error: "Acceso no encontrado" },
{ status: 404 }
);
}
const { password, ...rest } = acceso;
return NextResponse.json({
...rest,
tienePassword: !!password,
});
} catch (error) {
console.error("Error fetching acceso:", error);
return NextResponse.json(
{ error: "Error al obtener el acceso" },
{ status: 500 }
);
}
}
// PUT - Actualizar un acceso
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const validatedData = updateAccesoSchema.parse(body);
// Verificar que el acceso pertenece a la empresa
const existingAcceso = await prisma.clienteAcceso.findFirst({
where: {
id,
cliente: {
empresaId: session.user.empresaId,
},
},
});
if (!existingAcceso) {
return NextResponse.json(
{ error: "Acceso no encontrado" },
{ status: 404 }
);
}
// Preparar datos de actualización
const updateData: Record<string, unknown> = {};
if (validatedData.password) {
updateData.password = await bcrypt.hash(validatedData.password, 10);
}
if (validatedData.regenerarToken) {
updateData.token = crypto.randomBytes(32).toString("hex");
if (validatedData.tokenExpiraDias) {
const expira = new Date();
expira.setDate(expira.getDate() + validatedData.tokenExpiraDias);
updateData.tokenExpira = expira;
} else {
updateData.tokenExpira = null;
}
}
if (validatedData.activo !== undefined) {
updateData.activo = validatedData.activo;
}
if (validatedData.verFotos !== undefined) {
updateData.verFotos = validatedData.verFotos;
}
if (validatedData.verAvances !== undefined) {
updateData.verAvances = validatedData.verAvances;
}
if (validatedData.verGastos !== undefined) {
updateData.verGastos = validatedData.verGastos;
}
if (validatedData.verDocumentos !== undefined) {
updateData.verDocumentos = validatedData.verDocumentos;
}
if (validatedData.descargarPDF !== undefined) {
updateData.descargarPDF = validatedData.descargarPDF;
}
const acceso = await prisma.clienteAcceso.update({
where: { id },
data: updateData,
include: {
cliente: {
select: {
id: true,
nombre: true,
},
},
},
});
// Construir URL de acceso con token
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
const accessUrl = acceso.token ? `${baseUrl}/portal?token=${acceso.token}` : null;
return NextResponse.json({
id: acceso.id,
email: acceso.email,
token: acceso.token,
tokenExpira: acceso.tokenExpira,
accessUrl,
activo: acceso.activo,
cliente: acceso.cliente,
permisos: {
verFotos: acceso.verFotos,
verAvances: acceso.verAvances,
verGastos: acceso.verGastos,
verDocumentos: acceso.verDocumentos,
descargarPDF: acceso.descargarPDF,
},
});
} catch (error) {
console.error("Error updating acceso:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0].message },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Error al actualizar el acceso" },
{ status: 500 }
);
}
}
// DELETE - Eliminar un acceso
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
// Verificar que el acceso pertenece a la empresa
const acceso = await prisma.clienteAcceso.findFirst({
where: {
id,
cliente: {
empresaId: session.user.empresaId,
},
},
});
if (!acceso) {
return NextResponse.json(
{ error: "Acceso no encontrado" },
{ status: 404 }
);
}
await prisma.clienteAcceso.delete({
where: { id },
});
return NextResponse.json({ message: "Acceso eliminado exitosamente" });
} catch (error) {
console.error("Error deleting acceso:", error);
return NextResponse.json(
{ error: "Error al eliminar el acceso" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,178 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { z } from "zod";
import crypto from "crypto";
const createAccesoSchema = z.object({
clienteId: z.string(),
email: z.string().email("Email inválido"),
password: z.string().min(6, "La contraseña debe tener al menos 6 caracteres").optional(),
usarToken: z.boolean().default(false),
tokenExpiraDias: z.number().min(1).max(365).optional(),
verFotos: z.boolean().default(true),
verAvances: z.boolean().default(true),
verGastos: z.boolean().default(false),
verDocumentos: z.boolean().default(true),
descargarPDF: z.boolean().default(true),
});
// GET - Obtener accesos de clientes
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const clienteId = searchParams.get("clienteId");
const accesos = await prisma.clienteAcceso.findMany({
where: {
cliente: {
empresaId: session.user.empresaId,
...(clienteId && { id: clienteId }),
},
},
include: {
cliente: {
select: {
id: true,
nombre: true,
email: true,
},
},
},
orderBy: { createdAt: "desc" },
});
// No devolver contraseñas
const accesosSafe = accesos.map(({ password, ...rest }) => ({
...rest,
tienePassword: !!password,
}));
return NextResponse.json(accesosSafe);
} catch (error) {
console.error("Error fetching accesos:", error);
return NextResponse.json(
{ error: "Error al obtener los accesos" },
{ status: 500 }
);
}
}
// POST - Crear nuevo acceso para cliente
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const body = await request.json();
const validatedData = createAccesoSchema.parse(body);
// Verificar que el cliente pertenece a la empresa
const cliente = await prisma.cliente.findFirst({
where: {
id: validatedData.clienteId,
empresaId: session.user.empresaId,
},
});
if (!cliente) {
return NextResponse.json(
{ error: "Cliente no encontrado" },
{ status: 404 }
);
}
// Verificar que el email no esté en uso
const existingAcceso = await prisma.clienteAcceso.findUnique({
where: { email: validatedData.email },
});
if (existingAcceso) {
return NextResponse.json(
{ error: "Este email ya tiene un acceso registrado" },
{ status: 400 }
);
}
// Preparar datos
let hashedPassword: string | null = null;
let token: string | null = null;
let tokenExpira: Date | null = null;
if (validatedData.password) {
hashedPassword = await bcrypt.hash(validatedData.password, 10);
}
if (validatedData.usarToken) {
token = crypto.randomBytes(32).toString("hex");
if (validatedData.tokenExpiraDias) {
tokenExpira = new Date();
tokenExpira.setDate(tokenExpira.getDate() + validatedData.tokenExpiraDias);
}
}
// Crear acceso
const acceso = await prisma.clienteAcceso.create({
data: {
email: validatedData.email,
password: hashedPassword,
token,
tokenExpira,
verFotos: validatedData.verFotos,
verAvances: validatedData.verAvances,
verGastos: validatedData.verGastos,
verDocumentos: validatedData.verDocumentos,
descargarPDF: validatedData.descargarPDF,
clienteId: validatedData.clienteId,
},
include: {
cliente: {
select: {
id: true,
nombre: true,
},
},
},
});
// Construir URL de acceso con token
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
const accessUrl = token ? `${baseUrl}/portal?token=${token}` : null;
return NextResponse.json({
id: acceso.id,
email: acceso.email,
token: acceso.token,
tokenExpira: acceso.tokenExpira,
accessUrl,
cliente: acceso.cliente,
permisos: {
verFotos: acceso.verFotos,
verAvances: acceso.verAvances,
verGastos: acceso.verGastos,
verDocumentos: acceso.verDocumentos,
descargarPDF: acceso.descargarPDF,
},
}, { status: 201 });
} catch (error) {
console.error("Error creating acceso:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0].message },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Error al crear el acceso" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,184 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { unlink } from "fs/promises";
import path from "path";
// GET - Obtener una foto específica
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const foto = await prisma.fotoAvance.findFirst({
where: {
id,
obra: {
empresaId: session.user.empresaId,
},
},
include: {
subidoPor: {
select: {
nombre: true,
apellido: true,
},
},
fase: {
select: {
nombre: true,
},
},
obra: {
select: {
nombre: true,
},
},
},
});
if (!foto) {
return NextResponse.json(
{ error: "Foto no encontrada" },
{ status: 404 }
);
}
return NextResponse.json(foto);
} catch (error) {
console.error("Error fetching foto:", error);
return NextResponse.json(
{ error: "Error al obtener la foto" },
{ status: 500 }
);
}
}
// PUT - Actualizar datos de una foto
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
// Verificar que la foto pertenece a la empresa del usuario
const fotoExistente = await prisma.fotoAvance.findFirst({
where: {
id,
obra: {
empresaId: session.user.empresaId,
},
},
});
if (!fotoExistente) {
return NextResponse.json(
{ error: "Foto no encontrada" },
{ status: 404 }
);
}
const foto = await prisma.fotoAvance.update({
where: { id },
data: {
titulo: body.titulo,
descripcion: body.descripcion,
faseId: body.faseId || null,
},
include: {
subidoPor: {
select: {
nombre: true,
apellido: true,
},
},
fase: {
select: {
nombre: true,
},
},
},
});
return NextResponse.json(foto);
} catch (error) {
console.error("Error updating foto:", error);
return NextResponse.json(
{ error: "Error al actualizar la foto" },
{ status: 500 }
);
}
}
// DELETE - Eliminar una foto
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
// Verificar que la foto pertenece a la empresa del usuario
const foto = await prisma.fotoAvance.findFirst({
where: {
id,
obra: {
empresaId: session.user.empresaId,
},
},
});
if (!foto) {
return NextResponse.json(
{ error: "Foto no encontrada" },
{ status: 404 }
);
}
// Eliminar archivos físicos
try {
const filePath = path.join(process.cwd(), "public", foto.url);
await unlink(filePath);
if (foto.thumbnail) {
const thumbPath = path.join(process.cwd(), "public", foto.thumbnail);
await unlink(thumbPath);
}
} catch {
// Si no se pueden eliminar los archivos, continuar de todas formas
console.warn("No se pudieron eliminar los archivos físicos");
}
// Eliminar de la base de datos
await prisma.fotoAvance.delete({
where: { id },
});
return NextResponse.json({ message: "Foto eliminada exitosamente" });
} catch (error) {
console.error("Error deleting foto:", error);
return NextResponse.json(
{ error: "Error al eliminar la foto" },
{ status: 500 }
);
}
}

191
src/app/api/fotos/route.ts Normal file
View File

@@ -0,0 +1,191 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { writeFile, mkdir } from "fs/promises";
import path from "path";
// GET - Obtener fotos de una obra
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const obraId = searchParams.get("obraId");
const faseId = searchParams.get("faseId");
if (!obraId) {
return NextResponse.json(
{ error: "Se requiere obraId" },
{ status: 400 }
);
}
// Verificar que la obra pertenece a la empresa del usuario
const obra = await prisma.obra.findFirst({
where: {
id: obraId,
empresaId: session.user.empresaId,
},
});
if (!obra) {
return NextResponse.json(
{ error: "Obra no encontrada" },
{ status: 404 }
);
}
const fotos = await prisma.fotoAvance.findMany({
where: {
obraId,
...(faseId && { faseId }),
},
include: {
subidoPor: {
select: {
nombre: true,
apellido: true,
},
},
fase: {
select: {
nombre: true,
},
},
},
orderBy: {
fechaCaptura: "desc",
},
});
return NextResponse.json(fotos);
} catch (error) {
console.error("Error fetching fotos:", error);
return NextResponse.json(
{ error: "Error al obtener las fotos" },
{ status: 500 }
);
}
}
// POST - Subir una nueva foto
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.empresaId || !session?.user?.id) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("file") as File;
const obraId = formData.get("obraId") as string;
const faseId = formData.get("faseId") as string | null;
const titulo = formData.get("titulo") as string | null;
const descripcion = formData.get("descripcion") as string | null;
const latitud = formData.get("latitud") as string | null;
const longitud = formData.get("longitud") as string | null;
if (!file || !obraId) {
return NextResponse.json(
{ error: "Se requiere archivo y obraId" },
{ status: 400 }
);
}
// Verificar que la obra pertenece a la empresa del usuario
const obra = await prisma.obra.findFirst({
where: {
id: obraId,
empresaId: session.user.empresaId,
},
});
if (!obra) {
return NextResponse.json(
{ error: "Obra no encontrada" },
{ status: 404 }
);
}
// Validar tipo de archivo
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/heic"];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: "Tipo de archivo no permitido. Use JPG, PNG o WebP" },
{ status: 400 }
);
}
// Validar tamaño (máx 10MB)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
return NextResponse.json(
{ error: "El archivo es muy grande. Máximo 10MB" },
{ status: 400 }
);
}
// Crear directorio de uploads si no existe
const uploadDir = path.join(process.cwd(), "public", "uploads", "fotos", obraId);
await mkdir(uploadDir, { recursive: true });
// Generar nombre único para el archivo
const timestamp = Date.now();
const extension = file.name.split(".").pop() || "jpg";
const fileName = `${timestamp}.${extension}`;
// Leer el archivo
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Guardar imagen original
const filePath = path.join(uploadDir, fileName);
await writeFile(filePath, buffer);
// URLs públicas
const url = `/uploads/fotos/${obraId}/${fileName}`;
const thumbnail = url; // Usar la misma imagen como thumbnail por ahora
// Guardar en base de datos
const foto = await prisma.fotoAvance.create({
data: {
url,
thumbnail,
titulo,
descripcion,
fechaCaptura: new Date(),
latitud: latitud ? parseFloat(latitud) : null,
longitud: longitud ? parseFloat(longitud) : null,
tamanio: file.size,
tipo: file.type,
obraId,
faseId: faseId || null,
subidoPorId: session.user.id,
},
include: {
subidoPor: {
select: {
nombre: true,
apellido: true,
},
},
fase: {
select: {
nombre: true,
},
},
},
});
return NextResponse.json(foto, { status: 201 });
} catch (error) {
console.error("Error uploading foto:", error);
return NextResponse.json(
{ error: "Error al subir la foto" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// GET - Obtener notificaciones del usuario
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get("limit") || "20");
const unreadOnly = searchParams.get("unread") === "true";
const notificaciones = await prisma.notificacion.findMany({
where: {
userId: session.user.id,
...(unreadOnly && { leida: false }),
},
orderBy: { createdAt: "desc" },
take: limit,
});
// Contar no leídas
const unreadCount = await prisma.notificacion.count({
where: {
userId: session.user.id,
leida: false,
},
});
return NextResponse.json({
notificaciones,
unreadCount,
});
} catch (error) {
console.error("Error fetching notifications:", error);
return NextResponse.json(
{ error: "Error al obtener notificaciones" },
{ status: 500 }
);
}
}
// PUT - Marcar notificaciones como leídas
export async function PUT(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const body = await request.json();
const { notificationIds, markAllRead } = body;
if (markAllRead) {
await prisma.notificacion.updateMany({
where: {
userId: session.user.id,
leida: false,
},
data: { leida: true },
});
} else if (notificationIds && Array.isArray(notificationIds)) {
await prisma.notificacion.updateMany({
where: {
id: { in: notificationIds },
userId: session.user.id,
},
data: { leida: true },
});
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error updating notifications:", error);
return NextResponse.json(
{ error: "Error al actualizar notificaciones" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const subscribeSchema = z.object({
endpoint: z.string().url(),
keys: z.object({
p256dh: z.string(),
auth: z.string(),
}),
preferences: z.object({
notifyTareas: z.boolean().optional(),
notifyGastos: z.boolean().optional(),
notifyOrdenes: z.boolean().optional(),
notifyAvances: z.boolean().optional(),
notifyAlertas: z.boolean().optional(),
}).optional(),
});
// POST - Registrar suscripción push
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const body = await request.json();
const validatedData = subscribeSchema.parse(body);
// Crear o actualizar suscripción
const subscription = await prisma.pushSubscription.upsert({
where: { endpoint: validatedData.endpoint },
update: {
p256dh: validatedData.keys.p256dh,
auth: validatedData.keys.auth,
activo: true,
...(validatedData.preferences || {}),
},
create: {
endpoint: validatedData.endpoint,
p256dh: validatedData.keys.p256dh,
auth: validatedData.keys.auth,
userId: session.user.id,
...(validatedData.preferences || {}),
},
});
return NextResponse.json({
success: true,
subscriptionId: subscription.id,
}, { status: 201 });
} catch (error) {
console.error("Error subscribing:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0].message },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Error al registrar suscripción" },
{ status: 500 }
);
}
}
// DELETE - Cancelar suscripción push
export async function DELETE(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const endpoint = searchParams.get("endpoint");
if (!endpoint) {
return NextResponse.json(
{ error: "Endpoint requerido" },
{ status: 400 }
);
}
await prisma.pushSubscription.updateMany({
where: {
endpoint,
userId: session.user.id,
},
data: { activo: false },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error unsubscribing:", error);
return NextResponse.json(
{ error: "Error al cancelar suscripción" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,224 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const updateOrdenSchema = z.object({
estado: z.enum([
"BORRADOR", "PENDIENTE", "APROBADA", "ENVIADA",
"RECIBIDA_PARCIAL", "RECIBIDA", "CANCELADA"
]).optional(),
prioridad: z.enum(["BAJA", "NORMAL", "ALTA", "URGENTE"]).optional(),
fechaRequerida: z.string().optional().nullable(),
proveedorNombre: z.string().optional(),
proveedorRfc: z.string().optional().nullable(),
proveedorContacto: z.string().optional().nullable(),
proveedorTelefono: z.string().optional().nullable(),
proveedorEmail: z.string().email().optional().nullable(),
proveedorDireccion: z.string().optional().nullable(),
condicionesPago: z.string().optional().nullable(),
tiempoEntrega: z.string().optional().nullable(),
lugarEntrega: z.string().optional().nullable(),
notas: z.string().optional().nullable(),
notasInternas: z.string().optional().nullable(),
});
// GET - Obtener una orden específica
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const orden = await prisma.ordenCompra.findFirst({
where: {
id,
obra: {
empresaId: session.user.empresaId,
},
},
include: {
obra: {
select: { nombre: true, direccion: true },
},
creadoPor: {
select: { nombre: true, apellido: true, email: true },
},
aprobadoPor: {
select: { nombre: true, apellido: true, email: true },
},
items: {
include: {
material: {
select: { nombre: true, codigo: true },
},
},
},
},
});
if (!orden) {
return NextResponse.json(
{ error: "Orden no encontrada" },
{ status: 404 }
);
}
return NextResponse.json(orden);
} catch (error) {
console.error("Error fetching orden:", error);
return NextResponse.json(
{ error: "Error al obtener la orden" },
{ status: 500 }
);
}
}
// PUT - Actualizar una orden
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId || !session?.user?.id) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const validatedData = updateOrdenSchema.parse(body);
// Verificar que la orden pertenece a la empresa del usuario
const ordenExistente = await prisma.ordenCompra.findFirst({
where: {
id,
obra: {
empresaId: session.user.empresaId,
},
},
});
if (!ordenExistente) {
return NextResponse.json(
{ error: "Orden no encontrada" },
{ status: 404 }
);
}
// Preparar datos de actualización
const updateData: Record<string, unknown> = { ...validatedData };
// Si se cambia a APROBADA, registrar quién y cuándo
if (validatedData.estado === "APROBADA" && ordenExistente.estado !== "APROBADA") {
updateData.aprobadoPorId = session.user.id;
updateData.fechaAprobacion = new Date();
}
// Si se cambia a ENVIADA, registrar fecha de envío
if (validatedData.estado === "ENVIADA" && ordenExistente.estado !== "ENVIADA") {
updateData.fechaEnvio = new Date();
}
// Si se cambia a RECIBIDA o RECIBIDA_PARCIAL, registrar fecha de recepción
if (
(validatedData.estado === "RECIBIDA" || validatedData.estado === "RECIBIDA_PARCIAL") &&
ordenExistente.estado !== "RECIBIDA" &&
ordenExistente.estado !== "RECIBIDA_PARCIAL"
) {
updateData.fechaRecepcion = new Date();
}
if (validatedData.fechaRequerida !== undefined) {
updateData.fechaRequerida = validatedData.fechaRequerida
? new Date(validatedData.fechaRequerida)
: null;
}
const orden = await prisma.ordenCompra.update({
where: { id },
data: updateData,
include: {
creadoPor: {
select: { nombre: true, apellido: true },
},
aprobadoPor: {
select: { nombre: true, apellido: true },
},
items: true,
},
});
return NextResponse.json(orden);
} catch (error) {
console.error("Error updating orden:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0].message },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Error al actualizar la orden" },
{ status: 500 }
);
}
}
// DELETE - Eliminar una orden
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
// Verificar que la orden pertenece a la empresa del usuario
const orden = await prisma.ordenCompra.findFirst({
where: {
id,
obra: {
empresaId: session.user.empresaId,
},
},
});
if (!orden) {
return NextResponse.json(
{ error: "Orden no encontrada" },
{ status: 404 }
);
}
// Solo permitir eliminar órdenes en estado BORRADOR o CANCELADA
if (orden.estado !== "BORRADOR" && orden.estado !== "CANCELADA") {
return NextResponse.json(
{ error: "Solo se pueden eliminar órdenes en estado Borrador o Cancelada" },
{ status: 400 }
);
}
await prisma.ordenCompra.delete({
where: { id },
});
return NextResponse.json({ message: "Orden eliminada exitosamente" });
} catch (error) {
console.error("Error deleting orden:", error);
return NextResponse.json(
{ error: "Error al eliminar la orden" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,215 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const itemSchema = z.object({
codigo: z.string().optional().nullable(),
descripcion: z.string().min(1, "La descripción es requerida"),
unidad: z.enum([
"UNIDAD", "METRO", "METRO_CUADRADO", "METRO_CUBICO",
"KILOGRAMO", "TONELADA", "LITRO", "BOLSA", "PIEZA", "ROLLO", "CAJA"
]),
cantidad: z.number().min(0.01, "La cantidad debe ser mayor a 0"),
precioUnitario: z.number().min(0, "El precio debe ser mayor o igual a 0"),
descuento: z.number().min(0).default(0),
materialId: z.string().optional().nullable(),
});
const ordenCompraSchema = z.object({
prioridad: z.enum(["BAJA", "NORMAL", "ALTA", "URGENTE"]).default("NORMAL"),
fechaRequerida: z.string().optional().nullable(),
proveedorNombre: z.string().min(1, "El proveedor es requerido"),
proveedorRfc: z.string().optional().nullable(),
proveedorContacto: z.string().optional().nullable(),
proveedorTelefono: z.string().optional().nullable(),
proveedorEmail: z.string().email().optional().nullable(),
proveedorDireccion: z.string().optional().nullable(),
condicionesPago: z.string().optional().nullable(),
tiempoEntrega: z.string().optional().nullable(),
lugarEntrega: z.string().optional().nullable(),
notas: z.string().optional().nullable(),
notasInternas: z.string().optional().nullable(),
obraId: z.string(),
items: z.array(itemSchema).min(1, "Debe incluir al menos un item"),
});
// GET - Obtener órdenes de compra de una obra
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const obraId = searchParams.get("obraId");
const estado = searchParams.get("estado");
if (!obraId) {
return NextResponse.json(
{ error: "Se requiere obraId" },
{ status: 400 }
);
}
// Verificar que la obra pertenece a la empresa del usuario
const obra = await prisma.obra.findFirst({
where: {
id: obraId,
empresaId: session.user.empresaId,
},
});
if (!obra) {
return NextResponse.json(
{ error: "Obra no encontrada" },
{ status: 404 }
);
}
const ordenes = await prisma.ordenCompra.findMany({
where: {
obraId,
...(estado && { estado: estado as any }),
},
include: {
creadoPor: {
select: { nombre: true, apellido: true },
},
aprobadoPor: {
select: { nombre: true, apellido: true },
},
items: {
include: {
material: {
select: { nombre: true, codigo: true },
},
},
},
},
orderBy: { createdAt: "desc" },
});
return NextResponse.json(ordenes);
} catch (error) {
console.error("Error fetching ordenes:", error);
return NextResponse.json(
{ error: "Error al obtener las órdenes de compra" },
{ status: 500 }
);
}
}
// POST - Crear nueva orden de compra
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.empresaId || !session?.user?.id) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const body = await request.json();
const validatedData = ordenCompraSchema.parse(body);
// Verificar que la obra pertenece a la empresa del usuario
const obra = await prisma.obra.findFirst({
where: {
id: validatedData.obraId,
empresaId: session.user.empresaId,
},
});
if (!obra) {
return NextResponse.json(
{ error: "Obra no encontrada" },
{ status: 404 }
);
}
// Generar número de orden
const lastOrder = await prisma.ordenCompra.findFirst({
where: { obraId: validatedData.obraId },
orderBy: { createdAt: "desc" },
select: { numero: true },
});
let nextNumber = 1;
if (lastOrder?.numero) {
const match = lastOrder.numero.match(/OC-(\d+)/);
if (match) {
nextNumber = parseInt(match[1]) + 1;
}
}
const numero = `OC-${nextNumber.toString().padStart(4, "0")}`;
// Calcular totales
const items = validatedData.items.map((item) => {
const subtotal = item.cantidad * item.precioUnitario - item.descuento;
return { ...item, subtotal };
});
const subtotal = items.reduce((acc, item) => acc + item.subtotal, 0);
const iva = subtotal * 0.16; // 16% IVA
const total = subtotal + iva;
// Crear orden con items
const orden = await prisma.ordenCompra.create({
data: {
numero,
prioridad: validatedData.prioridad,
fechaRequerida: validatedData.fechaRequerida
? new Date(validatedData.fechaRequerida)
: null,
proveedorNombre: validatedData.proveedorNombre,
proveedorRfc: validatedData.proveedorRfc,
proveedorContacto: validatedData.proveedorContacto,
proveedorTelefono: validatedData.proveedorTelefono,
proveedorEmail: validatedData.proveedorEmail,
proveedorDireccion: validatedData.proveedorDireccion,
condicionesPago: validatedData.condicionesPago,
tiempoEntrega: validatedData.tiempoEntrega,
lugarEntrega: validatedData.lugarEntrega || obra.direccion,
notas: validatedData.notas,
notasInternas: validatedData.notasInternas,
subtotal,
iva,
total,
obraId: validatedData.obraId,
creadoPorId: session.user.id,
items: {
create: items.map((item) => ({
codigo: item.codigo,
descripcion: item.descripcion,
unidad: item.unidad,
cantidad: item.cantidad,
precioUnitario: item.precioUnitario,
descuento: item.descuento,
subtotal: item.subtotal,
materialId: item.materialId,
})),
},
},
include: {
creadoPor: {
select: { nombre: true, apellido: true },
},
items: true,
},
});
return NextResponse.json(orden, { status: 201 });
} catch (error) {
console.error("Error creating orden:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0].message },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Error al crear la orden de compra" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,272 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { z } from "zod";
import { cookies } from "next/headers";
import { SignJWT, jwtVerify } from "jose";
const loginSchema = z.object({
email: z.string().email("Email inválido"),
password: z.string().min(1, "La contraseña es requerida"),
});
const tokenLoginSchema = z.object({
token: z.string().min(1, "Token requerido"),
});
const SECRET = new TextEncoder().encode(
process.env.NEXTAUTH_SECRET || "portal-cliente-secret"
);
// Crear JWT para el portal
async function createPortalToken(clienteAccesoId: string, clienteId: string) {
return await new SignJWT({ clienteAccesoId, clienteId, type: "portal" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(SECRET);
}
// Verificar JWT del portal (función privada)
async function verifyPortalToken(token: string) {
try {
const { payload } = await jwtVerify(token, SECRET);
return payload as { clienteAccesoId: string; clienteId: string; type: string };
} catch {
return null;
}
}
// POST - Login con email/password o token
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Intentar login con token de acceso directo
if (body.token) {
const { token } = tokenLoginSchema.parse(body);
const acceso = await prisma.clienteAcceso.findFirst({
where: {
token,
activo: true,
OR: [
{ tokenExpira: null },
{ tokenExpira: { gt: new Date() } },
],
},
include: {
cliente: {
select: {
id: true,
nombre: true,
email: true,
empresa: {
select: { nombre: true },
},
},
},
},
});
if (!acceso) {
return NextResponse.json(
{ error: "Token inválido o expirado" },
{ status: 401 }
);
}
// Actualizar último acceso
await prisma.clienteAcceso.update({
where: { id: acceso.id },
data: { ultimoAcceso: new Date() },
});
// Crear JWT
const jwt = await createPortalToken(acceso.id, acceso.clienteId);
// Establecer cookie
const cookieStore = await cookies();
cookieStore.set("portal-token", jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 días
});
return NextResponse.json({
success: true,
cliente: acceso.cliente,
permisos: {
verFotos: acceso.verFotos,
verAvances: acceso.verAvances,
verGastos: acceso.verGastos,
verDocumentos: acceso.verDocumentos,
descargarPDF: acceso.descargarPDF,
},
});
}
// Login con email/password
const { email, password } = loginSchema.parse(body);
const acceso = await prisma.clienteAcceso.findUnique({
where: { email },
include: {
cliente: {
select: {
id: true,
nombre: true,
email: true,
empresa: {
select: { nombre: true },
},
},
},
},
});
if (!acceso || !acceso.activo) {
return NextResponse.json(
{ error: "Credenciales inválidas" },
{ status: 401 }
);
}
if (!acceso.password) {
return NextResponse.json(
{ error: "Este acceso solo permite login con token" },
{ status: 401 }
);
}
const passwordValid = await bcrypt.compare(password, acceso.password);
if (!passwordValid) {
return NextResponse.json(
{ error: "Credenciales inválidas" },
{ status: 401 }
);
}
// Actualizar último acceso
await prisma.clienteAcceso.update({
where: { id: acceso.id },
data: { ultimoAcceso: new Date() },
});
// Crear JWT
const jwt = await createPortalToken(acceso.id, acceso.clienteId);
// Establecer cookie
const cookieStore = await cookies();
cookieStore.set("portal-token", jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 días
});
return NextResponse.json({
success: true,
cliente: acceso.cliente,
permisos: {
verFotos: acceso.verFotos,
verAvances: acceso.verAvances,
verGastos: acceso.verGastos,
verDocumentos: acceso.verDocumentos,
descargarPDF: acceso.descargarPDF,
},
});
} catch (error) {
console.error("Error en login portal:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0].message },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Error al iniciar sesión" },
{ status: 500 }
);
}
}
// GET - Verificar sesión actual
export async function GET() {
try {
const cookieStore = await cookies();
const token = cookieStore.get("portal-token")?.value;
if (!token) {
return NextResponse.json(
{ error: "No autenticado" },
{ status: 401 }
);
}
const payload = await verifyPortalToken(token);
if (!payload || payload.type !== "portal") {
return NextResponse.json(
{ error: "Token inválido" },
{ status: 401 }
);
}
const acceso = await prisma.clienteAcceso.findUnique({
where: { id: payload.clienteAccesoId },
include: {
cliente: {
select: {
id: true,
nombre: true,
email: true,
empresa: {
select: { nombre: true },
},
},
},
},
});
if (!acceso || !acceso.activo) {
return NextResponse.json(
{ error: "Acceso desactivado" },
{ status: 401 }
);
}
return NextResponse.json({
success: true,
cliente: acceso.cliente,
permisos: {
verFotos: acceso.verFotos,
verAvances: acceso.verAvances,
verGastos: acceso.verGastos,
verDocumentos: acceso.verDocumentos,
descargarPDF: acceso.descargarPDF,
},
});
} catch (error) {
console.error("Error verificando sesión:", error);
return NextResponse.json(
{ error: "Error al verificar sesión" },
{ status: 500 }
);
}
}
// DELETE - Cerrar sesión
export async function DELETE() {
try {
const cookieStore = await cookies();
cookieStore.delete("portal-token");
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error cerrando sesión:", error);
return NextResponse.json(
{ error: "Error al cerrar sesión" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getPortalSession } from "@/lib/portal-auth";
// GET - Obtener detalle de obra para el cliente
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getPortalSession();
if (!session) {
return NextResponse.json(
{ error: "No autenticado" },
{ status: 401 }
);
}
const { id } = await params;
// Verificar que la obra pertenece al cliente
const obra = await prisma.obra.findFirst({
where: {
id,
clienteId: session.clienteId,
},
select: {
id: true,
nombre: true,
descripcion: true,
direccion: true,
estado: true,
porcentajeAvance: true,
presupuestoTotal: session.permisos.verGastos,
gastoTotal: session.permisos.verGastos,
fechaInicio: true,
fechaFinPrevista: true,
fechaFinReal: true,
imagenPortada: true,
supervisor: {
select: { nombre: true, apellido: true, email: true },
},
empresa: {
select: { nombre: true, telefono: true, email: true },
},
fases: session.permisos.verAvances ? {
orderBy: { orden: "asc" },
select: {
id: true,
nombre: true,
descripcion: true,
porcentajeAvance: true,
tareas: {
select: {
id: true,
nombre: true,
estado: true,
porcentajeAvance: true,
},
},
},
} : false,
fotos: session.permisos.verFotos ? {
orderBy: { fechaCaptura: "desc" },
take: 20,
select: {
id: true,
url: true,
thumbnail: true,
titulo: true,
descripcion: true,
fechaCaptura: true,
fase: {
select: { nombre: true },
},
},
} : false,
registrosAvance: session.permisos.verAvances ? {
orderBy: { createdAt: "desc" },
take: 10,
select: {
id: true,
descripcion: true,
porcentaje: true,
fotos: true,
createdAt: true,
registradoPor: {
select: { nombre: true, apellido: true },
},
},
} : false,
gastos: session.permisos.verGastos ? {
orderBy: { fecha: "desc" },
take: 20,
select: {
id: true,
concepto: true,
monto: true,
fecha: true,
categoria: true,
estado: true,
},
} : false,
},
});
if (!obra) {
return NextResponse.json(
{ error: "Obra no encontrada" },
{ status: 404 }
);
}
return NextResponse.json({
...obra,
permisos: session.permisos,
});
} catch (error) {
console.error("Error fetching obra del portal:", error);
return NextResponse.json(
{ error: "Error al obtener la obra" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getPortalSession } from "@/lib/portal-auth";
// GET - Obtener obras del cliente
export async function GET() {
try {
const session = await getPortalSession();
if (!session) {
return NextResponse.json(
{ error: "No autenticado" },
{ status: 401 }
);
}
// Obtener obras del cliente
const obras = await prisma.obra.findMany({
where: {
clienteId: session.clienteId,
},
select: {
id: true,
nombre: true,
descripcion: true,
direccion: true,
estado: true,
porcentajeAvance: true,
presupuestoTotal: session.permisos.verGastos,
gastoTotal: session.permisos.verGastos,
fechaInicio: true,
fechaFinPrevista: true,
fechaFinReal: true,
imagenPortada: true,
_count: {
select: {
fotos: true,
registrosAvance: true,
},
},
},
orderBy: { updatedAt: "desc" },
});
return NextResponse.json(obras);
} catch (error) {
console.error("Error fetching obras del portal:", error);
return NextResponse.json(
{ error: "Error al obtener las obras" },
{ status: 500 }
);
}
}

View File

@@ -1,14 +1,51 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/toaster";
import { AuthProvider } from "@/components/providers/auth-provider";
import { PWAProvider } from "@/components/pwa/pwa-provider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Sistema de Gestion de Obras",
description: "Aplicacion para la gestion integral de obras de construccion",
title: "Mexus - Gestion de Obras",
description: "Sistema de gestion integral de obras de construccion",
manifest: "/manifest.json",
icons: {
icon: [
{ url: "/favicon.png", sizes: "32x32", type: "image/png" },
{ url: "/icons/icon-192x192.png", sizes: "192x192", type: "image/png" },
{ url: "/icons/icon-512x512.png", sizes: "512x512", type: "image/png" },
],
apple: [
{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
],
},
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Mexus",
},
formatDetection: {
telephone: false,
},
openGraph: {
type: "website",
siteName: "Mexus",
title: "Mexus - Gestion de Obras",
description: "Sistema de gestion integral de obras de construccion",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#2563eb" },
{ media: "(prefers-color-scheme: dark)", color: "#1e40af" },
],
};
export default function RootLayout({
@@ -20,8 +57,10 @@ export default function RootLayout({
<html lang="es" suppressHydrationWarning>
<body className={inter.className}>
<AuthProvider>
<PWAProvider>
{children}
<Toaster />
</PWAProvider>
</AuthProvider>
</body>
</html>

18
src/app/portal/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Portal de Cliente - Mexus App",
description: "Portal de seguimiento de obras para clientes",
};
export default function PortalLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-gray-50">
{children}
</div>
);
}

View File

@@ -0,0 +1,578 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
ChevronLeft,
MapPin,
Calendar,
Building2,
User,
Phone,
Mail,
Loader2,
Camera,
CheckCircle2,
Clock,
DollarSign,
} from "lucide-react";
import { formatCurrency, formatDate, formatPercentage } from "@/lib/utils";
import {
ESTADO_OBRA_LABELS,
ESTADO_OBRA_COLORS,
ESTADO_TAREA_LABELS,
CATEGORIA_GASTO_LABELS,
ESTADO_GASTO_LABELS,
ESTADO_GASTO_COLORS,
type EstadoObra,
type EstadoTarea,
type CategoriaGasto,
type EstadoGasto,
} from "@/types";
interface ObraDetail {
id: string;
nombre: string;
descripcion: string | null;
direccion: string;
estado: EstadoObra;
porcentajeAvance: number;
presupuestoTotal?: number;
gastoTotal?: number;
fechaInicio: string | null;
fechaFinPrevista: string | null;
fechaFinReal: string | null;
imagenPortada: string | null;
supervisor?: { nombre: string; apellido: string; email: string | null };
empresa?: { nombre: string; telefono: string | null; email: string | null };
fases?: {
id: string;
nombre: string;
descripcion: string | null;
porcentajeAvance: number;
tareas: {
id: string;
nombre: string;
estado: EstadoTarea;
porcentajeAvance: number;
}[];
}[];
fotos?: {
id: string;
url: string;
thumbnail: string | null;
titulo: string | null;
descripcion: string | null;
fechaCaptura: string;
fase?: { nombre: string } | null;
}[];
registrosAvance?: {
id: string;
descripcion: string;
porcentaje: number;
fotos: string[];
createdAt: string;
registradoPor: { nombre: string; apellido: string };
}[];
gastos?: {
id: string;
concepto: string;
monto: number;
fecha: string;
categoria: CategoriaGasto;
estado: EstadoGasto;
}[];
permisos: {
verFotos: boolean;
verAvances: boolean;
verGastos: boolean;
verDocumentos: boolean;
descargarPDF: boolean;
};
}
export default function PortalObraDetailPage() {
const router = useRouter();
const params = useParams();
const [loading, setLoading] = useState(true);
const [obra, setObra] = useState<ObraDetail | null>(null);
const [selectedPhoto, setSelectedPhoto] = useState<string | null>(null);
useEffect(() => {
fetchObra();
}, [params.id]);
const fetchObra = async () => {
try {
const res = await fetch(`/api/portal/obras/${params.id}`);
if (!res.ok) {
if (res.status === 401) {
router.push("/portal");
return;
}
throw new Error("Error al cargar obra");
}
const data = await res.json();
setObra(data);
} catch (error) {
console.error("Error:", error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!obra) {
return (
<div className="min-h-screen flex items-center justify-center">
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Obra no encontrada</p>
<Link href="/portal/obras">
<Button variant="link">Volver a mis obras</Button>
</Link>
</CardContent>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<Link
href="/portal/obras"
className="flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Volver a mis obras
</Link>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">{obra.nombre}</h1>
<div className="flex items-center gap-4 mt-2">
<Badge className={ESTADO_OBRA_COLORS[obra.estado]}>
{ESTADO_OBRA_LABELS[obra.estado]}
</Badge>
<span className="flex items-center text-sm text-muted-foreground">
<MapPin className="h-4 w-4 mr-1" />
{obra.direccion}
</span>
</div>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-primary">
{formatPercentage(obra.porcentajeAvance)}
</div>
<p className="text-sm text-muted-foreground">Avance total</p>
</div>
</div>
</div>
</header>
{/* Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Progress Bar */}
<Card className="mb-6">
<CardContent className="py-4">
<Progress value={obra.porcentajeAvance} className="h-3" />
</CardContent>
</Card>
<Tabs defaultValue="general" className="space-y-6">
<TabsList className="flex-wrap">
<TabsTrigger value="general">General</TabsTrigger>
{obra.permisos.verAvances && obra.fases && (
<TabsTrigger value="avances">Avances</TabsTrigger>
)}
{obra.permisos.verFotos && obra.fotos && (
<TabsTrigger value="fotos">Fotos</TabsTrigger>
)}
{obra.permisos.verGastos && obra.gastos && (
<TabsTrigger value="finanzas">Finanzas</TabsTrigger>
)}
</TabsList>
{/* General Tab */}
<TabsContent value="general" className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
{/* Info del Proyecto */}
<Card>
<CardHeader>
<CardTitle>Informacion del Proyecto</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{obra.descripcion && (
<div>
<p className="text-sm font-medium text-muted-foreground">
Descripcion
</p>
<p>{obra.descripcion}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Calendar className="h-4 w-4" />
Inicio
</p>
<p>
{obra.fechaInicio
? formatDate(new Date(obra.fechaInicio))
: "Por definir"}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Calendar className="h-4 w-4" />
Fin Previsto
</p>
<p>
{obra.fechaFinPrevista
? formatDate(new Date(obra.fechaFinPrevista))
: "Por definir"}
</p>
</div>
</div>
{obra.permisos.verGastos && obra.presupuestoTotal !== undefined && (
<div className="pt-4 border-t">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-muted-foreground">
Presupuesto
</p>
<p className="text-lg font-semibold">
{formatCurrency(obra.presupuestoTotal)}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
Ejecutado
</p>
<p className="text-lg font-semibold">
{formatCurrency(obra.gastoTotal || 0)}
</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Contacto */}
<Card>
<CardHeader>
<CardTitle>Contacto</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{obra.empresa && (
<div>
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Building2 className="h-4 w-4" />
Constructora
</p>
<p className="font-medium">{obra.empresa.nombre}</p>
{obra.empresa.telefono && (
<p className="flex items-center gap-1 text-sm text-muted-foreground">
<Phone className="h-3 w-3" />
{obra.empresa.telefono}
</p>
)}
{obra.empresa.email && (
<p className="flex items-center gap-1 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />
{obra.empresa.email}
</p>
)}
</div>
)}
{obra.supervisor && (
<div className="pt-4 border-t">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<User className="h-4 w-4" />
Supervisor
</p>
<p className="font-medium">
{obra.supervisor.nombre} {obra.supervisor.apellido}
</p>
{obra.supervisor.email && (
<p className="flex items-center gap-1 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />
{obra.supervisor.email}
</p>
)}
</div>
)}
</CardContent>
</Card>
</div>
{/* Últimos Avances */}
{obra.permisos.verAvances && obra.registrosAvance && obra.registrosAvance.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Ultimos Avances</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{obra.registrosAvance.slice(0, 3).map((registro) => (
<div
key={registro.id}
className="flex items-start gap-4 p-4 rounded-lg bg-gray-50"
>
<div className="flex-1">
<p className="font-medium">{registro.descripcion}</p>
<p className="text-sm text-muted-foreground">
{registro.registradoPor.nombre}{" "}
{registro.registradoPor.apellido} -{" "}
{formatDate(new Date(registro.createdAt))}
</p>
</div>
<Badge variant="outline">
{formatPercentage(registro.porcentaje)}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* Avances Tab */}
{obra.permisos.verAvances && obra.fases && (
<TabsContent value="avances" className="space-y-6">
{obra.fases.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
No hay fases definidas para esta obra
</CardContent>
</Card>
) : (
obra.fases.map((fase) => (
<Card key={fase.id}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{fase.nombre}</CardTitle>
<div className="flex items-center gap-2">
<span className="text-sm">
{formatPercentage(fase.porcentajeAvance)}
</span>
<Progress
value={fase.porcentajeAvance}
className="w-24"
/>
</div>
</div>
{fase.descripcion && (
<CardDescription>{fase.descripcion}</CardDescription>
)}
</CardHeader>
<CardContent>
{fase.tareas.length === 0 ? (
<p className="text-sm text-muted-foreground">
Sin tareas definidas
</p>
) : (
<div className="space-y-2">
{fase.tareas.map((tarea) => (
<div
key={tarea.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-2">
{tarea.estado === "COMPLETADA" ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<Clock className="h-4 w-4 text-muted-foreground" />
)}
<span>{tarea.nombre}</span>
</div>
<Badge variant="outline">
{ESTADO_TAREA_LABELS[tarea.estado]}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
))
)}
</TabsContent>
)}
{/* Fotos Tab */}
{obra.permisos.verFotos && obra.fotos && (
<TabsContent value="fotos">
{obra.fotos.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<Camera className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">
No hay fotos disponibles
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{obra.fotos.map((foto) => (
<Card
key={foto.id}
className="overflow-hidden cursor-pointer hover:shadow-lg transition-shadow"
onClick={() => setSelectedPhoto(foto.url)}
>
<div className="aspect-square relative">
<img
src={foto.thumbnail || foto.url}
alt={foto.titulo || "Foto de avance"}
className="object-cover w-full h-full"
/>
</div>
<CardContent className="p-3">
{foto.titulo && (
<p className="font-medium text-sm truncate">
{foto.titulo}
</p>
)}
<p className="text-xs text-muted-foreground">
{formatDate(new Date(foto.fechaCaptura))}
</p>
{foto.fase && (
<Badge variant="outline" className="mt-1 text-xs">
{foto.fase.nombre}
</Badge>
)}
</CardContent>
</Card>
))}
</div>
)}
{/* Modal de foto */}
{selectedPhoto && (
<div
className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4"
onClick={() => setSelectedPhoto(null)}
>
<img
src={selectedPhoto}
alt="Foto ampliada"
className="max-w-full max-h-full object-contain"
/>
</div>
)}
</TabsContent>
)}
{/* Finanzas Tab */}
{obra.permisos.verGastos && obra.gastos && (
<TabsContent value="finanzas" className="space-y-6">
{/* Resumen */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Presupuesto
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(obra.presupuestoTotal || 0)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Ejecutado
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(obra.gastoTotal || 0)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Disponible
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{formatCurrency(
(obra.presupuestoTotal || 0) - (obra.gastoTotal || 0)
)}
</div>
</CardContent>
</Card>
</div>
{/* Lista de gastos */}
<Card>
<CardHeader>
<CardTitle>Ultimos Gastos</CardTitle>
</CardHeader>
<CardContent>
{obra.gastos.length === 0 ? (
<p className="text-center text-muted-foreground py-4">
No hay gastos registrados
</p>
) : (
<div className="space-y-2">
{obra.gastos.map((gasto) => (
<div
key={gasto.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div>
<p className="font-medium">{gasto.concepto}</p>
<p className="text-sm text-muted-foreground">
{CATEGORIA_GASTO_LABELS[gasto.categoria]} -{" "}
{formatDate(new Date(gasto.fecha))}
</p>
</div>
<div className="text-right">
<p className="font-bold">
{formatCurrency(gasto.monto)}
</p>
<Badge className={ESTADO_GASTO_COLORS[gasto.estado]}>
{ESTADO_GASTO_LABELS[gasto.estado]}
</Badge>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
)}
</Tabs>
</main>
</div>
);
}

View File

@@ -0,0 +1,242 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Building2,
MapPin,
Calendar,
Camera,
FileText,
LogOut,
Loader2,
} from "lucide-react";
import { formatCurrency, formatDate, formatPercentage } from "@/lib/utils";
import { ESTADO_OBRA_LABELS, ESTADO_OBRA_COLORS, type EstadoObra } from "@/types";
interface Obra {
id: string;
nombre: string;
descripcion: string | null;
direccion: string;
estado: EstadoObra;
porcentajeAvance: number;
presupuestoTotal?: number;
gastoTotal?: number;
fechaInicio: string | null;
fechaFinPrevista: string | null;
fechaFinReal: string | null;
imagenPortada: string | null;
_count: {
fotos: number;
registrosAvance: number;
};
}
interface ClienteInfo {
cliente: {
id: string;
nombre: string;
email: string | null;
empresa: { nombre: string };
};
permisos: {
verFotos: boolean;
verAvances: boolean;
verGastos: boolean;
verDocumentos: boolean;
descargarPDF: boolean;
};
}
export default function PortalObrasPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [obras, setObras] = useState<Obra[]>([]);
const [clienteInfo, setClienteInfo] = useState<ClienteInfo | null>(null);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
// Verificar autenticación
const authRes = await fetch("/api/portal/auth");
if (!authRes.ok) {
router.push("/portal");
return;
}
const authData = await authRes.json();
setClienteInfo(authData);
// Obtener obras
const obrasRes = await fetch("/api/portal/obras");
if (obrasRes.ok) {
const obrasData = await obrasRes.json();
setObras(obrasData);
}
} catch (error) {
console.error("Error:", error);
router.push("/portal");
} finally {
setLoading(false);
}
};
const handleLogout = async () => {
await fetch("/api/portal/auth", { method: "DELETE" });
router.push("/portal");
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="min-h-screen">
{/* Header */}
<header className="bg-white border-b sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-3">
<Building2 className="h-8 w-8 text-primary" />
<div>
<h1 className="font-semibold">Portal de Cliente</h1>
<p className="text-sm text-muted-foreground">
{clienteInfo?.cliente.empresa.nombre}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
{clienteInfo?.cliente.nombre}
</span>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<LogOut className="h-4 w-4 mr-2" />
Salir
</Button>
</div>
</div>
</div>
</header>
{/* Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h2 className="text-2xl font-bold">Mis Obras</h2>
<p className="text-muted-foreground">
Revisa el avance de tus proyectos
</p>
</div>
{obras.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<Building2 className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">
No tienes obras asignadas actualmente
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{obras.map((obra) => (
<Link key={obra.id} href={`/portal/obras/${obra.id}`}>
<Card className="h-full hover:shadow-lg transition-shadow cursor-pointer">
{obra.imagenPortada && (
<div className="aspect-video relative overflow-hidden rounded-t-lg">
<img
src={obra.imagenPortada}
alt={obra.nombre}
className="object-cover w-full h-full"
/>
</div>
)}
<CardHeader>
<div className="flex items-start justify-between">
<CardTitle className="text-lg">{obra.nombre}</CardTitle>
<Badge className={ESTADO_OBRA_COLORS[obra.estado]}>
{ESTADO_OBRA_LABELS[obra.estado]}
</Badge>
</div>
<CardDescription className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{obra.direccion}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Progreso */}
<div>
<div className="flex justify-between text-sm mb-1">
<span>Avance</span>
<span className="font-medium">
{formatPercentage(obra.porcentajeAvance)}
</span>
</div>
<Progress value={obra.porcentajeAvance} />
</div>
{/* Info */}
<div className="grid grid-cols-2 gap-4 text-sm">
{obra.fechaInicio && (
<div className="flex items-center gap-1 text-muted-foreground">
<Calendar className="h-3 w-3" />
<span>
{formatDate(new Date(obra.fechaInicio))}
</span>
</div>
)}
{clienteInfo?.permisos.verFotos && (
<div className="flex items-center gap-1 text-muted-foreground">
<Camera className="h-3 w-3" />
<span>{obra._count.fotos} fotos</span>
</div>
)}
{clienteInfo?.permisos.verAvances && (
<div className="flex items-center gap-1 text-muted-foreground">
<FileText className="h-3 w-3" />
<span>{obra._count.registrosAvance} avances</span>
</div>
)}
</div>
{/* Financiero (si tiene permiso) */}
{clienteInfo?.permisos.verGastos &&
obra.presupuestoTotal !== undefined && (
<div className="pt-2 border-t">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Presupuesto
</span>
<span className="font-medium">
{formatCurrency(obra.presupuestoTotal)}
</span>
</div>
</div>
)}
</CardContent>
</Card>
</Link>
))}
</div>
)}
</main>
</div>
);
}

186
src/app/portal/page.tsx Normal file
View File

@@ -0,0 +1,186 @@
"use client";
import { useState, useEffect, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Building2, Loader2 } from "lucide-react";
function PortalLoginContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [formData, setFormData] = useState({
email: "",
password: "",
});
// Verificar si hay token en la URL
useEffect(() => {
const token = searchParams.get("token");
if (token) {
loginWithToken(token);
}
}, [searchParams]);
// Verificar si ya está autenticado
useEffect(() => {
checkAuth();
}, []);
const checkAuth = async () => {
try {
const res = await fetch("/api/portal/auth");
if (res.ok) {
router.push("/portal/obras");
}
} catch {
// No autenticado, continuar en login
}
};
const loginWithToken = async (token: string) => {
setLoading(true);
setError("");
try {
const res = await fetch("/api/portal/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Error al iniciar sesión");
return;
}
router.push("/portal/obras");
} catch {
setError("Error de conexión");
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const res = await fetch("/api/portal/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Error al iniciar sesión");
return;
}
router.push("/portal/obras");
} catch {
setError("Error de conexión");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<div className="p-3 bg-primary/10 rounded-full">
<Building2 className="h-8 w-8 text-primary" />
</div>
</div>
<CardTitle className="text-2xl">Portal de Cliente</CardTitle>
<CardDescription>
Accede para ver el avance de tus obras
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Correo electronico</Label>
<Input
id="email"
type="email"
placeholder="tu@email.com"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Contrasena</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
required
/>
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Ingresando...
</>
) : (
"Ingresar"
)}
</Button>
</form>
<p className="mt-6 text-center text-sm text-muted-foreground">
Si no tienes acceso, contacta a tu constructora
</p>
</CardContent>
</Card>
</div>
);
}
export default function PortalLoginPage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
}
>
<PortalLoginContent />
</Suspense>
);
}

View File

@@ -0,0 +1,293 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Building2,
Pencil,
RefreshCw,
Layers,
ClipboardList,
UserPlus,
CheckCircle,
DollarSign,
Check,
X,
Package,
Send,
PackageCheck,
TrendingUp,
Camera,
BookOpen,
Boxes,
MessageSquare,
FileText,
Loader2,
ChevronDown,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { es } from "date-fns/locale";
import { TipoActividad } from "@prisma/client";
interface Actividad {
id: string;
tipo: TipoActividad;
descripcion: string;
detalles: string | null;
entidadTipo: string | null;
entidadId: string | null;
entidadNombre: string | null;
obraId: string | null;
user: { id: string; nombre: string; apellido: string } | null;
obra: { id: string; nombre: string } | null;
createdAt: string;
}
interface Props {
obraId?: string;
limit?: number;
showFilters?: boolean;
compact?: boolean;
}
const TIPO_ICONS: Record<TipoActividad, React.ReactNode> = {
OBRA_CREADA: <Building2 className="h-4 w-4" />,
OBRA_ACTUALIZADA: <Pencil className="h-4 w-4" />,
OBRA_ESTADO_CAMBIADO: <RefreshCw className="h-4 w-4" />,
FASE_CREADA: <Layers className="h-4 w-4" />,
TAREA_CREADA: <ClipboardList className="h-4 w-4" />,
TAREA_ASIGNADA: <UserPlus className="h-4 w-4" />,
TAREA_COMPLETADA: <CheckCircle className="h-4 w-4" />,
TAREA_ESTADO_CAMBIADO: <RefreshCw className="h-4 w-4" />,
GASTO_CREADO: <DollarSign className="h-4 w-4" />,
GASTO_APROBADO: <Check className="h-4 w-4" />,
GASTO_RECHAZADO: <X className="h-4 w-4" />,
ORDEN_CREADA: <Package className="h-4 w-4" />,
ORDEN_APROBADA: <Check className="h-4 w-4" />,
ORDEN_ENVIADA: <Send className="h-4 w-4" />,
ORDEN_RECIBIDA: <PackageCheck className="h-4 w-4" />,
AVANCE_REGISTRADO: <TrendingUp className="h-4 w-4" />,
FOTO_SUBIDA: <Camera className="h-4 w-4" />,
BITACORA_REGISTRADA: <BookOpen className="h-4 w-4" />,
MATERIAL_MOVIMIENTO: <Boxes className="h-4 w-4" />,
USUARIO_ASIGNADO: <UserPlus className="h-4 w-4" />,
COMENTARIO_AGREGADO: <MessageSquare className="h-4 w-4" />,
DOCUMENTO_SUBIDO: <FileText className="h-4 w-4" />,
};
const TIPO_COLORS: Record<TipoActividad, string> = {
OBRA_CREADA: "bg-blue-100 text-blue-600",
OBRA_ACTUALIZADA: "bg-gray-100 text-gray-600",
OBRA_ESTADO_CAMBIADO: "bg-purple-100 text-purple-600",
FASE_CREADA: "bg-indigo-100 text-indigo-600",
TAREA_CREADA: "bg-blue-100 text-blue-600",
TAREA_ASIGNADA: "bg-cyan-100 text-cyan-600",
TAREA_COMPLETADA: "bg-green-100 text-green-600",
TAREA_ESTADO_CAMBIADO: "bg-yellow-100 text-yellow-600",
GASTO_CREADO: "bg-orange-100 text-orange-600",
GASTO_APROBADO: "bg-green-100 text-green-600",
GASTO_RECHAZADO: "bg-red-100 text-red-600",
ORDEN_CREADA: "bg-purple-100 text-purple-600",
ORDEN_APROBADA: "bg-green-100 text-green-600",
ORDEN_ENVIADA: "bg-blue-100 text-blue-600",
ORDEN_RECIBIDA: "bg-green-100 text-green-600",
AVANCE_REGISTRADO: "bg-teal-100 text-teal-600",
FOTO_SUBIDA: "bg-pink-100 text-pink-600",
BITACORA_REGISTRADA: "bg-amber-100 text-amber-600",
MATERIAL_MOVIMIENTO: "bg-gray-100 text-gray-600",
USUARIO_ASIGNADO: "bg-cyan-100 text-cyan-600",
COMENTARIO_AGREGADO: "bg-blue-100 text-blue-600",
DOCUMENTO_SUBIDO: "bg-gray-100 text-gray-600",
};
const TIPO_LABELS: Partial<Record<TipoActividad, string>> = {
OBRA_CREADA: "Obras",
TAREA_CREADA: "Tareas",
GASTO_CREADO: "Gastos",
ORDEN_CREADA: "Ordenes",
AVANCE_REGISTRADO: "Avances",
FOTO_SUBIDA: "Fotos",
BITACORA_REGISTRADA: "Bitacora",
};
export function TimelineActividades({
obraId,
limit = 20,
showFilters = true,
compact = false,
}: Props) {
const [actividades, setActividades] = useState<Actividad[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const [tipoFilter, setTipoFilter] = useState<string>("all");
useEffect(() => {
fetchActividades(true);
}, [obraId, tipoFilter]);
const fetchActividades = async (reset = false) => {
if (reset) {
setLoading(true);
setOffset(0);
} else {
setLoadingMore(true);
}
try {
const params = new URLSearchParams();
params.append("limit", String(limit));
params.append("offset", String(reset ? 0 : offset));
if (obraId) params.append("obraId", obraId);
if (tipoFilter !== "all") params.append("tipo", tipoFilter);
const res = await fetch(`/api/actividades?${params.toString()}`);
if (res.ok) {
const data = await res.json();
if (reset) {
setActividades(data.actividades);
} else {
setActividades((prev) => [...prev, ...data.actividades]);
}
setHasMore(data.hasMore);
setOffset((reset ? 0 : offset) + data.actividades.length);
}
} catch (error) {
console.error("Error fetching actividades:", error);
} finally {
setLoading(false);
setLoadingMore(false);
}
};
const loadMore = () => {
fetchActividades(false);
};
if (loading) {
return (
<Card>
<CardContent className="py-8 text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{showFilters && (
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Actividad Reciente</h3>
<Select value={tipoFilter} onValueChange={setTipoFilter}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Filtrar por" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas</SelectItem>
{Object.entries(TIPO_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{actividades.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay actividad registrada
</CardContent>
</Card>
) : (
<div className="relative">
{/* Timeline line */}
<div className="absolute left-4 top-0 bottom-0 w-px bg-gray-200" />
<div className="space-y-4">
{actividades.map((actividad) => (
<div
key={actividad.id}
className={`relative flex items-start gap-4 ${compact ? "pl-8" : "pl-10"}`}
>
{/* Icon */}
<div
className={`absolute ${compact ? "left-0" : "left-0"} flex items-center justify-center w-8 h-8 rounded-full ${TIPO_COLORS[actividad.tipo]} ring-4 ring-white`}
>
{TIPO_ICONS[actividad.tipo]}
</div>
{/* Content */}
<Card className="flex-1">
<CardContent className={compact ? "p-3" : "p-4"}>
<div className="flex items-start justify-between gap-2">
<div>
<p className={compact ? "text-sm" : ""}>
{actividad.descripcion}
</p>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
{actividad.user && (
<span>
{actividad.user.nombre} {actividad.user.apellido}
</span>
)}
{actividad.user && actividad.obra && !obraId && (
<span></span>
)}
{actividad.obra && !obraId && (
<span>{actividad.obra.nombre}</span>
)}
</div>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDistanceToNow(new Date(actividad.createdAt), {
addSuffix: true,
locale: es,
})}
</span>
</div>
</CardContent>
</Card>
</div>
))}
</div>
{/* Load more */}
{hasMore && (
<div className="flex justify-center pt-4">
<Button
variant="outline"
onClick={loadMore}
disabled={loadingMore}
>
{loadingMore ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<ChevronDown className="h-4 w-4 mr-2" />
)}
Cargar mas
</Button>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,841 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Users,
Clock,
MapPin,
Plus,
ChevronLeft,
ChevronRight,
LogIn,
LogOut,
Edit,
Trash2,
Loader2,
UserCheck,
UserX,
CalendarDays,
} from "lucide-react";
import { toast } from "@/hooks/use-toast";
import { formatDate } from "@/lib/utils";
import {
TIPO_ASISTENCIA_LABELS,
TIPO_ASISTENCIA_COLORS,
type TipoAsistencia,
} from "@/types";
interface Empleado {
id: string;
nombre: string;
apellido: string;
puesto: string;
telefono: string | null;
}
interface AsistenciaEntry {
id: string;
fecha: string;
tipo: TipoAsistencia;
horaEntrada: string | null;
horaSalida: string | null;
latitudEntrada: number | null;
longitudEntrada: number | null;
latitudSalida: number | null;
longitudSalida: number | null;
horasTrabajadas: number | null;
horasExtra: number;
notas: string | null;
motivoAusencia: string | null;
empleado: {
id: string;
nombre: string;
apellido: string;
puesto: string;
};
registradoPor: {
nombre: string;
apellido: string;
};
}
interface ControlAsistenciaProps {
obraId: string;
}
export function ControlAsistencia({ obraId }: ControlAsistenciaProps) {
const router = useRouter();
const [asistencias, setAsistencias] = useState<AsistenciaEntry[]>([]);
const [empleados, setEmpleados] = useState<Empleado[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [selectedDate, setSelectedDate] = useState(
new Date().toISOString().split("T")[0]
);
const [editingEntry, setEditingEntry] = useState<AsistenciaEntry | null>(null);
const [form, setForm] = useState({
empleadoId: "",
tipo: "PRESENTE" as TipoAsistencia,
horaEntrada: "",
horaSalida: "",
horasExtra: "0",
notas: "",
motivoAusencia: "",
});
const [location, setLocation] = useState<{
lat: number | null;
lng: number | null;
}>({ lat: null, lng: null });
// Cargar empleados
useEffect(() => {
const fetchEmpleados = async () => {
try {
const response = await fetch(`/api/asistencia/empleados?obraId=${obraId}`);
if (response.ok) {
const data = await response.json();
setEmpleados(data);
}
} catch (error) {
console.error("Error fetching empleados:", error);
}
};
fetchEmpleados();
}, [obraId]);
// Cargar asistencias del día seleccionado
useEffect(() => {
const fetchAsistencias = async () => {
setIsLoading(true);
try {
const response = await fetch(
`/api/asistencia?obraId=${obraId}&fecha=${selectedDate}`
);
if (response.ok) {
const data = await response.json();
setAsistencias(data);
}
} catch (error) {
console.error("Error fetching asistencias:", error);
} finally {
setIsLoading(false);
}
};
fetchAsistencias();
}, [obraId, selectedDate]);
// Obtener ubicación actual
const getCurrentLocation = () => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
setLocation({
lat: position.coords.latitude,
lng: position.coords.longitude,
});
toast({ title: "Ubicacion capturada" });
},
(error) => {
console.error("Error getting location:", error);
toast({
title: "No se pudo obtener la ubicacion",
variant: "destructive",
});
}
);
}
};
const resetForm = () => {
setForm({
empleadoId: "",
tipo: "PRESENTE",
horaEntrada: "",
horaSalida: "",
horasExtra: "0",
notas: "",
motivoAusencia: "",
});
setLocation({ lat: null, lng: null });
setEditingEntry(null);
};
const handleSubmit = async () => {
if (!form.empleadoId) {
toast({
title: "Error",
description: "Selecciona un empleado",
variant: "destructive",
});
return;
}
setIsSubmitting(true);
try {
const url = editingEntry
? `/api/asistencia/${editingEntry.id}`
: "/api/asistencia";
const method = editingEntry ? "PUT" : "POST";
const body: Record<string, unknown> = {
...form,
fecha: selectedDate,
obraId,
horasExtra: parseFloat(form.horasExtra) || 0,
horaEntrada: form.horaEntrada
? `${selectedDate}T${form.horaEntrada}:00`
: null,
horaSalida: form.horaSalida
? `${selectedDate}T${form.horaSalida}:00`
: null,
};
if (!editingEntry && location.lat && location.lng) {
body.latitudEntrada = location.lat;
body.longitudEntrada = location.lng;
}
const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Error al guardar");
}
const savedEntry = await response.json();
if (editingEntry) {
setAsistencias((prev) =>
prev.map((a) => (a.id === editingEntry.id ? savedEntry : a))
);
toast({ title: "Asistencia actualizada" });
} else {
setAsistencias((prev) => [savedEntry, ...prev]);
toast({ title: "Asistencia registrada" });
}
setIsDialogOpen(false);
resetForm();
router.refresh();
} catch (error) {
toast({
title: "Error",
description:
error instanceof Error ? error.message : "Error al guardar",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
const handleEdit = (entry: AsistenciaEntry) => {
setEditingEntry(entry);
setForm({
empleadoId: entry.empleado.id,
tipo: entry.tipo,
horaEntrada: entry.horaEntrada
? new Date(entry.horaEntrada).toTimeString().slice(0, 5)
: "",
horaSalida: entry.horaSalida
? new Date(entry.horaSalida).toTimeString().slice(0, 5)
: "",
horasExtra: entry.horasExtra.toString(),
notas: entry.notas || "",
motivoAusencia: entry.motivoAusencia || "",
});
setIsDialogOpen(true);
};
const handleDelete = async () => {
if (!deleteId) return;
setIsDeleting(true);
try {
const response = await fetch(`/api/asistencia/${deleteId}`, {
method: "DELETE",
});
if (!response.ok) throw new Error("Error al eliminar");
setAsistencias((prev) => prev.filter((a) => a.id !== deleteId));
toast({ title: "Asistencia eliminada" });
router.refresh();
} catch {
toast({
title: "Error",
description: "No se pudo eliminar la asistencia",
variant: "destructive",
});
} finally {
setIsDeleting(false);
setDeleteId(null);
}
};
const handleQuickEntry = async (empleadoId: string) => {
getCurrentLocation();
const now = new Date();
const horaEntrada = now.toTimeString().slice(0, 5);
try {
const response = await fetch("/api/asistencia", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
empleadoId,
fecha: selectedDate,
obraId,
tipo: "PRESENTE",
horaEntrada: `${selectedDate}T${horaEntrada}:00`,
latitudEntrada: location.lat,
longitudEntrada: location.lng,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
const savedEntry = await response.json();
setAsistencias((prev) => [savedEntry, ...prev]);
toast({ title: "Entrada registrada" });
router.refresh();
} catch (error) {
toast({
title: "Error",
description:
error instanceof Error ? error.message : "Error al registrar entrada",
variant: "destructive",
});
}
};
const handleQuickExit = async (entry: AsistenciaEntry) => {
getCurrentLocation();
const now = new Date();
const horaSalida = now.toTimeString().slice(0, 5);
try {
const response = await fetch(`/api/asistencia/${entry.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
horaSalida: `${selectedDate}T${horaSalida}:00`,
latitudSalida: location.lat,
longitudSalida: location.lng,
}),
});
if (!response.ok) throw new Error("Error al registrar salida");
const savedEntry = await response.json();
setAsistencias((prev) =>
prev.map((a) => (a.id === entry.id ? savedEntry : a))
);
toast({ title: "Salida registrada" });
router.refresh();
} catch {
toast({
title: "Error",
description: "Error al registrar salida",
variant: "destructive",
});
}
};
const prevDay = () => {
const date = new Date(selectedDate);
date.setDate(date.getDate() - 1);
setSelectedDate(date.toISOString().split("T")[0]);
};
const nextDay = () => {
const date = new Date(selectedDate);
date.setDate(date.getDate() + 1);
setSelectedDate(date.toISOString().split("T")[0]);
};
const formatTime = (dateStr: string | null) => {
if (!dateStr) return "-";
return new Date(dateStr).toLocaleTimeString("es-MX", {
hour: "2-digit",
minute: "2-digit",
});
};
// Estadísticas del día
const stats = {
presentes: asistencias.filter((a) => a.tipo === "PRESENTE").length,
ausentes: asistencias.filter((a) => a.tipo === "AUSENTE").length,
retardos: asistencias.filter((a) => a.tipo === "RETARDO").length,
total: empleados.length,
sinRegistro: empleados.length - asistencias.length,
};
// Empleados sin registro de asistencia hoy
const empleadosSinRegistro = empleados.filter(
(emp) => !asistencias.find((a) => a.empleado.id === emp.id)
);
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Control de Asistencia
</CardTitle>
<CardDescription>
Registro de asistencia del personal en obra
</CardDescription>
</div>
<Button
onClick={() => {
resetForm();
setIsDialogOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
Registrar
</Button>
</div>
</CardHeader>
<CardContent>
{/* Navegación de fecha */}
<div className="flex items-center justify-between mb-6">
<Button variant="outline" size="icon" onClick={prevDay}>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
<CalendarDays className="h-5 w-5 text-muted-foreground" />
<Input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="w-auto"
/>
</div>
<Button variant="outline" size="icon" onClick={nextDay}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* Estadísticas del día */}
<div className="grid grid-cols-5 gap-4 mb-6">
<Card className="bg-green-50">
<CardContent className="pt-4 text-center">
<UserCheck className="h-6 w-6 mx-auto text-green-600 mb-1" />
<p className="text-2xl font-bold text-green-600">{stats.presentes}</p>
<p className="text-xs text-green-700">Presentes</p>
</CardContent>
</Card>
<Card className="bg-red-50">
<CardContent className="pt-4 text-center">
<UserX className="h-6 w-6 mx-auto text-red-600 mb-1" />
<p className="text-2xl font-bold text-red-600">{stats.ausentes}</p>
<p className="text-xs text-red-700">Ausentes</p>
</CardContent>
</Card>
<Card className="bg-yellow-50">
<CardContent className="pt-4 text-center">
<Clock className="h-6 w-6 mx-auto text-yellow-600 mb-1" />
<p className="text-2xl font-bold text-yellow-600">{stats.retardos}</p>
<p className="text-xs text-yellow-700">Retardos</p>
</CardContent>
</Card>
<Card className="bg-gray-50">
<CardContent className="pt-4 text-center">
<Users className="h-6 w-6 mx-auto text-gray-600 mb-1" />
<p className="text-2xl font-bold text-gray-600">{stats.sinRegistro}</p>
<p className="text-xs text-gray-700">Sin registro</p>
</CardContent>
</Card>
<Card className="bg-blue-50">
<CardContent className="pt-4 text-center">
<Users className="h-6 w-6 mx-auto text-blue-600 mb-1" />
<p className="text-2xl font-bold text-blue-600">{stats.total}</p>
<p className="text-xs text-blue-700">Total</p>
</CardContent>
</Card>
</div>
{/* Registro rápido para empleados sin asistencia */}
{empleadosSinRegistro.length > 0 && (
<div className="mb-6">
<h4 className="text-sm font-medium mb-2 text-muted-foreground">
Registro rapido de entrada
</h4>
<div className="flex flex-wrap gap-2">
{empleadosSinRegistro.slice(0, 10).map((emp) => (
<Button
key={emp.id}
variant="outline"
size="sm"
onClick={() => handleQuickEntry(emp.id)}
>
<LogIn className="h-3 w-3 mr-1" />
{emp.nombre} {emp.apellido.charAt(0)}.
</Button>
))}
{empleadosSinRegistro.length > 10 && (
<span className="text-sm text-muted-foreground self-center">
+{empleadosSinRegistro.length - 10} mas
</span>
)}
</div>
</div>
)}
{/* Tabla de asistencias */}
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : asistencias.length === 0 ? (
<div className="text-center py-12">
<Users className="mx-auto h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">
No hay registros de asistencia para esta fecha
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Empleado</TableHead>
<TableHead>Puesto</TableHead>
<TableHead>Estado</TableHead>
<TableHead>Entrada</TableHead>
<TableHead>Salida</TableHead>
<TableHead>Horas</TableHead>
<TableHead className="text-right">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{asistencias.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-medium">
{entry.empleado.nombre} {entry.empleado.apellido}
</TableCell>
<TableCell className="text-muted-foreground">
{entry.empleado.puesto}
</TableCell>
<TableCell>
<Badge className={TIPO_ASISTENCIA_COLORS[entry.tipo]}>
{TIPO_ASISTENCIA_LABELS[entry.tipo]}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{formatTime(entry.horaEntrada)}
{entry.latitudEntrada && (
<MapPin className="h-3 w-3 text-green-500" />
)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{entry.horaSalida ? (
<>
{formatTime(entry.horaSalida)}
{entry.latitudSalida && (
<MapPin className="h-3 w-3 text-red-500" />
)}
</>
) : entry.tipo === "PRESENTE" ? (
<Button
variant="outline"
size="sm"
onClick={() => handleQuickExit(entry)}
>
<LogOut className="h-3 w-3 mr-1" />
Salida
</Button>
) : (
"-"
)}
</div>
</TableCell>
<TableCell>
{entry.horasTrabajadas
? `${entry.horasTrabajadas.toFixed(1)}h`
: "-"}
{entry.horasExtra > 0 && (
<span className="text-green-600 ml-1">
+{entry.horasExtra}h
</span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(entry)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteId(entry.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{/* Dialog de formulario */}
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
setIsDialogOpen(open);
if (!open) resetForm();
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editingEntry ? "Editar Asistencia" : "Registrar Asistencia"}
</DialogTitle>
<DialogDescription>
{formatDate(selectedDate)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Empleado *</Label>
<Select
value={form.empleadoId}
onValueChange={(value) =>
setForm({ ...form, empleadoId: value })
}
disabled={!!editingEntry}
>
<SelectTrigger>
<SelectValue placeholder="Seleccionar empleado" />
</SelectTrigger>
<SelectContent>
{empleados.map((emp) => (
<SelectItem key={emp.id} value={emp.id}>
{emp.nombre} {emp.apellido} - {emp.puesto}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Estado *</Label>
<Select
value={form.tipo}
onValueChange={(value) =>
setForm({ ...form, tipo: value as TipoAsistencia })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(TIPO_ASISTENCIA_LABELS).map(
([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
{(form.tipo === "PRESENTE" || form.tipo === "RETARDO") && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Hora Entrada</Label>
<Input
type="time"
value={form.horaEntrada}
onChange={(e) =>
setForm({ ...form, horaEntrada: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Hora Salida</Label>
<Input
type="time"
value={form.horaSalida}
onChange={(e) =>
setForm({ ...form, horaSalida: e.target.value })
}
/>
</div>
</div>
)}
{(form.tipo === "PRESENTE" || form.tipo === "RETARDO") && (
<div className="space-y-2">
<Label>Horas Extra</Label>
<Input
type="number"
min="0"
step="0.5"
value={form.horasExtra}
onChange={(e) =>
setForm({ ...form, horasExtra: e.target.value })
}
/>
</div>
)}
{(form.tipo === "AUSENTE" ||
form.tipo === "PERMISO" ||
form.tipo === "INCAPACIDAD" ||
form.tipo === "VACACIONES") && (
<div className="space-y-2">
<Label>Motivo</Label>
<Textarea
value={form.motivoAusencia}
onChange={(e) =>
setForm({ ...form, motivoAusencia: e.target.value })
}
placeholder="Describe el motivo..."
rows={2}
/>
</div>
)}
<div className="space-y-2">
<Label>Notas</Label>
<Textarea
value={form.notas}
onChange={(e) => setForm({ ...form, notas: e.target.value })}
placeholder="Observaciones adicionales..."
rows={2}
/>
</div>
{!editingEntry && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={getCurrentLocation}
>
<MapPin className="mr-2 h-4 w-4" />
{location.lat
? `Ubicacion: ${location.lat.toFixed(4)}, ${location.lng?.toFixed(4)}`
: "Capturar ubicacion"}
</Button>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsDialogOpen(false);
resetForm();
}}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{editingEntry ? "Guardar" : "Registrar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Dialog de confirmación de eliminación */}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Eliminar registro de asistencia</AlertDialogTitle>
<AlertDialogDescription>
¿Estas seguro de que deseas eliminar este registro? Esta accion
no se puede deshacer.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? "Eliminando..." : "Eliminar"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,842 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
BookOpen,
Plus,
Calendar,
Users,
Thermometer,
Sun,
Cloud,
CloudRain,
CloudLightning,
Wind,
Snowflake,
Loader2,
ChevronLeft,
ChevronRight,
Edit,
Trash2,
AlertTriangle,
Shield,
ClipboardList,
Package,
Truck,
Eye,
} from "lucide-react";
import { toast } from "@/hooks/use-toast";
import { formatDate } from "@/lib/utils";
import { CONDICION_CLIMA_LABELS, type CondicionClima } from "@/types";
interface BitacoraEntry {
id: string;
fecha: string;
clima: CondicionClima;
temperaturaMin: number | null;
temperaturaMax: number | null;
condicionesExtra: string | null;
personalPropio: number;
personalSubcontrato: number;
personalDetalle: string | null;
actividadesRealizadas: string;
actividadesPendientes: string | null;
materialesUtilizados: string | null;
materialesRecibidos: string | null;
equipoUtilizado: string | null;
incidentes: string | null;
observaciones: string | null;
incidentesSeguridad: string | null;
platicaSeguridad: boolean;
temaSeguridad: string | null;
visitasInspeccion: string | null;
registradoPor: {
nombre: string;
apellido: string;
};
createdAt: string;
}
interface BitacoraObraProps {
obraId: string;
bitacoras: BitacoraEntry[];
}
const climaIcons: Record<CondicionClima, React.ReactNode> = {
SOLEADO: <Sun className="h-5 w-5 text-yellow-500" />,
NUBLADO: <Cloud className="h-5 w-5 text-gray-500" />,
PARCIALMENTE_NUBLADO: <Cloud className="h-5 w-5 text-gray-400" />,
LLUVIA_LIGERA: <CloudRain className="h-5 w-5 text-blue-400" />,
LLUVIA_FUERTE: <CloudRain className="h-5 w-5 text-blue-600" />,
TORMENTA: <CloudLightning className="h-5 w-5 text-purple-600" />,
VIENTO_FUERTE: <Wind className="h-5 w-5 text-teal-500" />,
FRIO_EXTREMO: <Snowflake className="h-5 w-5 text-cyan-500" />,
CALOR_EXTREMO: <Thermometer className="h-5 w-5 text-red-500" />,
};
const initialFormState = {
fecha: new Date().toISOString().split("T")[0],
clima: "SOLEADO" as CondicionClima,
temperaturaMin: "",
temperaturaMax: "",
condicionesExtra: "",
personalPropio: "0",
personalSubcontrato: "0",
personalDetalle: "",
actividadesRealizadas: "",
actividadesPendientes: "",
materialesUtilizados: "",
materialesRecibidos: "",
equipoUtilizado: "",
incidentes: "",
observaciones: "",
incidentesSeguridad: "",
platicaSeguridad: false,
temaSeguridad: "",
visitasInspeccion: "",
};
export function BitacoraObra({ obraId, bitacoras: initialBitacoras }: BitacoraObraProps) {
const router = useRouter();
const [bitacoras, setBitacoras] = useState<BitacoraEntry[]>(initialBitacoras);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false);
const [selectedEntry, setSelectedEntry] = useState<BitacoraEntry | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [form, setForm] = useState(initialFormState);
const [editingId, setEditingId] = useState<string | null>(null);
// Cargar bitácoras del mes actual
useEffect(() => {
const fetchBitacoras = async () => {
const mes = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, "0")}`;
try {
const response = await fetch(`/api/bitacora?obraId=${obraId}&mes=${mes}`);
if (response.ok) {
const data = await response.json();
setBitacoras(data);
}
} catch (error) {
console.error("Error fetching bitacoras:", error);
}
};
fetchBitacoras();
}, [obraId, currentMonth]);
const resetForm = () => {
setForm(initialFormState);
setEditingId(null);
};
const handleSubmit = async () => {
if (!form.actividadesRealizadas.trim()) {
toast({
title: "Error",
description: "Las actividades realizadas son requeridas",
variant: "destructive",
});
return;
}
setIsSubmitting(true);
try {
const url = editingId ? `/api/bitacora/${editingId}` : "/api/bitacora";
const method = editingId ? "PUT" : "POST";
const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...form,
temperaturaMin: form.temperaturaMin ? parseFloat(form.temperaturaMin) : null,
temperaturaMax: form.temperaturaMax ? parseFloat(form.temperaturaMax) : null,
personalPropio: parseInt(form.personalPropio) || 0,
personalSubcontrato: parseInt(form.personalSubcontrato) || 0,
obraId,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Error al guardar");
}
const savedEntry = await response.json();
if (editingId) {
setBitacoras((prev) =>
prev.map((b) => (b.id === editingId ? savedEntry : b))
);
toast({ title: "Bitácora actualizada exitosamente" });
} else {
setBitacoras((prev) => [savedEntry, ...prev]);
toast({ title: "Bitácora registrada exitosamente" });
}
setIsDialogOpen(false);
resetForm();
router.refresh();
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "Error al guardar la bitácora",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
const handleEdit = (entry: BitacoraEntry) => {
setEditingId(entry.id);
setForm({
fecha: entry.fecha.split("T")[0],
clima: entry.clima,
temperaturaMin: entry.temperaturaMin?.toString() || "",
temperaturaMax: entry.temperaturaMax?.toString() || "",
condicionesExtra: entry.condicionesExtra || "",
personalPropio: entry.personalPropio.toString(),
personalSubcontrato: entry.personalSubcontrato.toString(),
personalDetalle: entry.personalDetalle || "",
actividadesRealizadas: entry.actividadesRealizadas,
actividadesPendientes: entry.actividadesPendientes || "",
materialesUtilizados: entry.materialesUtilizados || "",
materialesRecibidos: entry.materialesRecibidos || "",
equipoUtilizado: entry.equipoUtilizado || "",
incidentes: entry.incidentes || "",
observaciones: entry.observaciones || "",
incidentesSeguridad: entry.incidentesSeguridad || "",
platicaSeguridad: entry.platicaSeguridad,
temaSeguridad: entry.temaSeguridad || "",
visitasInspeccion: entry.visitasInspeccion || "",
});
setIsDialogOpen(true);
};
const handleDelete = async () => {
if (!deleteId) return;
setIsDeleting(true);
try {
const response = await fetch(`/api/bitacora/${deleteId}`, {
method: "DELETE",
});
if (!response.ok) throw new Error("Error al eliminar");
setBitacoras((prev) => prev.filter((b) => b.id !== deleteId));
toast({ title: "Bitácora eliminada exitosamente" });
router.refresh();
} catch {
toast({
title: "Error",
description: "No se pudo eliminar la bitácora",
variant: "destructive",
});
} finally {
setIsDeleting(false);
setDeleteId(null);
}
};
const prevMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1));
};
const nextMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1));
};
const monthNames = [
"Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio",
"Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"
];
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<BookOpen className="h-5 w-5" />
Bitácora de Obra
</CardTitle>
<CardDescription>
Registro diario de actividades, personal y condiciones
</CardDescription>
</div>
<Button onClick={() => { resetForm(); setIsDialogOpen(true); }}>
<Plus className="mr-2 h-4 w-4" />
Nueva Entrada
</Button>
</div>
</CardHeader>
<CardContent>
{/* Navegación de mes */}
<div className="flex items-center justify-between mb-6">
<Button variant="outline" size="icon" onClick={prevMonth}>
<ChevronLeft className="h-4 w-4" />
</Button>
<h3 className="text-lg font-semibold">
{monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
</h3>
<Button variant="outline" size="icon" onClick={nextMonth}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* Lista de bitácoras */}
{bitacoras.length === 0 ? (
<div className="text-center py-12">
<BookOpen className="mx-auto h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No hay registros este mes</p>
<p className="text-sm text-muted-foreground">
Registra la primera entrada de la bitácora
</p>
</div>
) : (
<div className="space-y-4">
{bitacoras.map((entry) => (
<Card key={entry.id} className="border-l-4 border-l-primary">
<CardContent className="pt-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="flex flex-col items-center">
<span className="text-2xl font-bold">
{new Date(entry.fecha).getDate()}
</span>
<span className="text-xs text-muted-foreground">
{monthNames[new Date(entry.fecha).getMonth()].slice(0, 3)}
</span>
</div>
<div className="space-y-2">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
{climaIcons[entry.clima]}
<span className="text-sm">{CONDICION_CLIMA_LABELS[entry.clima]}</span>
</div>
{(entry.temperaturaMin || entry.temperaturaMax) && (
<span className="text-sm text-muted-foreground">
{entry.temperaturaMin && `${entry.temperaturaMin}°`}
{entry.temperaturaMin && entry.temperaturaMax && " - "}
{entry.temperaturaMax && `${entry.temperaturaMax}°`}
</span>
)}
<div className="flex items-center gap-1 text-sm">
<Users className="h-4 w-4" />
<span>{entry.personalPropio + entry.personalSubcontrato} personas</span>
</div>
</div>
<p className="text-sm line-clamp-2">{entry.actividadesRealizadas}</p>
<div className="flex flex-wrap gap-2">
{entry.incidentes && (
<Badge variant="destructive" className="text-xs">
<AlertTriangle className="h-3 w-3 mr-1" />
Incidente
</Badge>
)}
{entry.platicaSeguridad && (
<Badge variant="secondary" className="text-xs">
<Shield className="h-3 w-3 mr-1" />
Plática seguridad
</Badge>
)}
{entry.visitasInspeccion && (
<Badge variant="outline" className="text-xs">
<Eye className="h-3 w-3 mr-1" />
Visita
</Badge>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => {
setSelectedEntry(entry);
setIsViewDialogOpen(true);
}}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(entry)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteId(entry.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
Registrado por {entry.registradoPor.nombre} {entry.registradoPor.apellido}
</p>
</CardContent>
</Card>
))}
</div>
)}
{/* Dialog de formulario */}
<Dialog open={isDialogOpen} onOpenChange={(open) => { setIsDialogOpen(open); if (!open) resetForm(); }}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingId ? "Editar Bitácora" : "Nueva Entrada de Bitácora"}
</DialogTitle>
<DialogDescription>
Registra las actividades y condiciones del día
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Fecha y Clima */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Fecha *</Label>
<Input
type="date"
value={form.fecha}
onChange={(e) => setForm({ ...form, fecha: e.target.value })}
disabled={!!editingId}
/>
</div>
<div className="space-y-2">
<Label>Clima *</Label>
<Select
value={form.clima}
onValueChange={(value) => setForm({ ...form, clima: value as CondicionClima })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(CONDICION_CLIMA_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
<div className="flex items-center gap-2">
{climaIcons[value as CondicionClima]}
{label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Temperatura */}
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Temp. Mínima (°C)</Label>
<Input
type="number"
value={form.temperaturaMin}
onChange={(e) => setForm({ ...form, temperaturaMin: e.target.value })}
placeholder="15"
/>
</div>
<div className="space-y-2">
<Label>Temp. Máxima (°C)</Label>
<Input
type="number"
value={form.temperaturaMax}
onChange={(e) => setForm({ ...form, temperaturaMax: e.target.value })}
placeholder="28"
/>
</div>
<div className="space-y-2">
<Label>Condiciones Extra</Label>
<Input
value={form.condicionesExtra}
onChange={(e) => setForm({ ...form, condicionesExtra: e.target.value })}
placeholder="Humedad alta..."
/>
</div>
</div>
<Separator />
{/* Personal */}
<div>
<h4 className="font-medium mb-3 flex items-center gap-2">
<Users className="h-4 w-4" />
Personal en Obra
</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Personal Propio</Label>
<Input
type="number"
min="0"
value={form.personalPropio}
onChange={(e) => setForm({ ...form, personalPropio: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Personal Subcontrato</Label>
<Input
type="number"
min="0"
value={form.personalSubcontrato}
onChange={(e) => setForm({ ...form, personalSubcontrato: e.target.value })}
/>
</div>
</div>
<div className="space-y-2 mt-3">
<Label>Detalle del Personal</Label>
<Textarea
value={form.personalDetalle}
onChange={(e) => setForm({ ...form, personalDetalle: e.target.value })}
placeholder="3 albañiles, 2 fierreros, 1 electricista..."
rows={2}
/>
</div>
</div>
<Separator />
{/* Actividades */}
<div>
<h4 className="font-medium mb-3 flex items-center gap-2">
<ClipboardList className="h-4 w-4" />
Actividades
</h4>
<div className="space-y-3">
<div className="space-y-2">
<Label>Actividades Realizadas *</Label>
<Textarea
value={form.actividadesRealizadas}
onChange={(e) => setForm({ ...form, actividadesRealizadas: e.target.value })}
placeholder="Describe las actividades realizadas durante el día..."
rows={4}
/>
</div>
<div className="space-y-2">
<Label>Actividades Pendientes</Label>
<Textarea
value={form.actividadesPendientes}
onChange={(e) => setForm({ ...form, actividadesPendientes: e.target.value })}
placeholder="Actividades que quedaron pendientes..."
rows={2}
/>
</div>
</div>
</div>
<Separator />
{/* Materiales y Equipo */}
<div>
<h4 className="font-medium mb-3 flex items-center gap-2">
<Package className="h-4 w-4" />
Materiales y Equipo
</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Materiales Utilizados</Label>
<Textarea
value={form.materialesUtilizados}
onChange={(e) => setForm({ ...form, materialesUtilizados: e.target.value })}
placeholder="50 sacos cemento, 10m3 grava..."
rows={2}
/>
</div>
<div className="space-y-2">
<Label>Materiales Recibidos</Label>
<Textarea
value={form.materialesRecibidos}
onChange={(e) => setForm({ ...form, materialesRecibidos: e.target.value })}
placeholder="Entrega de varilla 3/8..."
rows={2}
/>
</div>
</div>
<div className="space-y-2 mt-3">
<Label>Equipo/Maquinaria Utilizado</Label>
<Textarea
value={form.equipoUtilizado}
onChange={(e) => setForm({ ...form, equipoUtilizado: e.target.value })}
placeholder="Retroexcavadora, revolvedora..."
rows={2}
/>
</div>
</div>
<Separator />
{/* Seguridad */}
<div>
<h4 className="font-medium mb-3 flex items-center gap-2">
<Shield className="h-4 w-4" />
Seguridad
</h4>
<div className="flex items-center gap-4 mb-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.platicaSeguridad}
onChange={(e) => setForm({ ...form, platicaSeguridad: e.target.checked })}
className="rounded"
/>
<span className="text-sm">Se realizó plática de seguridad</span>
</label>
</div>
{form.platicaSeguridad && (
<div className="space-y-2 mb-3">
<Label>Tema de la Plática</Label>
<Input
value={form.temaSeguridad}
onChange={(e) => setForm({ ...form, temaSeguridad: e.target.value })}
placeholder="Uso correcto de EPP..."
/>
</div>
)}
<div className="space-y-2">
<Label>Incidentes de Seguridad</Label>
<Textarea
value={form.incidentesSeguridad}
onChange={(e) => setForm({ ...form, incidentesSeguridad: e.target.value })}
placeholder="Describir incidentes de seguridad si los hubo..."
rows={2}
/>
</div>
</div>
<Separator />
{/* Incidentes y Observaciones */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Incidentes Generales</Label>
<Textarea
value={form.incidentes}
onChange={(e) => setForm({ ...form, incidentes: e.target.value })}
placeholder="Retrasos, problemas técnicos..."
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Observaciones</Label>
<Textarea
value={form.observaciones}
onChange={(e) => setForm({ ...form, observaciones: e.target.value })}
placeholder="Notas adicionales..."
rows={3}
/>
</div>
</div>
{/* Visitas */}
<div className="space-y-2">
<Label>Visitas de Inspección</Label>
<Textarea
value={form.visitasInspeccion}
onChange={(e) => setForm({ ...form, visitasInspeccion: e.target.value })}
placeholder="Ing. Pérez - Revisión estructural..."
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setIsDialogOpen(false); resetForm(); }} disabled={isSubmitting}>
Cancelar
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editingId ? "Guardar Cambios" : "Registrar Bitácora"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Dialog de vista detallada */}
<Dialog open={isViewDialogOpen} onOpenChange={setIsViewDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
Bitácora del {selectedEntry && formatDate(selectedEntry.fecha)}
</DialogTitle>
</DialogHeader>
{selectedEntry && (
<div className="space-y-4">
{/* Clima */}
<div className="flex items-center gap-4 p-4 bg-muted rounded-lg">
{climaIcons[selectedEntry.clima]}
<div>
<p className="font-medium">{CONDICION_CLIMA_LABELS[selectedEntry.clima]}</p>
{(selectedEntry.temperaturaMin || selectedEntry.temperaturaMax) && (
<p className="text-sm text-muted-foreground">
Temperatura: {selectedEntry.temperaturaMin}° - {selectedEntry.temperaturaMax}°
</p>
)}
</div>
</div>
{/* Personal */}
<div>
<h4 className="font-medium mb-2 flex items-center gap-2">
<Users className="h-4 w-4" />
Personal ({selectedEntry.personalPropio + selectedEntry.personalSubcontrato} personas)
</h4>
<p className="text-sm">
Propio: {selectedEntry.personalPropio} | Subcontrato: {selectedEntry.personalSubcontrato}
</p>
{selectedEntry.personalDetalle && (
<p className="text-sm text-muted-foreground mt-1">{selectedEntry.personalDetalle}</p>
)}
</div>
{/* Actividades */}
<div>
<h4 className="font-medium mb-2">Actividades Realizadas</h4>
<p className="text-sm whitespace-pre-wrap">{selectedEntry.actividadesRealizadas}</p>
</div>
{selectedEntry.actividadesPendientes && (
<div>
<h4 className="font-medium mb-2">Actividades Pendientes</h4>
<p className="text-sm whitespace-pre-wrap">{selectedEntry.actividadesPendientes}</p>
</div>
)}
{/* Materiales */}
{(selectedEntry.materialesUtilizados || selectedEntry.materialesRecibidos) && (
<div className="grid grid-cols-2 gap-4">
{selectedEntry.materialesUtilizados && (
<div>
<h4 className="font-medium mb-2">Materiales Utilizados</h4>
<p className="text-sm whitespace-pre-wrap">{selectedEntry.materialesUtilizados}</p>
</div>
)}
{selectedEntry.materialesRecibidos && (
<div>
<h4 className="font-medium mb-2">Materiales Recibidos</h4>
<p className="text-sm whitespace-pre-wrap">{selectedEntry.materialesRecibidos}</p>
</div>
)}
</div>
)}
{/* Seguridad */}
{(selectedEntry.platicaSeguridad || selectedEntry.incidentesSeguridad) && (
<div className="p-4 bg-yellow-50 rounded-lg">
<h4 className="font-medium mb-2 flex items-center gap-2">
<Shield className="h-4 w-4" />
Seguridad
</h4>
{selectedEntry.platicaSeguridad && (
<p className="text-sm">
Plática de seguridad: {selectedEntry.temaSeguridad || "Sí"}
</p>
)}
{selectedEntry.incidentesSeguridad && (
<p className="text-sm text-red-600 mt-2">
Incidentes: {selectedEntry.incidentesSeguridad}
</p>
)}
</div>
)}
{/* Incidentes */}
{selectedEntry.incidentes && (
<div className="p-4 bg-red-50 rounded-lg">
<h4 className="font-medium mb-2 flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Incidentes
</h4>
<p className="text-sm whitespace-pre-wrap">{selectedEntry.incidentes}</p>
</div>
)}
{/* Observaciones */}
{selectedEntry.observaciones && (
<div>
<h4 className="font-medium mb-2">Observaciones</h4>
<p className="text-sm whitespace-pre-wrap">{selectedEntry.observaciones}</p>
</div>
)}
<p className="text-xs text-muted-foreground pt-4 border-t">
Registrado por {selectedEntry.registradoPor.nombre} {selectedEntry.registradoPor.apellido} el{" "}
{formatDate(selectedEntry.createdAt)}
</p>
</div>
)}
</DialogContent>
</Dialog>
{/* Dialog de confirmación de eliminación */}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Eliminar entrada de bitácora</AlertDialogTitle>
<AlertDialogDescription>
¿Estás seguro de que deseas eliminar esta entrada? Esta acción no se puede deshacer.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? "Eliminando..." : "Eliminar"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,510 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
UserPlus,
Key,
Link as LinkIcon,
Copy,
Check,
Trash2,
RefreshCw,
Eye,
EyeOff,
Loader2,
} from "lucide-react";
import { formatDate } from "@/lib/utils";
interface ClienteAcceso {
id: string;
email: string;
token: string | null;
tokenExpira: string | null;
activo: boolean;
ultimoAcceso: string | null;
tienePassword: boolean;
verFotos: boolean;
verAvances: boolean;
verGastos: boolean;
verDocumentos: boolean;
descargarPDF: boolean;
cliente: {
id: string;
nombre: string;
};
}
interface Props {
clienteId: string;
clienteNombre: string;
clienteEmail?: string | null;
}
export function GestionarAccesoCliente({ clienteId, clienteNombre, clienteEmail }: Props) {
const [accesos, setAccesos] = useState<ClienteAcceso[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [copied, setCopied] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [newAccessUrl, setNewAccessUrl] = useState<string | null>(null);
const [formData, setFormData] = useState({
email: clienteEmail || "",
password: "",
usarToken: true,
tokenExpiraDias: 30,
verFotos: true,
verAvances: true,
verGastos: false,
verDocumentos: true,
descargarPDF: true,
});
useEffect(() => {
fetchAccesos();
}, [clienteId]);
const fetchAccesos = async () => {
try {
const res = await fetch(`/api/clientes-acceso?clienteId=${clienteId}`);
if (res.ok) {
const data = await res.json();
setAccesos(data);
}
} catch (error) {
console.error("Error:", error);
} finally {
setLoading(false);
}
};
const handleCreateAcceso = async () => {
setSaving(true);
try {
const res = await fetch("/api/clientes-acceso", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clienteId,
...formData,
}),
});
const data = await res.json();
if (!res.ok) {
alert(data.error || "Error al crear acceso");
return;
}
if (data.accessUrl) {
setNewAccessUrl(data.accessUrl);
}
await fetchAccesos();
setDialogOpen(false);
setFormData({
email: "",
password: "",
usarToken: true,
tokenExpiraDias: 30,
verFotos: true,
verAvances: true,
verGastos: false,
verDocumentos: true,
descargarPDF: true,
});
} catch (error) {
console.error("Error:", error);
alert("Error al crear acceso");
} finally {
setSaving(false);
}
};
const handleToggleActivo = async (acceso: ClienteAcceso) => {
try {
const res = await fetch(`/api/clientes-acceso/${acceso.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ activo: !acceso.activo }),
});
if (res.ok) {
await fetchAccesos();
}
} catch (error) {
console.error("Error:", error);
}
};
const handleRegenerarToken = async (accesoId: string) => {
try {
const res = await fetch(`/api/clientes-acceso/${accesoId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ regenerarToken: true, tokenExpiraDias: 30 }),
});
const data = await res.json();
if (res.ok && data.accessUrl) {
setNewAccessUrl(data.accessUrl);
await fetchAccesos();
}
} catch (error) {
console.error("Error:", error);
}
};
const handleDeleteAcceso = async (accesoId: string) => {
try {
const res = await fetch(`/api/clientes-acceso/${accesoId}`, {
method: "DELETE",
});
if (res.ok) {
await fetchAccesos();
}
} catch (error) {
console.error("Error:", error);
}
};
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text);
setCopied(id);
setTimeout(() => setCopied(null), 2000);
};
const getAccessUrl = (token: string) => {
const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
return `${baseUrl}/portal?token=${token}`;
};
if (loading) {
return (
<Card>
<CardContent className="py-8 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">Acceso al Portal</CardTitle>
<CardDescription>
Gestiona el acceso de {clienteNombre} al portal de cliente
</CardDescription>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button size="sm">
<UserPlus className="h-4 w-4 mr-2" />
Crear Acceso
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Crear Acceso al Portal</DialogTitle>
<DialogDescription>
Configura el acceso para que {clienteNombre} pueda ver sus obras
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="email">Email de acceso</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
placeholder="cliente@email.com"
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="usarToken">Acceso con enlace directo</Label>
<Switch
id="usarToken"
checked={formData.usarToken}
onCheckedChange={(checked) =>
setFormData({ ...formData, usarToken: checked })
}
/>
</div>
{!formData.usarToken && (
<div className="space-y-2">
<Label htmlFor="password">Contrasena</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
placeholder="Minimo 6 caracteres"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
)}
<div className="border-t pt-4">
<p className="text-sm font-medium mb-3">Permisos</p>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="verFotos">Ver fotos</Label>
<Switch
id="verFotos"
checked={formData.verFotos}
onCheckedChange={(checked) =>
setFormData({ ...formData, verFotos: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="verAvances">Ver avances</Label>
<Switch
id="verAvances"
checked={formData.verAvances}
onCheckedChange={(checked) =>
setFormData({ ...formData, verAvances: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="verGastos">Ver finanzas</Label>
<Switch
id="verGastos"
checked={formData.verGastos}
onCheckedChange={(checked) =>
setFormData({ ...formData, verGastos: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="descargarPDF">Descargar PDF</Label>
<Switch
id="descargarPDF"
checked={formData.descargarPDF}
onCheckedChange={(checked) =>
setFormData({ ...formData, descargarPDF: checked })
}
/>
</div>
</div>
</div>
<Button
onClick={handleCreateAcceso}
disabled={saving || !formData.email}
className="w-full"
>
{saving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creando...
</>
) : (
"Crear Acceso"
)}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{accesos.length === 0 ? (
<p className="text-center text-muted-foreground py-4">
Este cliente no tiene acceso al portal
</p>
) : (
<div className="space-y-3">
{accesos.map((acceso) => (
<div
key={acceso.id}
className="flex items-center justify-between p-3 rounded-lg border"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium">{acceso.email}</span>
<Badge variant={acceso.activo ? "default" : "secondary"}>
{acceso.activo ? "Activo" : "Inactivo"}
</Badge>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
{acceso.token && (
<span className="flex items-center gap-1">
<Key className="h-3 w-3" />
Acceso con enlace
</span>
)}
{acceso.tienePassword && (
<span className="flex items-center gap-1">
<Key className="h-3 w-3" />
Acceso con contrasena
</span>
)}
{acceso.ultimoAcceso && (
<span>
Ultimo acceso: {formatDate(new Date(acceso.ultimoAcceso))}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{acceso.token && (
<Button
variant="outline"
size="sm"
onClick={() =>
copyToClipboard(getAccessUrl(acceso.token!), acceso.id)
}
>
{copied === acceso.id ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleRegenerarToken(acceso.id)}
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleToggleActivo(acceso)}
>
{acceso.activo ? "Desactivar" : "Activar"}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm">
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Eliminar acceso</AlertDialogTitle>
<AlertDialogDescription>
Esta accion eliminara permanentemente el acceso de{" "}
{acceso.email} al portal.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteAcceso(acceso.id)}
className="bg-red-600 hover:bg-red-700"
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Modal para mostrar URL de acceso */}
<Dialog open={!!newAccessUrl} onOpenChange={() => setNewAccessUrl(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Enlace de Acceso Generado</DialogTitle>
<DialogDescription>
Comparte este enlace con el cliente para que acceda al portal
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-2">
<Input value={newAccessUrl || ""} readOnly className="font-mono text-sm" />
<Button
variant="outline"
onClick={() => {
if (newAccessUrl) {
copyToClipboard(newAccessUrl, "new-url");
}
}}
>
{copied === "new-url" ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-sm text-muted-foreground">
Este enlace permite acceso directo al portal sin necesidad de contrasena.
Puedes regenerarlo en cualquier momento desde esta pantalla.
</p>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,615 @@
"use client";
import { useState, useCallback, useRef } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Camera,
Upload,
X,
MapPin,
Calendar,
User,
Trash2,
ZoomIn,
Loader2,
ImageIcon,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { toast } from "@/hooks/use-toast";
import { formatDate } from "@/lib/utils";
interface Fase {
id: string;
nombre: string;
}
interface Foto {
id: string;
url: string;
thumbnail: string | null;
titulo: string | null;
descripcion: string | null;
fechaCaptura: string;
latitud: number | null;
longitud: number | null;
direccionGeo: string | null;
subidoPor: {
nombre: string;
apellido: string;
};
fase: {
nombre: string;
} | null;
}
interface GaleriaFotosProps {
obraId: string;
fotos: Foto[];
fases: Fase[];
}
export function GaleriaFotos({ obraId, fotos: initialFotos, fases }: GaleriaFotosProps) {
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const [fotos, setFotos] = useState<Foto[]>(initialFotos);
const [isUploading, setIsUploading] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [currentFotoIndex, setCurrentFotoIndex] = useState(0);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Estado para el formulario de subida
const [uploadForm, setUploadForm] = useState({
file: null as File | null,
titulo: "",
descripcion: "",
faseId: "",
latitud: null as number | null,
longitud: null as number | null,
});
const [preview, setPreview] = useState<string | null>(null);
// Obtener ubicación actual
const getLocation = useCallback(() => {
if (!navigator.geolocation) {
toast({
title: "Error",
description: "Geolocalización no disponible en este navegador",
variant: "destructive",
});
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
setUploadForm((prev) => ({
...prev,
latitud: position.coords.latitude,
longitud: position.coords.longitude,
}));
toast({
title: "Ubicación obtenida",
description: `${position.coords.latitude.toFixed(6)}, ${position.coords.longitude.toFixed(6)}`,
});
},
(error) => {
toast({
title: "Error de ubicación",
description: error.message,
variant: "destructive",
});
}
);
}, []);
// Manejar selección de archivo
const handleFileSelect = useCallback((file: File) => {
if (!file.type.startsWith("image/")) {
toast({
title: "Error",
description: "Solo se permiten imágenes",
variant: "destructive",
});
return;
}
if (file.size > 10 * 1024 * 1024) {
toast({
title: "Error",
description: "El archivo es muy grande. Máximo 10MB",
variant: "destructive",
});
return;
}
setUploadForm((prev) => ({ ...prev, file }));
setPreview(URL.createObjectURL(file));
setUploadDialogOpen(true);
// Intentar obtener ubicación automáticamente
getLocation();
}, [getLocation]);
// Drag & Drop handlers
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) {
handleFileSelect(file);
}
}, [handleFileSelect]);
// Subir foto
const handleUpload = async () => {
if (!uploadForm.file) return;
setIsUploading(true);
try {
const formData = new FormData();
formData.append("file", uploadForm.file);
formData.append("obraId", obraId);
if (uploadForm.titulo) formData.append("titulo", uploadForm.titulo);
if (uploadForm.descripcion) formData.append("descripcion", uploadForm.descripcion);
if (uploadForm.faseId) formData.append("faseId", uploadForm.faseId);
if (uploadForm.latitud) formData.append("latitud", uploadForm.latitud.toString());
if (uploadForm.longitud) formData.append("longitud", uploadForm.longitud.toString());
const response = await fetch("/api/fotos", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Error al subir la foto");
}
const newFoto = await response.json();
setFotos((prev) => [newFoto, ...prev]);
toast({ title: "Foto subida exitosamente" });
resetUploadForm();
router.refresh();
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "Error al subir la foto",
variant: "destructive",
});
} finally {
setIsUploading(false);
}
};
// Eliminar foto
const handleDelete = async () => {
if (!deleteId) return;
setIsDeleting(true);
try {
const response = await fetch(`/api/fotos/${deleteId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Error al eliminar la foto");
}
setFotos((prev) => prev.filter((f) => f.id !== deleteId));
toast({ title: "Foto eliminada exitosamente" });
router.refresh();
} catch {
toast({
title: "Error",
description: "No se pudo eliminar la foto",
variant: "destructive",
});
} finally {
setIsDeleting(false);
setDeleteId(null);
}
};
// Reset formulario
const resetUploadForm = () => {
setUploadForm({
file: null,
titulo: "",
descripcion: "",
faseId: "",
latitud: null,
longitud: null,
});
setPreview(null);
setUploadDialogOpen(false);
};
// Navegación en lightbox
const nextFoto = () => {
setCurrentFotoIndex((prev) => (prev + 1) % fotos.length);
};
const prevFoto = () => {
setCurrentFotoIndex((prev) => (prev - 1 + fotos.length) % fotos.length);
};
const openLightbox = (index: number) => {
setCurrentFotoIndex(index);
setLightboxOpen(true);
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Camera className="h-5 w-5" />
Fotos de Avance
</CardTitle>
<CardDescription>
{fotos.length} {fotos.length === 1 ? "foto" : "fotos"} del proyecto
</CardDescription>
</div>
<Button onClick={() => fileInputRef.current?.click()}>
<Upload className="mr-2 h-4 w-4" />
Subir Foto
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => e.target.files?.[0] && handleFileSelect(e.target.files[0])}
/>
</div>
</CardHeader>
<CardContent>
{/* Zona de Drag & Drop */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`mb-6 border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
}`}
>
<ImageIcon className="mx-auto h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-sm text-muted-foreground">
Arrastra una imagen aquí o{" "}
<button
type="button"
className="text-primary hover:underline"
onClick={() => fileInputRef.current?.click()}
>
haz clic para seleccionar
</button>
</p>
<p className="text-xs text-muted-foreground mt-1">
JPG, PNG o WebP. Máximo 10MB
</p>
</div>
{/* Galería */}
{fotos.length === 0 ? (
<div className="text-center py-12">
<Camera className="mx-auto h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No hay fotos registradas</p>
<p className="text-sm text-muted-foreground">
Sube la primera foto del avance de la obra
</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{fotos.map((foto, index) => (
<div
key={foto.id}
className="group relative aspect-square rounded-lg overflow-hidden bg-muted cursor-pointer"
onClick={() => openLightbox(index)}
>
<Image
src={foto.thumbnail || foto.url}
alt={foto.titulo || "Foto de avance"}
fill
className="object-cover transition-transform group-hover:scale-105"
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 33vw, 25vw"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors" />
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<ZoomIn className="h-8 w-8 text-white" />
</div>
{foto.latitud && foto.longitud && (
<div className="absolute top-2 left-2 bg-black/50 rounded-full p-1">
<MapPin className="h-4 w-4 text-white" />
</div>
)}
<button
type="button"
className="absolute top-2 right-2 bg-red-500/80 hover:bg-red-500 rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
setDeleteId(foto.id);
}}
>
<Trash2 className="h-4 w-4 text-white" />
</button>
{foto.titulo && (
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/70 to-transparent">
<p className="text-white text-sm truncate">{foto.titulo}</p>
</div>
)}
</div>
))}
</div>
)}
{/* Dialog de subida */}
<Dialog open={uploadDialogOpen} onOpenChange={resetUploadForm}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Subir Foto de Avance</DialogTitle>
<DialogDescription>
Agrega información sobre la foto
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{preview && (
<div className="relative aspect-video rounded-lg overflow-hidden bg-muted">
<Image
src={preview}
alt="Preview"
fill
className="object-contain"
/>
</div>
)}
<div className="space-y-2">
<Label>Título (opcional)</Label>
<Input
value={uploadForm.titulo}
onChange={(e) =>
setUploadForm((prev) => ({ ...prev, titulo: e.target.value }))
}
placeholder="Ej: Avance de cimentación"
/>
</div>
<div className="space-y-2">
<Label>Descripción (opcional)</Label>
<Textarea
value={uploadForm.descripcion}
onChange={(e) =>
setUploadForm((prev) => ({ ...prev, descripcion: e.target.value }))
}
placeholder="Describe el avance mostrado en la foto..."
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Fase de la obra (opcional)</Label>
<Select
value={uploadForm.faseId}
onValueChange={(value) =>
setUploadForm((prev) => ({ ...prev, faseId: value }))
}
>
<SelectTrigger>
<SelectValue placeholder="Seleccionar fase" />
</SelectTrigger>
<SelectContent>
{fases.map((fase) => (
<SelectItem key={fase.id} value={fase.id}>
{fase.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Ubicación</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={getLocation}
>
<MapPin className="mr-1 h-3 w-3" />
Obtener ubicación
</Button>
</div>
{uploadForm.latitud && uploadForm.longitud ? (
<p className="text-sm text-muted-foreground">
{uploadForm.latitud.toFixed(6)}, {uploadForm.longitud.toFixed(6)}
</p>
) : (
<p className="text-sm text-muted-foreground">
No se ha capturado la ubicación
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={resetUploadForm} disabled={isUploading}>
Cancelar
</Button>
<Button onClick={handleUpload} disabled={isUploading || !uploadForm.file}>
{isUploading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Subir Foto
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Lightbox */}
<Dialog open={lightboxOpen} onOpenChange={setLightboxOpen}>
<DialogContent className="max-w-4xl p-0 bg-black/95">
<div className="relative">
<button
type="button"
className="absolute top-4 right-4 z-10 bg-black/50 hover:bg-black/70 rounded-full p-2"
onClick={() => setLightboxOpen(false)}
>
<X className="h-6 w-6 text-white" />
</button>
{fotos.length > 1 && (
<>
<button
type="button"
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 bg-black/50 hover:bg-black/70 rounded-full p-2"
onClick={prevFoto}
>
<ChevronLeft className="h-6 w-6 text-white" />
</button>
<button
type="button"
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 bg-black/50 hover:bg-black/70 rounded-full p-2"
onClick={nextFoto}
>
<ChevronRight className="h-6 w-6 text-white" />
</button>
</>
)}
<div className="relative aspect-[4/3] md:aspect-video">
{fotos[currentFotoIndex] && (
<Image
src={fotos[currentFotoIndex].url}
alt={fotos[currentFotoIndex].titulo || "Foto de avance"}
fill
className="object-contain"
sizes="100vw"
priority
/>
)}
</div>
{fotos[currentFotoIndex] && (
<div className="p-4 bg-black/80 text-white">
<div className="flex items-start justify-between">
<div>
{fotos[currentFotoIndex].titulo && (
<h3 className="font-semibold text-lg">
{fotos[currentFotoIndex].titulo}
</h3>
)}
{fotos[currentFotoIndex].descripcion && (
<p className="text-gray-300 mt-1">
{fotos[currentFotoIndex].descripcion}
</p>
)}
</div>
<span className="text-sm text-gray-400">
{currentFotoIndex + 1} / {fotos.length}
</span>
</div>
<div className="flex flex-wrap gap-4 mt-3 text-sm text-gray-400">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDate(fotos[currentFotoIndex].fechaCaptura)}
</span>
<span className="flex items-center gap-1">
<User className="h-4 w-4" />
{fotos[currentFotoIndex].subidoPor.nombre}{" "}
{fotos[currentFotoIndex].subidoPor.apellido}
</span>
{fotos[currentFotoIndex].fase && (
<span>Fase: {fotos[currentFotoIndex].fase.nombre}</span>
)}
{fotos[currentFotoIndex].latitud && fotos[currentFotoIndex].longitud && (
<a
href={`https://maps.google.com/?q=${fotos[currentFotoIndex].latitud},${fotos[currentFotoIndex].longitud}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-blue-400 hover:underline"
>
<MapPin className="h-4 w-4" />
Ver en mapa
</a>
)}
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Dialog de confirmación de eliminación */}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Eliminar foto</AlertDialogTitle>
<AlertDialogDescription>
¿Estás seguro de que deseas eliminar esta foto? Esta acción no se
puede deshacer.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? "Eliminando..." : "Eliminar"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,359 @@
"use client";
import { useState, useMemo } from "react";
import { Gantt, Task, ViewMode } from "gantt-task-react";
import "gantt-task-react/dist/index.css";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Calendar, ZoomIn, ZoomOut, ListTree } from "lucide-react";
import { formatDate } from "@/lib/utils";
interface Fase {
id: string;
nombre: string;
descripcion: string | null;
orden: number;
fechaInicio: string | null;
fechaFin: string | null;
porcentajeAvance: number;
tareas: Tarea[];
}
interface Tarea {
id: string;
nombre: string;
descripcion: string | null;
estado: string;
fechaInicio: string | null;
fechaFin: string | null;
porcentajeAvance: number;
}
interface Props {
obraId: string;
obraNombre: string;
fechaInicioObra: string | null;
fechaFinObra: string | null;
fases: Fase[];
onTaskUpdate?: (taskId: string, start: Date, end: Date) => void;
}
const ESTADO_COLORS: Record<string, string> = {
PENDIENTE: "#9CA3AF",
EN_PROGRESO: "#3B82F6",
COMPLETADA: "#22C55E",
BLOQUEADA: "#EF4444",
};
export function DiagramaGantt({
obraId,
obraNombre,
fechaInicioObra,
fechaFinObra,
fases,
onTaskUpdate,
}: Props) {
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Week);
const [showTaskList, setShowTaskList] = useState(true);
// Convertir fases y tareas a formato del Gantt
const tasks: Task[] = useMemo(() => {
const result: Task[] = [];
const defaultStart = new Date();
const defaultEnd = new Date();
defaultEnd.setDate(defaultEnd.getDate() + 7);
// Agregar proyecto principal
result.push({
start: fechaInicioObra ? new Date(fechaInicioObra) : defaultStart,
end: fechaFinObra ? new Date(fechaFinObra) : defaultEnd,
name: obraNombre,
id: `obra-${obraId}`,
type: "project",
progress: fases.length > 0
? fases.reduce((acc, f) => acc + f.porcentajeAvance, 0) / fases.length
: 0,
hideChildren: false,
styles: {
backgroundColor: "#6366F1",
backgroundSelectedColor: "#4F46E5",
progressColor: "#4338CA",
progressSelectedColor: "#3730A3",
},
});
// Agregar fases y tareas
fases.forEach((fase) => {
const faseStart = fase.fechaInicio
? new Date(fase.fechaInicio)
: defaultStart;
const faseEnd = fase.fechaFin ? new Date(fase.fechaFin) : defaultEnd;
// Si la fase tiene tareas, usar las fechas de las tareas
let minStart = faseStart;
let maxEnd = faseEnd;
if (fase.tareas.length > 0) {
const tareaStarts = fase.tareas
.filter((t) => t.fechaInicio)
.map((t) => new Date(t.fechaInicio!));
const tareaEnds = fase.tareas
.filter((t) => t.fechaFin)
.map((t) => new Date(t.fechaFin!));
if (tareaStarts.length > 0) {
minStart = new Date(Math.min(...tareaStarts.map((d) => d.getTime())));
}
if (tareaEnds.length > 0) {
maxEnd = new Date(Math.max(...tareaEnds.map((d) => d.getTime())));
}
}
// Agregar fase
result.push({
start: minStart,
end: maxEnd,
name: fase.nombre,
id: `fase-${fase.id}`,
type: "project",
progress: fase.porcentajeAvance,
project: `obra-${obraId}`,
hideChildren: false,
styles: {
backgroundColor: "#8B5CF6",
backgroundSelectedColor: "#7C3AED",
progressColor: "#6D28D9",
progressSelectedColor: "#5B21B6",
},
});
// Agregar tareas de la fase
fase.tareas.forEach((tarea, index) => {
const tareaStart = tarea.fechaInicio
? new Date(tarea.fechaInicio)
: new Date(minStart.getTime() + index * 86400000);
const tareaEnd = tarea.fechaFin
? new Date(tarea.fechaFin)
: new Date(tareaStart.getTime() + 7 * 86400000);
const color = ESTADO_COLORS[tarea.estado] || "#9CA3AF";
result.push({
start: tareaStart,
end: tareaEnd,
name: tarea.nombre,
id: `tarea-${tarea.id}`,
type: "task",
progress: tarea.porcentajeAvance,
project: `fase-${fase.id}`,
styles: {
backgroundColor: color,
backgroundSelectedColor: color,
progressColor: "#1F2937",
progressSelectedColor: "#111827",
},
});
});
});
return result;
}, [obraId, obraNombre, fechaInicioObra, fechaFinObra, fases]);
// Manejar cambios de fecha en tareas
const handleTaskChange = (task: Task) => {
if (onTaskUpdate && task.id.startsWith("tarea-")) {
const tareaId = task.id.replace("tarea-", "");
onTaskUpdate(tareaId, task.start, task.end);
}
};
// Manejar doble click para expandir/contraer
const handleDoubleClick = (task: Task) => {
console.log("Double clicked on:", task.name);
};
// Calcular estadísticas
const stats = useMemo(() => {
const totalTareas = fases.reduce((acc, f) => acc + f.tareas.length, 0);
const completadas = fases.reduce(
(acc, f) => acc + f.tareas.filter((t) => t.estado === "COMPLETADA").length,
0
);
const enProgreso = fases.reduce(
(acc, f) => acc + f.tareas.filter((t) => t.estado === "EN_PROGRESO").length,
0
);
const bloqueadas = fases.reduce(
(acc, f) => acc + f.tareas.filter((t) => t.estado === "BLOQUEADA").length,
0
);
return { totalTareas, completadas, enProgreso, bloqueadas };
}, [fases]);
if (fases.length === 0) {
return (
<Card>
<CardContent className="py-12 text-center">
<Calendar className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">
No hay fases definidas para mostrar el diagrama de Gantt
</p>
<p className="text-sm text-muted-foreground mt-2">
Crea fases y tareas para visualizar el cronograma
</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{/* Estadísticas */}
<div className="grid gap-4 grid-cols-2 md:grid-cols-4">
<Card>
<CardContent className="pt-4">
<div className="text-2xl font-bold">{stats.totalTareas}</div>
<p className="text-sm text-muted-foreground">Total Tareas</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="text-2xl font-bold text-green-600">
{stats.completadas}
</div>
<p className="text-sm text-muted-foreground">Completadas</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="text-2xl font-bold text-blue-600">
{stats.enProgreso}
</div>
<p className="text-sm text-muted-foreground">En Progreso</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="text-2xl font-bold text-red-600">
{stats.bloqueadas}
</div>
<p className="text-sm text-muted-foreground">Bloqueadas</p>
</CardContent>
</Card>
</div>
{/* Controles */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<CardTitle>Diagrama de Gantt</CardTitle>
<CardDescription>
Visualiza el cronograma del proyecto
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowTaskList(!showTaskList)}
>
<ListTree className="h-4 w-4 mr-2" />
{showTaskList ? "Ocultar lista" : "Mostrar lista"}
</Button>
<Select
value={viewMode}
onValueChange={(value) => setViewMode(value as ViewMode)}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ViewMode.Day}>Día</SelectItem>
<SelectItem value={ViewMode.Week}>Semana</SelectItem>
<SelectItem value={ViewMode.Month}>Mes</SelectItem>
<SelectItem value={ViewMode.Year}>Año</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent className="p-0 overflow-auto">
<div className="min-w-[800px]">
<Gantt
tasks={tasks}
viewMode={viewMode}
onDateChange={handleTaskChange}
onDoubleClick={handleDoubleClick}
listCellWidth={showTaskList ? "155px" : ""}
columnWidth={
viewMode === ViewMode.Day
? 60
: viewMode === ViewMode.Week
? 200
: viewMode === ViewMode.Month
? 300
: 400
}
barFill={60}
barCornerRadius={4}
barProgressColor="#1F2937"
barProgressSelectedColor="#111827"
projectBackgroundColor="#8B5CF6"
projectProgressColor="#6D28D9"
todayColor="rgba(239, 68, 68, 0.2)"
locale="es"
/>
</div>
</CardContent>
</Card>
{/* Leyenda */}
<Card>
<CardContent className="py-3">
<div className="flex flex-wrap items-center gap-4">
<span className="text-sm font-medium">Leyenda:</span>
<div className="flex items-center gap-1">
<div className="w-4 h-4 rounded bg-gray-400" />
<span className="text-sm">Pendiente</span>
</div>
<div className="flex items-center gap-1">
<div className="w-4 h-4 rounded bg-blue-500" />
<span className="text-sm">En Progreso</span>
</div>
<div className="flex items-center gap-1">
<div className="w-4 h-4 rounded bg-green-500" />
<span className="text-sm">Completada</span>
</div>
<div className="flex items-center gap-1">
<div className="w-4 h-4 rounded bg-red-500" />
<span className="text-sm">Bloqueada</span>
</div>
<div className="flex items-center gap-1">
<div className="w-4 h-4 rounded bg-purple-500" />
<span className="text-sm">Fase</span>
</div>
<div className="flex items-center gap-1">
<div className="w-4 h-4 rounded bg-indigo-500" />
<span className="text-sm">Proyecto</span>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -11,9 +11,10 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Bell, LogOut, User, Settings } from "lucide-react";
import { LogOut, User, Settings } from "lucide-react";
import { getInitials } from "@/lib/utils";
import { ROLES_LABELS } from "@/types";
import { NotificationBell } from "@/components/notifications/notification-bell";
export function Header() {
const { data: session } = useSession();
@@ -31,12 +32,7 @@ export function Header() {
</div>
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
3
</span>
</Button>
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -0,0 +1,286 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Bell,
Check,
CheckCheck,
ClipboardList,
DollarSign,
Package,
TrendingUp,
AlertTriangle,
MessageSquare,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { es } from "date-fns/locale";
interface Notification {
id: string;
tipo: string;
titulo: string;
mensaje: string;
url: string | null;
leida: boolean;
createdAt: string;
}
const TIPO_ICONS: Record<string, React.ReactNode> = {
TAREA_ASIGNADA: <ClipboardList className="h-4 w-4 text-blue-500" />,
TAREA_COMPLETADA: <Check className="h-4 w-4 text-green-500" />,
GASTO_PENDIENTE: <DollarSign className="h-4 w-4 text-yellow-500" />,
GASTO_APROBADO: <DollarSign className="h-4 w-4 text-green-500" />,
ORDEN_APROBADA: <Package className="h-4 w-4 text-purple-500" />,
AVANCE_REGISTRADO: <TrendingUp className="h-4 w-4 text-blue-500" />,
ALERTA_INVENTARIO: <AlertTriangle className="h-4 w-4 text-red-500" />,
GENERAL: <MessageSquare className="h-4 w-4 text-gray-500" />,
RECORDATORIO: <Bell className="h-4 w-4 text-orange-500" />,
};
export function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
fetchNotifications();
// Polling cada 30 segundos
const interval = setInterval(fetchNotifications, 30000);
return () => clearInterval(interval);
}, []);
// Registrar service worker y suscribirse a push
useEffect(() => {
if ("serviceWorker" in navigator && "PushManager" in window) {
registerServiceWorker();
}
}, []);
const registerServiceWorker = async () => {
try {
const registration = await navigator.serviceWorker.register("/sw.js");
console.log("Service Worker registrado:", registration);
// Solicitar permiso para notificaciones
const permission = await Notification.requestPermission();
if (permission === "granted") {
await subscribeToPush(registration);
}
} catch (error) {
console.error("Error registrando Service Worker:", error);
}
};
const subscribeToPush = async (registration: ServiceWorkerRegistration) => {
try {
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
if (!vapidPublicKey) {
console.warn("VAPID public key not configured");
return;
}
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource,
});
// Enviar suscripción al servidor
const p256dhKey = subscription.getKey("p256dh");
const authKey = subscription.getKey("auth");
if (p256dhKey && authKey) {
const p256dhArray = new Uint8Array(p256dhKey);
const authArray = new Uint8Array(authKey);
await fetch("/api/notifications/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
endpoint: subscription.endpoint,
keys: {
p256dh: arrayBufferToBase64(p256dhArray),
auth: arrayBufferToBase64(authArray),
},
}),
});
}
console.log("Suscrito a push notifications");
} catch (error) {
console.error("Error suscribiendo a push:", error);
}
};
const fetchNotifications = async () => {
try {
const res = await fetch("/api/notifications?limit=10");
if (res.ok) {
const data = await res.json();
setNotifications(data.notificaciones);
setUnreadCount(data.unreadCount);
}
} catch (error) {
console.error("Error fetching notifications:", error);
} finally {
setLoading(false);
}
};
const markAsRead = async (notificationId: string) => {
try {
await fetch("/api/notifications", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notificationIds: [notificationId] }),
});
setNotifications((prev) =>
prev.map((n) => (n.id === notificationId ? { ...n, leida: true } : n))
);
setUnreadCount((prev) => Math.max(0, prev - 1));
} catch (error) {
console.error("Error marking as read:", error);
}
};
const markAllAsRead = async () => {
try {
await fetch("/api/notifications", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ markAllRead: true }),
});
setNotifications((prev) => prev.map((n) => ({ ...n, leida: true })));
setUnreadCount(0);
} catch (error) {
console.error("Error marking all as read:", error);
}
};
const handleNotificationClick = (notification: Notification) => {
if (!notification.leida) {
markAsRead(notification.id);
}
if (notification.url) {
window.location.href = notification.url;
}
setIsOpen(false);
};
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs"
>
{unreadCount > 9 ? "9+" : unreadCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80" align="end">
<DropdownMenuLabel className="flex items-center justify-between">
<span>Notificaciones</span>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-auto p-1 text-xs"
onClick={(e) => {
e.preventDefault();
markAllAsRead();
}}
>
<CheckCheck className="h-3 w-3 mr-1" />
Marcar todas
</Button>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup className="max-h-[400px] overflow-y-auto">
{loading ? (
<div className="py-4 text-center text-sm text-muted-foreground">
Cargando...
</div>
) : notifications.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
<Bell className="h-8 w-8 mx-auto mb-2 opacity-50" />
No tienes notificaciones
</div>
) : (
notifications.map((notification) => (
<DropdownMenuItem
key={notification.id}
className={`flex items-start gap-3 p-3 cursor-pointer ${
!notification.leida ? "bg-blue-50" : ""
}`}
onClick={() => handleNotificationClick(notification)}
>
<div className="flex-shrink-0 mt-0.5">
{TIPO_ICONS[notification.tipo] || TIPO_ICONS.GENERAL}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{notification.titulo}
</p>
<p className="text-xs text-muted-foreground line-clamp-2">
{notification.mensaje}
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatDistanceToNow(new Date(notification.createdAt), {
addSuffix: true,
locale: es,
})}
</p>
</div>
{!notification.leida && (
<div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" />
)}
</DropdownMenuItem>
))
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
// Utilidad para convertir VAPID key
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Utilidad para convertir ArrayBuffer a Base64
function arrayBufferToBase64(buffer: Uint8Array): string {
let binary = "";
for (let i = 0; i < buffer.byteLength; i++) {
binary += String.fromCharCode(buffer[i]);
}
return btoa(binary);
}

View File

@@ -0,0 +1,747 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Separator } from "@/components/ui/separator";
import {
ShoppingCart,
Plus,
Trash2,
Edit,
Eye,
Loader2,
Package,
Send,
CheckCircle,
XCircle,
Clock,
FileText,
} from "lucide-react";
import { toast } from "@/hooks/use-toast";
import { formatCurrency, formatDate } from "@/lib/utils";
import {
ESTADO_ORDEN_COMPRA_LABELS,
ESTADO_ORDEN_COMPRA_COLORS,
PRIORIDAD_ORDEN_LABELS,
PRIORIDAD_ORDEN_COLORS,
UNIDAD_MEDIDA_LABELS,
type EstadoOrdenCompra,
type PrioridadOrden,
type UnidadMedida,
} from "@/types";
interface ItemOrden {
id?: string;
codigo: string;
descripcion: string;
unidad: UnidadMedida;
cantidad: number;
precioUnitario: number;
descuento: number;
subtotal: number;
materialId?: string | null;
}
interface OrdenCompra {
id: string;
numero: string;
estado: EstadoOrdenCompra;
prioridad: PrioridadOrden;
fechaEmision: string;
fechaRequerida: string | null;
fechaAprobacion: string | null;
fechaEnvio: string | null;
fechaRecepcion: string | null;
proveedorNombre: string;
proveedorRfc: string | null;
proveedorContacto: string | null;
proveedorTelefono: string | null;
proveedorEmail: string | null;
subtotal: number;
iva: number;
total: number;
condicionesPago: string | null;
tiempoEntrega: string | null;
lugarEntrega: string | null;
notas: string | null;
items: ItemOrden[];
creadoPor: { nombre: string; apellido: string };
aprobadoPor?: { nombre: string; apellido: string } | null;
}
interface OrdenesCompraProps {
obraId: string;
obraDireccion: string;
}
const initialItemState: ItemOrden = {
codigo: "",
descripcion: "",
unidad: "UNIDAD",
cantidad: 1,
precioUnitario: 0,
descuento: 0,
subtotal: 0,
};
export function OrdenesCompra({ obraId, obraDireccion }: OrdenesCompraProps) {
const router = useRouter();
const [ordenes, setOrdenes] = useState<OrdenCompra[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false);
const [selectedOrden, setSelectedOrden] = useState<OrdenCompra | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [filterEstado, setFilterEstado] = useState<string>("all");
const [form, setForm] = useState({
prioridad: "NORMAL" as PrioridadOrden,
fechaRequerida: "",
proveedorNombre: "",
proveedorRfc: "",
proveedorContacto: "",
proveedorTelefono: "",
proveedorEmail: "",
proveedorDireccion: "",
condicionesPago: "",
tiempoEntrega: "",
lugarEntrega: obraDireccion,
notas: "",
});
const [items, setItems] = useState<ItemOrden[]>([{ ...initialItemState }]);
// Cargar órdenes
useEffect(() => {
const fetchOrdenes = async () => {
setIsLoading(true);
try {
const url = filterEstado === "all"
? `/api/ordenes-compra?obraId=${obraId}`
: `/api/ordenes-compra?obraId=${obraId}&estado=${filterEstado}`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
setOrdenes(data);
}
} catch (error) {
console.error("Error fetching ordenes:", error);
} finally {
setIsLoading(false);
}
};
fetchOrdenes();
}, [obraId, filterEstado]);
const resetForm = () => {
setForm({
prioridad: "NORMAL",
fechaRequerida: "",
proveedorNombre: "",
proveedorRfc: "",
proveedorContacto: "",
proveedorTelefono: "",
proveedorEmail: "",
proveedorDireccion: "",
condicionesPago: "",
tiempoEntrega: "",
lugarEntrega: obraDireccion,
notas: "",
});
setItems([{ ...initialItemState }]);
};
const updateItem = (index: number, field: keyof ItemOrden, value: unknown) => {
const newItems = [...items];
newItems[index] = { ...newItems[index], [field]: value };
// Recalcular subtotal
const cantidad = field === "cantidad" ? (value as number) : newItems[index].cantidad;
const precioUnitario = field === "precioUnitario" ? (value as number) : newItems[index].precioUnitario;
const descuento = field === "descuento" ? (value as number) : newItems[index].descuento;
newItems[index].subtotal = cantidad * precioUnitario - descuento;
setItems(newItems);
};
const addItem = () => {
setItems([...items, { ...initialItemState }]);
};
const removeItem = (index: number) => {
if (items.length > 1) {
setItems(items.filter((_, i) => i !== index));
}
};
const calculateTotals = () => {
const subtotal = items.reduce((acc, item) => acc + item.subtotal, 0);
const iva = subtotal * 0.16;
const total = subtotal + iva;
return { subtotal, iva, total };
};
const handleSubmit = async () => {
if (!form.proveedorNombre.trim()) {
toast({ title: "Error", description: "El proveedor es requerido", variant: "destructive" });
return;
}
const invalidItems = items.filter((item) => !item.descripcion.trim() || item.cantidad <= 0);
if (invalidItems.length > 0) {
toast({ title: "Error", description: "Todos los items deben tener descripción y cantidad válida", variant: "destructive" });
return;
}
setIsSubmitting(true);
try {
const response = await fetch("/api/ordenes-compra", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...form,
obraId,
items: items.map((item) => ({
...item,
cantidad: Number(item.cantidad),
precioUnitario: Number(item.precioUnitario),
descuento: Number(item.descuento),
})),
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Error al crear la orden");
}
const newOrden = await response.json();
setOrdenes((prev) => [newOrden, ...prev]);
toast({ title: "Orden de compra creada exitosamente" });
setIsDialogOpen(false);
resetForm();
router.refresh();
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "Error al crear la orden",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
const handleUpdateStatus = async (id: string, newEstado: EstadoOrdenCompra) => {
try {
const response = await fetch(`/api/ordenes-compra/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ estado: newEstado }),
});
if (!response.ok) throw new Error("Error al actualizar");
const updatedOrden = await response.json();
setOrdenes((prev) => prev.map((o) => (o.id === id ? updatedOrden : o)));
toast({ title: `Orden ${ESTADO_ORDEN_COMPRA_LABELS[newEstado].toLowerCase()}` });
router.refresh();
} catch {
toast({ title: "Error", description: "No se pudo actualizar el estado", variant: "destructive" });
}
};
const handleDelete = async () => {
if (!deleteId) return;
setIsDeleting(true);
try {
const response = await fetch(`/api/ordenes-compra/${deleteId}`, { method: "DELETE" });
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
setOrdenes((prev) => prev.filter((o) => o.id !== deleteId));
toast({ title: "Orden eliminada" });
router.refresh();
} catch (error) {
toast({ title: "Error", description: error instanceof Error ? error.message : "No se pudo eliminar", variant: "destructive" });
} finally {
setIsDeleting(false);
setDeleteId(null);
}
};
const totals = calculateTotals();
// Estadísticas
const stats = {
total: ordenes.length,
pendientes: ordenes.filter((o) => o.estado === "PENDIENTE").length,
aprobadas: ordenes.filter((o) => o.estado === "APROBADA" || o.estado === "ENVIADA").length,
recibidas: ordenes.filter((o) => o.estado === "RECIBIDA" || o.estado === "RECIBIDA_PARCIAL").length,
montoTotal: ordenes.reduce((acc, o) => acc + o.total, 0),
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<ShoppingCart className="h-5 w-5" />
Ordenes de Compra
</CardTitle>
<CardDescription>
Gestiona las ordenes de compra de materiales
</CardDescription>
</div>
<Button onClick={() => { resetForm(); setIsDialogOpen(true); }}>
<Plus className="mr-2 h-4 w-4" />
Nueva Orden
</Button>
</div>
</CardHeader>
<CardContent>
{/* Estadísticas */}
<div className="grid grid-cols-5 gap-4 mb-6">
<Card className="bg-gray-50">
<CardContent className="pt-4 text-center">
<FileText className="h-6 w-6 mx-auto text-gray-600 mb-1" />
<p className="text-2xl font-bold">{stats.total}</p>
<p className="text-xs text-gray-600">Total</p>
</CardContent>
</Card>
<Card className="bg-yellow-50">
<CardContent className="pt-4 text-center">
<Clock className="h-6 w-6 mx-auto text-yellow-600 mb-1" />
<p className="text-2xl font-bold text-yellow-600">{stats.pendientes}</p>
<p className="text-xs text-yellow-700">Pendientes</p>
</CardContent>
</Card>
<Card className="bg-blue-50">
<CardContent className="pt-4 text-center">
<Send className="h-6 w-6 mx-auto text-blue-600 mb-1" />
<p className="text-2xl font-bold text-blue-600">{stats.aprobadas}</p>
<p className="text-xs text-blue-700">En proceso</p>
</CardContent>
</Card>
<Card className="bg-green-50">
<CardContent className="pt-4 text-center">
<CheckCircle className="h-6 w-6 mx-auto text-green-600 mb-1" />
<p className="text-2xl font-bold text-green-600">{stats.recibidas}</p>
<p className="text-xs text-green-700">Recibidas</p>
</CardContent>
</Card>
<Card className="bg-purple-50">
<CardContent className="pt-4 text-center">
<Package className="h-6 w-6 mx-auto text-purple-600 mb-1" />
<p className="text-lg font-bold text-purple-600">{formatCurrency(stats.montoTotal)}</p>
<p className="text-xs text-purple-700">Monto total</p>
</CardContent>
</Card>
</div>
{/* Filtro */}
<div className="flex gap-4 mb-6">
<Select value={filterEstado} onValueChange={setFilterEstado}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Filtrar por estado" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos los estados</SelectItem>
{Object.entries(ESTADO_ORDEN_COMPRA_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Tabla de órdenes */}
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : ordenes.length === 0 ? (
<div className="text-center py-12">
<ShoppingCart className="mx-auto h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No hay ordenes de compra</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Numero</TableHead>
<TableHead>Proveedor</TableHead>
<TableHead>Estado</TableHead>
<TableHead>Prioridad</TableHead>
<TableHead>Fecha</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead className="text-right">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ordenes.map((orden) => (
<TableRow key={orden.id}>
<TableCell className="font-medium">{orden.numero}</TableCell>
<TableCell>{orden.proveedorNombre}</TableCell>
<TableCell>
<Badge className={ESTADO_ORDEN_COMPRA_COLORS[orden.estado]}>
{ESTADO_ORDEN_COMPRA_LABELS[orden.estado]}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className={PRIORIDAD_ORDEN_COLORS[orden.prioridad]}>
{PRIORIDAD_ORDEN_LABELS[orden.prioridad]}
</Badge>
</TableCell>
<TableCell>{new Date(orden.fechaEmision).toLocaleDateString("es-MX")}</TableCell>
<TableCell className="text-right font-bold">{formatCurrency(orden.total)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => { setSelectedOrden(orden); setIsViewDialogOpen(true); }}>
<Eye className="h-4 w-4" />
</Button>
{orden.estado === "BORRADOR" && (
<Button variant="ghost" size="icon" onClick={() => handleUpdateStatus(orden.id, "PENDIENTE")}>
<Send className="h-4 w-4 text-blue-500" />
</Button>
)}
{orden.estado === "PENDIENTE" && (
<Button variant="ghost" size="icon" onClick={() => handleUpdateStatus(orden.id, "APROBADA")}>
<CheckCircle className="h-4 w-4 text-green-500" />
</Button>
)}
{(orden.estado === "BORRADOR" || orden.estado === "CANCELADA") && (
<Button variant="ghost" size="icon" onClick={() => setDeleteId(orden.id)}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{/* Dialog de nueva orden */}
<Dialog open={isDialogOpen} onOpenChange={(open) => { setIsDialogOpen(open); if (!open) resetForm(); }}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Nueva Orden de Compra</DialogTitle>
<DialogDescription>Crea una orden de compra para materiales</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Proveedor */}
<div>
<h4 className="font-medium mb-3">Datos del Proveedor</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Nombre/Razon Social *</Label>
<Input value={form.proveedorNombre} onChange={(e) => setForm({ ...form, proveedorNombre: e.target.value })} placeholder="Nombre del proveedor" />
</div>
<div className="space-y-2">
<Label>RFC</Label>
<Input value={form.proveedorRfc} onChange={(e) => setForm({ ...form, proveedorRfc: e.target.value })} placeholder="RFC" />
</div>
<div className="space-y-2">
<Label>Contacto</Label>
<Input value={form.proveedorContacto} onChange={(e) => setForm({ ...form, proveedorContacto: e.target.value })} placeholder="Nombre del contacto" />
</div>
<div className="space-y-2">
<Label>Telefono</Label>
<Input value={form.proveedorTelefono} onChange={(e) => setForm({ ...form, proveedorTelefono: e.target.value })} placeholder="Teléfono" />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input type="email" value={form.proveedorEmail} onChange={(e) => setForm({ ...form, proveedorEmail: e.target.value })} placeholder="correo@ejemplo.com" />
</div>
</div>
</div>
<Separator />
{/* Condiciones */}
<div>
<h4 className="font-medium mb-3">Condiciones</h4>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Prioridad</Label>
<Select value={form.prioridad} onValueChange={(value) => setForm({ ...form, prioridad: value as PrioridadOrden })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(PRIORIDAD_ORDEN_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Fecha Requerida</Label>
<Input type="date" value={form.fechaRequerida} onChange={(e) => setForm({ ...form, fechaRequerida: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Tiempo de Entrega</Label>
<Input value={form.tiempoEntrega} onChange={(e) => setForm({ ...form, tiempoEntrega: e.target.value })} placeholder="Ej: 3-5 días" />
</div>
<div className="space-y-2">
<Label>Condiciones de Pago</Label>
<Input value={form.condicionesPago} onChange={(e) => setForm({ ...form, condicionesPago: e.target.value })} placeholder="Ej: Contado, Crédito 30 días" />
</div>
<div className="col-span-2 space-y-2">
<Label>Lugar de Entrega</Label>
<Input value={form.lugarEntrega} onChange={(e) => setForm({ ...form, lugarEntrega: e.target.value })} placeholder="Dirección de entrega" />
</div>
</div>
</div>
<Separator />
{/* Items */}
<div>
<div className="flex justify-between items-center mb-3">
<h4 className="font-medium">Items de la Orden</h4>
<Button type="button" variant="outline" size="sm" onClick={addItem}>
<Plus className="h-4 w-4 mr-1" /> Agregar Item
</Button>
</div>
<div className="space-y-4">
{items.map((item, index) => (
<div key={index} className="grid grid-cols-12 gap-2 items-end border p-3 rounded-lg">
<div className="col-span-4 space-y-1">
<Label className="text-xs">Descripcion *</Label>
<Input value={item.descripcion} onChange={(e) => updateItem(index, "descripcion", e.target.value)} placeholder="Descripción del item" />
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Unidad</Label>
<Select value={item.unidad} onValueChange={(value) => updateItem(index, "unidad", value)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(UNIDAD_MEDIDA_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="col-span-1 space-y-1">
<Label className="text-xs">Cantidad</Label>
<Input type="number" min="0.01" step="0.01" value={item.cantidad} onChange={(e) => updateItem(index, "cantidad", parseFloat(e.target.value) || 0)} />
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Precio Unit.</Label>
<Input type="number" min="0" step="0.01" value={item.precioUnitario} onChange={(e) => updateItem(index, "precioUnitario", parseFloat(e.target.value) || 0)} />
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Subtotal</Label>
<Input value={formatCurrency(item.subtotal)} disabled />
</div>
<div className="col-span-1">
<Button type="button" variant="ghost" size="icon" onClick={() => removeItem(index)} disabled={items.length === 1}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
))}
</div>
{/* Totales */}
<div className="mt-4 border-t pt-4">
<div className="flex justify-end">
<div className="w-64 space-y-2">
<div className="flex justify-between"><span>Subtotal:</span><span>{formatCurrency(totals.subtotal)}</span></div>
<div className="flex justify-between"><span>IVA (16%):</span><span>{formatCurrency(totals.iva)}</span></div>
<div className="flex justify-between font-bold text-lg border-t pt-2"><span>Total:</span><span>{formatCurrency(totals.total)}</span></div>
</div>
</div>
</div>
</div>
<Separator />
{/* Notas */}
<div className="space-y-2">
<Label>Notas</Label>
<Textarea value={form.notas} onChange={(e) => setForm({ ...form, notas: e.target.value })} placeholder="Notas adicionales para el proveedor..." rows={2} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setIsDialogOpen(false); resetForm(); }} disabled={isSubmitting}>Cancelar</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Crear Orden
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Dialog de vista detallada */}
<Dialog open={isViewDialogOpen} onOpenChange={setIsViewDialogOpen}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Orden de Compra {selectedOrden?.numero}</DialogTitle>
</DialogHeader>
{selectedOrden && (
<div className="space-y-4">
<div className="flex justify-between items-start">
<div>
<Badge className={ESTADO_ORDEN_COMPRA_COLORS[selectedOrden.estado]}>
{ESTADO_ORDEN_COMPRA_LABELS[selectedOrden.estado]}
</Badge>
<Badge variant="outline" className={`ml-2 ${PRIORIDAD_ORDEN_COLORS[selectedOrden.prioridad]}`}>
{PRIORIDAD_ORDEN_LABELS[selectedOrden.prioridad]}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
Creado por {selectedOrden.creadoPor.nombre} {selectedOrden.creadoPor.apellido}
</p>
</div>
<div className="grid grid-cols-2 gap-4 p-4 bg-muted rounded-lg">
<div>
<p className="text-sm font-medium text-muted-foreground">Proveedor</p>
<p className="font-medium">{selectedOrden.proveedorNombre}</p>
{selectedOrden.proveedorRfc && <p className="text-sm">{selectedOrden.proveedorRfc}</p>}
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Fecha Emision</p>
<p>{formatDate(selectedOrden.fechaEmision)}</p>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Descripcion</TableHead>
<TableHead>Unidad</TableHead>
<TableHead className="text-right">Cantidad</TableHead>
<TableHead className="text-right">P. Unitario</TableHead>
<TableHead className="text-right">Subtotal</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedOrden.items.map((item, idx) => (
<TableRow key={idx}>
<TableCell>{item.descripcion}</TableCell>
<TableCell>{UNIDAD_MEDIDA_LABELS[item.unidad]}</TableCell>
<TableCell className="text-right">{item.cantidad}</TableCell>
<TableCell className="text-right">{formatCurrency(item.precioUnitario)}</TableCell>
<TableCell className="text-right">{formatCurrency(item.subtotal)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex justify-end">
<div className="w-64 space-y-2">
<div className="flex justify-between"><span>Subtotal:</span><span>{formatCurrency(selectedOrden.subtotal)}</span></div>
<div className="flex justify-between"><span>IVA:</span><span>{formatCurrency(selectedOrden.iva)}</span></div>
<div className="flex justify-between font-bold text-lg border-t pt-2"><span>Total:</span><span>{formatCurrency(selectedOrden.total)}</span></div>
</div>
</div>
{selectedOrden.notas && (
<div>
<p className="text-sm font-medium text-muted-foreground">Notas</p>
<p className="text-sm">{selectedOrden.notas}</p>
</div>
)}
{/* Acciones de estado */}
<div className="flex gap-2 justify-end border-t pt-4">
{selectedOrden.estado === "BORRADOR" && (
<Button onClick={() => { handleUpdateStatus(selectedOrden.id, "PENDIENTE"); setIsViewDialogOpen(false); }}>
<Send className="mr-2 h-4 w-4" /> Enviar a Aprobacion
</Button>
)}
{selectedOrden.estado === "PENDIENTE" && (
<>
<Button variant="destructive" onClick={() => { handleUpdateStatus(selectedOrden.id, "CANCELADA"); setIsViewDialogOpen(false); }}>
<XCircle className="mr-2 h-4 w-4" /> Cancelar
</Button>
<Button onClick={() => { handleUpdateStatus(selectedOrden.id, "APROBADA"); setIsViewDialogOpen(false); }}>
<CheckCircle className="mr-2 h-4 w-4" /> Aprobar
</Button>
</>
)}
{selectedOrden.estado === "APROBADA" && (
<Button onClick={() => { handleUpdateStatus(selectedOrden.id, "ENVIADA"); setIsViewDialogOpen(false); }}>
<Send className="mr-2 h-4 w-4" /> Marcar como Enviada
</Button>
)}
{selectedOrden.estado === "ENVIADA" && (
<Button onClick={() => { handleUpdateStatus(selectedOrden.id, "RECIBIDA"); setIsViewDialogOpen(false); }}>
<CheckCircle className="mr-2 h-4 w-4" /> Marcar como Recibida
</Button>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Dialog de confirmación de eliminación */}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Eliminar orden de compra</AlertDialogTitle>
<AlertDialogDescription>
¿Estas seguro de que deseas eliminar esta orden? Esta accion no se puede deshacer.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isDeleting} className="bg-red-600 hover:bg-red-700">
{isDeleting ? "Eliminando..." : "Eliminar"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,237 @@
"use client";
import {
Document,
Page,
Text,
View,
} from "@react-pdf/renderer";
import { styles, formatDatePDF } from "./styles";
import { CONDICION_CLIMA_LABELS, type CondicionClima } from "@/types";
interface BitacoraEntry {
id: string;
fecha: Date | string;
clima: CondicionClima;
temperaturaMin: number | null;
temperaturaMax: number | null;
condicionesExtra: string | null;
personalPropio: number;
personalSubcontrato: number;
personalDetalle: string | null;
actividadesRealizadas: string;
actividadesPendientes: string | null;
materialesUtilizados: string | null;
materialesRecibidos: string | null;
equipoUtilizado: string | null;
incidentes: string | null;
observaciones: string | null;
incidentesSeguridad: string | null;
platicaSeguridad: boolean;
temaSeguridad: string | null;
visitasInspeccion: string | null;
registradoPor: { nombre: string; apellido: string };
}
interface BitacoraPDFProps {
obra: {
nombre: string;
direccion: string;
};
bitacoras: BitacoraEntry[];
mes?: string;
empresaNombre?: string;
}
export function BitacoraPDF({ obra, bitacoras, mes, empresaNombre = "Mexus App" }: BitacoraPDFProps) {
// Calcular estadisticas
const totalPersonal = bitacoras.reduce((acc, b) => acc + b.personalPropio + b.personalSubcontrato, 0);
const diasConIncidentes = bitacoras.filter(b => b.incidentes || b.incidentesSeguridad).length;
const platicasSeguridad = bitacoras.filter(b => b.platicaSeguridad).length;
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Bitacora de Obra</Text>
<Text style={styles.headerSubtitle}>{obra.nombre}</Text>
<Text style={styles.headerDate}>
{mes ? `Mes: ${mes}` : `Generado el ${formatDatePDF(new Date())}`} | {empresaNombre}
</Text>
</View>
{/* Info de la Obra */}
<View style={[styles.section, { backgroundColor: "#f8fafc", padding: 10, borderRadius: 4 }]}>
<View style={styles.row}>
<Text style={styles.label}>Direccion:</Text>
<Text style={styles.value}>{obra.direccion}</Text>
</View>
</View>
{/* Resumen del Periodo */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Resumen del Periodo</Text>
<View style={styles.statsGrid}>
<View style={[styles.statBox, { width: "22%" }]}>
<Text style={styles.statLabel}>Dias Registrados</Text>
<Text style={styles.statValue}>{bitacoras.length}</Text>
</View>
<View style={[styles.statBox, { width: "22%" }]}>
<Text style={styles.statLabel}>Personal Total</Text>
<Text style={styles.statValue}>{totalPersonal}</Text>
<Text style={styles.statSubtext}>jornadas-hombre</Text>
</View>
<View style={[styles.statBox, { width: "22%", backgroundColor: diasConIncidentes > 0 ? "#fee2e2" : "#dcfce7" }]}>
<Text style={styles.statLabel}>Dias con Incidentes</Text>
<Text style={[styles.statValue, diasConIncidentes > 0 ? styles.textDanger : styles.textSuccess]}>
{diasConIncidentes}
</Text>
</View>
<View style={[styles.statBox, { width: "22%" }]}>
<Text style={styles.statLabel}>Platicas Seguridad</Text>
<Text style={styles.statValue}>{platicasSeguridad}</Text>
</View>
</View>
</View>
{/* Entradas de Bitacora */}
{bitacoras.map((entry, index) => (
<View key={entry.id} style={[styles.card, { marginBottom: 15 }]} wrap={false}>
{/* Encabezado de la entrada */}
<View style={{ flexDirection: "row", justifyContent: "space-between", marginBottom: 8, borderBottomWidth: 1, borderBottomColor: "#e2e8f0", paddingBottom: 6 }}>
<View>
<Text style={{ fontSize: 12, fontFamily: "Helvetica-Bold", color: "#1e40af" }}>
{formatDatePDF(entry.fecha)}
</Text>
<Text style={{ fontSize: 8, color: "#64748b" }}>
Registrado por: {entry.registradoPor.nombre} {entry.registradoPor.apellido}
</Text>
</View>
<View style={{ alignItems: "flex-end" }}>
<Text style={{ fontSize: 9, color: "#475569" }}>
{CONDICION_CLIMA_LABELS[entry.clima]}
</Text>
{(entry.temperaturaMin || entry.temperaturaMax) && (
<Text style={{ fontSize: 8, color: "#94a3b8" }}>
{entry.temperaturaMin && `${entry.temperaturaMin}°`}
{entry.temperaturaMin && entry.temperaturaMax && " - "}
{entry.temperaturaMax && `${entry.temperaturaMax}°`}
</Text>
)}
</View>
</View>
{/* Personal */}
<View style={{ marginBottom: 6 }}>
<Text style={{ fontSize: 8, color: "#64748b", marginBottom: 2 }}>PERSONAL EN OBRA</Text>
<Text style={{ fontSize: 9 }}>
Propio: {entry.personalPropio} | Subcontrato: {entry.personalSubcontrato} |
Total: {entry.personalPropio + entry.personalSubcontrato}
</Text>
{entry.personalDetalle && (
<Text style={{ fontSize: 8, color: "#64748b", marginTop: 2 }}>{entry.personalDetalle}</Text>
)}
</View>
{/* Actividades */}
<View style={{ marginBottom: 6 }}>
<Text style={{ fontSize: 8, color: "#64748b", marginBottom: 2 }}>ACTIVIDADES REALIZADAS</Text>
<Text style={{ fontSize: 9 }}>{entry.actividadesRealizadas}</Text>
</View>
{entry.actividadesPendientes && (
<View style={{ marginBottom: 6 }}>
<Text style={{ fontSize: 8, color: "#64748b", marginBottom: 2 }}>ACTIVIDADES PENDIENTES</Text>
<Text style={{ fontSize: 9 }}>{entry.actividadesPendientes}</Text>
</View>
)}
{/* Materiales */}
{(entry.materialesUtilizados || entry.materialesRecibidos) && (
<View style={{ flexDirection: "row", marginBottom: 6 }}>
{entry.materialesUtilizados && (
<View style={{ width: "50%", paddingRight: 5 }}>
<Text style={{ fontSize: 8, color: "#64748b", marginBottom: 2 }}>MATERIALES UTILIZADOS</Text>
<Text style={{ fontSize: 8 }}>{entry.materialesUtilizados}</Text>
</View>
)}
{entry.materialesRecibidos && (
<View style={{ width: "50%", paddingLeft: 5 }}>
<Text style={{ fontSize: 8, color: "#64748b", marginBottom: 2 }}>MATERIALES RECIBIDOS</Text>
<Text style={{ fontSize: 8 }}>{entry.materialesRecibidos}</Text>
</View>
)}
</View>
)}
{/* Equipo */}
{entry.equipoUtilizado && (
<View style={{ marginBottom: 6 }}>
<Text style={{ fontSize: 8, color: "#64748b", marginBottom: 2 }}>EQUIPO/MAQUINARIA</Text>
<Text style={{ fontSize: 8 }}>{entry.equipoUtilizado}</Text>
</View>
)}
{/* Seguridad */}
{(entry.platicaSeguridad || entry.incidentesSeguridad) && (
<View style={{ backgroundColor: "#fef9c3", padding: 6, borderRadius: 3, marginBottom: 6 }}>
<Text style={{ fontSize: 8, color: "#854d0e", fontFamily: "Helvetica-Bold", marginBottom: 2 }}>
SEGURIDAD
</Text>
{entry.platicaSeguridad && (
<Text style={{ fontSize: 8 }}>
Platica de seguridad: {entry.temaSeguridad || "Si"}
</Text>
)}
{entry.incidentesSeguridad && (
<Text style={{ fontSize: 8, color: "#dc2626" }}>
Incidente: {entry.incidentesSeguridad}
</Text>
)}
</View>
)}
{/* Incidentes generales */}
{entry.incidentes && (
<View style={{ backgroundColor: "#fee2e2", padding: 6, borderRadius: 3, marginBottom: 6 }}>
<Text style={{ fontSize: 8, color: "#991b1b", fontFamily: "Helvetica-Bold", marginBottom: 2 }}>
INCIDENTES
</Text>
<Text style={{ fontSize: 8 }}>{entry.incidentes}</Text>
</View>
)}
{/* Observaciones */}
{entry.observaciones && (
<View style={{ marginBottom: 6 }}>
<Text style={{ fontSize: 8, color: "#64748b", marginBottom: 2 }}>OBSERVACIONES</Text>
<Text style={{ fontSize: 8 }}>{entry.observaciones}</Text>
</View>
)}
{/* Visitas */}
{entry.visitasInspeccion && (
<View>
<Text style={{ fontSize: 8, color: "#64748b", marginBottom: 2 }}>VISITAS DE INSPECCION</Text>
<Text style={{ fontSize: 8 }}>{entry.visitasInspeccion}</Text>
</View>
)}
</View>
))}
{bitacoras.length === 0 && (
<View style={{ padding: 40, alignItems: "center" }}>
<Text style={styles.textMuted}>No hay entradas de bitacora para este periodo</Text>
</View>
)}
{/* Footer */}
<View style={styles.footer} fixed>
<Text style={styles.footerText}>{empresaNombre} - Sistema de Gestion de Obras</Text>
<Text style={styles.pageNumber} render={({ pageNumber, totalPages }) => `Pagina ${pageNumber} de ${totalPages}`} />
</View>
</Page>
</Document>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { pdf } from "@react-pdf/renderer";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { FileDown, Loader2 } from "lucide-react";
import { toast } from "@/hooks/use-toast";
interface ExportPDFButtonProps {
document: React.ReactElement;
fileName: string;
variant?: "default" | "outline" | "ghost" | "secondary";
size?: "default" | "sm" | "lg" | "icon";
className?: string;
children?: React.ReactNode;
}
export function ExportPDFButton({
document,
fileName,
variant = "outline",
size = "sm",
className,
children,
}: ExportPDFButtonProps) {
const [isGenerating, setIsGenerating] = useState(false);
const handleDownload = async () => {
setIsGenerating(true);
try {
const blob = await pdf(document).toBlob();
const url = URL.createObjectURL(blob);
const link = window.document.createElement("a");
link.href = url;
link.download = `${fileName}.pdf`;
window.document.body.appendChild(link);
link.click();
window.document.body.removeChild(link);
URL.revokeObjectURL(url);
toast({
title: "PDF generado exitosamente",
description: `El archivo ${fileName}.pdf se ha descargado`,
});
} catch (error) {
console.error("Error generating PDF:", error);
toast({
title: "Error al generar PDF",
description: "No se pudo generar el documento. Intente de nuevo.",
variant: "destructive",
});
} finally {
setIsGenerating(false);
}
};
return (
<Button
variant={variant}
size={size}
onClick={handleDownload}
disabled={isGenerating}
className={className}
>
{isGenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileDown className="mr-2 h-4 w-4" />
)}
{children || "Exportar PDF"}
</Button>
);
}
interface ExportMenuProps {
options: {
label: string;
document: React.ReactElement;
fileName: string;
}[];
variant?: "default" | "outline" | "ghost" | "secondary";
size?: "default" | "sm" | "lg" | "icon";
}
export function ExportPDFMenu({ options, variant = "outline", size = "sm" }: ExportMenuProps) {
const [isGenerating, setIsGenerating] = useState<string | null>(null);
const handleDownload = async (option: typeof options[0]) => {
setIsGenerating(option.fileName);
try {
const blob = await pdf(option.document).toBlob();
const url = URL.createObjectURL(blob);
const link = window.document.createElement("a");
link.href = url;
link.download = `${option.fileName}.pdf`;
window.document.body.appendChild(link);
link.click();
window.document.body.removeChild(link);
URL.revokeObjectURL(url);
toast({
title: "PDF generado exitosamente",
description: `El archivo ${option.fileName}.pdf se ha descargado`,
});
} catch (error) {
console.error("Error generating PDF:", error);
toast({
title: "Error al generar PDF",
description: "No se pudo generar el documento. Intente de nuevo.",
variant: "destructive",
});
} finally {
setIsGenerating(null);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={variant} size={size} disabled={!!isGenerating}>
{isGenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileDown className="mr-2 h-4 w-4" />
)}
Exportar PDF
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{options.map((option) => (
<DropdownMenuItem
key={option.fileName}
onClick={() => handleDownload(option)}
disabled={!!isGenerating}
>
{isGenerating === option.fileName ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileDown className="mr-2 h-4 w-4" />
)}
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,192 @@
"use client";
import {
Document,
Page,
Text,
View,
} from "@react-pdf/renderer";
import { styles, formatCurrencyPDF, formatDatePDF } from "./styles";
import {
ESTADO_GASTO_LABELS,
CATEGORIA_GASTO_LABELS,
type EstadoGasto,
type CategoriaGasto,
} from "@/types";
interface Gasto {
id: string;
concepto: string;
monto: number;
fecha: Date | string;
categoria: CategoriaGasto;
estado: EstadoGasto;
proveedor: string | null;
factura: string | null;
notas: string | null;
creadoPor: { nombre: string; apellido: string };
}
interface GastosPDFProps {
obra: {
nombre: string;
direccion: string;
presupuestoTotal: number;
};
gastos: Gasto[];
periodo?: { inicio: string; fin: string };
empresaNombre?: string;
}
export function GastosPDF({ obra, gastos, periodo, empresaNombre = "Mexus App" }: GastosPDFProps) {
// Calcular totales por categoria
const totalesPorCategoria = gastos.reduce((acc, gasto) => {
if (!acc[gasto.categoria]) {
acc[gasto.categoria] = 0;
}
acc[gasto.categoria] += gasto.monto;
return acc;
}, {} as Record<CategoriaGasto, number>);
// Calcular totales por estado
const totalesPorEstado = gastos.reduce((acc, gasto) => {
if (!acc[gasto.estado]) {
acc[gasto.estado] = 0;
}
acc[gasto.estado] += gasto.monto;
return acc;
}, {} as Record<EstadoGasto, number>);
const totalGastos = gastos.reduce((acc, g) => acc + g.monto, 0);
const gastosAprobados = totalesPorEstado["APROBADO"] || 0;
const gastosPagados = totalesPorEstado["PAGADO"] || 0;
const gastosPendientes = totalesPorEstado["PENDIENTE"] || 0;
const porcentajePresupuesto = obra.presupuestoTotal > 0
? (totalGastos / obra.presupuestoTotal) * 100
: 0;
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Reporte de Gastos</Text>
<Text style={styles.headerSubtitle}>{obra.nombre}</Text>
<Text style={styles.headerDate}>
{periodo
? `Periodo: ${formatDatePDF(periodo.inicio)} - ${formatDatePDF(periodo.fin)}`
: `Generado el ${formatDatePDF(new Date())}`
} | {empresaNombre}
</Text>
</View>
{/* Resumen */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Resumen de Gastos</Text>
<View style={styles.statsGrid}>
<View style={[styles.statBox, { width: "22%" }]}>
<Text style={styles.statLabel}>Total Gastos</Text>
<Text style={styles.statValue}>{formatCurrencyPDF(totalGastos)}</Text>
<Text style={styles.statSubtext}>{gastos.length} registros</Text>
</View>
<View style={[styles.statBox, { width: "22%" }]}>
<Text style={styles.statLabel}>Presupuesto</Text>
<Text style={styles.statValue}>{formatCurrencyPDF(obra.presupuestoTotal)}</Text>
<Text style={styles.statSubtext}>{porcentajePresupuesto.toFixed(1)}% utilizado</Text>
</View>
<View style={[styles.statBox, { width: "22%", backgroundColor: "#dcfce7" }]}>
<Text style={styles.statLabel}>Pagados</Text>
<Text style={[styles.statValue, { color: "#166534" }]}>{formatCurrencyPDF(gastosPagados)}</Text>
</View>
<View style={[styles.statBox, { width: "22%", backgroundColor: "#fef9c3" }]}>
<Text style={styles.statLabel}>Pendientes</Text>
<Text style={[styles.statValue, { color: "#854d0e" }]}>{formatCurrencyPDF(gastosPendientes)}</Text>
</View>
</View>
</View>
{/* Gastos por Categoria */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Gastos por Categoria</Text>
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.tableHeaderCell, { width: "50%" }]}>Categoria</Text>
<Text style={[styles.tableHeaderCell, { width: "25%", textAlign: "right" }]}>Monto</Text>
<Text style={[styles.tableHeaderCell, { width: "25%", textAlign: "right" }]}>% del Total</Text>
</View>
{Object.entries(totalesPorCategoria)
.sort(([, a], [, b]) => b - a)
.map(([categoria, monto], index) => (
<View key={categoria} style={[styles.tableRow, index % 2 === 1 ? styles.tableRowAlt : {}]}>
<Text style={[styles.tableCell, { width: "50%" }]}>
{CATEGORIA_GASTO_LABELS[categoria as CategoriaGasto]}
</Text>
<Text style={[styles.tableCell, { width: "25%", textAlign: "right" }]}>
{formatCurrencyPDF(monto)}
</Text>
<Text style={[styles.tableCell, { width: "25%", textAlign: "right" }]}>
{totalGastos > 0 ? ((monto / totalGastos) * 100).toFixed(1) : 0}%
</Text>
</View>
))}
<View style={[styles.tableRow, { backgroundColor: "#f1f5f9" }]}>
<Text style={[styles.tableCell, { width: "50%", fontFamily: "Helvetica-Bold" }]}>TOTAL</Text>
<Text style={[styles.tableCell, { width: "25%", textAlign: "right", fontFamily: "Helvetica-Bold" }]}>
{formatCurrencyPDF(totalGastos)}
</Text>
<Text style={[styles.tableCell, { width: "25%", textAlign: "right", fontFamily: "Helvetica-Bold" }]}>
100%
</Text>
</View>
</View>
</View>
{/* Detalle de Gastos */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Detalle de Gastos</Text>
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.tableHeaderCell, { width: "12%" }]}>Fecha</Text>
<Text style={[styles.tableHeaderCell, { width: "30%" }]}>Concepto</Text>
<Text style={[styles.tableHeaderCell, { width: "18%" }]}>Categoria</Text>
<Text style={[styles.tableHeaderCell, { width: "12%" }]}>Estado</Text>
<Text style={[styles.tableHeaderCell, { width: "13%", textAlign: "right" }]}>Monto</Text>
<Text style={[styles.tableHeaderCell, { width: "15%" }]}>Responsable</Text>
</View>
{gastos.map((gasto, index) => (
<View key={gasto.id} style={[styles.tableRow, index % 2 === 1 ? styles.tableRowAlt : {}]}>
<Text style={[styles.tableCell, { width: "12%" }]}>
{new Date(gasto.fecha).toLocaleDateString("es-MX")}
</Text>
<Text style={[styles.tableCell, { width: "30%" }]}>{gasto.concepto}</Text>
<Text style={[styles.tableCell, { width: "18%" }]}>
{CATEGORIA_GASTO_LABELS[gasto.categoria]}
</Text>
<Text style={[
styles.tableCell,
{ width: "12%" },
gasto.estado === "PAGADO" ? styles.textSuccess : gasto.estado === "RECHAZADO" ? styles.textDanger : {}
]}>
{ESTADO_GASTO_LABELS[gasto.estado]}
</Text>
<Text style={[styles.tableCell, { width: "13%", textAlign: "right", fontFamily: "Helvetica-Bold" }]}>
{formatCurrencyPDF(gasto.monto)}
</Text>
<Text style={[styles.tableCell, { width: "15%", fontSize: 7 }]}>
{gasto.creadoPor.nombre} {gasto.creadoPor.apellido.charAt(0)}.
</Text>
</View>
))}
</View>
</View>
{/* Footer */}
<View style={styles.footer} fixed>
<Text style={styles.footerText}>{empresaNombre} - Sistema de Gestion de Obras</Text>
<Text style={styles.pageNumber} render={({ pageNumber, totalPages }) => `Pagina ${pageNumber} de ${totalPages}`} />
</View>
</Page>
</Document>
);
}

View File

@@ -0,0 +1,6 @@
export { ReporteObraPDF } from "./reporte-obra-pdf";
export { PresupuestoPDF } from "./presupuesto-pdf";
export { GastosPDF } from "./gastos-pdf";
export { BitacoraPDF } from "./bitacora-pdf";
export { ExportPDFButton, ExportPDFMenu } from "./export-pdf-button";
export * from "./styles";

View File

@@ -0,0 +1,173 @@
"use client";
import {
Document,
Page,
Text,
View,
} from "@react-pdf/renderer";
import { styles, formatCurrencyPDF, formatDatePDF } from "./styles";
interface PartidaPresupuesto {
codigo: string;
descripcion: string;
unidad: string;
cantidad: number;
precioUnitario: number;
total: number;
}
interface Presupuesto {
nombre: string;
total: number;
aprobado: boolean;
createdAt: Date | string;
partidas: PartidaPresupuesto[];
}
interface PresupuestoPDFProps {
obra: {
nombre: string;
direccion: string;
cliente: { nombre: string } | null;
};
presupuesto: Presupuesto;
empresaNombre?: string;
}
export function PresupuestoPDF({ obra, presupuesto, empresaNombre = "Mexus App" }: PresupuestoPDFProps) {
// Agrupar partidas por categoria (primeros 2 caracteres del codigo)
const partidasAgrupadas = presupuesto.partidas.reduce((acc, partida) => {
const categoria = partida.codigo.slice(0, 2);
if (!acc[categoria]) {
acc[categoria] = [];
}
acc[categoria].push(partida);
return acc;
}, {} as Record<string, PartidaPresupuesto[]>);
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Presupuesto de Obra</Text>
<Text style={styles.headerSubtitle}>{presupuesto.nombre}</Text>
<Text style={styles.headerDate}>
Generado el {formatDatePDF(new Date())} | {empresaNombre}
</Text>
</View>
{/* Info de la Obra */}
<View style={[styles.section, { backgroundColor: "#f8fafc", padding: 12, borderRadius: 4 }]}>
<View style={styles.twoColumn}>
<View style={styles.column}>
<View style={styles.row}>
<Text style={styles.label}>Obra:</Text>
<Text style={[styles.value, styles.textBold]}>{obra.nombre}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Direccion:</Text>
<Text style={styles.value}>{obra.direccion}</Text>
</View>
</View>
<View style={styles.column}>
<View style={styles.row}>
<Text style={styles.label}>Cliente:</Text>
<Text style={styles.value}>{obra.cliente?.nombre || "Sin cliente"}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Estado:</Text>
<Text style={[styles.value, presupuesto.aprobado ? styles.textSuccess : {}]}>
{presupuesto.aprobado ? "APROBADO" : "PENDIENTE"}
</Text>
</View>
</View>
</View>
</View>
{/* Resumen */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Resumen del Presupuesto</Text>
<View style={styles.statsGrid}>
<View style={[styles.statBox, { width: "30%" }]}>
<Text style={styles.statLabel}>Total Partidas</Text>
<Text style={styles.statValue}>{presupuesto.partidas.length}</Text>
</View>
<View style={[styles.statBox, { width: "30%" }]}>
<Text style={styles.statLabel}>Fecha Creacion</Text>
<Text style={[styles.statValue, { fontSize: 12 }]}>{formatDatePDF(presupuesto.createdAt)}</Text>
</View>
<View style={[styles.statBox, { width: "30%", backgroundColor: "#dbeafe" }]}>
<Text style={styles.statLabel}>Total Presupuesto</Text>
<Text style={[styles.statValue, { color: "#1e40af" }]}>{formatCurrencyPDF(presupuesto.total)}</Text>
</View>
</View>
</View>
{/* Tabla de Partidas */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Detalle de Partidas</Text>
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.tableHeaderCell, { width: "12%" }]}>Codigo</Text>
<Text style={[styles.tableHeaderCell, { width: "38%" }]}>Descripcion</Text>
<Text style={[styles.tableHeaderCell, { width: "10%" }]}>Unidad</Text>
<Text style={[styles.tableHeaderCell, { width: "10%", textAlign: "right" }]}>Cant.</Text>
<Text style={[styles.tableHeaderCell, { width: "15%", textAlign: "right" }]}>P. Unit.</Text>
<Text style={[styles.tableHeaderCell, { width: "15%", textAlign: "right" }]}>Total</Text>
</View>
{presupuesto.partidas.map((partida, index) => (
<View key={index} style={[styles.tableRow, index % 2 === 1 ? styles.tableRowAlt : {}]}>
<Text style={[styles.tableCell, { width: "12%", fontFamily: "Helvetica-Bold" }]}>
{partida.codigo}
</Text>
<Text style={[styles.tableCell, { width: "38%" }]}>{partida.descripcion}</Text>
<Text style={[styles.tableCell, { width: "10%" }]}>{partida.unidad}</Text>
<Text style={[styles.tableCell, { width: "10%", textAlign: "right" }]}>
{partida.cantidad.toFixed(2)}
</Text>
<Text style={[styles.tableCell, { width: "15%", textAlign: "right" }]}>
{formatCurrencyPDF(partida.precioUnitario)}
</Text>
<Text style={[styles.tableCell, { width: "15%", textAlign: "right", fontFamily: "Helvetica-Bold" }]}>
{formatCurrencyPDF(partida.total)}
</Text>
</View>
))}
</View>
{/* Total */}
<View style={{
flexDirection: "row",
justifyContent: "flex-end",
marginTop: 15,
paddingTop: 10,
borderTopWidth: 2,
borderTopColor: "#1e40af"
}}>
<Text style={{ fontSize: 12, marginRight: 20, color: "#64748b" }}>TOTAL:</Text>
<Text style={{ fontSize: 16, fontFamily: "Helvetica-Bold", color: "#1e40af" }}>
{formatCurrencyPDF(presupuesto.total)}
</Text>
</View>
</View>
{/* Notas */}
<View style={[styles.section, { marginTop: 20 }]}>
<Text style={{ fontSize: 8, color: "#94a3b8" }}>
Notas: Este presupuesto tiene validez de 30 dias a partir de la fecha de emision.
Los precios no incluyen IVA salvo que se indique lo contrario.
Sujeto a cambios segun disponibilidad de materiales.
</Text>
</View>
{/* Footer */}
<View style={styles.footer} fixed>
<Text style={styles.footerText}>{empresaNombre} - Sistema de Gestion de Obras</Text>
<Text style={styles.pageNumber} render={({ pageNumber, totalPages }) => `Pagina ${pageNumber} de ${totalPages}`} />
</View>
</Page>
</Document>
);
}

View File

@@ -0,0 +1,227 @@
"use client";
import {
Document,
Page,
Text,
View,
} from "@react-pdf/renderer";
import { styles, formatCurrencyPDF, formatDatePDF, formatPercentagePDF } from "./styles";
import {
ESTADO_OBRA_LABELS,
ESTADO_TAREA_LABELS,
CATEGORIA_GASTO_LABELS,
type EstadoObra,
type EstadoTarea,
type CategoriaGasto,
} from "@/types";
interface ObraReportData {
nombre: string;
descripcion: string | null;
direccion: string;
estado: EstadoObra;
porcentajeAvance: number;
presupuestoTotal: number;
gastoTotal: number;
fechaInicio: Date | string | null;
fechaFinPrevista: Date | string | null;
cliente: { nombre: string } | null;
supervisor: { nombre: string; apellido: string } | null;
fases: {
nombre: string;
porcentajeAvance: number;
tareas: {
nombre: string;
estado: EstadoTarea;
}[];
}[];
gastos: {
concepto: string;
monto: number;
fecha: Date | string;
categoria: CategoriaGasto;
}[];
}
interface ReporteObraPDFProps {
obra: ObraReportData;
empresaNombre?: string;
}
export function ReporteObraPDF({ obra, empresaNombre = "Mexus App" }: ReporteObraPDFProps) {
const variacion = obra.presupuestoTotal - obra.gastoTotal;
const porcentajeGastado = obra.presupuestoTotal > 0
? (obra.gastoTotal / obra.presupuestoTotal) * 100
: 0;
const tareasCompletadas = obra.fases.reduce(
(acc, fase) => acc + fase.tareas.filter((t) => t.estado === "COMPLETADA").length,
0
);
const tareasTotal = obra.fases.reduce((acc, fase) => acc + fase.tareas.length, 0);
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>{obra.nombre}</Text>
<Text style={styles.headerSubtitle}>Reporte General de Obra</Text>
<Text style={styles.headerDate}>
Generado el {formatDatePDF(new Date())} | {empresaNombre}
</Text>
</View>
{/* Stats Grid */}
<View style={styles.statsGrid}>
<View style={[styles.statBox, { width: "22%" }]}>
<Text style={styles.statLabel}>Avance</Text>
<Text style={styles.statValue}>{formatPercentagePDF(obra.porcentajeAvance)}</Text>
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${Math.min(obra.porcentajeAvance, 100)}%` }]} />
</View>
</View>
<View style={[styles.statBox, { width: "22%" }]}>
<Text style={styles.statLabel}>Presupuesto</Text>
<Text style={styles.statValue}>{formatCurrencyPDF(obra.presupuestoTotal)}</Text>
<Text style={styles.statSubtext}>Total aprobado</Text>
</View>
<View style={[styles.statBox, { width: "22%" }]}>
<Text style={styles.statLabel}>Gastado</Text>
<Text style={styles.statValue}>{formatCurrencyPDF(obra.gastoTotal)}</Text>
<Text style={styles.statSubtext}>{formatPercentagePDF(porcentajeGastado)} del presupuesto</Text>
</View>
<View style={[styles.statBox, { width: "22%" }]}>
<Text style={styles.statLabel}>Variacion</Text>
<Text style={[styles.statValue, variacion >= 0 ? styles.textSuccess : styles.textDanger]}>
{variacion >= 0 ? "+" : ""}{formatCurrencyPDF(variacion)}
</Text>
<Text style={styles.statSubtext}>{variacion >= 0 ? "Bajo presupuesto" : "Sobre presupuesto"}</Text>
</View>
</View>
{/* Info General */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Informacion General</Text>
<View style={styles.twoColumn}>
<View style={styles.column}>
<View style={styles.row}>
<Text style={styles.label}>Estado:</Text>
<Text style={styles.value}>{ESTADO_OBRA_LABELS[obra.estado]}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Direccion:</Text>
<Text style={styles.value}>{obra.direccion}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Cliente:</Text>
<Text style={styles.value}>{obra.cliente?.nombre || "Sin asignar"}</Text>
</View>
</View>
<View style={styles.column}>
<View style={styles.row}>
<Text style={styles.label}>Supervisor:</Text>
<Text style={styles.value}>
{obra.supervisor ? `${obra.supervisor.nombre} ${obra.supervisor.apellido}` : "Sin asignar"}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Fecha Inicio:</Text>
<Text style={styles.value}>
{obra.fechaInicio ? formatDatePDF(obra.fechaInicio) : "No definida"}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Fecha Fin Prevista:</Text>
<Text style={styles.value}>
{obra.fechaFinPrevista ? formatDatePDF(obra.fechaFinPrevista) : "No definida"}
</Text>
</View>
</View>
</View>
{obra.descripcion && (
<View style={[styles.row, { marginTop: 10 }]}>
<Text style={styles.label}>Descripcion:</Text>
<Text style={[styles.value, { width: "100%" }]}>{obra.descripcion}</Text>
</View>
)}
</View>
{/* Fases y Avance */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
Fases del Proyecto ({tareasCompletadas}/{tareasTotal} tareas completadas)
</Text>
{obra.fases.length === 0 ? (
<Text style={styles.textMuted}>No hay fases definidas</Text>
) : (
obra.fases.map((fase, index) => (
<View key={index} style={styles.card}>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
<Text style={styles.cardTitle}>{fase.nombre}</Text>
<Text style={styles.textMuted}>{formatPercentagePDF(fase.porcentajeAvance)}</Text>
</View>
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${Math.min(fase.porcentajeAvance, 100)}%` }]} />
</View>
{fase.tareas.length > 0 && (
<View style={{ marginTop: 8 }}>
{fase.tareas.slice(0, 5).map((tarea, idx) => (
<View key={idx} style={{ flexDirection: "row", marginBottom: 2 }}>
<Text style={{ fontSize: 8, marginRight: 5 }}>
{tarea.estado === "COMPLETADA" ? "✓" : "○"}
</Text>
<Text style={{ fontSize: 8, color: tarea.estado === "COMPLETADA" ? "#16a34a" : "#64748b" }}>
{tarea.nombre} - {ESTADO_TAREA_LABELS[tarea.estado]}
</Text>
</View>
))}
{fase.tareas.length > 5 && (
<Text style={{ fontSize: 8, color: "#94a3b8", fontStyle: "italic" }}>
+{fase.tareas.length - 5} tareas mas...
</Text>
)}
</View>
)}
</View>
))
)}
</View>
{/* Gastos Recientes */}
{obra.gastos.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Ultimos Gastos</Text>
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.tableHeaderCell, { width: "40%" }]}>Concepto</Text>
<Text style={[styles.tableHeaderCell, { width: "25%" }]}>Categoria</Text>
<Text style={[styles.tableHeaderCell, { width: "15%" }]}>Fecha</Text>
<Text style={[styles.tableHeaderCell, { width: "20%", textAlign: "right" }]}>Monto</Text>
</View>
{obra.gastos.slice(0, 10).map((gasto, index) => (
<View key={index} style={[styles.tableRow, index % 2 === 1 ? styles.tableRowAlt : {}]}>
<Text style={[styles.tableCell, { width: "40%" }]}>{gasto.concepto}</Text>
<Text style={[styles.tableCell, { width: "25%" }]}>{CATEGORIA_GASTO_LABELS[gasto.categoria]}</Text>
<Text style={[styles.tableCell, { width: "15%" }]}>
{new Date(gasto.fecha).toLocaleDateString("es-MX")}
</Text>
<Text style={[styles.tableCell, { width: "20%", textAlign: "right" }]}>
{formatCurrencyPDF(gasto.monto)}
</Text>
</View>
))}
</View>
</View>
)}
{/* Footer */}
<View style={styles.footer} fixed>
<Text style={styles.footerText}>{empresaNombre} - Sistema de Gestion de Obras</Text>
<Text style={styles.pageNumber} render={({ pageNumber, totalPages }) => `Pagina ${pageNumber} de ${totalPages}`} />
</View>
</Page>
</Document>
);
}

View File

@@ -0,0 +1,228 @@
import { StyleSheet } from "@react-pdf/renderer";
export const styles = StyleSheet.create({
page: {
padding: 40,
fontSize: 10,
fontFamily: "Helvetica",
},
header: {
marginBottom: 20,
borderBottomWidth: 2,
borderBottomColor: "#2563eb",
paddingBottom: 10,
},
headerTitle: {
fontSize: 24,
fontWeight: "bold",
color: "#1e3a8a",
marginBottom: 5,
},
headerSubtitle: {
fontSize: 12,
color: "#64748b",
},
headerDate: {
fontSize: 9,
color: "#94a3b8",
marginTop: 5,
},
section: {
marginBottom: 15,
},
sectionTitle: {
fontSize: 14,
fontWeight: "bold",
color: "#1e40af",
marginBottom: 8,
paddingBottom: 4,
borderBottomWidth: 1,
borderBottomColor: "#e2e8f0",
},
row: {
flexDirection: "row",
marginBottom: 4,
},
label: {
width: "30%",
color: "#64748b",
fontSize: 9,
},
value: {
width: "70%",
fontSize: 10,
},
table: {
width: "100%",
marginTop: 10,
},
tableHeader: {
flexDirection: "row",
backgroundColor: "#f1f5f9",
borderBottomWidth: 1,
borderBottomColor: "#e2e8f0",
paddingVertical: 6,
paddingHorizontal: 4,
},
tableHeaderCell: {
fontSize: 9,
fontWeight: "bold",
color: "#475569",
},
tableRow: {
flexDirection: "row",
borderBottomWidth: 1,
borderBottomColor: "#f1f5f9",
paddingVertical: 6,
paddingHorizontal: 4,
},
tableRowAlt: {
backgroundColor: "#fafafa",
},
tableCell: {
fontSize: 9,
color: "#334155",
},
statsGrid: {
flexDirection: "row",
flexWrap: "wrap",
marginBottom: 15,
},
statBox: {
width: "25%",
padding: 10,
backgroundColor: "#f8fafc",
borderRadius: 4,
marginRight: 10,
marginBottom: 10,
},
statLabel: {
fontSize: 8,
color: "#64748b",
marginBottom: 4,
},
statValue: {
fontSize: 16,
fontWeight: "bold",
color: "#1e293b",
},
statSubtext: {
fontSize: 8,
color: "#94a3b8",
marginTop: 2,
},
badge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
fontSize: 8,
fontWeight: "bold",
},
badgeGreen: {
backgroundColor: "#dcfce7",
color: "#166534",
},
badgeBlue: {
backgroundColor: "#dbeafe",
color: "#1e40af",
},
badgeYellow: {
backgroundColor: "#fef9c3",
color: "#854d0e",
},
badgeRed: {
backgroundColor: "#fee2e2",
color: "#991b1b",
},
badgeGray: {
backgroundColor: "#f1f5f9",
color: "#475569",
},
progressBar: {
height: 8,
backgroundColor: "#e2e8f0",
borderRadius: 4,
marginTop: 4,
},
progressFill: {
height: 8,
backgroundColor: "#2563eb",
borderRadius: 4,
},
footer: {
position: "absolute",
bottom: 30,
left: 40,
right: 40,
flexDirection: "row",
justifyContent: "space-between",
borderTopWidth: 1,
borderTopColor: "#e2e8f0",
paddingTop: 10,
},
footerText: {
fontSize: 8,
color: "#94a3b8",
},
pageNumber: {
fontSize: 8,
color: "#64748b",
},
textMuted: {
color: "#64748b",
fontSize: 9,
},
textSuccess: {
color: "#16a34a",
},
textDanger: {
color: "#dc2626",
},
textBold: {
fontWeight: "bold",
},
divider: {
borderBottomWidth: 1,
borderBottomColor: "#e2e8f0",
marginVertical: 10,
},
card: {
backgroundColor: "#f8fafc",
borderRadius: 4,
padding: 12,
marginBottom: 10,
},
cardTitle: {
fontSize: 11,
fontWeight: "bold",
marginBottom: 6,
color: "#1e293b",
},
twoColumn: {
flexDirection: "row",
justifyContent: "space-between",
},
column: {
width: "48%",
},
});
export const formatCurrencyPDF = (value: number): string => {
return new Intl.NumberFormat("es-MX", {
style: "currency",
currency: "MXN",
}).format(value);
};
export const formatDatePDF = (date: Date | string): string => {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("es-MX", {
day: "2-digit",
month: "long",
year: "numeric",
});
};
export const formatPercentagePDF = (value: number): string => {
return `${value.toFixed(1)}%`;
};

View File

@@ -0,0 +1,235 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Download, X, Smartphone, Share, Plus } from "lucide-react";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
export function PWAInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [showInstallDialog, setShowInstallDialog] = useState(false);
const [showIOSInstructions, setShowIOSInstructions] = useState(false);
const [isStandalone, setIsStandalone] = useState(false);
const [isIOS, setIsIOS] = useState(false);
useEffect(() => {
// Verificar si ya está instalada como PWA
const checkStandalone = () => {
const standalone = window.matchMedia("(display-mode: standalone)").matches ||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
setIsStandalone(standalone);
};
// Detectar iOS
const checkIOS = () => {
const isIOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as typeof window & { MSStream?: unknown }).MSStream;
setIsIOS(isIOSDevice);
};
checkStandalone();
checkIOS();
// Capturar el evento beforeinstallprompt
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
// Mostrar el diálogo después de un pequeño delay
const dismissed = localStorage.getItem("pwa-install-dismissed");
const dismissedTime = dismissed ? parseInt(dismissed) : 0;
const oneWeek = 7 * 24 * 60 * 60 * 1000;
if (!dismissed || Date.now() - dismissedTime > oneWeek) {
setTimeout(() => setShowInstallDialog(true), 3000);
}
};
// Escuchar cuando la app es instalada
const handleAppInstalled = () => {
setDeferredPrompt(null);
setShowInstallDialog(false);
console.log("PWA instalada exitosamente");
};
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
window.addEventListener("appinstalled", handleAppInstalled);
// Registrar el Service Worker
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").then((registration) => {
console.log("Service Worker registrado:", registration.scope);
}).catch((error) => {
console.error("Error al registrar Service Worker:", error);
});
}
return () => {
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
window.removeEventListener("appinstalled", handleAppInstalled);
};
}, []);
const handleInstallClick = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") {
console.log("Usuario aceptó instalar la PWA");
} else {
console.log("Usuario rechazó instalar la PWA");
}
setDeferredPrompt(null);
setShowInstallDialog(false);
};
const handleDismiss = () => {
localStorage.setItem("pwa-install-dismissed", Date.now().toString());
setShowInstallDialog(false);
};
// Si ya está instalada, no mostrar nada
if (isStandalone) {
return null;
}
// Mostrar instrucciones para iOS
if (isIOS && !deferredPrompt) {
return (
<>
{/* Botón flotante para iOS */}
<Button
variant="outline"
size="sm"
className="fixed bottom-4 right-4 z-50 shadow-lg bg-white"
onClick={() => setShowIOSInstructions(true)}
>
<Download className="h-4 w-4 mr-2" />
Instalar App
</Button>
<Dialog open={showIOSInstructions} onOpenChange={setShowIOSInstructions}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Smartphone className="h-5 w-5" />
Instalar Mexus en tu iPhone
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100 text-blue-600 font-bold">
1
</div>
<div>
<p className="font-medium">Toca el botón compartir</p>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Share className="h-4 w-4" />
en la barra de Safari
</div>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100 text-blue-600 font-bold">
2
</div>
<div>
<p className="font-medium">Selecciona &quot;Agregar a inicio&quot;</p>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Plus className="h-4 w-4" />
Add to Home Screen
</div>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100 text-blue-600 font-bold">
3
</div>
<div>
<p className="font-medium">Confirma tocando &quot;Agregar&quot;</p>
<p className="text-sm text-muted-foreground">
La app aparecerá en tu pantalla de inicio
</p>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowIOSInstructions(false)}>
Entendido
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
// Diálogo de instalación para Android/Desktop
return (
<Dialog open={showInstallDialog} onOpenChange={setShowInstallDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Smartphone className="h-5 w-5 text-primary" />
Instalar Mexus
</DialogTitle>
<DialogDescription>
Instala la aplicación para un acceso más rápido y una mejor experiencia.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Download className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium">Acceso rápido</p>
<p className="text-xs text-muted-foreground">
Abre la app directamente desde tu pantalla de inicio
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Smartphone className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium">Experiencia nativa</p>
<p className="text-xs text-muted-foreground">
Funciona como una app nativa en tu dispositivo
</p>
</div>
</div>
</div>
<DialogFooter className="flex-col gap-2 sm:flex-row">
<Button
variant="ghost"
onClick={handleDismiss}
className="w-full sm:w-auto"
>
<X className="h-4 w-4 mr-2" />
Ahora no
</Button>
<Button onClick={handleInstallClick} className="w-full sm:w-auto">
<Download className="h-4 w-4 mr-2" />
Instalar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { useEffect } from "react";
import { PWAInstallPrompt } from "./pwa-install-prompt";
export function PWAProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Registrar el Service Worker al cargar
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js")
.then((registration) => {
console.log("Service Worker registrado con scope:", registration.scope);
// Verificar actualizaciones
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener("statechange", () => {
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
// Hay una nueva versión disponible
console.log("Nueva versión disponible");
// Aquí podrías mostrar un toast o notificación
}
});
}
});
})
.catch((error) => {
console.error("Error registrando Service Worker:", error);
});
});
// Manejar actualizaciones del service worker
navigator.serviceWorker.addEventListener("controllerchange", () => {
console.log("Service Worker actualizado");
});
}
}, []);
return (
<>
{children}
<PWAInstallPrompt />
</>
);
}

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

262
src/lib/activity-log.ts Normal file
View File

@@ -0,0 +1,262 @@
import { prisma } from "@/lib/prisma";
import { TipoActividad } from "@prisma/client";
interface LogActivityParams {
tipo: TipoActividad;
descripcion: string;
detalles?: Record<string, unknown>;
entidadTipo?: string;
entidadId?: string;
entidadNombre?: string;
obraId?: string;
userId?: string;
empresaId: string;
ipAddress?: string;
userAgent?: string;
}
// Registrar una actividad
export async function logActivity({
tipo,
descripcion,
detalles,
entidadTipo,
entidadId,
entidadNombre,
obraId,
userId,
empresaId,
ipAddress,
userAgent,
}: LogActivityParams) {
try {
const actividad = await prisma.actividadLog.create({
data: {
tipo,
descripcion,
detalles: detalles ? JSON.stringify(detalles) : null,
entidadTipo,
entidadId,
entidadNombre,
obraId,
userId,
empresaId,
ipAddress,
userAgent,
},
});
return actividad;
} catch (error) {
console.error("Error logging activity:", error);
return null;
}
}
// Templates de actividades comunes
export const ActivityTemplates = {
obraCreada: (obraNombre: string, obraId: string, userId: string, empresaId: string) =>
logActivity({
tipo: "OBRA_CREADA",
descripcion: `Obra "${obraNombre}" creada`,
entidadTipo: "obra",
entidadId: obraId,
entidadNombre: obraNombre,
obraId,
userId,
empresaId,
}),
obraActualizada: (obraNombre: string, obraId: string, userId: string, empresaId: string, cambios?: Record<string, unknown>) =>
logActivity({
tipo: "OBRA_ACTUALIZADA",
descripcion: `Obra "${obraNombre}" actualizada`,
detalles: cambios,
entidadTipo: "obra",
entidadId: obraId,
entidadNombre: obraNombre,
obraId,
userId,
empresaId,
}),
tareaCreada: (tareaNombre: string, tareaId: string, obraId: string, userId: string, empresaId: string) =>
logActivity({
tipo: "TAREA_CREADA",
descripcion: `Tarea "${tareaNombre}" creada`,
entidadTipo: "tarea",
entidadId: tareaId,
entidadNombre: tareaNombre,
obraId,
userId,
empresaId,
}),
tareaCompletada: (tareaNombre: string, tareaId: string, obraId: string, userId: string, empresaId: string) =>
logActivity({
tipo: "TAREA_COMPLETADA",
descripcion: `Tarea "${tareaNombre}" completada`,
entidadTipo: "tarea",
entidadId: tareaId,
entidadNombre: tareaNombre,
obraId,
userId,
empresaId,
}),
gastoCreado: (concepto: string, monto: number, gastoId: string, obraId: string, userId: string, empresaId: string) =>
logActivity({
tipo: "GASTO_CREADO",
descripcion: `Gasto "${concepto}" por $${monto.toLocaleString()} registrado`,
detalles: { monto },
entidadTipo: "gasto",
entidadId: gastoId,
entidadNombre: concepto,
obraId,
userId,
empresaId,
}),
gastoAprobado: (concepto: string, gastoId: string, obraId: string, userId: string, empresaId: string) =>
logActivity({
tipo: "GASTO_APROBADO",
descripcion: `Gasto "${concepto}" aprobado`,
entidadTipo: "gasto",
entidadId: gastoId,
entidadNombre: concepto,
obraId,
userId,
empresaId,
}),
ordenCreada: (numero: string, ordenId: string, obraId: string, userId: string, empresaId: string) =>
logActivity({
tipo: "ORDEN_CREADA",
descripcion: `Orden de compra ${numero} creada`,
entidadTipo: "orden",
entidadId: ordenId,
entidadNombre: numero,
obraId,
userId,
empresaId,
}),
ordenAprobada: (numero: string, ordenId: string, obraId: string, userId: string, empresaId: string) =>
logActivity({
tipo: "ORDEN_APROBADA",
descripcion: `Orden de compra ${numero} aprobada`,
entidadTipo: "orden",
entidadId: ordenId,
entidadNombre: numero,
obraId,
userId,
empresaId,
}),
avanceRegistrado: (porcentaje: number, obraId: string, obraNombre: string, userId: string, empresaId: string) =>
logActivity({
tipo: "AVANCE_REGISTRADO",
descripcion: `Avance de ${porcentaje}% registrado en ${obraNombre}`,
detalles: { porcentaje },
entidadTipo: "obra",
entidadId: obraId,
entidadNombre: obraNombre,
obraId,
userId,
empresaId,
}),
fotoSubida: (titulo: string | null, fotoId: string, obraId: string, userId: string, empresaId: string) =>
logActivity({
tipo: "FOTO_SUBIDA",
descripcion: titulo ? `Foto "${titulo}" subida` : "Foto subida",
entidadTipo: "foto",
entidadId: fotoId,
entidadNombre: titulo || "Foto",
obraId,
userId,
empresaId,
}),
bitacoraRegistrada: (fecha: string, obraId: string, obraNombre: string, userId: string, empresaId: string) =>
logActivity({
tipo: "BITACORA_REGISTRADA",
descripcion: `Bitácora del ${fecha} registrada para ${obraNombre}`,
entidadTipo: "bitacora",
obraId,
userId,
empresaId,
}),
materialMovimiento: (
materialNombre: string,
tipo: "ENTRADA" | "SALIDA" | "AJUSTE",
cantidad: number,
materialId: string,
obraId: string | null,
userId: string,
empresaId: string
) =>
logActivity({
tipo: "MATERIAL_MOVIMIENTO",
descripcion: `${tipo === "ENTRADA" ? "Entrada" : tipo === "SALIDA" ? "Salida" : "Ajuste"} de ${cantidad} unidades de "${materialNombre}"`,
detalles: { tipoMovimiento: tipo, cantidad },
entidadTipo: "material",
entidadId: materialId,
entidadNombre: materialNombre,
obraId: obraId || undefined,
userId,
empresaId,
}),
};
// Obtener el icono según el tipo de actividad
export const ACTIVIDAD_ICONS: Record<TipoActividad, string> = {
OBRA_CREADA: "building-2",
OBRA_ACTUALIZADA: "pencil",
OBRA_ESTADO_CAMBIADO: "refresh-cw",
FASE_CREADA: "layers",
TAREA_CREADA: "clipboard-list",
TAREA_ASIGNADA: "user-plus",
TAREA_COMPLETADA: "check-circle",
TAREA_ESTADO_CAMBIADO: "refresh-cw",
GASTO_CREADO: "dollar-sign",
GASTO_APROBADO: "check",
GASTO_RECHAZADO: "x",
ORDEN_CREADA: "package",
ORDEN_APROBADA: "check",
ORDEN_ENVIADA: "send",
ORDEN_RECIBIDA: "package-check",
AVANCE_REGISTRADO: "trending-up",
FOTO_SUBIDA: "camera",
BITACORA_REGISTRADA: "book-open",
MATERIAL_MOVIMIENTO: "boxes",
USUARIO_ASIGNADO: "user-plus",
COMENTARIO_AGREGADO: "message-square",
DOCUMENTO_SUBIDO: "file-text",
};
// Colores por tipo
export const ACTIVIDAD_COLORS: Record<TipoActividad, string> = {
OBRA_CREADA: "text-blue-500",
OBRA_ACTUALIZADA: "text-gray-500",
OBRA_ESTADO_CAMBIADO: "text-purple-500",
FASE_CREADA: "text-indigo-500",
TAREA_CREADA: "text-blue-500",
TAREA_ASIGNADA: "text-cyan-500",
TAREA_COMPLETADA: "text-green-500",
TAREA_ESTADO_CAMBIADO: "text-yellow-500",
GASTO_CREADO: "text-orange-500",
GASTO_APROBADO: "text-green-500",
GASTO_RECHAZADO: "text-red-500",
ORDEN_CREADA: "text-purple-500",
ORDEN_APROBADA: "text-green-500",
ORDEN_ENVIADA: "text-blue-500",
ORDEN_RECIBIDA: "text-green-500",
AVANCE_REGISTRADO: "text-teal-500",
FOTO_SUBIDA: "text-pink-500",
BITACORA_REGISTRADA: "text-amber-500",
MATERIAL_MOVIMIENTO: "text-gray-500",
USUARIO_ASIGNADO: "text-cyan-500",
COMENTARIO_AGREGADO: "text-blue-500",
DOCUMENTO_SUBIDO: "text-gray-500",
};

68
src/lib/portal-auth.ts Normal file
View File

@@ -0,0 +1,68 @@
import { cookies } from "next/headers";
import { jwtVerify } from "jose";
import { prisma } from "@/lib/prisma";
const SECRET = new TextEncoder().encode(
process.env.NEXTAUTH_SECRET || "portal-cliente-secret"
);
export interface PortalSession {
clienteAccesoId: string;
clienteId: string;
permisos: {
verFotos: boolean;
verAvances: boolean;
verGastos: boolean;
verDocumentos: boolean;
descargarPDF: boolean;
};
}
export async function getPortalSession(): Promise<PortalSession | null> {
try {
const cookieStore = await cookies();
const token = cookieStore.get("portal-token")?.value;
if (!token) {
return null;
}
const { payload } = await jwtVerify(token, SECRET);
if (payload.type !== "portal") {
return null;
}
const acceso = await prisma.clienteAcceso.findUnique({
where: { id: payload.clienteAccesoId as string },
select: {
id: true,
clienteId: true,
activo: true,
verFotos: true,
verAvances: true,
verGastos: true,
verDocumentos: true,
descargarPDF: true,
},
});
if (!acceso || !acceso.activo) {
return null;
}
return {
clienteAccesoId: acceso.id,
clienteId: acceso.clienteId,
permisos: {
verFotos: acceso.verFotos,
verAvances: acceso.verAvances,
verGastos: acceso.verGastos,
verDocumentos: acceso.verDocumentos,
descargarPDF: acceso.descargarPDF,
},
};
} catch {
return null;
}
}

View File

@@ -0,0 +1,221 @@
import webpush from "web-push";
import { prisma } from "@/lib/prisma";
import { TipoNotificacion } from "@prisma/client";
// Configurar VAPID (generar claves con: npx web-push generate-vapid-keys)
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "";
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || "";
const VAPID_SUBJECT = process.env.VAPID_SUBJECT || "mailto:admin@mexusapp.com";
if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
}
interface NotificationPayload {
title: string;
body: string;
icon?: string;
badge?: string;
url?: string;
tag?: string;
}
interface CreateNotificationParams {
userId: string;
tipo: TipoNotificacion;
titulo: string;
mensaje: string;
url?: string;
metadata?: Record<string, unknown>;
sendPush?: boolean;
}
// Crear una notificación y opcionalmente enviar push
export async function createNotification({
userId,
tipo,
titulo,
mensaje,
url,
metadata,
sendPush = true,
}: CreateNotificationParams) {
// Guardar notificación en BD
const notificacion = await prisma.notificacion.create({
data: {
tipo,
titulo,
mensaje,
url,
metadata: metadata ? JSON.stringify(metadata) : null,
userId,
},
});
// Enviar push si está habilitado
if (sendPush && VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
await sendPushToUser(userId, {
title: titulo,
body: mensaje,
url: url || "/",
tag: `notification-${notificacion.id}`,
});
// Marcar como enviada
await prisma.notificacion.update({
where: { id: notificacion.id },
data: { enviada: true },
});
}
return notificacion;
}
// Enviar push a un usuario específico
export async function sendPushToUser(userId: string, payload: NotificationPayload) {
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) {
console.warn("VAPID keys not configured, skipping push notification");
return { sent: 0, failed: 0 };
}
const subscriptions = await prisma.pushSubscription.findMany({
where: {
userId,
activo: true,
},
});
let sent = 0;
let failed = 0;
for (const sub of subscriptions) {
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: {
p256dh: sub.p256dh,
auth: sub.auth,
},
},
JSON.stringify(payload)
);
sent++;
} catch (error: any) {
console.error("Error sending push:", error);
failed++;
// Si la suscripción expiró o es inválida, desactivarla
if (error.statusCode === 404 || error.statusCode === 410) {
await prisma.pushSubscription.update({
where: { id: sub.id },
data: { activo: false },
});
}
}
}
return { sent, failed };
}
// Enviar push a todos los usuarios de una empresa
export async function sendPushToEmpresa(
empresaId: string,
payload: NotificationPayload,
options?: {
excludeUserId?: string;
roles?: string[];
}
) {
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) {
console.warn("VAPID keys not configured, skipping push notification");
return { sent: 0, failed: 0 };
}
const subscriptions = await prisma.pushSubscription.findMany({
where: {
activo: true,
user: {
empresaId,
...(options?.excludeUserId && { id: { not: options.excludeUserId } }),
...(options?.roles && { role: { in: options.roles as any } }),
},
},
});
let sent = 0;
let failed = 0;
for (const sub of subscriptions) {
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: {
p256dh: sub.p256dh,
auth: sub.auth,
},
},
JSON.stringify(payload)
);
sent++;
} catch (error: any) {
console.error("Error sending push:", error);
failed++;
if (error.statusCode === 404 || error.statusCode === 410) {
await prisma.pushSubscription.update({
where: { id: sub.id },
data: { activo: false },
});
}
}
}
return { sent, failed };
}
// Notificaciones predefinidas
export const NotificationTemplates = {
tareaAsignada: (tareaName: string, obraName: string) => ({
tipo: "TAREA_ASIGNADA" as TipoNotificacion,
titulo: "Nueva tarea asignada",
mensaje: `Se te ha asignado la tarea "${tareaName}" en ${obraName}`,
}),
tareaCompletada: (tareaName: string, userName: string) => ({
tipo: "TAREA_COMPLETADA" as TipoNotificacion,
titulo: "Tarea completada",
mensaje: `${userName} ha completado la tarea "${tareaName}"`,
}),
gastoPendiente: (concepto: string, monto: number) => ({
tipo: "GASTO_PENDIENTE" as TipoNotificacion,
titulo: "Nuevo gasto pendiente",
mensaje: `Hay un nuevo gasto por aprobar: ${concepto} ($${monto.toLocaleString()})`,
}),
gastoAprobado: (concepto: string) => ({
tipo: "GASTO_APROBADO" as TipoNotificacion,
titulo: "Gasto aprobado",
mensaje: `Tu gasto "${concepto}" ha sido aprobado`,
}),
ordenAprobada: (numero: string) => ({
tipo: "ORDEN_APROBADA" as TipoNotificacion,
titulo: "Orden de compra aprobada",
mensaje: `La orden ${numero} ha sido aprobada`,
}),
avanceRegistrado: (obraName: string, porcentaje: number) => ({
tipo: "AVANCE_REGISTRADO" as TipoNotificacion,
titulo: "Nuevo avance registrado",
mensaje: `Se ha registrado un avance de ${porcentaje}% en ${obraName}`,
}),
alertaInventario: (materialName: string, stockActual: number) => ({
tipo: "ALERTA_INVENTARIO" as TipoNotificacion,
titulo: "Alerta de inventario",
mensaje: `El material "${materialName}" tiene stock bajo (${stockActual} unidades)`,
}),
};

View File

@@ -8,6 +8,12 @@ import {
EstadoFactura,
TipoMovimiento,
UnidadMedida,
CondicionClima,
TipoAsistencia,
EstadoOrdenCompra,
PrioridadOrden,
TipoNotificacion,
TipoActividad,
} from "@prisma/client";
export type {
@@ -20,6 +26,12 @@ export type {
EstadoFactura,
TipoMovimiento,
UnidadMedida,
CondicionClima,
TipoAsistencia,
EstadoOrdenCompra,
PrioridadOrden,
TipoNotificacion,
TipoActividad,
};
export interface DashboardStats {
@@ -149,3 +161,79 @@ export const TIPO_FACTURA_LABELS: Record<TipoFactura, string> = {
EMITIDA: "Emitida",
RECIBIDA: "Recibida",
};
export const CONDICION_CLIMA_LABELS: Record<CondicionClima, string> = {
SOLEADO: "Soleado",
NUBLADO: "Nublado",
PARCIALMENTE_NUBLADO: "Parcialmente Nublado",
LLUVIA_LIGERA: "Lluvia Ligera",
LLUVIA_FUERTE: "Lluvia Fuerte",
TORMENTA: "Tormenta",
VIENTO_FUERTE: "Viento Fuerte",
FRIO_EXTREMO: "Frio Extremo",
CALOR_EXTREMO: "Calor Extremo",
};
export const CONDICION_CLIMA_ICONS: Record<CondicionClima, string> = {
SOLEADO: "sun",
NUBLADO: "cloud",
PARCIALMENTE_NUBLADO: "cloud-sun",
LLUVIA_LIGERA: "cloud-drizzle",
LLUVIA_FUERTE: "cloud-rain",
TORMENTA: "cloud-lightning",
VIENTO_FUERTE: "wind",
FRIO_EXTREMO: "snowflake",
CALOR_EXTREMO: "thermometer",
};
export const TIPO_ASISTENCIA_LABELS: Record<TipoAsistencia, string> = {
PRESENTE: "Presente",
AUSENTE: "Ausente",
RETARDO: "Retardo",
PERMISO: "Permiso",
INCAPACIDAD: "Incapacidad",
VACACIONES: "Vacaciones",
};
export const TIPO_ASISTENCIA_COLORS: Record<TipoAsistencia, string> = {
PRESENTE: "bg-green-100 text-green-800",
AUSENTE: "bg-red-100 text-red-800",
RETARDO: "bg-yellow-100 text-yellow-800",
PERMISO: "bg-blue-100 text-blue-800",
INCAPACIDAD: "bg-purple-100 text-purple-800",
VACACIONES: "bg-cyan-100 text-cyan-800",
};
export const ESTADO_ORDEN_COMPRA_LABELS: Record<EstadoOrdenCompra, string> = {
BORRADOR: "Borrador",
PENDIENTE: "Pendiente",
APROBADA: "Aprobada",
ENVIADA: "Enviada",
RECIBIDA_PARCIAL: "Recibida Parcial",
RECIBIDA: "Recibida",
CANCELADA: "Cancelada",
};
export const ESTADO_ORDEN_COMPRA_COLORS: Record<EstadoOrdenCompra, string> = {
BORRADOR: "bg-gray-100 text-gray-800",
PENDIENTE: "bg-yellow-100 text-yellow-800",
APROBADA: "bg-blue-100 text-blue-800",
ENVIADA: "bg-purple-100 text-purple-800",
RECIBIDA_PARCIAL: "bg-orange-100 text-orange-800",
RECIBIDA: "bg-green-100 text-green-800",
CANCELADA: "bg-red-100 text-red-800",
};
export const PRIORIDAD_ORDEN_LABELS: Record<PrioridadOrden, string> = {
BAJA: "Baja",
NORMAL: "Normal",
ALTA: "Alta",
URGENTE: "Urgente",
};
export const PRIORIDAD_ORDEN_COLORS: Record<PrioridadOrden, string> = {
BAJA: "bg-gray-100 text-gray-800",
NORMAL: "bg-blue-100 text-blue-800",
ALTA: "bg-orange-100 text-orange-800",
URGENTE: "bg-red-100 text-red-800",
};