feat(geoportal-v2): export toolbar + Semnez ca picker + CF intern/Extras split

V2 panel toolbar replaces the single "Comandă CF" button with two rows:

  [Încadrare] [Pl. situație] [Coord.] [DXF]       ← 4 exports
  [CF intern] [Extras CF]                          ← 2 CF flows

Each export button pops an inline modal:
  - PIZ / PAD: SignAsPicker (PFA / PJA radio list, manual-add inline,
    co-signer slot on PIZ) + basemap toggle (google / orto for PIZ).
  - Coord / DXF: no picker — single-click download via JWT proxy.

"CF intern" is the free copycf flow from eTerra (proxied via gis-api);
"Extras CF" keeps the existing CfOrderModal (1 credit ePay). The two
modes are now visually balanced as a 2-button row.

Sign-as picker rows merge user-owned Signatory table entries with the
SIGN_AS_DEFAULT_OPTIONS env-driven fallback (org-wide hardcoded options;
defaults seed two Studii de teren entries — Tiurbe PFA + SRL PJA). New
rows added via the picker's "Adaugă autorizație" inline form write to
the Signatory table; ENV rows are read-only.

Architots side ships fully:
  - prisma Signatory model + ALTER TABLE applied (per the schema-drift
    feedback memory).
  - /api/sign-as-options (GET, POST) + /api/sign-as-options/[id]
    (PATCH, DELETE).
  - /api/cf-intern/order and /api/gis/parcel/[id]/{piz,pad,coords,dxf}
    proxy routes — auth check + JWT forward, stream binary back.
  - gis-api thin client extended with the matching exports.* namespace.

