feat: Initial commit - Mexus App

Sistema de Gestión de Obras de Construcción completo con:
- Dashboard con KPIs y gráficos
- Módulo de obras con fases y tareas
- Control financiero (gastos, presupuestos)
- Gestión de recursos (personal, subcontratistas)
- Inventario de materiales con alertas de stock
- Reportes con exportación CSV
- Autenticación con roles (NextAuth.js v5)
- API REST completa
- Documentación de API y base de datos
- Configuración Docker para despliegue

Stack: Next.js 14+, TypeScript, Tailwind CSS, Prisma, PostgreSQL

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mexus
2026-01-19 01:10:55 +00:00
commit 86bfbd2039
82 changed files with 18845 additions and 0 deletions

111
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,111 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import type { Role } from "@prisma/client";
declare module "next-auth" {
interface User {
id: string;
email: string;
nombre: string;
apellido: string;
role: Role;
empresaId: string;
}
interface Session {
user: {
id: string;
email: string;
nombre: string;
apellido: string;
role: Role;
empresaId: string;
};
}
}
declare module "@auth/core/jwt" {
interface JWT {
id: string;
role: Role;
empresaId: string;
nombre: string;
apellido: string;
}
}
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
include: { empresa: true },
});
if (!user || !user.activo) {
return null;
}
const passwordMatch = await bcrypt.compare(
credentials.password as string,
user.password
);
if (!passwordMatch) {
return null;
}
return {
id: user.id,
email: user.email,
nombre: user.nombre,
apellido: user.apellido,
role: user.role,
empresaId: user.empresaId,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
token.empresaId = user.empresaId;
token.nombre = user.nombre;
token.apellido = user.apellido;
}
return token;
},
async session({ session, token }) {
if (token) {
session.user.id = token.id as string;
session.user.role = token.role;
session.user.empresaId = token.empresaId;
session.user.nombre = token.nombre;
session.user.apellido = token.apellido;
}
return session;
},
},
pages: {
signIn: "/login",
error: "/login",
},
session: {
strategy: "jwt",
maxAge: 24 * 60 * 60, // 24 hours
},
});

11
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
export default prisma;

49
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,49 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat("es-MX", {
style: "currency",
currency: "MXN",
}).format(amount);
}
export function formatDate(date: Date | string): string {
return new Intl.DateTimeFormat("es-MX", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(date));
}
export function formatDateShort(date: Date | string): string {
return new Intl.DateTimeFormat("es-MX", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date(date));
}
export function formatPercentage(value: number): string {
return `${value.toFixed(1)}%`;
}
export function calculatePercentage(value: number, total: number): number {
if (total === 0) return 0;
return (value / total) * 100;
}
export function getInitials(nombre: string, apellido?: string): string {
const first = nombre?.charAt(0).toUpperCase() || "";
const last = apellido?.charAt(0).toUpperCase() || "";
return `${first}${last}`;
}
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return `${text.substring(0, maxLength)}...`;
}

207
src/lib/validations.ts Normal file
View File

