feat: Add catalog import and progress tracking features

1. Catalog Import (Generadores de Precios):
   - ImportacionCatalogo model for tracking import history
   - POST /api/importar - Process Excel/CSV files
   - GET /api/importar - List import history
   - ImportadorCatalogo component with:
     - Template download for materials, labor, equipment
     - Auto unit mapping (PZA→PIEZA, M2→METRO_CUADRADO, etc.)
     - FSR auto-calculation for labor imports
     - Import history view

2. Progress Tracking (Control de Avance por Partida):
   - AvancePartida model with quantity, percentage, amount tracking
   - CRUD API endpoints for avances
   - GET /api/avances/resumen - Summary per presupuesto
   - ControlAvancePartidas component with:
     - Global progress summary cards
     - Per-partida progress table with bars
     - Register progress dialog
     - Auto-calculation of accumulated values

3. Bug fixes:
   - ClientDate component to fix hydration mismatch errors
   - Date formatting now consistent between server and client

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mexus
2026-02-05 09:00:36 +00:00
parent 56e39af3ff
commit e964e8f0b5
13 changed files with 1930 additions and 9 deletions

View File

@@ -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])
}

View File

@@ -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) {
</div>
</CardHeader>
<CardContent>
<PartidasManager
presupuestoId={presupuesto.id}
presupuestoNombre={presupuesto.nombre}
partidas={presupuesto.partidas}
total={presupuesto.total}
aprobado={presupuesto.aprobado}
/>
<Tabs defaultValue="partidas" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="partidas">Partidas</TabsTrigger>
<TabsTrigger value="avances">Control de Avance</TabsTrigger>
</TabsList>
<TabsContent value="partidas">
<PartidasManager
presupuestoId={presupuesto.id}
presupuestoNombre={presupuesto.nombre}
partidas={presupuesto.partidas}
total={presupuesto.total}
aprobado={presupuesto.aprobado}
/>
</TabsContent>
<TabsContent value="avances">
<ControlAvancePartidas
presupuestoId={presupuesto.id}
presupuestoNombre={presupuesto.nombre}
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
))}

View File

@@ -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 && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDateShort(obra.fechaInicio)}
<ClientDate date={obra.fechaInicio} />
</div>
)}
{obra.supervisor && (

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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<string, UnidadMedida> = {
"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 }
);
}
}

View File

@@ -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) {
</Badge>
)}
</Button>
<ImportadorCatalogo />
<Button asChild>
<Link href="/apu/nuevo">
<Plus className="mr-2 h-4 w-4" />

View File

