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:
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
209
src/app/api/avances/[id]/route.ts
Normal file
209
src/app/api/avances/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
122
src/app/api/avances/resumen/route.ts
Normal file
122
src/app/api/avances/resumen/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
197
src/app/api/avances/route.ts
Normal file
197
src/app/api/avances/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
345
src/app/api/importar/route.ts
Normal file
345
src/app/api/importar/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
452
src/components/avances/control-avance-partidas.tsx
Normal file
452
src/components/avances/control-avance-partidas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/components/avances/index.ts
Normal file
1
src/components/avances/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ControlAvancePartidas } from "./control-avance-partidas";
|
||||
450
src/components/importar/importador-catalogo.tsx
Normal file
450
src/components/importar/importador-catalogo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/components/importar/index.ts
Normal file
1
src/components/importar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ImportadorCatalogo } from "./importador-catalogo";
|
||||
56
src/components/ui/client-date.tsx
Normal file
56
src/components/ui/client-date.tsx
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user