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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user