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>
4
.gitignore
vendored
@@ -52,3 +52,7 @@ logs/
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
|
||||
# Uploads (user content)
|
||||
public/uploads/fotos/*
|
||||
!public/uploads/.gitkeep
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
After Width: | Height: | Size: 70 B |
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 70 B |
36
public/icons/README.md
Normal 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)
|
||||
BIN
public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 70 B |
BIN
public/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 70 B |
BIN
public/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 70 B |
BIN
public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 70 B |
BIN
public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 70 B |
BIN
public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 70 B |
BIN
public/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 70 B |
BIN
public/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 70 B |
38
public/icons/icon.svg
Normal 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
@@ -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
@@ -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
89
scripts/generate-icons-simple.js
Normal 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
@@ -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();
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
70
src/app/api/actividades/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
222
src/app/api/asistencia/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
91
src/app/api/asistencia/empleados/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
236
src/app/api/asistencia/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
194
src/app/api/bitacora/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
191
src/app/api/bitacora/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
239
src/app/api/clientes-acceso/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
178
src/app/api/clientes-acceso/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
184
src/app/api/fotos/[id]/route.ts
Normal 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
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
84
src/app/api/notifications/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
103
src/app/api/notifications/subscribe/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
224
src/app/api/ordenes-compra/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
215
src/app/api/ordenes-compra/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
272
src/app/api/portal/auth/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
125
src/app/api/portal/obras/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
52
src/app/api/portal/obras/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
578
src/app/portal/obras/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
src/app/portal/obras/page.tsx
Normal 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
@@ -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>
|
||||
);
|
||||
}
|
||||
293
src/components/actividades/timeline-actividades.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
841
src/components/asistencia/control-asistencia.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
842
src/components/bitacora/bitacora-obra.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
510
src/components/clientes/gestionar-acceso-cliente.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
615
src/components/fotos/galeria-fotos.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
359
src/components/gantt/diagrama-gantt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
286
src/components/notifications/notification-bell.tsx
Normal 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);
|
||||
}
|
||||
747
src/components/ordenes/ordenes-compra.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
237
src/components/pdf/bitacora-pdf.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
src/components/pdf/export-pdf-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
192
src/components/pdf/gastos-pdf.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/components/pdf/index.ts
Normal 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";
|
||||
173
src/components/pdf/presupuesto-pdf.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
src/components/pdf/reporte-obra-pdf.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
228
src/components/pdf/styles.ts
Normal 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)}%`;
|
||||
};
|
||||
235
src/components/pwa/pwa-install-prompt.tsx
Normal 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 "Agregar a inicio"</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 "Agregar"</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>
|
||||
);
|
||||
}
|
||||
47
src/components/pwa/pwa-provider.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
src/components/ui/switch.tsx
Normal 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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
221
src/lib/push-notifications.ts
Normal 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)`,
|
||||
}),
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
|
||||