feat(ancpi): complete ePay UI redesign + ZIP download + smart batch ordering

UI Redesign:
- ePay auto-connect when UAT is selected (no manual button)
- Credit badge with tooltip ("N credite ePay disponibile")
- Search result cards show CF status: Valid (green), Expirat (orange),
  Lipsă (gray), Se proceseaza (yellow pulse)
- Action buttons on each card: download/update/order CF extract
- "Lista mea" numbered rows + CF Status column + smart batch button
  "Scoate Extrase CF": skips valid, re-orders expired, orders new
- "Descarca Extrase CF" button → ZIP archive with numbered files
- Extrase CF tab simplified: clean table, filters (Toate/Valabile/
  Expirate/In procesare), search, download-all ZIP

Backend:
- GET /api/ancpi/download-zip?ids=... → JSZip streaming
- GET /api/ancpi/orders: multi-cadastral status check with statusMap
  (valid/expired/none/processing) + latestById

Data:
- Simulated expired extract for 328611 (Cluj-Napoca, expired 2026-03-17)
- Cleaned old error records from DB

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-23 09:13:51 +02:00
parent b7302d274a
commit a59d9bc923
5 changed files with 955 additions and 337 deletions
+114
View File
@@ -0,0 +1,114 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage";
import JSZip from "jszip";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/ancpi/download-zip?ids=id1,id2,id3
*
* Streams a ZIP file containing all requested CF extract PDFs.
* Files named: {index:02d}_Extras CF_{nrCadastral} - {DD-MM-YYYY}.pdf
* Index = position in the ids array (preserves list order).
*/
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const idsParam = url.searchParams.get("ids");
if (!idsParam) {
return NextResponse.json(
{ error: "Parametru 'ids' lipsa." },
{ status: 400 },
);
}
const ids = idsParam.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length === 0) {
return NextResponse.json(
{ error: "Lista de id-uri goala." },
{ status: 400 },
);
}
// Fetch all extract records
const extracts = await prisma.cfExtract.findMany({
where: { id: { in: ids } },
select: {
id: true,
nrCadastral: true,
minioPath: true,
documentDate: true,
completedAt: true,
},
});
// Build a map for ordering
const extractMap = new Map(extracts.map((e) => [e.id, e]));
const zip = new JSZip();
let filesAdded = 0;
for (let i = 0; i < ids.length; i++) {
const id = ids[i]!;
const extract = extractMap.get(id);
if (!extract?.minioPath) continue;
const dateForName = extract.documentDate ?? extract.completedAt ?? new Date();
const d = new Date(dateForName);
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const idx = String(i + 1).padStart(2, "0");
const fileName = `${idx}_Extras CF_${extract.nrCadastral} - ${dd}-${mm}-${yyyy}.pdf`;
try {
const stream = await getCfExtractStream(extract.minioPath);
// Collect stream into buffer
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
}
const buffer = Buffer.concat(chunks);
zip.file(fileName, buffer);
filesAdded++;
} catch (err) {
console.error(`[download-zip] Failed to fetch ${extract.minioPath}:`, err);
// Skip this file but continue
}
}
if (filesAdded === 0) {
return NextResponse.json(
{ error: "Niciun fisier PDF gasit." },
{ status: 404 },
);
}
const zipBuffer = await zip.generateAsync({
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
});
const today = new Date();
const todayStr = `${String(today.getDate()).padStart(2, "0")}-${String(today.getMonth() + 1).padStart(2, "0")}-${today.getFullYear()}`;
const archiveName = `Extrase_CF_${filesAdded}_${todayStr}.zip`;
return new Response(new Uint8Array(zipBuffer), {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${encodeURIComponent(archiveName)}"`,
"Content-Length": String(zipBuffer.length),
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+85 -3
View File
@@ -7,18 +7,38 @@ export const dynamic = "force-dynamic";
/**
* GET /api/ancpi/orders — list all CF extract orders.
*
* Query params: ?nrCadastral=&status=&limit=50&offset=0
* Query params:
* ?nrCadastral=123 — single cadastral number
* ?nrCadastral=123,456 — comma-separated for batch status check
* ?status=completed — filter by status
* ?limit=50&offset=0 — pagination
*
* When nrCadastral contains commas, returns an extra `statusMap` field:
* { orders, total, statusMap: { "123": "valid", "456": "expired", "789": "none" } }
* - "valid" = completed + expiresAt > now
* - "expired" = completed + expiresAt <= now
* - "none" = no completed record
*/
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const nrCadastral = url.searchParams.get("nrCadastral") || undefined;
const nrCadastralParam = url.searchParams.get("nrCadastral") || undefined;
const status = url.searchParams.get("status") || undefined;
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50"), 200);
const offset = parseInt(url.searchParams.get("offset") ?? "0");
// Check if multi-cadastral query
const cadastralNumbers = nrCadastralParam
? nrCadastralParam.split(",").map((s) => s.trim()).filter(Boolean)
: [];
const isMulti = cadastralNumbers.length > 1;
const where: Record<string, unknown> = {};
if (nrCadastral) where.nrCadastral = nrCadastral;
if (cadastralNumbers.length === 1) {
where.nrCadastral = cadastralNumbers[0];
} else if (isMulti) {
where.nrCadastral = { in: cadastralNumbers };
}
if (status) where.status = status;
const [orders, total] = await Promise.all([
@@ -31,6 +51,68 @@ export async function GET(req: Request) {
prisma.cfExtract.count({ where }),
]);
// Build statusMap for multi-cadastral queries (or single if requested)
if (cadastralNumbers.length > 0) {
const now = new Date();
// For status map, we need completed records for each cadastral number
const completedRecords = await prisma.cfExtract.findMany({
where: {
nrCadastral: { in: cadastralNumbers },
status: "completed",
},
orderBy: { createdAt: "desc" },
select: {
id: true,
nrCadastral: true,
expiresAt: true,
completedAt: true,
minioPath: true,
},
});
const statusMap: Record<string, string> = {};
const latestById: Record<string, typeof completedRecords[number]> = {};
// Find latest completed record per cadastral number
for (const rec of completedRecords) {
const existing = latestById[rec.nrCadastral];
if (!existing) {
latestById[rec.nrCadastral] = rec;
}
}
for (const nr of cadastralNumbers) {
const rec = latestById[nr];
if (!rec) {
statusMap[nr] = "none";
} else if (rec.expiresAt && rec.expiresAt <= now) {
statusMap[nr] = "expired";
} else {
statusMap[nr] = "valid";
}
}
// Also check for active (in-progress) orders
const activeRecords = await prisma.cfExtract.findMany({
where: {
nrCadastral: { in: cadastralNumbers },
status: {
in: ["pending", "queued", "cart", "searching", "ordering", "polling", "downloading"],
},
},
select: { nrCadastral: true },
});
for (const rec of activeRecords) {
// If there's an active order, mark as "processing" (takes priority over "none")
if (statusMap[rec.nrCadastral] === "none") {
statusMap[rec.nrCadastral] = "processing";
}
}
return NextResponse.json({ orders, total, statusMap, latestById });
}
return NextResponse.json({ orders, total });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";