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:
@@ -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,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Check,
|
||||
ArrowDownLeft,
|
||||
ArrowUpRight,
|
||||
Radio,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu";
|
||||
import type { RegistryEntry } from "../types";
|
||||
import { DEFAULT_DOC_TYPE_LABELS } from "../types";
|
||||
import { DEFAULT_DOC_TYPE_LABELS, EXTERNAL_STATUS_LABELS } from "../types";
|
||||
import { getOverdueDays } from "../services/registry-service";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
@@ -383,6 +384,31 @@ export function RegistryTable({
|
||||
{(entry.trackedDeadlines ?? []).length}
|
||||
</Badge>
|
||||
)}
|
||||
{entry.externalStatusTracking?.active && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] px-1 py-0 ${
|
||||
entry.externalStatusTracking.semanticStatus === "solutionat"
|
||||
? "border-green-400 text-green-600"
|
||||
: entry.externalStatusTracking.semanticStatus === "trimis"
|
||||
? "border-blue-400 text-blue-600"
|
||||
: entry.externalStatusTracking.semanticStatus === "in-operare"
|
||||
? "border-amber-400 text-amber-600"
|
||||
: entry.externalStatusTracking.semanticStatus === "respins"
|
||||
? "border-red-400 text-red-600"
|
||||
: "border-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Radio className="mr-0.5 inline h-2.5 w-2.5" />
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Status extern: {EXTERNAL_STATUS_LABELS[entry.externalStatusTracking.semanticStatus]}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Status Monitor Config — Dialog for activating external status monitoring.
|
||||
*
|
||||
* Shows when a recipient matches a known authority and registration number
|
||||
* is filled. Lets user confirm/set petitioner name and activates monitoring.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Radio } from "lucide-react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import type { RegistryEntry, ExternalStatusTracking } from "../types";
|
||||
import type { AuthorityConfig } from "../services/authority-catalog";
|
||||
|
||||
interface StatusMonitorConfigProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
entry: RegistryEntry;
|
||||
authority: AuthorityConfig;
|
||||
onActivate: (tracking: ExternalStatusTracking) => void;
|
||||
}
|
||||
|
||||
export function StatusMonitorConfig({
|
||||
open,
|
||||
onOpenChange,
|
||||
entry,
|
||||
authority,
|
||||
onActivate,
|
||||
}: StatusMonitorConfigProps) {
|
||||
const [petitionerName, setPetitionerName] = useState("");
|
||||
const [regNumber, setRegNumber] = useState(
|
||||
entry.recipientRegNumber ?? "",
|
||||
);
|
||||
const [regDate, setRegDate] = useState("");
|
||||
|
||||
// Convert YYYY-MM-DD to dd.mm.yyyy
|
||||
useEffect(() => {
|
||||
if (entry.recipientRegDate) {
|
||||
const parts = entry.recipientRegDate.split("-");
|
||||
if (parts.length === 3) {
|
||||
setRegDate(`${parts[2]}.${parts[1]}.${parts[0]}`);
|
||||
}
|
||||
}
|
||||
}, [entry.recipientRegDate]);
|
||||
|
||||
// Load saved petitioner name from localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(
|
||||
`status-monitor-petitioner:${authority.id}`,
|
||||
);
|
||||
if (saved) setPetitionerName(saved);
|
||||
}, [authority.id]);
|
||||
|
||||
const canActivate =
|
||||
petitionerName.trim().length >= 3 &&
|
||||
regNumber.trim().length > 0 &&
|
||||
regDate.trim().length > 0;
|
||||
|
||||
const handleActivate = () => {
|
||||
// Save petitioner name for future use
|
||||
localStorage.setItem(
|
||||
`status-monitor-petitioner:${authority.id}`,
|
||||
petitionerName.trim(),
|
||||
);
|
||||
|
||||
const tracking: ExternalStatusTracking = {
|
||||
authorityId: authority.id,
|
||||
petitionerName: petitionerName.trim(),
|
||||
regNumber: regNumber.trim(),
|
||||
regDate: regDate.trim(),
|
||||
lastCheckAt: null,
|
||||
lastStatusRow: null,
|
||||
statusHash: "",
|
||||
semanticStatus: "necunoscut",
|
||||
history: [],
|
||||
active: true,
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
onActivate(tracking);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Radio className="h-4 w-4" />
|
||||
Monitorizare status extern
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{authority.name} suporta verificarea automata a statusului.
|
||||
Configureaza datele de mai jos pentru a activa monitorizarea.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="petitioner-name">
|
||||
Nume deponent (cum apare la autoritate)
|
||||
</Label>
|
||||
<Input
|
||||
id="petitioner-name"
|
||||
value={petitionerName}
|
||||
onChange={(e) => setPetitionerName(e.target.value)}
|
||||
placeholder="ex: BELETAGE SRL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="reg-number">Nr. inregistrare</Label>
|
||||
<Input
|
||||
id="reg-number"
|
||||
value={regNumber}
|
||||
onChange={(e) => setRegNumber(e.target.value)}
|
||||
placeholder="ex: 12345"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="reg-date">Data inregistrare (zz.ll.aaaa)</Label>
|
||||
<Input
|
||||
id="reg-date"
|
||||
value={regDate}
|
||||
onChange={(e) => setRegDate(e.target.value)}
|
||||
placeholder="ex: 11.03.2026"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Verificarea se face automat de 4 ori pe zi (9:00, 12:00, 15:00,
|
||||
17:00) in zilele lucratoare. Poti verifica manual oricand din
|
||||
panoul de detalii.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Anuleaza
|
||||
</Button>
|
||||
<Button onClick={handleActivate} disabled={!canActivate}>
|
||||
Activeaza monitorizarea
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user