diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 2807b9c..828825d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -146,6 +146,9 @@ model User {
pushSubscriptions PushSubscription[]
notificaciones Notificacion[]
actividades ActividadLog[]
+ avancesRegistrados AvancePartida[] @relation("AvancesRegistrados")
+ avancesAprobados AvancePartida[] @relation("AvancesAprobados")
+ importaciones ImportacionCatalogo[]
@@index([empresaId])
@@index([email])
@@ -174,6 +177,7 @@ model Empresa {
equiposMaquinaria EquipoMaquinaria[]
apus AnalisisPrecioUnitario[]
configuracionAPU ConfiguracionAPU?
+ importaciones ImportacionCatalogo[]
}
model Cliente {
@@ -360,11 +364,40 @@ model PartidaPresupuesto {
// Relations
gastos Gasto[]
+ avances AvancePartida[]
@@index([presupuestoId])
@@index([apuId])
}
+model AvancePartida {
+ id String @id @default(cuid())
+ fecha DateTime @default(now())
+ cantidadEjecutada Float // Cantidad ejecutada en este registro
+ cantidadAcumulada Float // Cantidad acumulada hasta este registro
+ porcentajeAvance Float // Porcentaje de avance (0-100)
+ montoEjecutado Float // Monto correspondiente a este avance
+ montoAcumulado Float // Monto acumulado hasta este registro
+ notas String?
+ fotos String[]
+
+ partidaId String
+ partida PartidaPresupuesto @relation(fields: [partidaId], references: [id], onDelete: Cascade)
+ registradoPorId String
+ registradoPor User @relation("AvancesRegistrados", fields: [registradoPorId], references: [id])
+ aprobadoPorId String?
+ aprobadoPor User? @relation("AvancesAprobados", fields: [aprobadoPorId], references: [id])
+ aprobado Boolean @default(false)
+ fechaAprobacion DateTime?
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([partidaId])
+ @@index([registradoPorId])
+ @@index([fecha])
+}
+
model Gasto {
id String @id @default(cuid())
concepto String
@@ -1074,3 +1107,41 @@ model ConfiguracionAPU {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+
+// ============== IMPORTACIÓN DE CATÁLOGOS ==============
+
+enum TipoImportacion {
+ MATERIALES
+ MANO_OBRA
+ EQUIPOS
+}
+
+enum EstadoImportacion {
+ PENDIENTE
+ PROCESANDO
+ COMPLETADA
+ ERROR
+}
+
+model ImportacionCatalogo {
+ id String @id @default(cuid())
+ tipo TipoImportacion
+ nombreArchivo String
+ estado EstadoImportacion @default(PENDIENTE)
+ registrosTotal Int @default(0)
+ registrosCreados Int @default(0)
+ registrosActualizados Int @default(0)
+ registrosError Int @default(0)
+ errores String? // JSON con detalles de errores
+ fuenteDatos String? // BIMSA, Proveedor, Manual, etc.
+ empresaId String
+ empresa Empresa @relation(fields: [empresaId], references: [id])
+ creadoPorId String
+ creadoPor User @relation(fields: [creadoPorId], references: [id])
+ createdAt DateTime @default(now())
+ completadoAt DateTime?
+
+ @@index([empresaId])
+ @@index([tipo])
+ @@index([estado])
+}
diff --git a/src/app/(dashboard)/obras/[id]/obra-detail-client.tsx b/src/app/(dashboard)/obras/[id]/obra-detail-client.tsx
index 7f37ef5..cfdacce 100644
--- a/src/app/(dashboard)/obras/[id]/obra-detail-client.tsx
+++ b/src/app/(dashboard)/obras/[id]/obra-detail-client.tsx
@@ -53,6 +53,7 @@ import {
BitacoraPDF,
} from "@/components/pdf";
import { PartidasManager, ExplosionInsumos } from "@/components/presupuesto";
+import { ControlAvancePartidas } from "@/components/avances";
// Componente de carga
const LoadingSpinner = () => (
@@ -600,13 +601,27 @@ export function ObraDetailClient({ obra }: ObraDetailProps) {
-
+
+
+ Partidas
+ Control de Avance
+
+
+
+
+
+
+
+
))}
diff --git a/src/app/(dashboard)/obras/obras-client.tsx b/src/app/(dashboard)/obras/obras-client.tsx
index b44b4f6..1c76982 100644
--- a/src/app/(dashboard)/obras/obras-client.tsx
+++ b/src/app/(dashboard)/obras/obras-client.tsx
@@ -44,8 +44,8 @@ import {
import {
formatCurrency,
formatPercentage,
- formatDateShort,
} from "@/lib/utils";
+import { ClientDate } from "@/components/ui/client-date";
import {
ESTADO_OBRA_LABELS,
ESTADO_OBRA_COLORS,
@@ -229,7 +229,7 @@ export function ObrasClient({ obras }: { obras: Obra[] }) {
{obra.fechaInicio && (
- {formatDateShort(obra.fechaInicio)}
+
)}
{obra.supervisor && (
diff --git a/src/app/api/avances/[id]/route.ts b/src/app/api/avances/[id]/route.ts
new file mode 100644
index 0000000..dffce47
--- /dev/null
+++ b/src/app/api/avances/[id]/route.ts
@@ -0,0 +1,209 @@
+import { NextResponse } from "next/server";
+import { auth } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+
+// GET - Get avance by ID
+export async function GET(
+ request: Request,
+ { 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 avance = await prisma.avancePartida.findFirst({
+ where: {
+ id,
+ partida: {
+ presupuesto: {
+ obra: {
+ empresaId: session.user.empresaId,
+ },
+ },
+ },
+ },
+ include: {
+ partida: {
+ select: {
+ id: true,
+ codigo: true,
+ descripcion: true,
+ cantidad: true,
+ precioUnitario: true,
+ total: true,
+ presupuesto: {
+ select: {
+ id: true,
+ nombre: true,
+ obra: {
+ select: { id: true, nombre: true },
+ },
+ },
+ },
+ },
+ },
+ registradoPor: {
+ select: { nombre: true, apellido: true },
+ },
+ aprobadoPor: {
+ select: { nombre: true, apellido: true },
+ },
+ },
+ });
+
+ if (!avance) {
+ return NextResponse.json({ error: "Avance no encontrado" }, { status: 404 });
+ }
+
+ return NextResponse.json(avance);
+ } catch (error) {
+ console.error("Error fetching avance:", error);
+ return NextResponse.json(
+ { error: "Error al obtener avance" },
+ { status: 500 }
+ );
+ }
+}
+
+// PUT - Approve avance
+export async function PUT(
+ request: Request,
+ { 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();
+
+ // Verify access
+ const existing = await prisma.avancePartida.findFirst({
+ where: {
+ id,
+ partida: {
+ presupuesto: {
+ obra: {
+ empresaId: session.user.empresaId,
+ },
+ },
+ },
+ },
+ });
+
+ if (!existing) {
+ return NextResponse.json({ error: "Avance no encontrado" }, { status: 404 });
+ }
+
+ // Only allow approving/unapproving
+ const avance = await prisma.avancePartida.update({
+ where: { id },
+ data: {
+ aprobado: body.aprobado ?? existing.aprobado,
+ aprobadoPorId: body.aprobado ? session.user.id : null,
+ fechaAprobacion: body.aprobado ? new Date() : null,
+ notas: body.notas ?? existing.notas,
+ },
+ include: {
+ partida: {
+ select: {
+ codigo: true,
+ descripcion: true,
+ },
+ },
+ registradoPor: {
+ select: { nombre: true, apellido: true },
+ },
+ aprobadoPor: {
+ select: { nombre: true, apellido: true },
+ },
+ },
+ });
+
+ return NextResponse.json(avance);
+ } catch (error) {
+ console.error("Error updating avance:", error);
+ return NextResponse.json(
+ { error: "Error al actualizar avance" },
+ { status: 500 }
+ );
+ }
+}
+
+// DELETE - Delete avance (only the most recent one for a partida)
+export async function DELETE(
+ request: Request,
+ { 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;
+
+ // Verify access and check if it's the most recent
+ const avance = await prisma.avancePartida.findFirst({
+ where: {
+ id,
+ partida: {
+ presupuesto: {
+ obra: {
+ empresaId: session.user.empresaId,
+ },
+ },
+ },
+ },
+ include: {
+ partida: {
+ include: {
+ avances: {
+ orderBy: { fecha: "desc" },
+ take: 1,
+ },
+ },
+ },
+ },
+ });
+
+ if (!avance) {
+ return NextResponse.json({ error: "Avance no encontrado" }, { status: 404 });
+ }
+
+ // Check if it's the most recent avance
+ const mostRecent = avance.partida.avances[0];
+ if (mostRecent && mostRecent.id !== id) {
+ return NextResponse.json(
+ { error: "Solo se puede eliminar el ultimo avance registrado" },
+ { status: 400 }
+ );
+ }
+
+ // Check if it's approved
+ if (avance.aprobado) {
+ return NextResponse.json(
+ { error: "No se puede eliminar un avance aprobado" },
+ { status: 400 }
+ );
+ }
+
+ await prisma.avancePartida.delete({
+ where: { id },
+ });
+
+ return NextResponse.json({ message: "Avance eliminado correctamente" });
+ } catch (error) {
+ console.error("Error deleting avance:", error);
+ return NextResponse.json(
+ { error: "Error al eliminar avance" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/avances/resumen/route.ts b/src/app/api/avances/resumen/route.ts
new file mode 100644
index 0000000..c363ad4
--- /dev/null
+++ b/src/app/api/avances/resumen/route.ts
@@ -0,0 +1,122 @@
+import { NextResponse } from "next/server";
+import { auth } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+
+// GET - Get avance summary for a presupuesto
+export async function GET(request: Request) {
+ try {
+ const session = await auth();
+ if (!session?.user?.empresaId) {
+ return NextResponse.json({ error: "No autorizado" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const presupuestoId = searchParams.get("presupuestoId");
+ const obraId = searchParams.get("obraId");
+
+ if (!presupuestoId && !obraId) {
+ return NextResponse.json(
+ { error: "Se requiere presupuestoId u obraId" },
+ { status: 400 }
+ );
+ }
+
+ // Get partidas with their latest avance
+ const partidas = await prisma.partidaPresupuesto.findMany({
+ where: {
+ presupuesto: {
+ ...(presupuestoId && { id: presupuestoId }),
+ ...(obraId && { obraId }),
+ obra: {
+ empresaId: session.user.empresaId,
+ },
+ },
+ },
+ include: {
+ apu: {
+ select: { codigo: true, descripcion: true },
+ },
+ avances: {
+ orderBy: { fecha: "desc" },
+ take: 1,
+ include: {
+ registradoPor: {
+ select: { nombre: true, apellido: true },
+ },
+ },
+ },
+ presupuesto: {
+ select: {
+ id: true,
+ nombre: true,
+ total: true,
+ },
+ },
+ },
+ orderBy: { codigo: "asc" },
+ });
+
+ // Calculate summary
+ let totalPresupuestado = 0;
+ let totalEjecutado = 0;
+ let partidasConAvance = 0;
+
+ const partidasConResumen = partidas.map((partida) => {
+ const ultimoAvance = partida.avances[0];
+ const cantidadAcumulada = ultimoAvance?.cantidadAcumulada || 0;
+ const montoAcumulado = ultimoAvance?.montoAcumulado || 0;
+ const porcentajeAvance = ultimoAvance?.porcentajeAvance || 0;
+
+ totalPresupuestado += partida.total;
+ totalEjecutado += montoAcumulado;
+ if (cantidadAcumulada > 0) partidasConAvance++;
+
+ return {
+ id: partida.id,
+ codigo: partida.codigo,
+ descripcion: partida.descripcion,
+ unidad: partida.unidad,
+ cantidadPresupuestada: partida.cantidad,
+ cantidadEjecutada: cantidadAcumulada,
+ cantidadPendiente: partida.cantidad - cantidadAcumulada,
+ porcentajeAvance,
+ precioUnitario: partida.precioUnitario,
+ montoPresupuestado: partida.total,
+ montoEjecutado: montoAcumulado,
+ montoPendiente: partida.total - montoAcumulado,
+ apu: partida.apu,
+ ultimoAvance: ultimoAvance
+ ? {
+ fecha: ultimoAvance.fecha,
+ cantidadEjecutada: ultimoAvance.cantidadEjecutada,
+ registradoPor: ultimoAvance.registradoPor,
+ aprobado: ultimoAvance.aprobado,
+ }
+ : null,
+ };
+ });
+
+ const porcentajeAvanceGlobal = totalPresupuestado > 0
+ ? (totalEjecutado / totalPresupuestado) * 100
+ : 0;
+
+ return NextResponse.json({
+ resumen: {
+ totalPartidas: partidas.length,
+ partidasConAvance,
+ partidasSinAvance: partidas.length - partidasConAvance,
+ totalPresupuestado,
+ totalEjecutado,
+ totalPendiente: totalPresupuestado - totalEjecutado,
+ porcentajeAvanceGlobal,
+ },
+ partidas: partidasConResumen,
+ });
+ } catch (error) {
+ console.error("Error fetching avance summary:", error);
+ return NextResponse.json(
+ { error: "Error al obtener resumen de avances" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/avances/route.ts b/src/app/api/avances/route.ts
new file mode 100644
index 0000000..76f35c2
--- /dev/null
+++ b/src/app/api/avances/route.ts
@@ -0,0 +1,197 @@
+import { NextResponse } from "next/server";
+import { auth } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+import { z } from "zod";
+
+const avanceSchema = z.object({
+ partidaId: z.string().min(1, "Partida requerida"),
+ cantidadEjecutada: z.number().min(0, "Cantidad debe ser positiva"),
+ notas: z.string().optional(),
+ fotos: z.array(z.string()).optional(),
+});
+
+// GET - List avances (optionally filtered by partida or presupuesto)
+export async function GET(request: Request) {
+ try {
+ const session = await auth();
+ if (!session?.user?.empresaId) {
+ return NextResponse.json({ error: "No autorizado" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const partidaId = searchParams.get("partidaId");
+ const presupuestoId = searchParams.get("presupuestoId");
+ const obraId = searchParams.get("obraId");
+
+ // Build where clause
+ const where: any = {
+ partida: {
+ presupuesto: {
+ obra: {
+ empresaId: session.user.empresaId,
+ },
+ },
+ },
+ };
+
+ if (partidaId) {
+ where.partidaId = partidaId;
+ }
+
+ if (presupuestoId) {
+ where.partida = {
+ ...where.partida,
+ presupuestoId,
+ };
+ }
+
+ if (obraId) {
+ where.partida = {
+ ...where.partida,
+ presupuesto: {
+ ...where.partida.presupuesto,
+ obraId,
+ },
+ };
+ }
+
+ const avances = await prisma.avancePartida.findMany({
+ where,
+ orderBy: { fecha: "desc" },
+ include: {
+ partida: {
+ select: {
+ id: true,
+ codigo: true,
+ descripcion: true,
+ cantidad: true,
+ precioUnitario: true,
+ total: true,
+ presupuesto: {
+ select: {
+ id: true,
+ nombre: true,
+ obra: {
+ select: { id: true, nombre: true },
+ },
+ },
+ },
+ },
+ },
+ registradoPor: {
+ select: { nombre: true, apellido: true },
+ },
+ aprobadoPor: {
+ select: { nombre: true, apellido: true },
+ },
+ },
+ });
+
+ return NextResponse.json(avances);
+ } catch (error) {
+ console.error("Error fetching avances:", error);
+ return NextResponse.json(
+ { error: "Error al obtener avances" },
+ { status: 500 }
+ );
+ }
+}
+
+// POST - Create new avance
+export async function POST(request: Request) {
+ 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 = avanceSchema.parse(body);
+
+ // Get partida and verify access
+ const partida = await prisma.partidaPresupuesto.findFirst({
+ where: {
+ id: validatedData.partidaId,
+ presupuesto: {
+ obra: {
+ empresaId: session.user.empresaId,
+ },
+ },
+ },
+ include: {
+ avances: {
+ orderBy: { fecha: "desc" },
+ take: 1,
+ },
+ },
+ });
+
+ if (!partida) {
+ return NextResponse.json(
+ { error: "Partida no encontrada" },
+ { status: 404 }
+ );
+ }
+
+ // Calculate accumulated values
+ const lastAvance = partida.avances[0];
+ const previousAcumulado = lastAvance?.cantidadAcumulada || 0;
+ const cantidadAcumulada = previousAcumulado + validatedData.cantidadEjecutada;
+
+ // Validate not exceeding total
+ if (cantidadAcumulada > partida.cantidad) {
+ return NextResponse.json(
+ {
+ error: `La cantidad acumulada (${cantidadAcumulada.toFixed(2)}) excede la cantidad presupuestada (${partida.cantidad.toFixed(2)})`
+ },
+ { status: 400 }
+ );
+ }
+
+ const porcentajeAvance = (cantidadAcumulada / partida.cantidad) * 100;
+ const montoEjecutado = validatedData.cantidadEjecutada * partida.precioUnitario;
+ const montoAcumulado = cantidadAcumulada * partida.precioUnitario;
+
+ const avance = await prisma.avancePartida.create({
+ data: {
+ fecha: new Date(),
+ cantidadEjecutada: validatedData.cantidadEjecutada,
+ cantidadAcumulada,
+ porcentajeAvance,
+ montoEjecutado,
+ montoAcumulado,
+ notas: validatedData.notas,
+ fotos: validatedData.fotos || [],
+ partidaId: validatedData.partidaId,
+ registradoPorId: session.user.id,
+ },
+ include: {
+ partida: {
+ select: {
+ codigo: true,
+ descripcion: true,
+ cantidad: true,
+ total: true,
+ },
+ },
+ registradoPor: {
+ select: { nombre: true, apellido: true },
+ },
+ },
+ });
+
+ return NextResponse.json(avance);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: error.errors[0].message },
+ { status: 400 }
+ );
+ }
+ console.error("Error creating avance:", error);
+ return NextResponse.json(
+ { error: "Error al registrar avance" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/importar/route.ts b/src/app/api/importar/route.ts
new file mode 100644
index 0000000..040d561
--- /dev/null
+++ b/src/app/api/importar/route.ts
@@ -0,0 +1,345 @@
+import { NextResponse } from "next/server";
+import { auth } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+import * as XLSX from "xlsx";
+import { TipoImportacion, UnidadMedida } from "@prisma/client";
+
+// Map common unit names to UnidadMedida enum
+const UNIDAD_MAP: Record = {
+ "PZA": "PIEZA",
+ "PIEZA": "PIEZA",
+ "PZ": "PIEZA",
+ "UN": "UNIDAD",
+ "UNIDAD": "UNIDAD",
+ "M": "METRO",
+ "ML": "METRO",
+ "METRO": "METRO",
+ "M2": "METRO_CUADRADO",
+ "M²": "METRO_CUADRADO",
+ "METRO CUADRADO": "METRO_CUADRADO",
+ "M3": "METRO_CUBICO",
+ "M³": "METRO_CUBICO",
+ "METRO CUBICO": "METRO_CUBICO",
+ "KG": "KILOGRAMO",
+ "KILOGRAMO": "KILOGRAMO",
+ "TON": "TONELADA",
+ "TONELADA": "TONELADA",
+ "LT": "LITRO",
+ "L": "LITRO",
+ "LITRO": "LITRO",
+ "BOLSA": "BOLSA",
+ "BLS": "BOLSA",
+ "ROLLO": "ROLLO",
+ "RLL": "ROLLO",
+ "CAJA": "CAJA",
+ "CJA": "CAJA",
+ "HORA": "HORA",
+ "HR": "HORA",
+ "JORNADA": "JORNADA",
+ "JOR": "JORNADA",
+ "VIAJE": "VIAJE",
+ "VJE": "VIAJE",
+ "LOTE": "LOTE",
+ "LTE": "LOTE",
+ "GLOBAL": "GLOBAL",
+ "GLB": "GLOBAL",
+};
+
+function parseUnidad(unidad: string): UnidadMedida {
+ const normalized = unidad.toUpperCase().trim();
+ return UNIDAD_MAP[normalized] || "UNIDAD";
+}
+
+// GET - List import history
+export async function GET(request: Request) {
+ try {
+ const session = await auth();
+ if (!session?.user?.empresaId) {
+ return NextResponse.json({ error: "No autorizado" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const tipo = searchParams.get("tipo") as TipoImportacion | null;
+
+ const where: any = { empresaId: session.user.empresaId };
+ if (tipo) {
+ where.tipo = tipo;
+ }
+
+ const importaciones = await prisma.importacionCatalogo.findMany({
+ where,
+ orderBy: { createdAt: "desc" },
+ take: 50,
+ include: {
+ creadoPor: {
+ select: { nombre: true, apellido: true },
+ },
+ },
+ });
+
+ return NextResponse.json(importaciones);
+ } catch (error) {
+ console.error("Error fetching imports:", error);
+ return NextResponse.json(
+ { error: "Error al obtener historial de importaciones" },
+ { status: 500 }
+ );
+ }
+}
+
+// POST - Process import file
+export async function POST(request: Request) {
+ 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 tipo = formData.get("tipo") as TipoImportacion;
+ const fuente = formData.get("fuente") as string || "Manual";
+
+ if (!file) {
+ return NextResponse.json({ error: "Archivo requerido" }, { status: 400 });
+ }
+
+ if (!tipo || !["MATERIALES", "MANO_OBRA", "EQUIPOS"].includes(tipo)) {
+ return NextResponse.json({ error: "Tipo de importacion invalido" }, { status: 400 });
+ }
+
+ // Create import record
+ const importacion = await prisma.importacionCatalogo.create({
+ data: {
+ tipo,
+ nombreArchivo: file.name,
+ estado: "PROCESANDO",
+ fuenteDatos: fuente,
+ empresaId: session.user.empresaId,
+ creadoPorId: session.user.id,
+ },
+ });
+
+ try {
+ // Read file
+ const buffer = await file.arrayBuffer();
+ const workbook = XLSX.read(buffer, { type: "array" });
+ const sheetName = workbook.SheetNames[0];
+ const worksheet = workbook.Sheets[sheetName];
+ const data = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
+
+ if (!data.length) {
+ throw new Error("El archivo esta vacio");
+ }
+
+ let created = 0;
+ let updated = 0;
+ let errors = 0;
+ const errorDetails: string[] = [];
+
+ // Process based on type
+ if (tipo === "MATERIALES") {
+ for (let i = 0; i < data.length; i++) {
+ const row = data[i] as any;
+ try {
+ const codigo = String(row.codigo || row.Codigo || row.CODIGO || row.clave || row.Clave || "").trim();
+ const nombre = String(row.nombre || row.Nombre || row.NOMBRE || row.descripcion || row.Descripcion || "").trim();
+ const unidadStr = String(row.unidad || row.Unidad || row.UNIDAD || "UNIDAD").trim();
+ const precio = parseFloat(row.precio || row.Precio || row.PRECIO || row.precioUnitario || row.PrecioUnitario || 0);
+
+ if (!codigo || !nombre) {
+ errors++;
+ errorDetails.push(`Fila ${i + 2}: Codigo o nombre faltante`);
+ continue;
+ }
+
+ const unidad = parseUnidad(unidadStr);
+
+ // Check if exists
+ const existing = await prisma.material.findFirst({
+ where: { codigo, empresaId: session.user.empresaId },
+ });
+
+ if (existing) {
+ await prisma.material.update({
+ where: { id: existing.id },
+ data: { nombre, unidad, precioUnitario: precio || existing.precioUnitario },
+ });
+ updated++;
+ } else {
+ await prisma.material.create({
+ data: {
+ codigo,
+ nombre,
+ unidad,
+ precioUnitario: precio || 0,
+ empresaId: session.user.empresaId,
+ },
+ });
+ created++;
+ }
+ } catch (err) {
+ errors++;
+ errorDetails.push(`Fila ${i + 2}: ${err instanceof Error ? err.message : "Error desconocido"}`);
+ }
+ }
+ } else if (tipo === "MANO_OBRA") {
+ for (let i = 0; i < data.length; i++) {
+ const row = data[i] as any;
+ try {
+ const codigo = String(row.codigo || row.Codigo || row.CODIGO || "").trim();
+ const nombre = String(row.nombre || row.Nombre || row.NOMBRE || row.categoria || row.Categoria || "").trim();
+ const salarioDiario = parseFloat(row.salario || row.Salario || row.salarioDiario || row.SalarioDiario || 0);
+
+ if (!codigo || !nombre) {
+ errors++;
+ errorDetails.push(`Fila ${i + 2}: Codigo o nombre faltante`);
+ continue;
+ }
+
+ // Check if exists
+ const existing = await prisma.categoriaTrabajoAPU.findFirst({
+ where: { codigo, empresaId: session.user.empresaId },
+ });
+
+ // Calculate FSR with defaults
+ const factorIMSS = 0.2675;
+ const factorINFONAVIT = 0.05;
+ const factorRetiro = 0.02;
+ const factorVacaciones = 0.0411;
+ const factorPrimaVac = 0.0103;
+ const factorAguinaldo = 0.0411;
+ const factorSalarioReal = 1 + factorIMSS + factorINFONAVIT + factorRetiro + factorVacaciones + factorPrimaVac + factorAguinaldo;
+ const salarioReal = salarioDiario * factorSalarioReal;
+
+ if (existing) {
+ await prisma.categoriaTrabajoAPU.update({
+ where: { id: existing.id },
+ data: {
+ nombre,
+ salarioDiario: salarioDiario || existing.salarioDiario,
+ factorSalarioReal,
+ salarioReal: salarioDiario ? salarioReal : existing.salarioReal,
+ },
+ });
+ updated++;
+ } else {
+ await prisma.categoriaTrabajoAPU.create({
+ data: {
+ codigo,
+ nombre,
+ categoria: "PEON", // Default
+ salarioDiario: salarioDiario || 0,
+ factorIMSS,
+ factorINFONAVIT,
+ factorRetiro,
+ factorVacaciones,
+ factorPrimaVac,
+ factorAguinaldo,
+ factorSalarioReal,
+ salarioReal,
+ empresaId: session.user.empresaId,
+ },
+ });
+ created++;
+ }
+ } catch (err) {
+ errors++;
+ errorDetails.push(`Fila ${i + 2}: ${err instanceof Error ? err.message : "Error desconocido"}`);
+ }
+ }
+ } else if (tipo === "EQUIPOS") {
+ for (let i = 0; i < data.length; i++) {
+ const row = data[i] as any;
+ try {
+ const codigo = String(row.codigo || row.Codigo || row.CODIGO || "").trim();
+ const nombre = String(row.nombre || row.Nombre || row.NOMBRE || row.descripcion || row.Descripcion || "").trim();
+ const costoHorario = parseFloat(row.costoHorario || row.CostoHorario || row.precio || row.Precio || 0);
+
+ if (!codigo || !nombre) {
+ errors++;
+ errorDetails.push(`Fila ${i + 2}: Codigo o nombre faltante`);
+ continue;
+ }
+
+ // Check if exists
+ const existing = await prisma.equipoMaquinaria.findFirst({
+ where: { codigo, empresaId: session.user.empresaId },
+ });
+
+ if (existing) {
+ await prisma.equipoMaquinaria.update({
+ where: { id: existing.id },
+ data: {
+ nombre,
+ costoHorario: costoHorario || existing.costoHorario,
+ },
+ });
+ updated++;
+ } else {
+ await prisma.equipoMaquinaria.create({
+ data: {
+ codigo,
+ nombre,
+ tipo: "MAQUINARIA_LIGERA", // Default
+ valorAdquisicion: 0,
+ vidaUtilHoras: 10000,
+ costoHorario: costoHorario || 0,
+ empresaId: session.user.empresaId,
+ },
+ });
+ created++;
+ }
+ } catch (err) {
+ errors++;
+ errorDetails.push(`Fila ${i + 2}: ${err instanceof Error ? err.message : "Error desconocido"}`);
+ }
+ }
+ }
+
+ // Update import record
+ await prisma.importacionCatalogo.update({
+ where: { id: importacion.id },
+ data: {
+ estado: errors > 0 && created === 0 && updated === 0 ? "ERROR" : "COMPLETADA",
+ registrosTotal: data.length,
+ registrosCreados: created,
+ registrosActualizados: updated,
+ registrosError: errors,
+ errores: errorDetails.length > 0 ? JSON.stringify(errorDetails.slice(0, 50)) : null,
+ completadoAt: new Date(),
+ },
+ });
+
+ return NextResponse.json({
+ success: true,
+ importacionId: importacion.id,
+ total: data.length,
+ creados: created,
+ actualizados: updated,
+ errores: errors,
+ detalleErrores: errorDetails.slice(0, 10),
+ });
+
+ } catch (err) {
+ // Update import record with error
+ await prisma.importacionCatalogo.update({
+ where: { id: importacion.id },
+ data: {
+ estado: "ERROR",
+ errores: JSON.stringify([err instanceof Error ? err.message : "Error desconocido"]),
+ completadoAt: new Date(),
+ },
+ });
+
+ throw err;
+ }
+
+ } catch (error) {
+ console.error("Error processing import:", error);
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : "Error al procesar importacion" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/components/apu/apu-list.tsx b/src/components/apu/apu-list.tsx
index 5bdb7bf..6a3b9d6 100644
--- a/src/components/apu/apu-list.tsx
+++ b/src/components/apu/apu-list.tsx
@@ -51,6 +51,7 @@ import {
} from "lucide-react";
import { UNIDAD_MEDIDA_LABELS } from "@/types";
import { UnidadMedida } from "@prisma/client";
+import { ImportadorCatalogo } from "@/components/importar";
interface APU {
id: string;
@@ -193,6 +194,7 @@ export function APUList({ apus: initialApus }: APUListProps) {
)}
+