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
+2
View File
@@ -13,6 +13,7 @@ import {
import { useAuth } from "@/core/auth";
import { signIn, signOut } from "next-auth/react";
import { ThemeToggle } from "@/shared/components/common/theme-toggle";
import { NotificationBell } from "./notification-bell";
interface HeaderProps {
onToggleSidebar?: () => void;
@@ -35,6 +36,7 @@ export function Header({ onToggleSidebar }: HeaderProps) {
</div>
<div className="flex items-center gap-3">
<NotificationBell />
<ThemeToggle />
<DropdownMenu>
@@ -0,0 +1,168 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Bell, Check, CheckCheck, AlertTriangle } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/components/ui/popover";
import { ScrollArea } from "@/shared/components/ui/scroll-area";
import type { AppNotification } from "@/core/notifications/app-notifications";
const POLL_INTERVAL = 60_000; // 60s
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "acum";
if (mins < 60) return `acum ${mins} min`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `acum ${hours} ore`;
const days = Math.floor(hours / 24);
if (days === 1) return "ieri";
return `acum ${days} zile`;
}
export function NotificationBell() {
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const fetchUnreadCount = useCallback(async () => {
try {
const res = await fetch("/api/notifications/app?limit=1");
if (!res.ok) return;
const data = (await res.json()) as { unreadCount: number };
setUnreadCount(data.unreadCount);
} catch { /* ignore */ }
}, []);
const fetchAll = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/notifications/app?limit=30");
if (!res.ok) return;
const data = (await res.json()) as {
notifications: AppNotification[];
unreadCount: number;
};
setNotifications(data.notifications);
setUnreadCount(data.unreadCount);
} catch { /* ignore */ }
setLoading(false);
}, []);
// Poll unread count
useEffect(() => {
fetchUnreadCount();
const id = setInterval(fetchUnreadCount, POLL_INTERVAL);
return () => clearInterval(id);
}, [fetchUnreadCount]);
// Fetch full list when popover opens
useEffect(() => {
if (open) fetchAll();
}, [open, fetchAll]);
const handleMarkRead = async (id: string) => {
await fetch("/api/notifications/app", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "mark-read", id }),
});
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, readAt: new Date().toISOString() } : n)),
);
setUnreadCount((c) => Math.max(0, c - 1));
};
const handleMarkAllRead = async () => {
await fetch("/api/notifications/app", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "mark-all-read" }),
});
setNotifications((prev) =>
prev.map((n) => ({ ...n, readAt: n.readAt ?? new Date().toISOString() })),
);
setUnreadCount(0);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 h-4 min-w-4 rounded-full bg-destructive text-[10px] font-medium text-white flex items-center justify-center px-1">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b">
<span className="text-sm font-medium">Notificari</span>
{unreadCount > 0 && (
<button
onClick={handleMarkAllRead}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<CheckCheck className="h-3 w-3" />
Marcheaza toate ca citite
</button>
)}
</div>
{/* List */}
<ScrollArea className="max-h-80">
{loading && notifications.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
Se incarca...
</div>
) : notifications.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
Nicio notificare
</div>
) : (
notifications.map((n) => (
<button
key={n.id}
onClick={() => !n.readAt && handleMarkRead(n.id)}
className={`w-full flex items-start gap-2.5 px-3 py-2.5 text-left border-b border-border/30 last:border-0 hover:bg-muted/50 transition-colors ${
!n.readAt ? "bg-primary/5" : ""
}`}
>
<div className="mt-0.5 shrink-0">
{n.type === "sync-error" ? (
<AlertTriangle className="h-4 w-4 text-destructive" />
) : (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className={`text-sm truncate ${!n.readAt ? "font-medium" : ""}`}>
{n.title}
</p>
{!n.readAt && (
<span className="shrink-0 h-2 w-2 rounded-full bg-primary" />
)}
</div>
<p className="text-xs text-muted-foreground truncate">{n.message}</p>
<p className="text-[10px] text-muted-foreground/60 mt-0.5">
{relativeTime(n.createdAt)}
</p>
</div>
</button>
))
)}
</ScrollArea>
</PopoverContent>
</Popover>
);
}