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:
262
src/lib/activity-log.ts
Normal file
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
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
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)`,
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user