@@ -0,0 +1,207 @@
import { z } from "zod";
// Auth validations
export const loginSchema = z.object({
email: z.string().email("Email invalido"),
password: z.string().min(6, "La contrasena debe tener al menos 6 caracteres"),
});
export const registerSchema = z
.object({
email: z.string().email("Email invalido"),
password: z
.string()
.min(6, "La contrasena debe tener al menos 6 caracteres"),
confirmPassword: z.string(),
nombre: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
apellido: z.string().min(2, "El apellido debe tener al menos 2 caracteres"),
empresaNombre: z
.string()
.min(2, "El nombre de empresa debe tener al menos 2 caracteres"),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Las contrasenas no coinciden",
path: ["confirmPassword"],
});
// Obra validations
export const obraSchema = z.object({
nombre: z
.string()
.min(3, "El nombre debe tener al menos 3 caracteres")
.max(100, "El nombre no puede exceder 100 caracteres"),
descripcion: z.string().optional(),
direccion: z
.string()
.min(5, "La direccion debe tener al menos 5 caracteres"),
fechaInicio: z.string().optional(),
fechaFinPrevista: z.string().optional(),
clienteId: z.string().optional(),
supervisorId: z.string().optional(),
});
// Gasto validations
export const gastoSchema = z.object({
concepto: z
.string()
.min(3, "El concepto debe tener al menos 3 caracteres")
.max(200, "El concepto no puede exceder 200 caracteres"),
descripcion: z.string().optional(),
monto: z.number().positive("El monto debe ser mayor a 0"),
fecha: z.string(),
categoria: z.enum([
"MATERIALES",
"MANO_DE_OBRA",
"EQUIPOS",
"SUBCONTRATISTAS",
"PERMISOS",
"TRANSPORTE",
"SERVICIOS",
"OTROS",
]),
obraId: z.string(),
partidaId: z.string().optional(),
notas: z.string().optional(),
});
// Presupuesto validations
export const presupuestoSchema = z.object({
nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"),
descripcion: z.string().optional(),
obraId: z.string(),
});
export const partidaPresupuestoSchema = z.object({
codigo: z.string().min(1, "El codigo es requerido"),
descripcion: z.string().min(3, "La descripcion es requerida"),
unidad: z.enum([
"UNIDAD",
"METRO",
"METRO_CUADRADO",
"METRO_CUBICO",
"KILOGRAMO",
"TONELADA",
"LITRO",
"BOLSA",
"PIEZA",
"ROLLO",
"CAJA",
]),
cantidad: z.number().positive("La cantidad debe ser mayor a 0"),
precioUnitario: z.number().positive("El precio debe ser mayor a 0"),
categoria: z.enum([
"MATERIALES",
"MANO_DE_OBRA",
"EQUIPOS",
"SUBCONTRATISTAS",
"PERMISOS",
"TRANSPORTE",
"SERVICIOS",
"OTROS",
]),
presupuestoId: z.string(),
});
// Material validations
export const materialSchema = z.object({
codigo: z.string().min(1, "El codigo es requerido"),
nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"),
descripcion: z.string().optional(),
unidad: z.enum([
"UNIDAD",
"METRO",
"METRO_CUADRADO",
"METRO_CUBICO",
"KILOGRAMO",
"TONELADA",
"LITRO",
"BOLSA",
"PIEZA",
"ROLLO",
"CAJA",
]),
precioUnitario: z.number().positive("El precio debe ser mayor a 0"),
stockMinimo: z.number().min(0, "El stock minimo no puede ser negativo"),
ubicacion: z.string().optional(),
});
// Empleado validations
export const empleadoSchema = z.object({
nombre: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
apellido: z.string().min(2, "El apellido debe tener al menos 2 caracteres"),
documento: z.string().optional(),
telefono: z.string().optional(),
email: z.string().email("Email invalido").optional().or(z.literal("")),
puesto: z.string().min(2, "El puesto es requerido"),
salarioBase: z.number().optional(),
fechaIngreso: z.string(),
});
// Subcontratista validations
export const subcontratistaSchema = z.object({
nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"),
rfc: z.string().optional(),
especialidad: z.string().min(3, "La especialidad es requerida"),
telefono: z.string().optional(),
email: z.string().email("Email invalido").optional().or(z.literal("")),
direccion: z.string().optional(),
});
// Factura validations
export const facturaSchema = z.object({
numero: z.string().min(1, "El numero de factura es requerido"),
tipo: z.enum(["EMITIDA", "RECIBIDA"]),
concepto: z.string().min(3, "El concepto es requerido"),
monto: z.number().positive("El monto debe ser mayor a 0"),
iva: z.number().min(0, "El IVA no puede ser negativo"),
fechaEmision: z.string(),
fechaVencimiento: z.string().optional(),
obraId: z.string(),
proveedorNombre: z.string().optional(),
proveedorRfc: z.string().optional(),
clienteNombre: z.string().optional(),
clienteRfc: z.string().optional(),
});
// Cliente validations
export const clienteSchema = z.object({
nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"),
rfc: z.string().optional(),
direccion: z.string().optional(),
telefono: z.string().optional(),
email: z.string().email("Email invalido").optional().or(z.literal("")),
});
// Fase validations
export const faseSchema = z.object({
nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"),
descripcion: z.string().optional(),
fechaInicio: z.string().optional(),
fechaFin: z.string().optional(),
obraId: z.string(),
});
// Tarea validations
export const tareaSchema = z.object({
nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"),
descripcion: z.string().optional(),
prioridad: z.number().min(1).max(5),
fechaInicio: z.string().optional(),
fechaFin: z.string().optional(),
faseId: z.string(),
asignadoId: z.string().optional(),
});
export type LoginInput = z.infer<typeof loginSchema>;
export type RegisterInput = z.infer<typeof registerSchema>;
export type ObraInput = z.infer<typeof obraSchema>;
export type GastoInput = z.infer<typeof gastoSchema>;
export type PresupuestoInput = z.infer<typeof presupuestoSchema>;
export type PartidaPresupuestoInput = z.infer<typeof partidaPresupuestoSchema>;
export type MaterialInput = z.infer<typeof materialSchema>;
export type EmpleadoInput = z.infer<typeof empleadoSchema>;
export type SubcontratistaInput = z.infer<typeof subcontratistaSchema>;
export type FacturaInput = z.infer<typeof facturaSchema>;
export type ClienteInput = z.infer<typeof clienteSchema>;
export type FaseInput = z.infer<typeof faseSchema>;
export type TareaInput = z.infer<typeof tareaSchema>;