@@ -0,0 +1,452 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { toast } from "@/hooks/use-toast";
import {
TrendingUp,
Plus,
Loader2,
CheckCircle2,
Clock,
DollarSign,
BarChart3,
History,
} from "lucide-react";
import { UNIDAD_MEDIDA_LABELS } from "@/types";
import { UnidadMedida } from "@prisma/client";
interface PartidaResumen {
id: string;
codigo: string;
descripcion: string;
unidad: UnidadMedida;
cantidadPresupuestada: number;
cantidadEjecutada: number;
cantidadPendiente: number;
porcentajeAvance: number;
precioUnitario: number;
montoPresupuestado: number;
montoEjecutado: number;
montoPendiente: number;
apu: { codigo: string; descripcion: string } | null;
ultimoAvance: {
fecha: string;
cantidadEjecutada: number;
registradoPor: { nombre: string; apellido: string };
aprobado: boolean;
} | null;
}
interface ResumenGeneral {
totalPartidas: number;
partidasConAvance: number;
partidasSinAvance: number;
totalPresupuestado: number;
totalEjecutado: number;
totalPendiente: number;
porcentajeAvanceGlobal: number;
}
interface ControlAvancePartidasProps {
presupuestoId: string;
presupuestoNombre: string;
}
export function ControlAvancePartidas({
presupuestoId,
presupuestoNombre,
}: ControlAvancePartidasProps) {
const [loading, setLoading] = useState(true);
const [resumen, setResumen] = useState<ResumenGeneral | null>(null);
const [partidas, setPartidas] = useState<PartidaResumen[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedPartida, setSelectedPartida] = useState<PartidaResumen | null>(null);
const [registrando, setRegistrando] = useState(false);
const [cantidad, setCantidad] = useState("");
const [notas, setNotas] = useState("");
useEffect(() => {
fetchData();
}, [presupuestoId]);
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch(`/api/avances/resumen?presupuestoId=${presupuestoId}`);
if (!response.ok) throw new Error("Error al cargar datos");
const data = await response.json();
setResumen(data.resumen);
setPartidas(data.partidas);
} catch (err) {
toast({
title: "Error",
description: "No se pudieron cargar los datos de avance",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const openRegistrarAvance = (partida: PartidaResumen) => {
setSelectedPartida(partida);
setCantidad("");
setNotas("");
setDialogOpen(true);
};
const handleRegistrarAvance = async () => {
if (!selectedPartida || !cantidad) return;
const cantidadNum = parseFloat(cantidad);
if (isNaN(cantidadNum) || cantidadNum <= 0) {
toast({
title: "Error",
description: "Ingrese una cantidad valida",
variant: "destructive",
});
return;
}
setRegistrando(true);
try {
const response = await fetch("/api/avances", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
partidaId: selectedPartida.id,
cantidadEjecutada: cantidadNum,
notas: notas || undefined,
}),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error || "Error al registrar avance");
}
toast({
title: "Avance registrado",
description: `Se registro avance de ${cantidadNum} ${UNIDAD_MEDIDA_LABELS[selectedPartida.unidad]}`,
});
setDialogOpen(false);
fetchData();
} catch (err) {
toast({
title: "Error",
description: err instanceof Error ? err.message : "No se pudo registrar el avance",
variant: "destructive",
});
} finally {
setRegistrando(false);
}
};
const formatCurrency = (value: number) =>
value.toLocaleString("es-MX", { style: "currency", currency: "MXN" });
const formatNumber = (value: number, decimals = 2) =>
value.toLocaleString("es-MX", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-slate-400" />
</div>
);
}
return (
<div className="space-y-6">
{/* Summary Cards */}
{resumen && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-500 flex items-center gap-1">
<TrendingUp className="h-4 w-4" />
Avance Global
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">
{formatNumber(resumen.porcentajeAvanceGlobal, 1)}%
</div>
<Progress value={resumen.porcentajeAvanceGlobal} className="mt-2" />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-500 flex items-center gap-1">
<DollarSign className="h-4 w-4" />
Ejecutado
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{formatCurrency(resumen.totalEjecutado)}
</div>
<p className="text-xs text-slate-500 mt-1">
de {formatCurrency(resumen.totalPresupuestado)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-500 flex items-center gap-1">
<Clock className="h-4 w-4" />
Pendiente
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-orange-600">
{formatCurrency(resumen.totalPendiente)}
</div>
<p className="text-xs text-slate-500 mt-1">
{formatNumber(100 - resumen.porcentajeAvanceGlobal, 1)}% restante
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-500 flex items-center gap-1">
<BarChart3 className="h-4 w-4" />
Partidas
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{resumen.partidasConAvance}/{resumen.totalPartidas}
</div>
<p className="text-xs text-slate-500 mt-1">con avance registrado</p>
</CardContent>
</Card>
</div>
)}
{/* Partidas Table */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Control de Avance por Partida</span>
</CardTitle>
<CardDescription>
Registre el avance fisico de cada partida del presupuesto
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Codigo</TableHead>
<TableHead>Descripcion</TableHead>
<TableHead className="w-[80px]">Unidad</TableHead>
<TableHead className="w-[100px] text-right">Presup.</TableHead>
<TableHead className="w-[100px] text-right">Ejecutado</TableHead>
<TableHead className="w-[120px]">Avance</TableHead>
<TableHead className="w-[120px] text-right">Monto Ejec.</TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{partidas.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-slate-500">
No hay partidas en este presupuesto
</TableCell>
</TableRow>
) : (
partidas.map((partida) => (
<TableRow key={partida.id}>
<TableCell className="font-mono font-medium">
{partida.codigo}
</TableCell>
<TableCell>
<div>
{partida.descripcion}
{partida.ultimoAvance && (
<div className="text-xs text-slate-500 flex items-center gap-1 mt-1">
<History className="h-3 w-3" />
Ultimo: {new Date(partida.ultimoAvance.fecha).toLocaleDateString("es-MX")}
{partida.ultimoAvance.aprobado && (
<CheckCircle2 className="h-3 w-3 text-green-500 ml-1" />
)}
</div>
)}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{UNIDAD_MEDIDA_LABELS[partida.unidad]}
</Badge>
</TableCell>
<TableCell className="text-right font-mono">
{formatNumber(partida.cantidadPresupuestada)}
</TableCell>
<TableCell className="text-right font-mono">
{formatNumber(partida.cantidadEjecutada)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress
value={partida.porcentajeAvance}
className="flex-1 h-2"
/>
<span className="text-xs font-medium w-12 text-right">
{formatNumber(partida.porcentajeAvance, 1)}%
</span>
</div>
</TableCell>
<TableCell className="text-right font-mono font-semibold">
{formatCurrency(partida.montoEjecutado)}
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => openRegistrarAvance(partida)}
disabled={partida.porcentajeAvance >= 100}
>
<Plus className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Register Avance Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Registrar Avance</DialogTitle>
<DialogDescription>
{selectedPartida && (
<>
{selectedPartida.codigo} - {selectedPartida.descripcion}
</>
)}
</DialogDescription>
</DialogHeader>
{selectedPartida && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-slate-500">Cantidad Presupuestada:</span>
<p className="font-medium">
{formatNumber(selectedPartida.cantidadPresupuestada)}{" "}
{UNIDAD_MEDIDA_LABELS[selectedPartida.unidad]}
</p>
</div>
<div>
<span className="text-slate-500">Cantidad Ejecutada:</span>
<p className="font-medium">
{formatNumber(selectedPartida.cantidadEjecutada)}{" "}
{UNIDAD_MEDIDA_LABELS[selectedPartida.unidad]}
</p>
</div>
<div>
<span className="text-slate-500">Cantidad Pendiente:</span>
<p className="font-medium text-orange-600">
{formatNumber(selectedPartida.cantidadPendiente)}{" "}
{UNIDAD_MEDIDA_LABELS[selectedPartida.unidad]}
</p>
</div>
<div>
<span className="text-slate-500">Avance Actual:</span>
<p className="font-medium">
{formatNumber(selectedPartida.porcentajeAvance, 1)}%
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="cantidad">
Cantidad Ejecutada ({UNIDAD_MEDIDA_LABELS[selectedPartida.unidad]})
</Label>
<Input
id="cantidad"
type="number"
step="0.01"
min="0"
max={selectedPartida.cantidadPendiente}
value={cantidad}
onChange={(e) => setCantidad(e.target.value)}
placeholder={`Max: ${formatNumber(selectedPartida.cantidadPendiente)}`}
/>
{cantidad && !isNaN(parseFloat(cantidad)) && (
<p className="text-xs text-slate-500">
Monto: {formatCurrency(parseFloat(cantidad) * selectedPartida.precioUnitario)}
{" | "}
Nuevo avance: {formatNumber(
((selectedPartida.cantidadEjecutada + parseFloat(cantidad)) /
selectedPartida.cantidadPresupuestada) *
100,
1
)}%
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="notas">Notas (opcional)</Label>
<Textarea
id="notas"
value={notas}
onChange={(e) => setNotas(e.target.value)}
placeholder="Observaciones sobre este avance..."
rows={3}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancelar
</Button>
<Button onClick={handleRegistrarAvance} disabled={registrando || !cantidad}>
{registrando && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Registrar Avance
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1 @@
export { ControlAvancePartidas } from "./control-avance-partidas";

View File

@@ -0,0 +1,450 @@
"use client";
import { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { toast } from "@/hooks/use-toast";
import {
Upload,
FileSpreadsheet,
Download,
Loader2,
CheckCircle2,
XCircle,
AlertTriangle,
Package,
Users,
Wrench,
} from "lucide-react";
import * as XLSX from "xlsx";
interface ImportResult {
success: boolean;
total: number;
creados: number;
actualizados: number;
errores: number;
detalleErrores: string[];
}
interface ImportHistory {
id: string;
tipo: string;
nombreArchivo: string;
estado: string;
registrosTotal: number;
registrosCreados: number;
registrosActualizados: number;
registrosError: number;
fuenteDatos: string | null;
createdAt: string;
creadoPor: { nombre: string; apellido: string };
}
const TIPO_OPTIONS = [
{ value: "MATERIALES", label: "Materiales", icon: Package },
{ value: "MANO_OBRA", label: "Mano de Obra", icon: Users },
{ value: "EQUIPOS", label: "Equipos", icon: Wrench },
];
const FUENTE_OPTIONS = [
"BIMSA",
"Proveedor",
"Catalogo Interno",
"Manual",
"Otro",
];
export function ImportadorCatalogo() {
const [open, setOpen] = useState(false);
const [tipo, setTipo] = useState<string>("");
const [fuente, setFuente] = useState<string>("Manual");
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<ImportResult | null>(null);
const [history, setHistory] = useState<ImportHistory[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadHistory = async () => {
setLoadingHistory(true);
try {
const response = await fetch("/api/importar");
if (response.ok) {
const data = await response.json();
setHistory(data);
}
} catch (err) {
console.error("Error loading history:", err);
} finally {
setLoadingHistory(false);
}
};
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen);
if (isOpen) {
loadHistory();
} else {
// Reset state when closing
setFile(null);
setResult(null);
setTipo("");
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
const ext = selectedFile.name.split(".").pop()?.toLowerCase();
if (!["xlsx", "xls", "csv"].includes(ext || "")) {
toast({
title: "Formato no soportado",
description: "Solo se permiten archivos Excel (.xlsx, .xls) o CSV",
variant: "destructive",
});
return;
}
setFile(selectedFile);
setResult(null);
}
};
const handleImport = async () => {
if (!file || !tipo) {
toast({
title: "Datos incompletos",
description: "Seleccione un tipo y un archivo",
variant: "destructive",
});
return;
}
setLoading(true);
setResult(null);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("tipo", tipo);
formData.append("fuente", fuente);
const response = await fetch("/api/importar", {
method: "POST",
body: formData,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Error al importar");
}
setResult(data);
loadHistory();
toast({
title: "Importacion completada",
description: `${data.creados} creados, ${data.actualizados} actualizados, ${data.errores} errores`,
});
} catch (err) {
toast({
title: "Error",
description: err instanceof Error ? err.message : "Error al importar",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const downloadTemplate = (templateTipo: string) => {
let headers: string[];
let sampleData: any[];
switch (templateTipo) {
case "MATERIALES":
headers = ["codigo", "nombre", "unidad", "precio"];
sampleData = [
{ codigo: "MAT-001", nombre: "Cemento Portland CPC 40", unidad: "BOLSA", precio: 185.50 },
{ codigo: "MAT-002", nombre: "Varilla corrugada 3/8\"", unidad: "KG", precio: 24.80 },
{ codigo: "MAT-003", nombre: "Arena de rio", unidad: "M3", precio: 450.00 },
];
break;
case "MANO_OBRA":
headers = ["codigo", "nombre", "salario"];
sampleData = [
{ codigo: "MO-001", nombre: "Peon", salario: 350.00 },
{ codigo: "MO-002", nombre: "Ayudante de albanil", salario: 400.00 },
{ codigo: "MO-003", nombre: "Oficial albanil", salario: 550.00 },
];
break;
case "EQUIPOS":
headers = ["codigo", "nombre", "costoHorario"];
sampleData = [
{ codigo: "EQ-001", nombre: "Revolvedora 1 saco", costoHorario: 85.00 },
{ codigo: "EQ-002", nombre: "Vibrador de concreto", costoHorario: 65.00 },
{ codigo: "EQ-003", nombre: "Retroexcavadora", costoHorario: 850.00 },
];
break;
default:
return;
}
const ws = XLSX.utils.json_to_sheet(sampleData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Plantilla");
XLSX.writeFile(wb, `plantilla-${templateTipo.toLowerCase()}.xlsx`);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="outline">
<Upload className="mr-2 h-4 w-4" />
Importar Catalogo
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" />
Importar Catalogo de Precios
</DialogTitle>
<DialogDescription>
Cargue un archivo Excel o CSV con datos de materiales, mano de obra o equipos
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto space-y-6">
{/* Import Form */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Nueva Importacion</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Tipo de Catalogo</Label>
<Select value={tipo} onValueChange={setTipo}>
<SelectTrigger>
<SelectValue placeholder="Seleccione tipo" />
</SelectTrigger>
<SelectContent>
{TIPO_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<div className="flex items-center gap-2">
<opt.icon className="h-4 w-4" />
{opt.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Fuente de Datos</Label>
<Select value={fuente} onValueChange={setFuente}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FUENTE_OPTIONS.map((f) => (
<SelectItem key={f} value={f}>
{f}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Archivo</Label>
<div className="flex gap-2">
<Input
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleFileChange}
ref={fileInputRef}
className="flex-1"
/>
{tipo && (
<Button
variant="outline"
size="sm"
onClick={() => downloadTemplate(tipo)}
>
<Download className="mr-2 h-4 w-4" />
Plantilla
</Button>
)}
</div>
{file && (
<p className="text-sm text-slate-500">
Archivo: {file.name} ({(file.size / 1024).toFixed(1)} KB)
</p>
)}
</div>
<Button
onClick={handleImport}
disabled={!file || !tipo || loading}
className="w-full"
>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Importar
</Button>
{/* Result */}
{result && (
<Card className={result.errores > 0 ? "border-yellow-500" : "border-green-500"}>
<CardContent className="pt-4">
<div className="flex items-center gap-4">
{result.errores === 0 ? (
<CheckCircle2 className="h-8 w-8 text-green-500" />
) : result.creados === 0 && result.actualizados === 0 ? (
<XCircle className="h-8 w-8 text-red-500" />
) : (
<AlertTriangle className="h-8 w-8 text-yellow-500" />
)}
<div className="flex-1">
<p className="font-medium">
{result.errores === 0
? "Importacion exitosa"
: result.creados === 0 && result.actualizados === 0
? "Error en importacion"
: "Importacion con advertencias"}
</p>
<div className="flex gap-4 text-sm text-slate-600 mt-1">
<span>{result.total} registros</span>
<span className="text-green-600">{result.creados} creados</span>
<span className="text-blue-600">{result.actualizados} actualizados</span>
{result.errores > 0 && (
<span className="text-red-600">{result.errores} errores</span>
)}
</div>
{result.detalleErrores.length > 0 && (
<div className="mt-2 text-xs text-red-600">
{result.detalleErrores.map((err, i) => (
<p key={i}>{err}</p>
))}
</div>
)}
</div>
</div>
</CardContent>
</Card>
)}
</CardContent>
</Card>
{/* History */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Historial de Importaciones</CardTitle>
</CardHeader>
<CardContent>
{loadingHistory ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-slate-400" />
</div>
) : history.length === 0 ? (
<p className="text-center py-8 text-slate-500">
No hay importaciones previas
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Fecha</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Archivo</TableHead>
<TableHead>Fuente</TableHead>
<TableHead className="text-center">Resultado</TableHead>
<TableHead>Usuario</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{history.slice(0, 10).map((imp) => (
<TableRow key={imp.id}>
<TableCell className="text-sm">
{new Date(imp.createdAt).toLocaleDateString("es-MX", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
})}
</TableCell>
<TableCell>
<Badge variant="outline">
{imp.tipo === "MATERIALES" && <Package className="mr-1 h-3 w-3" />}
{imp.tipo === "MANO_OBRA" && <Users className="mr-1 h-3 w-3" />}
{imp.tipo === "EQUIPOS" && <Wrench className="mr-1 h-3 w-3" />}
{imp.tipo.replace("_", " ")}
</Badge>
</TableCell>
<TableCell className="text-sm max-w-[150px] truncate">
{imp.nombreArchivo}
</TableCell>
<TableCell className="text-sm">{imp.fuenteDatos || "-"}</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1 text-xs">
<span className="text-green-600">{imp.registrosCreados}</span>
<span>/</span>
<span className="text-blue-600">{imp.registrosActualizados}</span>
<span>/</span>
<span className="text-red-600">{imp.registrosError}</span>
</div>
</TableCell>
<TableCell className="text-sm">
{imp.creadoPor.nombre} {imp.creadoPor.apellido}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1 @@
export { ImportadorCatalogo } from "./importador-catalogo";

View File

@@ -0,0 +1,56 @@
"use client";
import { useState, useEffect } from "react";
interface ClientDateProps {
date: Date | string | null | undefined;
format?: "short" | "long" | "time";
fallback?: string;
}
export function ClientDate({ date, format = "short", fallback = "-" }: ClientDateProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!date) return <>{fallback}</>;
// During SSR and initial hydration, return a placeholder
if (!mounted) {
return <span suppressHydrationWarning>--/--/----</span>;
}
const dateObj = typeof date === "string" ? new Date(date) : date;
let formatted: string;
switch (format) {
case "long":
formatted = new Intl.DateTimeFormat("es-MX", {
year: "numeric",
month: "long",
day: "numeric",
}).format(dateObj);
break;
case "time":
formatted = new Intl.DateTimeFormat("es-MX", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(dateObj);
break;
case "short":
default:
formatted = new Intl.DateTimeFormat("es-MX", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(dateObj);
break;
}
return <span suppressHydrationWarning>{formatted}</span>;
}