feat: Add major features - Mejoras 5-10

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

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

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

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

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

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

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

View File

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