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
|
# OS
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Uploads (user content)
|
||||||
|
public/uploads/fotos/*
|
||||||
|
!public/uploads/.gitkeep
|
||||||
|
|||||||
@@ -7,11 +7,19 @@ const nextConfig = {
|
|||||||
protocol: "https",
|
protocol: "https",
|
||||||
hostname: "**",
|
hostname: "**",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
protocol: "http",
|
||||||
|
hostname: "localhost",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "http",
|
||||||
|
hostname: "192.168.10.197",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: "2mb",
|
bodySizeLimit: "10mb",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
686
package-lock.json
generated
@@ -34,14 +34,18 @@
|
|||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^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-tabs": "^1.1.1",
|
||||||
"@radix-ui/react-toast": "^1.2.2",
|
"@radix-ui/react-toast": "^1.2.2",
|
||||||
"@radix-ui/react-tooltip": "^1.1.3",
|
"@radix-ui/react-tooltip": "^1.1.3",
|
||||||
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
"gantt-task-react": "^0.3.9",
|
||||||
|
"jose": "^6.1.3",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"next": "^14.2.28",
|
"next": "^14.2.28",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
@@ -51,6 +55,7 @@
|
|||||||
"recharts": "^2.13.0",
|
"recharts": "^2.13.0",
|
||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.4",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -58,6 +63,7 @@
|
|||||||
"@types/node": "^20.17.6",
|
"@types/node": "^20.17.6",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-next": "^14.2.28",
|
"eslint-config-next": "^14.2.28",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
|
|||||||
@@ -106,6 +106,14 @@ model User {
|
|||||||
tareasAsignadas TareaObra[]
|
tareasAsignadas TareaObra[]
|
||||||
obrasSupervision Obra[] @relation("ObraSupervisor")
|
obrasSupervision Obra[] @relation("ObraSupervisor")
|
||||||
registrosAvance RegistroAvance[]
|
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([empresaId])
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@ -145,10 +153,40 @@ model Cliente {
|
|||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
obras Obra[]
|
obras Obra[]
|
||||||
|
accesos ClienteAcceso[]
|
||||||
|
|
||||||
@@index([empresaId])
|
@@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 {
|
model Obra {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
nombre String
|
nombre String
|
||||||
@@ -180,6 +218,11 @@ model Obra {
|
|||||||
asignaciones AsignacionEmpleado[]
|
asignaciones AsignacionEmpleado[]
|
||||||
contratos ContratoSubcontratista[]
|
contratos ContratoSubcontratista[]
|
||||||
registrosAvance RegistroAvance[]
|
registrosAvance RegistroAvance[]
|
||||||
|
fotos FotoAvance[]
|
||||||
|
bitacoras BitacoraObra[]
|
||||||
|
asistencias Asistencia[]
|
||||||
|
ordenesCompra OrdenCompra[]
|
||||||
|
actividadesLog ActividadLog[]
|
||||||
|
|
||||||
@@index([empresaId])
|
@@index([empresaId])
|
||||||
@@index([estado])
|
@@index([estado])
|
||||||
@@ -201,6 +244,7 @@ model FaseObra {
|
|||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
tareas TareaObra[]
|
tareas TareaObra[]
|
||||||
|
fotos FotoAvance[]
|
||||||
|
|
||||||
@@index([obraId])
|
@@index([obraId])
|
||||||
}
|
}
|
||||||
@@ -350,6 +394,7 @@ model Material {
|
|||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
movimientos MovimientoInventario[]
|
movimientos MovimientoInventario[]
|
||||||
|
itemsOrden ItemOrdenCompra[]
|
||||||
|
|
||||||
@@unique([codigo, empresaId])
|
@@unique([codigo, empresaId])
|
||||||
@@index([empresaId])
|
@@index([empresaId])
|
||||||
@@ -392,6 +437,7 @@ model Empleado {
|
|||||||
// Relations
|
// Relations
|
||||||
asignaciones AsignacionEmpleado[]
|
asignaciones AsignacionEmpleado[]
|
||||||
jornadas JornadaTrabajo[]
|
jornadas JornadaTrabajo[]
|
||||||
|
asistencias Asistencia[]
|
||||||
|
|
||||||
@@index([empresaId])
|
@@index([empresaId])
|
||||||
}
|
}
|
||||||
@@ -464,3 +510,388 @@ model ContratoSubcontratista {
|
|||||||
@@index([subcontratistaId])
|
@@index([subcontratistaId])
|
||||||
@@index([obraId])
|
@@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 EstadoTarea,
|
||||||
type EstadoGasto,
|
type EstadoGasto,
|
||||||
type CategoriaGasto,
|
type CategoriaGasto,
|
||||||
|
type CondicionClima,
|
||||||
} from "@/types";
|
} 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 {
|
interface ObraDetailProps {
|
||||||
obra: {
|
obra: {
|
||||||
@@ -106,6 +118,43 @@ interface ObraDetailProps {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
registradoPor: { nombre: string; apellido: string };
|
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,12 +188,81 @@ export function ObraDetailClient({ obra }: ObraDetailProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/obras/${obra.id}/editar`}>
|
<div className="flex gap-2">
|
||||||
<Button>
|
<ExportPDFMenu
|
||||||
<Edit className="h-4 w-4 mr-2" />
|
options={[
|
||||||
Editar
|
{
|
||||||
</Button>
|
label: "Reporte General",
|
||||||
</Link>
|
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" />
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress and Stats */}
|
{/* Progress and Stats */}
|
||||||
@@ -207,11 +325,16 @@ export function ObraDetailClient({ obra }: ObraDetailProps) {
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs defaultValue="general" className="space-y-4">
|
<Tabs defaultValue="general" className="space-y-4">
|
||||||
<TabsList>
|
<TabsList className="flex-wrap">
|
||||||
<TabsTrigger value="general">General</TabsTrigger>
|
<TabsTrigger value="general">General</TabsTrigger>
|
||||||
<TabsTrigger value="cronograma">Cronograma</TabsTrigger>
|
<TabsTrigger value="cronograma">Cronograma</TabsTrigger>
|
||||||
|
<TabsTrigger value="gantt">Gantt</TabsTrigger>
|
||||||
<TabsTrigger value="presupuesto">Presupuesto</TabsTrigger>
|
<TabsTrigger value="presupuesto">Presupuesto</TabsTrigger>
|
||||||
<TabsTrigger value="gastos">Gastos</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>
|
<TabsTrigger value="avances">Avances</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -362,6 +485,33 @@ export function ObraDetailClient({ obra }: ObraDetailProps) {
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="presupuesto" className="space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h3 className="text-lg font-semibold">Presupuestos</h3>
|
<h3 className="text-lg font-semibold">Presupuestos</h3>
|
||||||
@@ -447,6 +597,36 @@ export function ObraDetailClient({ obra }: ObraDetailProps) {
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="avances" className="space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h3 className="text-lg font-semibold">Registros de Avance</h3>
|
<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 } },
|
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 { Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { AuthProvider } from "@/components/providers/auth-provider";
|
import { AuthProvider } from "@/components/providers/auth-provider";
|
||||||
|
import { PWAProvider } from "@/components/pwa/pwa-provider";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Sistema de Gestion de Obras",
|
title: "Mexus - Gestion de Obras",
|
||||||
description: "Aplicacion para la gestion integral de obras de construccion",
|
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({
|
export default function RootLayout({
|
||||||
@@ -20,8 +57,10 @@ export default function RootLayout({
|
|||||||
<html lang="es" suppressHydrationWarning>
|
<html lang="es" suppressHydrationWarning>
|
||||||
<body className={inter.className}>
|
<body className={inter.className}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
{children}
|
<PWAProvider>
|
||||||
<Toaster />
|
{children}
|
||||||
|
<Toaster />
|
||||||
|
</PWAProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { getInitials } from "@/lib/utils";
|
||||||
import { ROLES_LABELS } from "@/types";
|
import { ROLES_LABELS } from "@/types";
|
||||||
|
import { NotificationBell } from "@/components/notifications/notification-bell";
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
@@ -31,12 +32,7 @@ export function Header() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="icon" className="relative">
|
<NotificationBell />
|
||||||
<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>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<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,
|
EstadoFactura,
|
||||||
TipoMovimiento,
|
TipoMovimiento,
|
||||||
UnidadMedida,
|
UnidadMedida,
|
||||||
|
CondicionClima,
|
||||||
|
TipoAsistencia,
|
||||||
|
EstadoOrdenCompra,
|
||||||
|
PrioridadOrden,
|
||||||
|
TipoNotificacion,
|
||||||
|
TipoActividad,
|
||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -20,6 +26,12 @@ export type {
|
|||||||
EstadoFactura,
|
EstadoFactura,
|
||||||
TipoMovimiento,
|
TipoMovimiento,
|
||||||
UnidadMedida,
|
UnidadMedida,
|
||||||
|
CondicionClima,
|
||||||
|
TipoAsistencia,
|
||||||
|
EstadoOrdenCompra,
|
||||||
|
PrioridadOrden,
|
||||||
|
TipoNotificacion,
|
||||||
|
TipoActividad,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DashboardStats {
|
export interface DashboardStats {
|
||||||
@@ -149,3 +161,79 @@ export const TIPO_FACTURA_LABELS: Record<TipoFactura, string> = {
|
|||||||
EMITIDA: "Emitida",
|
EMITIDA: "Emitida",
|
||||||
RECIBIDA: "Recibida",
|
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",
|
||||||
|
};
|
||||||
|
|||||||