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:
111
src/lib/auth.ts
Normal file
111
src/lib/auth.ts
Normal 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
11
src/lib/prisma.ts
Normal 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
49
src/lib/utils.ts
Normal 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
207
src/lib/validations.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user