feat: external status monitor for registratura (Primaria Cluj-Napoca)

- Add ExternalStatusTracking types + ExternalDocStatus semantic states
- Authority catalog with Primaria Cluj-Napoca (POST scraper + HTML parser)
- Status check service: batch + single entry, change detection via hash
- API routes: cron-triggered batch (/api/registratura/status-check) +
  user-triggered single (/api/registratura/status-check/single)
- Add "status-change" notification type with instant email on change
- Table badge: Radio icon color-coded by status (amber/blue/green/red)
- Detail panel: full monitoring section with status, history, manual check
- Auto-detection: prompt when recipient matches known authority
- Activation dialog: configure petitioner name + confirm registration data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-11 14:42:21 +02:00
parent 1c51236c31
commit d7bd1a7f5d
10 changed files with 1201 additions and 6 deletions
@@ -20,6 +20,10 @@ import {
X,
Image as ImageIcon,
Reply,
Radio,
RefreshCw,
ChevronDown,
ChevronUp,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
@@ -33,7 +37,9 @@ import {
SheetDescription,
} from "@/shared/components/ui/sheet";
import type { RegistryEntry } from "../types";
import { DEFAULT_DOC_TYPE_LABELS } from "../types";
import { DEFAULT_DOC_TYPE_LABELS, EXTERNAL_STATUS_LABELS } from "../types";
import type { ExternalDocStatus } from "../types";
import { getAuthority } from "../services/authority-catalog";
import { getOverdueDays } from "../services/registry-service";
import { pathFileName, shareLabelFor } from "@/config/nas-paths";
import { cn } from "@/shared/lib/utils";
@@ -42,6 +48,8 @@ import {
AttachmentPreview,
getPreviewableAttachments,
} from "./attachment-preview";
import { findAuthorityForContact } from "../services/authority-catalog";
import { StatusMonitorConfig } from "./status-monitor-config";
interface RegistryEntryDetailProps {
entry: RegistryEntry | null;
@@ -145,6 +153,15 @@ export function RegistryEntryDetail({
}: RegistryEntryDetailProps) {
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
const [copiedPath, setCopiedPath] = useState<string | null>(null);
const [monitorConfigOpen, setMonitorConfigOpen] = useState(false);
// Auto-detect if recipient matches a known authority
const matchedAuthority = useMemo(() => {
if (!entry) return undefined;
if (entry.externalStatusTracking?.active) return undefined;
if (!entry.recipientRegNumber) return undefined;
return findAuthorityForContact(entry.recipient);
}, [entry]);
const previewableAtts = useMemo(
() => (entry ? getPreviewableAttachments(entry.attachments) : []),
@@ -586,8 +603,65 @@ export function RegistryEntryDetail({
</DetailSection>
)}
{/* ── External tracking ── */}
{(entry.externalStatusUrl || entry.externalTrackingId) && (
{/* ── External status monitoring ── */}
{entry.externalStatusTracking?.active && (
<ExternalStatusSection
entry={entry}
/>
)}
{/* ── Auto-detect: suggest monitoring activation ── */}
{matchedAuthority && !entry.externalStatusTracking?.active && (
<div className="rounded-lg border border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/50 dark:bg-blue-950/20 p-3">
<div className="flex items-start gap-2">
<Radio className="h-4 w-4 text-blue-500 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="text-xs font-medium">
{matchedAuthority.name} suporta monitorizare automata
</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
Se poate verifica automat statusul cererii nr.{" "}
{entry.recipientRegNumber} de 4 ori pe zi.
</p>
<Button
variant="outline"
size="sm"
className="mt-2 h-6 text-xs"
onClick={() => setMonitorConfigOpen(true)}
>
Configureaza monitorizarea
</Button>
</div>
</div>
<StatusMonitorConfig
open={monitorConfigOpen}
onOpenChange={setMonitorConfigOpen}
entry={entry}
authority={matchedAuthority}
onActivate={async (tracking) => {
// Save tracking to entry via API
try {
await fetch("/api/registratura", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: entry.id,
updates: { externalStatusTracking: tracking },
}),
});
window.location.reload();
} catch {
// Best effort
}
}}
/>
</div>
)}
{/* ── External tracking (legacy fields) ── */}
{!entry.externalStatusTracking?.active &&
(entry.externalStatusUrl || entry.externalTrackingId) && (
<DetailSection title="Urmărire externă">
<div className="grid grid-cols-2 gap-3">
{entry.externalTrackingId && (
@@ -655,6 +729,174 @@ export function RegistryEntryDetail({
// ── Sub-components ──
// ── External Status Monitoring Section ──
const STATUS_COLORS: Record<ExternalDocStatus, string> = {
"in-operare": "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
trimis: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
solutionat: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
respins: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
necunoscut: "bg-muted text-muted-foreground",
};
function ExternalStatusSection({ entry }: { entry: RegistryEntry }) {
const tracking = entry.externalStatusTracking;
if (!tracking) return null;
const [checking, setChecking] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const authority = getAuthority(tracking.authorityId);
const handleManualCheck = useCallback(async () => {
setChecking(true);
try {
await fetch("/api/registratura/status-check/single", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entryId: entry.id }),
});
// Reload page to show updated status
window.location.reload();
} catch {
// Ignore — user will see if it worked on reload
} finally {
setChecking(false);
}
}, [entry.id]);
const relativeTime = (iso: string) => {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "chiar acum";
if (mins < 60) return `acum ${mins} min`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `acum ${hrs}h`;
const days = Math.floor(hrs / 24);
return `acum ${days}z`;
};
return (
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Monitorizare status extern
</h3>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={handleManualCheck}
disabled={checking}
>
<RefreshCw className={cn("h-3 w-3 mr-1", checking && "animate-spin")} />
{checking ? "Se verifică..." : "Verifică acum"}
</Button>
</div>
<div className="space-y-2">
{/* Authority + status badge */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">
{authority?.name ?? tracking.authorityId}
</span>
<Badge className={cn("text-[10px] px-1.5 py-0", STATUS_COLORS[tracking.semanticStatus])}>
<Radio className="mr-0.5 inline h-2.5 w-2.5" />
{EXTERNAL_STATUS_LABELS[tracking.semanticStatus]}
</Badge>
</div>
{/* Last check time */}
{tracking.lastCheckAt && (
<p className="text-[10px] text-muted-foreground">
Ultima verificare: {relativeTime(tracking.lastCheckAt)}
</p>
)}
{/* Error state */}
{tracking.lastError && (
<p className="text-[10px] text-red-500">{tracking.lastError}</p>
)}
{/* Latest status row */}
{tracking.lastStatusRow && (
<div className="rounded border bg-muted/30 p-2 text-xs space-y-1">
<div className="flex gap-3">
<span>
<span className="text-muted-foreground">Sursa:</span>{" "}
{tracking.lastStatusRow.sursa}
</span>
<span>
<span className="text-muted-foreground"></span>{" "}
{tracking.lastStatusRow.destinatie}
</span>
</div>
{tracking.lastStatusRow.modRezolvare && (
<div>
<span className="text-muted-foreground">Rezolvare:</span>{" "}
<span className="font-medium">{tracking.lastStatusRow.modRezolvare}</span>
</div>
)}
{tracking.lastStatusRow.comentarii && (
<div className="text-muted-foreground">
{tracking.lastStatusRow.comentarii}
</div>
)}
<div className="text-muted-foreground">
{tracking.lastStatusRow.dataVenire} {tracking.lastStatusRow.oraVenire}
</div>
</div>
)}
{/* History toggle */}
{tracking.history.length > 0 && (
<div>
<button
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setShowHistory(!showHistory)}
>
{showHistory ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
Istoric ({tracking.history.length} schimbări)
</button>
{showHistory && (
<div className="mt-1 space-y-1.5 max-h-48 overflow-y-auto">
{[...tracking.history].reverse().map((change, i) => (
<div
key={`${change.timestamp}-${i}`}
className="rounded border bg-muted/20 p-1.5 text-[10px]"
>
<div className="flex items-center gap-2">
<Badge
className={cn(
"text-[8px] px-1 py-0",
STATUS_COLORS[change.semanticStatus],
)}
>
{EXTERNAL_STATUS_LABELS[change.semanticStatus]}
</Badge>
<span className="text-muted-foreground">
{new Date(change.timestamp).toLocaleString("ro-RO")}
</span>
</div>
<div className="mt-0.5 text-muted-foreground">
{change.row.sursa} {change.row.destinatie}
{change.row.modRezolvare ? ` (${change.row.modRezolvare})` : ""}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
function DetailSection({
title,
children,