From 5e4618b309437d60fe647ed825032ec7be7c6cfc Mon Sep 17 00:00:00 2001 From: Claude VM Date: Wed, 20 May 2026 07:53:15 +0300 Subject: [PATCH] =?UTF-8?q?feat(geoportal-v2):=20inline=20CF=20order=20mod?= =?UTF-8?q?al=20=E2=80=94=20confirmation=20+=20animated=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marius: click "Comandă CF" from the card itself, no new-tab to parcel-sync. Show "Ești sigur? Costă 1 credit, mai ai X" first. Animate the order through its phases until done. New component cf-order-modal.tsx — a 7-state machine over a single shadcn-style dialog: loading-status — checks /api/ancpi/session for connection + credits not-connected — ePay session offline → prompt to connect via parcel-sync (the only place credentials live) no-credits — 0 credits, can't proceed ready — confirmation: 1 credit cost, current balance, projected balance after the order, all in rounded chips with Coins icon placing — POST /api/ancpi/order, spinner on step 1 processing — poll /api/ancpi/orders every 3s until status becomes completed/done/minioPath populated. Shows live elapsed seconds; 90s timeout falls through to error with "verifică din nou peste câteva minute". done — checkmark anim + "Descarcă PDF" if document URL came back error — destructive panel + Reîncearcă button Animations (tailwindcss-animate utilities): - Modal backdrop: fade-in 200ms - Modal card: zoom-in-95 + slide-in-from-bottom 200ms - Step rows: active row gets primary-tinted bg + Loader2 spin, done rows turn emerald + Check icon zooms in 300ms - Success/error final state: rounded badge + icon zooms in 500ms Footer adapts per phase: Anulează+Confirmă (ready), Conectează ePay (not-connected), Închide (loading/no-credits), Închide fereastra (placing/processing — order continues in bg), Gata (done), Închide+ Reîncearcă (error). Wires into feature-info-panel by replacing the "open /parcel-sync" click handler with setCfModalOpen(true). Modal mounts at the panel's root with fixed positioning + z-50 so it overlays the map. Backdrop click dismisses except during placing/processing. Uses the legacy /api/ancpi/* endpoints (not /api/cf/* gis-ac route) per Marius's earlier decision to keep credit tracking on his own ePay session. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/modules/geoportal/v2/cf-order-modal.tsx | 572 ++++++++++++++++++ .../geoportal/v2/feature-info-panel.tsx | 21 +- 2 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 src/modules/geoportal/v2/cf-order-modal.tsx diff --git a/src/modules/geoportal/v2/cf-order-modal.tsx b/src/modules/geoportal/v2/cf-order-modal.tsx new file mode 100644 index 0000000..727760b --- /dev/null +++ b/src/modules/geoportal/v2/cf-order-modal.tsx @@ -0,0 +1,572 @@ +"use client"; + +// Inline CF order flow for the V2 panel — replaces the deep-link to +// /parcel-sync. Marius rejected the gis-api shared-pool model +// ([[architools-cutover-state-2026-05-19]]), so this stays on the +// legacy `/api/ancpi/order` route which uses HIS connected ePay +// session and decrements his own credit balance. +// +// State machine: +// loading-status → checking ePay session + credit count +// not-connected → user must connect ePay first (deep-link out) +// no-credits → 0 credits, nothing to do +// ready → confirmation step ("1 credit, mai ai X") +// placing → POST /api/ancpi/order +// processing → poll /api/ancpi/orders for completion +// done → final state, optional download link +// error → any failure with retry option + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + X, Loader2, Check, AlertCircle, FileText, Coins, + Plug, ArrowRight, Download, +} from "lucide-react"; +import { cn } from "@/shared/lib/utils"; + +type Phase = + | "loading-status" + | "not-connected" + | "no-credits" + | "ready" + | "placing" + | "processing" + | "done" + | "error"; + +interface SessionStatus { + connected: boolean; + credits?: number | null; +} + +interface OrderListResponse { + total?: number; + orders?: Array<{ + id: string; + nrCadastral: string; + status: string; + epayStatus?: string | null; + documentName?: string | null; + minioPath?: string | null; + completedAt?: string | null; + }>; +} + +interface Props { + open: boolean; + cadastralRef: string; + siruta: string; + /** Optional — passed through to the order body. The legacy route + * tolerates blanks when judet/uat ids default to 0. */ + judetName?: string; + uatName?: string; + onClose: () => void; +} + +const POLL_INTERVAL_MS = 3_000; +const POLL_TIMEOUT_MS = 90_000; + +export function CfOrderModal({ + open, + cadastralRef, + siruta, + judetName, + uatName, + onClose, +}: Props) { + const [phase, setPhase] = useState("loading-status"); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [orderId, setOrderId] = useState(null); + const [documentUrl, setDocumentUrl] = useState(null); + const [elapsed, setElapsed] = useState(0); + + const startedAtRef = useRef(0); + const pollAbortRef = useRef(null); + + // Reset state on open + useEffect(() => { + if (!open) return; + setPhase("loading-status"); + setStatus(null); + setError(null); + setOrderId(null); + setDocumentUrl(null); + setElapsed(0); + void loadStatus(); + return () => { + pollAbortRef.current?.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, cadastralRef, siruta]); + + // Elapsed timer during processing + useEffect(() => { + if (phase !== "processing" && phase !== "placing") return; + const id = setInterval(() => { + if (startedAtRef.current) { + setElapsed(Math.round((Date.now() - startedAtRef.current) / 1000)); + } + }, 500); + return () => clearInterval(id); + }, [phase]); + + const loadStatus = useCallback(async () => { + try { + const res = await fetch("/api/ancpi/session", { cache: "no-store" }); + const data = (await res.json()) as SessionStatus; + setStatus(data); + if (!data.connected) { + setPhase("not-connected"); + return; + } + if (data.credits != null && data.credits < 1) { + setPhase("no-credits"); + return; + } + setPhase("ready"); + } catch { + setError("Nu pot verifica sesiunea ePay."); + setPhase("error"); + } + }, []); + + const placeOrder = useCallback(async () => { + setPhase("placing"); + setError(null); + startedAtRef.current = Date.now(); + + try { + const res = await fetch("/api/ancpi/order", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + parcels: [ + { + nrCadastral: cadastralRef, + nrCF: null, + siruta, + judetIndex: 0, + judetName: judetName ?? "", + uatId: 0, + uatName: uatName ?? "", + }, + ], + }), + }); + const data = (await res.json()) as { + orders?: Array<{ id: string; nrCadastral: string; status: string }>; + error?: string; + }; + if (!res.ok || data.error) { + setError(data.error ?? "Eroare la plasarea comenzii"); + setPhase("error"); + return; + } + const placed = (data.orders ?? [])[0]; + if (!placed) { + setError("Răspuns invalid de la server"); + setPhase("error"); + return; + } + setOrderId(placed.id); + setPhase("processing"); + void pollUntilDone(placed.id); + } catch { + setError("Eroare de rețea"); + setPhase("error"); + } + }, [cadastralRef, siruta, judetName, uatName]); + + const pollUntilDone = useCallback( + async (id: string) => { + pollAbortRef.current?.abort(); + const ctrl = new AbortController(); + pollAbortRef.current = ctrl; + + const startedAt = Date.now(); + while (!ctrl.signal.aborted) { + if (Date.now() - startedAt > POLL_TIMEOUT_MS) { + setError( + "Procesarea durează mai mult decât ne-am așteptat. Comanda e plasată — verifică din nou peste câteva minute.", + ); + setPhase("error"); + return; + } + try { + const res = await fetch( + `/api/ancpi/orders?nrCadastral=${encodeURIComponent(cadastralRef)}&limit=5`, + { signal: ctrl.signal, cache: "no-store" }, + ); + if (res.ok) { + const data = (await res.json()) as OrderListResponse; + const row = (data.orders ?? []).find((o) => o.id === id); + if (row) { + const s = (row.status || "").toLowerCase(); + if ( + s === "completed" || + s === "done" || + row.minioPath || + row.documentName + ) { + setDocumentUrl(`/api/ancpi/download?id=${row.id}`); + setPhase("done"); + return; + } + if (s === "failed" || s === "error") { + setError("Comanda a eșuat la ANCPI."); + setPhase("error"); + return; + } + } + } + } catch { + if (ctrl.signal.aborted) return; + } + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + } + }, + [cadastralRef], + ); + + const goToParcelSync = useCallback(() => { + const url = `/parcel-sync?tab=epay&cad=${encodeURIComponent(cadastralRef)}`; + window.open(url, "_blank", "noopener,noreferrer"); + }, [cadastralRef]); + + if (!open) return null; + + return ( +
{ + // dismiss on backdrop click only when not mid-flight + if ( + e.target === e.currentTarget && + phase !== "placing" && + phase !== "processing" + ) { + onClose(); + } + }} + > +
+ {/* Header */} +
+
+

+ + Comandă extras CF +

+

+ {cadastralRef} +

+
+ +
+ + {/* Body */} +
+ {/* Loading session status */} + {phase === "loading-status" && ( +
+ + Verificare credite ePay… +
+ )} + + {/* Not connected */} + {phase === "not-connected" && ( +
+
+ +
+

+ ePay neconectat +

+

+ Conectează-te în secțiunea Parcel Sync → ePay pentru + a putea comanda extrase CF. +

+
+
+ +
+ )} + + {/* No credits */} + {phase === "no-credits" && ( +
+
+ +
+

+ Credite insuficiente +

+

+ Ai 0 credite. Reîncarcă-ți contul ePay ANCPI înainte + de a comanda. +

+
+
+
+ )} + + {/* Ready — confirmation */} + {phase === "ready" && ( +
+

+ Ești pe cale să comanzi un extras de Carte Funciară pentru + această parcelă. +

+
+
+ + Cost +
+ 1 credit +
+
+
+ + Sold disponibil +
+ + {status?.credits ?? "—"} credite + +
+
+ După comandă + + {status?.credits != null + ? `${status.credits - 1} credite` + : "—"} + +
+
+ )} + + {/* Placing or Processing */} + {(phase === "placing" || phase === "processing") && ( +
+ + + + {phase === "processing" && ( +

+ Poate dura până la 60 de secunde. Poți închide fereastra — + comanda continuă în fundal. +

+ )} +
+ )} + + {/* Done */} + {phase === "done" && ( +
+
+
+ +
+
+

Extras CF gata!

+

+ Documentul a fost generat și descărcat în spațiul tău. +

+
+
+ {documentUrl && ( + + + Descarcă PDF + + )} +
+ )} + + {/* Error */} + {phase === "error" && ( +
+
+
+ +
+
+

Comanda nu a putut fi plasată

+

+ {error ?? "Eroare necunoscută"} +

+
+
+
+ )} +
+ + {/* Footer */} +
+ {phase === "ready" && ( + <> + + + + )} + {(phase === "loading-status" || phase === "no-credits") && ( + + )} + {phase === "not-connected" && ( + + )} + {(phase === "placing" || phase === "processing") && ( + + )} + {phase === "done" && ( + + )} + {phase === "error" && ( + <> + + + + )} +
+
+
+ ); +} + +function StepRow({ + done, + active, + pending, + label, +}: { + done: boolean; + active: boolean; + pending?: boolean; + label: string; +}) { + return ( +
+ + {done ? ( + + ) : active ? ( + + ) : ( + + )} + + + {label} + +
+ ); +} diff --git a/src/modules/geoportal/v2/feature-info-panel.tsx b/src/modules/geoportal/v2/feature-info-panel.tsx index 8022fd9..702317a 100644 --- a/src/modules/geoportal/v2/feature-info-panel.tsx +++ b/src/modules/geoportal/v2/feature-info-panel.tsx @@ -9,6 +9,7 @@ import { Factory, Warehouse, } from "lucide-react"; import { cn } from "@/shared/lib/utils"; +import { CfOrderModal } from "./cf-order-modal"; const AUTH_RETRY_KEY = "gis_panel_auth_retry"; @@ -403,6 +404,7 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa const [buildings, setBuildings] = useState(null); const [condoOwners, setCondoOwners] = useState(null); const [condoLoading, setCondoLoading] = useState(false); + const [cfModalOpen, setCfModalOpen] = useState(false); const authRetriedRef = useRef( typeof window !== "undefined" && @@ -1215,18 +1217,23 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa
+ + {/* CF order modal — confirmation + animated multi-step progress */} + setCfModalOpen(false)} + /> ); }