feat(registratura): auto-close monitoring on resolved, inline check, edit tracking

- Auto-deactivate monitoring when status is 'solutionat' or 'respins'
- 'Verifica acum' shows result inline (no page reload/close)
- 'Modifica' button opens edit dialog to fix petitioner/regNumber/date
- Show monitoring section even when inactive (final status visible)
- Display tracking config info (nr, date, petitioner) in detail view

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-30 09:17:18 +03:00
parent 89e7d08d19
commit 5bcf65ff02
3 changed files with 223 additions and 75 deletions
@@ -166,11 +166,21 @@ export function RegistryEntryDetail({
const [previewIndex, setPreviewIndex] = useState<number | null>(null); const [previewIndex, setPreviewIndex] = useState<number | null>(null);
const [copiedPath, setCopiedPath] = useState<string | null>(null); const [copiedPath, setCopiedPath] = useState<string | null>(null);
const [monitorConfigOpen, setMonitorConfigOpen] = useState(false); const [monitorConfigOpen, setMonitorConfigOpen] = useState(false);
const [monitorEditMode, setMonitorEditMode] = useState(false);
// Auto-detect if recipient matches a known authority // Authority for existing tracking or auto-detected from recipient
const trackingAuthority = useMemo(() => {
if (!entry) return undefined;
if (entry.externalStatusTracking) {
return getAuthority(entry.externalStatusTracking.authorityId) ?? undefined;
}
return undefined;
}, [entry]);
// Auto-detect if recipient matches a known authority (only when no tracking)
const matchedAuthority = useMemo(() => { const matchedAuthority = useMemo(() => {
if (!entry) return undefined; if (!entry) return undefined;
if (entry.externalStatusTracking?.active) return undefined; if (entry.externalStatusTracking) return undefined;
if (!entry.recipientRegNumber) return undefined; if (!entry.recipientRegNumber) return undefined;
return findAuthorityForContact(entry.recipient); return findAuthorityForContact(entry.recipient);
}, [entry]); }, [entry]);
@@ -757,14 +767,47 @@ export function RegistryEntryDetail({
)} )}
{/* ── External status monitoring ── */} {/* ── External status monitoring ── */}
{entry.externalStatusTracking?.active && ( {entry.externalStatusTracking && (
<ExternalStatusSection <>
entry={entry} <ExternalStatusSection
/> entry={entry}
onEdit={() => {
setMonitorEditMode(true);
setMonitorConfigOpen(true);
}}
/>
{trackingAuthority && (
<StatusMonitorConfig
open={monitorConfigOpen && monitorEditMode}
onOpenChange={(open) => {
setMonitorConfigOpen(open);
if (!open) setMonitorEditMode(false);
}}
entry={entry}
authority={trackingAuthority}
editMode
onActivate={async (tracking) => {
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
}
}}
/>
)}
</>
)} )}
{/* ── Auto-detect: suggest monitoring activation ── */} {/* ── Auto-detect: suggest monitoring activation ── */}
{matchedAuthority && !entry.externalStatusTracking?.active && ( {matchedAuthority && !entry.externalStatusTracking && (
<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="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"> <div className="flex items-start gap-2">
<Radio className="h-4 w-4 text-blue-500 mt-0.5 shrink-0" /> <Radio className="h-4 w-4 text-blue-500 mt-0.5 shrink-0" />
@@ -780,7 +823,10 @@ export function RegistryEntryDetail({
variant="outline" variant="outline"
size="sm" size="sm"
className="mt-2 h-6 text-xs" className="mt-2 h-6 text-xs"
onClick={() => setMonitorConfigOpen(true)} onClick={() => {
setMonitorEditMode(false);
setMonitorConfigOpen(true);
}}
> >
Configureaza monitorizarea Configureaza monitorizarea
</Button> </Button>
@@ -788,12 +834,11 @@ export function RegistryEntryDetail({
</div> </div>
<StatusMonitorConfig <StatusMonitorConfig
open={monitorConfigOpen} open={monitorConfigOpen && !monitorEditMode}
onOpenChange={setMonitorConfigOpen} onOpenChange={setMonitorConfigOpen}
entry={entry} entry={entry}
authority={matchedAuthority} authority={matchedAuthority}
onActivate={async (tracking) => { onActivate={async (tracking) => {
// Save tracking to entry via API
try { try {
await fetch("/api/registratura", { await fetch("/api/registratura", {
method: "PUT", method: "PUT",
@@ -892,26 +937,54 @@ const STATUS_COLORS: Record<ExternalDocStatus, string> = {
necunoscut: "bg-muted text-muted-foreground", necunoscut: "bg-muted text-muted-foreground",
}; };
function ExternalStatusSection({ entry }: { entry: RegistryEntry }) { function ExternalStatusSection({
entry,
onEdit,
}: {
entry: RegistryEntry;
onEdit: () => void;
}) {
const tracking = entry.externalStatusTracking; const tracking = entry.externalStatusTracking;
if (!tracking) return null; if (!tracking) return null;
const [checking, setChecking] = useState(false); const [checking, setChecking] = useState(false);
const [checkResult, setCheckResult] = useState<{
changed: boolean;
error: string | null;
newStatus?: string;
} | null>(null);
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false);
const [liveTracking, setLiveTracking] = useState(tracking);
const authority = getAuthority(tracking.authorityId); const authority = getAuthority(tracking.authorityId);
const handleManualCheck = useCallback(async () => { const handleManualCheck = useCallback(async () => {
setChecking(true); setChecking(true);
setCheckResult(null);
try { try {
await fetch("/api/registratura/status-check/single", { const res = await fetch("/api/registratura/status-check/single", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entryId: entry.id }), body: JSON.stringify({ entryId: entry.id }),
}); });
// Reload page to show updated status const data = (await res.json()) as {
window.location.reload(); changed: boolean;
} catch { error: string | null;
// Ignore — user will see if it worked on reload newStatus?: string;
tracking?: typeof tracking;
};
setCheckResult({
changed: data.changed,
error: data.error,
newStatus: data.newStatus,
});
if (data.tracking) {
setLiveTracking(data.tracking);
}
} catch (err) {
setCheckResult({
changed: false,
error: err instanceof Error ? err.message : "Eroare conexiune",
});
} finally { } finally {
setChecking(false); setChecking(false);
} }
@@ -928,80 +1001,121 @@ function ExternalStatusSection({ entry }: { entry: RegistryEntry }) {
return `acum ${days}z`; return `acum ${days}z`;
}; };
const t = liveTracking;
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider"> <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Monitorizare status extern Monitorizare status extern
{!t.active && (
<span className="ml-1.5 text-[10px] font-normal normal-case">(oprita)</span>
)}
</h3> </h3>
<Button <div className="flex gap-1">
variant="ghost" <Button
size="sm" variant="ghost"
className="h-6 px-2 text-xs" size="sm"
onClick={handleManualCheck} className="h-6 px-2 text-xs"
disabled={checking} onClick={onEdit}
> >
<RefreshCw className={cn("h-3 w-3 mr-1", checking && "animate-spin")} /> <Pencil className="h-3 w-3 mr-1" />
{checking ? "Se verifică..." : "Verifică acum"} Modifica
</Button> </Button>
{t.active && (
<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 verifica..." : "Verifica acum"}
</Button>
)}
</div>
</div> </div>
{/* Inline check result */}
{checkResult && (
<div className={cn(
"rounded border px-2.5 py-1.5 text-xs mb-2",
checkResult.error
? "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400"
: checkResult.changed
? "border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950/30 dark:text-green-400"
: "border-muted bg-muted/30 text-muted-foreground",
)}>
{checkResult.error
? `Eroare: ${checkResult.error}`
: checkResult.changed
? `Status actualizat: ${EXTERNAL_STATUS_LABELS[checkResult.newStatus as ExternalDocStatus] ?? checkResult.newStatus}`
: "Nicio schimbare detectata"}
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
{/* Authority + status badge */} {/* Authority + status badge */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{authority?.name ?? tracking.authorityId} {authority?.name ?? t.authorityId}
</span> </span>
<Badge className={cn("text-[10px] px-1.5 py-0", STATUS_COLORS[tracking.semanticStatus])}> <Badge className={cn("text-[10px] px-1.5 py-0", STATUS_COLORS[t.semanticStatus])}>
<Radio className="mr-0.5 inline h-2.5 w-2.5" /> <Radio className="mr-0.5 inline h-2.5 w-2.5" />
{EXTERNAL_STATUS_LABELS[tracking.semanticStatus]} {EXTERNAL_STATUS_LABELS[t.semanticStatus]}
</Badge> </Badge>
</div> </div>
{/* Last check time */} {/* Last check time */}
{tracking.lastCheckAt && ( {t.lastCheckAt && (
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
Ultima verificare: {relativeTime(tracking.lastCheckAt)} Ultima verificare: {relativeTime(t.lastCheckAt)}
</p> </p>
)} )}
{/* Error state */} {/* Error state */}
{tracking.lastError && ( {t.lastError && (
<p className="text-[10px] text-red-500">{tracking.lastError}</p> <p className="text-[10px] text-red-500">{t.lastError}</p>
)} )}
{/* Latest status row */} {/* Latest status row */}
{tracking.lastStatusRow && ( {t.lastStatusRow && (
<div className="rounded border bg-muted/30 p-2 text-xs space-y-1"> <div className="rounded border bg-muted/30 p-2 text-xs space-y-1">
<div className="flex gap-3"> <div className="flex gap-3">
<span> <span>
<span className="text-muted-foreground">Sursa:</span>{" "} <span className="text-muted-foreground">Sursa:</span>{" "}
{tracking.lastStatusRow.sursa} {t.lastStatusRow.sursa}
</span> </span>
<span> <span>
<span className="text-muted-foreground"></span>{" "} <span className="text-muted-foreground"></span>{" "}
{tracking.lastStatusRow.destinatie} {t.lastStatusRow.destinatie}
</span> </span>
</div> </div>
{tracking.lastStatusRow.modRezolvare && ( {t.lastStatusRow.modRezolvare && (
<div> <div>
<span className="text-muted-foreground">Rezolvare:</span>{" "} <span className="text-muted-foreground">Rezolvare:</span>{" "}
<span className="font-medium">{tracking.lastStatusRow.modRezolvare}</span> <span className="font-medium">{t.lastStatusRow.modRezolvare}</span>
</div> </div>
)} )}
{tracking.lastStatusRow.comentarii && ( {t.lastStatusRow.comentarii && (
<div className="text-muted-foreground"> <div className="text-muted-foreground">
{tracking.lastStatusRow.comentarii} {t.lastStatusRow.comentarii}
</div> </div>
)} )}
<div className="text-muted-foreground"> <div className="text-muted-foreground">
{tracking.lastStatusRow.dataVenire} {tracking.lastStatusRow.oraVenire} {t.lastStatusRow.dataVenire} {t.lastStatusRow.oraVenire}
</div> </div>
</div> </div>
)} )}
{/* Tracking config info */}
<div className="text-[10px] text-muted-foreground">
Nr: {t.regNumber} | Data: {t.regDate} | Deponent: {t.petitionerName}
</div>
{/* History toggle */} {/* History toggle */}
{tracking.history.length > 0 && ( {t.history.length > 0 && (
<div> <div>
<button <button
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors" className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
@@ -1012,12 +1126,12 @@ function ExternalStatusSection({ entry }: { entry: RegistryEntry }) {
) : ( ) : (
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
)} )}
Istoric ({tracking.history.length} schimbări) Istoric ({t.history.length} schimbari)
</button> </button>
{showHistory && ( {showHistory && (
<div className="mt-1 space-y-1.5 max-h-48 overflow-y-auto"> <div className="mt-1 space-y-1.5 max-h-48 overflow-y-auto">
{[...tracking.history].reverse().map((change, i) => ( {[...t.history].reverse().map((change, i) => (
<div <div
key={`${change.timestamp}-${i}`} key={`${change.timestamp}-${i}`}
className="rounded border bg-muted/20 p-1.5 text-[10px]" className="rounded border bg-muted/20 p-1.5 text-[10px]"
@@ -29,6 +29,8 @@ interface StatusMonitorConfigProps {
entry: RegistryEntry; entry: RegistryEntry;
authority: AuthorityConfig; authority: AuthorityConfig;
onActivate: (tracking: ExternalStatusTracking) => void; onActivate: (tracking: ExternalStatusTracking) => void;
/** When true, pre-fills from existing tracking data for editing */
editMode?: boolean;
} }
export function StatusMonitorConfig({ export function StatusMonitorConfig({
@@ -37,30 +39,35 @@ export function StatusMonitorConfig({
entry, entry,
authority, authority,
onActivate, onActivate,
editMode,
}: StatusMonitorConfigProps) { }: StatusMonitorConfigProps) {
const existing = entry.externalStatusTracking;
const [petitionerName, setPetitionerName] = useState(""); const [petitionerName, setPetitionerName] = useState("");
const [regNumber, setRegNumber] = useState( const [regNumber, setRegNumber] = useState(
entry.recipientRegNumber ?? "", entry.recipientRegNumber ?? "",
); );
const [regDate, setRegDate] = useState(""); const [regDate, setRegDate] = useState("");
// Convert YYYY-MM-DD to dd.mm.yyyy // Pre-fill: edit mode uses existing tracking, otherwise entry fields
useEffect(() => { useEffect(() => {
if (entry.recipientRegDate) { if (editMode && existing) {
const parts = entry.recipientRegDate.split("-"); setPetitionerName(existing.petitionerName);
if (parts.length === 3) { setRegNumber(existing.regNumber);
setRegDate(`${parts[2]}.${parts[1]}.${parts[0]}`); setRegDate(existing.regDate);
} else {
setRegNumber(entry.recipientRegNumber ?? "");
if (entry.recipientRegDate) {
const parts = entry.recipientRegDate.split("-");
if (parts.length === 3) {
setRegDate(`${parts[2]}.${parts[1]}.${parts[0]}`);
}
} }
const saved = localStorage.getItem(
`status-monitor-petitioner:${authority.id}`,
);
if (saved) setPetitionerName(saved);
} }
}, [entry.recipientRegDate]); }, [editMode, existing, entry.recipientRegNumber, entry.recipientRegDate, authority.id]);
// Load saved petitioner name from localStorage
useEffect(() => {
const saved = localStorage.getItem(
`status-monitor-petitioner:${authority.id}`,
);
if (saved) setPetitionerName(saved);
}, [authority.id]);
const canActivate = const canActivate =
petitionerName.trim().length >= 3 && petitionerName.trim().length >= 3 &&
@@ -74,19 +81,28 @@ export function StatusMonitorConfig({
petitionerName.trim(), petitionerName.trim(),
); );
const tracking: ExternalStatusTracking = { const tracking: ExternalStatusTracking = editMode && existing
authorityId: authority.id, ? {
petitionerName: petitionerName.trim(), ...existing,
regNumber: regNumber.trim(), petitionerName: petitionerName.trim(),
regDate: regDate.trim(), regNumber: regNumber.trim(),
lastCheckAt: null, regDate: regDate.trim(),
lastStatusRow: null, active: true,
statusHash: "", lastError: null,
semanticStatus: "necunoscut", }
history: [], : {
active: true, authorityId: authority.id,
lastError: null, petitionerName: petitionerName.trim(),
}; regNumber: regNumber.trim(),
regDate: regDate.trim(),
lastCheckAt: null,
lastStatusRow: null,
statusHash: "",
semanticStatus: "necunoscut",
history: [],
active: true,
lastError: null,
};
onActivate(tracking); onActivate(tracking);
onOpenChange(false); onOpenChange(false);
@@ -98,11 +114,12 @@ export function StatusMonitorConfig({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Radio className="h-4 w-4" /> <Radio className="h-4 w-4" />
Monitorizare status extern {editMode ? "Modifica monitorizarea" : "Monitorizare status extern"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{authority.name} suporta verificarea automata a statusului. {editMode
Configureaza datele de mai jos pentru a activa monitorizarea. ? "Modifica datele de monitorizare. Istoricul se pastreaza."
: `${authority.name} suporta verificarea automata a statusului. Configureaza datele de mai jos pentru a activa monitorizarea.`}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -152,7 +169,7 @@ export function StatusMonitorConfig({
Anuleaza Anuleaza
</Button> </Button>
<Button onClick={handleActivate} disabled={!canActivate}> <Button onClick={handleActivate} disabled={!canActivate}>
Activeaza monitorizarea {editMode ? "Salveaza" : "Activeaza monitorizarea"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -229,6 +229,14 @@ export async function runStatusCheck(
tracking.statusHash = newHash; tracking.statusHash = newHash;
tracking.semanticStatus = checkResult.newStatus; tracking.semanticStatus = checkResult.newStatus;
// Auto-deactivate monitoring when resolved or rejected
if (
checkResult.newStatus === "solutionat" ||
checkResult.newStatus === "respins"
) {
tracking.active = false;
}
// Cap history at 50 // Cap history at 50
tracking.history.push(change); tracking.history.push(change);
if (tracking.history.length > 50) { if (tracking.history.length > 50) {
@@ -436,6 +444,15 @@ export async function checkSingleEntry(
tracking.lastStatusRow = result.newRow; tracking.lastStatusRow = result.newRow;
tracking.statusHash = newHash; tracking.statusHash = newHash;
tracking.semanticStatus = result.newStatus; tracking.semanticStatus = result.newStatus;
// Auto-deactivate monitoring when resolved or rejected
if (
result.newStatus === "solutionat" ||
result.newStatus === "respins"
) {
tracking.active = false;
}
tracking.history.push(change); tracking.history.push(change);
if (tracking.history.length > 50) { if (tracking.history.length > 50) {
tracking.history = tracking.history.slice(-50); tracking.history = tracking.history.slice(-50);