feat: county sync on monitor page + in-app notification system

- GET /api/eterra/counties — distinct county list from GisUat
- POST /api/eterra/sync-county — background sync all UATs in a county
  (TERENURI + CLADIRI + INTRAVILAN), magic mode for enriched UATs,
  concurrency guard, creates notification on completion
- In-app notification service (KeyValueStore, CRUD, unread count)
- GET/PATCH /api/notifications/app — list and mark-read endpoints
- NotificationBell component in header with popover + polling
- Monitor page: county select dropdown + SyncTestButton with customBody

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-04-07 22:56:59 +03:00
parent 8222be2f0e
commit f44d57629f
8 changed files with 742 additions and 3 deletions
+47 -3
View File
@@ -16,6 +16,8 @@ export default function MonitorPage() {
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState("");
const [logs, setLogs] = useState<{ time: string; type: "info" | "ok" | "error" | "wait"; msg: string }[]>([]);
const [counties, setCounties] = useState<string[]>([]);
const [selectedCounty, setSelectedCounty] = useState("");
const rebuildPrevRef = useRef<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -40,6 +42,14 @@ export default function MonitorPage() {
setLogs((prev) => [{ time: new Date().toLocaleTimeString("ro-RO"), type, msg }, ...prev.slice(0, 49)]);
}, []);
// Fetch counties for sync selector
useEffect(() => {
fetch("/api/eterra/counties")
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d: { counties: string[] }) => setCounties(d.counties ?? []))
.catch(() => {});
}, []);
// Cleanup poll on unmount
useEffect(() => {
return () => { if (pollRef.current) clearInterval(pollRef.current); };
@@ -273,6 +283,38 @@ export default function MonitorPage() {
pollRef={pollRef}
/>
</div>
{/* County sync */}
<div className="flex items-end gap-3 mt-2 pt-3 border-t border-border/50">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Sync pe judet</span>
<select
value={selectedCounty}
onChange={(e) => setSelectedCounty(e.target.value)}
className="h-9 w-52 rounded-md border border-border bg-background px-3 text-sm"
>
<option value="">Alege judet...</option>
{counties.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<SyncTestButton
label={selectedCounty ? `Sync ${selectedCounty}` : "Sync Judet"}
description="TERENURI + CLADIRI + INTRAVILAN pentru tot judetul"
siruta=""
mode="base"
includeNoGeometry={false}
actionKey="sync-county"
actionLoading={actionLoading}
setActionLoading={setActionLoading}
addLog={addLog}
pollRef={pollRef}
customEndpoint="/api/eterra/sync-county"
customBody={{ county: selectedCounty }}
disabled={!selectedCounty}
/>
</div>
{logs.length > 0 && (
<div className="border border-border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
@@ -366,13 +408,15 @@ function ActionButton({ label, description, loading, onClick }: {
);
}
function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, actionKey, actionLoading, setActionLoading, addLog, pollRef, customEndpoint }: {
function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, actionKey, actionLoading, setActionLoading, addLog, pollRef, customEndpoint, customBody, disabled }: {
label: string; description: string; siruta: string; mode: "base" | "magic";
includeNoGeometry: boolean; actionKey: string; actionLoading: string;
setActionLoading: (v: string) => void;
addLog: (type: "info" | "ok" | "error" | "wait", msg: string) => void;
pollRef: React.MutableRefObject<ReturnType<typeof setInterval> | null>;
customEndpoint?: string;
customBody?: Record<string, unknown>;
disabled?: boolean;
}) {
const startTimeRef = useRef<number>(0);
const formatElapsed = () => {
@@ -389,7 +433,7 @@ function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, a
addLog("info", `[${label}] Pornire...`);
try {
const endpoint = customEndpoint ?? "/api/eterra/sync-background";
const body = customEndpoint ? {} : { siruta, mode, includeNoGeometry };
const body = customEndpoint ? (customBody ?? {}) : { siruta, mode, includeNoGeometry };
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -439,7 +483,7 @@ function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, a
}, 3 * 60 * 60_000);
} catch { addLog("error", `[${label}] Eroare retea`); setActionLoading(""); }
}}
disabled={!!actionLoading}
disabled={!!actionLoading || !!disabled}
className="flex flex-col items-start px-4 py-3 rounded-lg border border-border hover:border-primary/50 hover:bg-primary/5 transition-colors disabled:opacity-50 text-left"
>
<span className="font-medium text-sm">{actionLoading === actionKey ? "Se ruleaza..." : label}</span>