Until the gis-api endpoints ship (next session — full spec in
docs/plans/005-gis-api-export-endpoints.md), each export proxy returns
501 "…urmează" with a Romanian message so the modal shows what's
coming instead of a hard error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-21 07:57:55 +03:00
parent 36840f31f6
commit 71cfc29f9a
15 changed files with 1917 additions and 15 deletions
+146 -15
View File
@@ -7,9 +7,11 @@ import {
Home, Building, Building2, MapPin, ChevronRight, Users,
Sparkles, ShieldCheck, AlertTriangle, HelpCircle,
Factory, Warehouse,
Map as MapIcon, FileSpreadsheet, FileBox, Receipt, ScrollText,
} from "lucide-react";
import { cn } from "@/shared/lib/utils";
import { CfOrderModal } from "./cf-order-modal";
import { ExportModal, type ExportKind } from "./exports/export-modal";
import { useUatName } from "./uat-lookup";
const AUTH_RETRY_KEY = "gis_panel_auth_retry";
@@ -406,14 +408,63 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa
const [condoOwners, setCondoOwners] = useState<CondoOwner[] | null>(null);
const [condoLoading, setCondoLoading] = useState(false);
const [cfModalOpen, setCfModalOpen] = useState(false);
const [cfInternBusy, setCfInternBusy] = useState(false);
const [cfInternError, setCfInternError] = useState<string | null>(null);
const [exportKind, setExportKind] = useState<ExportKind | null>(null);
// Close the CF modal whenever the user switches to a different
// parcel — keeps the modal scoped to a single decision instead of
// silently re-targeting mid-flight.
// Close the modals whenever the user switches to a different parcel —
// keeps the modal scoped to a single decision instead of silently
// re-targeting mid-flight.
useEffect(() => {
setCfModalOpen(false);
setExportKind(null);
setCfInternError(null);
}, [feature.cadastralRef, feature.siruta, feature.layerId]);
const handleCfIntern = useCallback(async () => {
if (!feature.cadastralRef || !feature.siruta) return;
setCfInternBusy(true);
setCfInternError(null);
try {
const res = await fetch("/api/cf-intern/order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nrCadastral: feature.cadastralRef,
siruta: feature.siruta,
}),
});
if (!res.ok) {
let msg = `Eroare HTTP ${res.status}`;
const text = await res.text();
try {
const j = JSON.parse(text);
if (typeof j.error === "string") msg = j.error;
} catch {
if (text) msg += `: ${text.slice(0, 200)}`;
}
setCfInternError(msg);
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const cd = res.headers.get("content-disposition") ?? "";
const m = /filename\*?=(?:UTF-8''|"?)([^";]+)/i.exec(cd);
const filename = m?.[1]
? decodeURIComponent(m[1])
: `cf_intern_${feature.cadastralRef}.pdf`;
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 30_000);
} catch (err) {
setCfInternError(err instanceof Error ? err.message : String(err));
} finally {
setCfInternBusy(false);
}
}, [feature.cadastralRef, feature.siruta]);
const authRetriedRef = useRef<boolean>(
typeof window !== "undefined" &&
sessionStorage.getItem(AUTH_RETRY_KEY) === "1",
@@ -1297,18 +1348,85 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa
</div>
)}
{/* Actions toolbar */}
<div className="flex flex-wrap gap-1 border-t bg-muted/30 p-1.5">
<button
type="button"
onClick={() => setCfModalOpen(true)}
disabled={!feature.cadastralRef || !feature.siruta}
className="inline-flex items-center gap-1 rounded bg-background px-2 py-1 text-[11px] font-medium hover:bg-muted disabled:opacity-50"
title="Comandă extras Carte Funciară (1 credit ePay)"
>
<FileText className="h-3 w-3" />
Comandă CF
</button>
{/* Actions toolbar — exports + CF split */}
<div className="space-y-1 border-t bg-muted/30 p-1.5">
{/* 4 export buttons row: PIZ / Plan situație / Coord / DXF */}
<div className="grid grid-cols-4 gap-1">
<button
type="button"
onClick={() => setExportKind("piz")}
disabled={!feature.cadastralRef}
className="inline-flex flex-col items-center gap-0.5 rounded bg-background px-1 py-1.5 text-[10px] font-medium hover:bg-muted disabled:opacity-50"
title="Plan de încadrare în zonă (PDF, 1:5000)"
>
<MapIcon className="h-3.5 w-3.5" />
<span>Încadrare</span>
</button>
<button
type="button"
onClick={() => setExportKind("pad")}
disabled={!feature.cadastralRef}
className="inline-flex flex-col items-center gap-0.5 rounded bg-background px-1 py-1.5 text-[10px] font-medium hover:bg-muted disabled:opacity-50"
title="Plan de situație / amplasament (PDF)"
>
<ScrollText className="h-3.5 w-3.5" />
<span>Pl. situație</span>
</button>
<button
type="button"
onClick={() => setExportKind("coords")}
disabled={!feature.cadastralRef}
className="inline-flex flex-col items-center gap-0.5 rounded bg-background px-1 py-1.5 text-[10px] font-medium hover:bg-muted disabled:opacity-50"
title="Coordonate Stereo70 (XLSX)"
>
<FileSpreadsheet className="h-3.5 w-3.5" />
<span>Coord.</span>
</button>
<button
type="button"
onClick={() => setExportKind("dxf")}
disabled={!feature.cadastralRef}
className="inline-flex flex-col items-center gap-0.5 rounded bg-background px-1 py-1.5 text-[10px] font-medium hover:bg-muted disabled:opacity-50"
title="Export DXF (parcela + vecini)"
>
<FileBox className="h-3.5 w-3.5" />
<span>DXF</span>
</button>
</div>
{/* CF row: intern (free, ~2-3s) vs ANCPI extract (1 credit ePay) */}
<div className="grid grid-cols-2 gap-1">
<button
type="button"
onClick={handleCfIntern}
disabled={cfInternBusy || !feature.cadastralRef || !feature.siruta}
className="inline-flex items-center justify-center gap-1 rounded bg-background px-2 py-1.5 text-[11px] font-medium hover:bg-muted disabled:opacity-50"
title="Descarcă extras CF intern din eTerra (gratuit)"
>
{cfInternBusy ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<FileText className="h-3 w-3" />
)}
CF intern
</button>
<button
type="button"
onClick={() => setCfModalOpen(true)}
disabled={!feature.cadastralRef || !feature.siruta}
className="inline-flex items-center justify-center gap-1 rounded bg-background px-2 py-1.5 text-[11px] font-medium hover:bg-muted disabled:opacity-50"
title="Comandă extras de Carte Funciară prin ePay (1 credit)"
>
<Receipt className="h-3 w-3" />
Extras CF
</button>
</div>
{cfInternError && (
<div className="rounded border border-destructive/40 bg-destructive/10 px-2 py-1 text-[10px] text-destructive">
{cfInternError}
</div>
)}
</div>
{/* CF order modal — confirmation + animated multi-step progress */}
@@ -1318,6 +1436,19 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa
siruta={feature.siruta}
onClose={() => setCfModalOpen(false)}
/>
{/* Export modal — used by all 4 of PIZ/PAD/Coord/DXF buttons */}
{exportKind && (
<ExportModal
open
kind={exportKind}
parcelId={detail?.id ? String(detail.id) : feature.id}
cadastralRef={feature.cadastralRef}
uatName={uatName ?? null}
layerId={feature.layerId}
onClose={() => setExportKind(null)}
/>
)}
</div>
);
}