feat(sync-management): rule-based sync scheduling page + API

Phase 1 of unified sync scheduler:

- New Prisma model GisSyncRule: per-UAT or per-county sync frequency
  rules with priority, time windows, step selection (T/C/N/E)
- CRUD API: /api/eterra/sync-rules (list, create, update, delete, bulk)
- Global default frequency via KeyValueStore
- /sync-management page with 3 tabs:
  - Reguli: table with filters, add dialog (UAT search + county select)
  - Status: stats cards, frequency distribution, coverage overview
  - Judete: quick county-level frequency assignment
- Monitor page: link to sync management from eTerra actions section

Rule resolution: UAT-specific > county default > global default.
Scheduler engine (Phase 2) will read these rules to automate syncs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-04-08 12:03:50 +03:00
parent 34be6c58bc
commit 0cce1c8170
8 changed files with 1380 additions and 1 deletions
+30
View File
@@ -19,6 +19,36 @@ model KeyValueStore {
@@index([namespace])
}
// ─── GIS: Sync Scheduling ──────────────────────────────────────────
model GisSyncRule {
id String @id @default(uuid())
siruta String? /// Set = UAT-specific rule
county String? /// Set = county-wide default rule
frequency String /// "3x-daily"|"daily"|"weekly"|"monthly"|"manual"
syncTerenuri Boolean @default(true)
syncCladiri Boolean @default(true)
syncNoGeom Boolean @default(false)
syncEnrich Boolean @default(false)
priority Int @default(5) /// 1=highest, 10=lowest
enabled Boolean @default(true)
allowedHoursStart Int? /// null = no restriction, e.g. 1 for 01:00
allowedHoursEnd Int? /// e.g. 5 for 05:00
allowedDays String? /// e.g. "1,2,3,4,5" for weekdays, null = all days
lastSyncAt DateTime?
lastSyncStatus String? /// "done"|"error"
lastSyncError String?
nextDueAt DateTime?
label String? /// Human-readable note
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([siruta, county])
@@index([enabled, nextDueAt])
@@index([county])
@@index([frequency])
}
// ─── GIS: eTerra ParcelSync ────────────────────────────────────────
model GisFeature {
+9 -1
View File
@@ -447,7 +447,15 @@ export default function MonitorPage() {
{/* Sync actions */}
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Sincronizare eTerra</h3>
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Sincronizare eTerra</h3>
<a
href="/sync-management"
className="text-xs text-primary hover:underline"
>
Gestioneaza reguli sync
</a>
</div>
<div className="flex flex-wrap gap-3">
<SyncTestButton
label="Sync All Romania"
+847
View File
@@ -0,0 +1,847 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/shared/components/ui/tabs";
/* ─── Types ──────────────────────────────────────────────── */
type SyncRule = {
id: string;
siruta: string | null;
county: string | null;
frequency: string;
syncTerenuri: boolean;
syncCladiri: boolean;
syncNoGeom: boolean;
syncEnrich: boolean;
priority: number;
enabled: boolean;
allowedHoursStart: number | null;
allowedHoursEnd: number | null;
allowedDays: string | null;
lastSyncAt: string | null;
lastSyncStatus: string | null;
lastSyncError: string | null;
nextDueAt: string | null;
label: string | null;
createdAt: string;
// enriched
uatName: string | null;
uatCount: number;
};
type SchedulerStats = {
totalRules: number;
activeRules: number;
dueNow: number;
withErrors: number;
frequencyDistribution: Record<string, number>;
totalCounties: number;
countiesWithRules: number;
};
type CountyOverview = {
county: string;
totalUats: number;
withRules: number;
defaultFreq: string | null;
};
/* ─── Constants ──────────────────────────────────────────── */
const FREQ_LABELS: Record<string, string> = {
"3x-daily": "3x/zi",
daily: "Zilnic",
weekly: "Saptamanal",
monthly: "Lunar",
manual: "Manual",
};
const FREQ_COLORS: Record<string, string> = {
"3x-daily": "bg-red-500/20 text-red-400",
daily: "bg-orange-500/20 text-orange-400",
weekly: "bg-blue-500/20 text-blue-400",
monthly: "bg-gray-500/20 text-gray-400",
manual: "bg-purple-500/20 text-purple-400",
};
/* ─── Page ───────────────────────────────────────────────── */
export default function SyncManagementPage() {
const [rules, setRules] = useState<SyncRule[]>([]);
const [globalDefault, setGlobalDefault] = useState("monthly");
const [stats, setStats] = useState<SchedulerStats | null>(null);
const [counties, setCounties] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [showAddDialog, setShowAddDialog] = useState(false);
const [filterCounty, setFilterCounty] = useState("");
const [filterFreq, setFilterFreq] = useState("");
const fetchRules = useCallback(async () => {
try {
const res = await fetch("/api/eterra/sync-rules");
if (res.ok) {
const d = (await res.json()) as { rules: SyncRule[]; globalDefault: string };
setRules(d.rules);
setGlobalDefault(d.globalDefault);
}
} catch { /* noop */ }
}, []);
const fetchStats = useCallback(async () => {
try {
const res = await fetch("/api/eterra/sync-rules/scheduler");
if (res.ok) {
const d = (await res.json()) as { stats: SchedulerStats };
setStats(d.stats);
}
} catch { /* noop */ }
}, []);
const fetchCounties = useCallback(async () => {
try {
const res = await fetch("/api/eterra/counties");
if (res.ok) {
const d = (await res.json()) as { counties: string[] };
setCounties(d.counties ?? []);
}
} catch { /* noop */ }
}, []);
useEffect(() => {
void Promise.all([fetchRules(), fetchStats(), fetchCounties()]).then(() =>
setLoading(false),
);
}, [fetchRules, fetchStats, fetchCounties]);
// Auto-refresh every 30s
useEffect(() => {
const iv = setInterval(() => {
void fetchRules();
void fetchStats();
}, 30_000);
return () => clearInterval(iv);
}, [fetchRules, fetchStats]);
const toggleEnabled = async (rule: SyncRule) => {
await fetch(`/api/eterra/sync-rules/${rule.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !rule.enabled }),
});
void fetchRules();
};
const deleteRule = async (rule: SyncRule) => {
await fetch(`/api/eterra/sync-rules/${rule.id}`, { method: "DELETE" });
void fetchRules();
void fetchStats();
};
const updateGlobalDefault = async (freq: string) => {
await fetch("/api/eterra/sync-rules/global-default", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ frequency: freq }),
});
setGlobalDefault(freq);
};
const filteredRules = rules.filter((r) => {
if (filterCounty && r.county !== filterCounty && r.siruta) {
// For UAT rules, need to check if UAT is in filtered county — skip for now, show all UAT rules when county filter is set
return false;
}
if (filterCounty && r.county && r.county !== filterCounty) return false;
if (filterFreq && r.frequency !== filterFreq) return false;
return true;
});
// Build county overview from stats
const countyOverview: CountyOverview[] = counties.map((c) => {
const countyRule = rules.find((r) => r.county === c && !r.siruta);
const uatRules = rules.filter((r) => r.county === null && r.siruta !== null);
return {
county: c,
totalUats: 0, // filled by separate query if needed
withRules: (countyRule ? 1 : 0) + uatRules.length,
defaultFreq: countyRule?.frequency ?? null,
};
});
if (loading) {
return (
<div className="mx-auto max-w-6xl p-6">
<h1 className="text-2xl font-bold mb-4">Sync Management</h1>
<div className="h-64 rounded-lg bg-muted/50 animate-pulse" />
</div>
);
}
return (
<div className="mx-auto max-w-6xl p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Sync Management</h1>
<p className="text-sm text-muted-foreground">
Reguli de sincronizare eTerra {rules.length} reguli configurate
</p>
</div>
<a
href="/monitor"
className="px-4 py-2 rounded border border-border text-sm hover:bg-muted/50 transition-colors"
>
Monitor
</a>
</div>
{/* Global Default */}
<div className="rounded-lg border border-border bg-card p-4 flex items-center justify-between">
<div>
<span className="text-sm font-medium">Frecventa implicita (UAT-uri fara regula)</span>
<p className="text-xs text-muted-foreground mt-0.5">
Se aplica la UAT-urile care nu au regula specifica si nici regula de judet
</p>
</div>
<select
value={globalDefault}
onChange={(e) => void updateGlobalDefault(e.target.value)}
className="h-9 rounded-md border border-border bg-background px-3 text-sm"
>
{Object.entries(FREQ_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
<Tabs defaultValue="rules">
<TabsList>
<TabsTrigger value="rules">Reguli ({rules.length})</TabsTrigger>
<TabsTrigger value="status">Status</TabsTrigger>
<TabsTrigger value="counties">Judete ({counties.length})</TabsTrigger>
</TabsList>
{/* ═══ RULES TAB ═══ */}
<TabsContent value="rules" className="space-y-4 mt-4">
{/* Filters + Add button */}
<div className="flex items-center gap-3 flex-wrap">
<select
value={filterCounty}
onChange={(e) => setFilterCounty(e.target.value)}
className="h-9 w-48 rounded-md border border-border bg-background px-3 text-sm"
>
<option value="">Toate judetele</option>
{counties.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<select
value={filterFreq}
onChange={(e) => setFilterFreq(e.target.value)}
className="h-9 w-40 rounded-md border border-border bg-background px-3 text-sm"
>
<option value="">Toate frecventele</option>
{Object.entries(FREQ_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<div className="ml-auto">
<button
onClick={() => setShowAddDialog(true)}
className="h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90"
>
Adauga regula
</button>
</div>
</div>
{/* Rules table */}
{filteredRules.length === 0 ? (
<div className="rounded-lg border border-border bg-card p-8 text-center text-muted-foreground">
Nicio regula {filterCounty || filterFreq ? "pentru filtrul selectat" : "configurata"}. Apasa &quot;Adauga regula&quot; pentru a incepe.
</div>
) : (
<div className="rounded-lg border border-border bg-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left py-2.5 px-3 font-medium">Scope</th>
<th className="text-left py-2.5 px-3 font-medium">Frecventa</th>
<th className="text-left py-2.5 px-3 font-medium">Pasi</th>
<th className="text-left py-2.5 px-3 font-medium">Prioritate</th>
<th className="text-left py-2.5 px-3 font-medium">Ultimul sync</th>
<th className="text-left py-2.5 px-3 font-medium">Urmatorul</th>
<th className="text-center py-2.5 px-3 font-medium">Activ</th>
<th className="text-right py-2.5 px-3 font-medium">Actiuni</th>
</tr>
</thead>
<tbody>
{filteredRules.map((r) => (
<RuleRow
key={r.id}
rule={r}
onToggle={() => void toggleEnabled(r)}
onDelete={() => void deleteRule(r)}
/>
))}
</tbody>
</table>
</div>
</div>
)}
</TabsContent>
{/* ═══ STATUS TAB ═══ */}
<TabsContent value="status" className="space-y-4 mt-4">
{stats && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard label="Total reguli" value={stats.totalRules} />
<StatCard label="Active" value={stats.activeRules} />
<StatCard
label="Scadente acum"
value={stats.dueNow}
highlight={stats.dueNow > 0}
/>
<StatCard
label="Cu erori"
value={stats.withErrors}
highlight={stats.withErrors > 0}
error
/>
</div>
<div className="rounded-lg border border-border bg-card p-4">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">
Distributie frecvente
</h3>
<div className="flex flex-wrap gap-3">
{Object.entries(FREQ_LABELS).map(([key, label]) => {
const count = stats.frequencyDistribution[key] ?? 0;
return (
<div
key={key}
className="flex items-center gap-2 px-3 py-2 rounded-md border border-border"
>
<FreqBadge freq={key} />
<span className="text-sm font-medium">{count}</span>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
);
})}
</div>
</div>
<div className="rounded-lg border border-border bg-card p-4">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">
Acoperire
</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Judete cu reguli:</span>{" "}
<span className="font-medium">{stats.countiesWithRules} / {stats.totalCounties}</span>
</div>
<div>
<span className="text-muted-foreground">Default global:</span>{" "}
<FreqBadge freq={globalDefault} />
</div>
</div>
</div>
{/* Overdue rules */}
{stats.dueNow > 0 && (
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/5 p-4">
<h3 className="text-sm font-semibold text-yellow-400 mb-2">
Reguli scadente ({stats.dueNow})
</h3>
<p className="text-xs text-muted-foreground">
Scheduler-ul va procesa aceste reguli la urmatorul tick.
(Scheduler-ul unificat va fi activat in Phase 2)
</p>
</div>
)}
</>
)}
</TabsContent>
{/* ═══ COUNTIES TAB ═══ */}
<TabsContent value="counties" className="space-y-4 mt-4">
<p className="text-sm text-muted-foreground">
Seteaza frecventa de sync la nivel de judet. UAT-urile cu regula proprie o vor suprascrie.
</p>
<div className="rounded-lg border border-border bg-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left py-2.5 px-3 font-medium">Judet</th>
<th className="text-left py-2.5 px-3 font-medium">Frecventa curenta</th>
<th className="text-right py-2.5 px-3 font-medium">Seteaza frecventa</th>
</tr>
</thead>
<tbody>
{counties.map((c) => (
<CountyRow
key={c}
county={c}
currentFreq={countyOverview.find((o) => o.county === c)?.defaultFreq ?? null}
globalDefault={globalDefault}
onSetFreq={async (freq) => {
await fetch("/api/eterra/sync-rules/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "set-county-frequency",
county: c,
frequency: freq,
}),
});
void fetchRules();
void fetchStats();
}}
/>
))}
</tbody>
</table>
</div>
</div>
</TabsContent>
</Tabs>
{/* Add Rule Dialog */}
{showAddDialog && (
<AddRuleDialog
counties={counties}
onClose={() => setShowAddDialog(false)}
onCreated={() => {
setShowAddDialog(false);
void fetchRules();
void fetchStats();
}}
/>
)}
</div>
);
}
/* ─── Sub-components ─────────────────────────────────────── */
function FreqBadge({ freq }: { freq: string }) {
return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${FREQ_COLORS[freq] ?? "bg-muted text-muted-foreground"}`}>
{FREQ_LABELS[freq] ?? freq}
</span>
);
}
function StatCard({ label, value, highlight, error }: {
label: string; value: number; highlight?: boolean; error?: boolean;
}) {
return (
<div className={`rounded-lg border p-4 ${
highlight
? error ? "border-red-500/30 bg-red-500/5" : "border-yellow-500/30 bg-yellow-500/5"
: "border-border bg-card"
}`}>
<div className="text-xs text-muted-foreground mb-1">{label}</div>
<div className={`text-2xl font-bold tabular-nums ${
highlight ? (error ? "text-red-400" : "text-yellow-400") : ""
}`}>
{value}
</div>
</div>
);
}
function RuleRow({ rule, onToggle, onDelete }: {
rule: SyncRule; onToggle: () => void; onDelete: () => void;
}) {
const scope = rule.siruta
? (rule.uatName ?? rule.siruta)
: rule.county
? `Judet: ${rule.county}`
: "Global";
const scopeSub = rule.siruta
? `SIRUTA ${rule.siruta}`
: rule.uatCount > 0
? `${rule.uatCount} UAT-uri`
: null;
const isOverdue = rule.nextDueAt && new Date(rule.nextDueAt) < new Date();
return (
<tr className={`border-b border-border/50 ${!rule.enabled ? "opacity-50" : ""}`}>
<td className="py-2.5 px-3">
<div className="font-medium">{scope}</div>
{scopeSub && <div className="text-xs text-muted-foreground">{scopeSub}</div>}
{rule.label && <div className="text-xs text-blue-400 mt-0.5">{rule.label}</div>}
</td>
<td className="py-2.5 px-3">
<FreqBadge freq={rule.frequency} />
</td>
<td className="py-2.5 px-3">
<div className="flex gap-1">
{rule.syncTerenuri && <StepIcon label="T" title="Terenuri" />}
{rule.syncCladiri && <StepIcon label="C" title="Cladiri" />}
{rule.syncNoGeom && <StepIcon label="N" title="No-geom" />}
{rule.syncEnrich && <StepIcon label="E" title="Enrichment" />}
</div>
</td>
<td className="py-2.5 px-3 tabular-nums">{rule.priority}</td>
<td className="py-2.5 px-3">
{rule.lastSyncAt ? (
<div className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full shrink-0 ${
rule.lastSyncStatus === "done" ? "bg-green-400" :
rule.lastSyncStatus === "error" ? "bg-red-400" : "bg-gray-400"
}`} />
<span className="text-xs">{relativeTime(rule.lastSyncAt)}</span>
</div>
) : (
<span className="text-xs text-muted-foreground">Niciodata</span>
)}
</td>
<td className="py-2.5 px-3">
{rule.nextDueAt ? (
<span className={`text-xs ${isOverdue ? "text-yellow-400 font-medium" : "text-muted-foreground"}`}>
{isOverdue ? "Scadent" : relativeTime(rule.nextDueAt)}
</span>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</td>
<td className="py-2.5 px-3 text-center">
<button
onClick={onToggle}
className={`w-8 h-5 rounded-full transition-colors relative ${
rule.enabled ? "bg-green-500" : "bg-muted"
}`}
>
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
rule.enabled ? "left-3.5" : "left-0.5"
}`} />
</button>
</td>
<td className="py-2.5 px-3 text-right">
<button
onClick={onDelete}
className="text-xs text-muted-foreground hover:text-red-400 transition-colors"
>
Sterge
</button>
</td>
</tr>
);
}
function StepIcon({ label, title }: { label: string; title: string }) {
return (
<span
title={title}
className="w-5 h-5 rounded text-[10px] font-bold flex items-center justify-center bg-muted text-muted-foreground"
>
{label}
</span>
);
}
function CountyRow({ county, currentFreq, globalDefault, onSetFreq }: {
county: string;
currentFreq: string | null;
globalDefault: string;
onSetFreq: (freq: string) => Promise<void>;
}) {
const [saving, setSaving] = useState(false);
return (
<tr className="border-b border-border/50">
<td className="py-2.5 px-3 font-medium">{county}</td>
<td className="py-2.5 px-3">
{currentFreq ? (
<FreqBadge freq={currentFreq} />
) : (
<span className="text-xs text-muted-foreground">
Implicit ({FREQ_LABELS[globalDefault] ?? globalDefault})
</span>
)}
</td>
<td className="py-2.5 px-3 text-right">
<select
value={currentFreq ?? ""}
disabled={saving}
onChange={async (e) => {
if (!e.target.value) return;
setSaving(true);
await onSetFreq(e.target.value);
setSaving(false);
}}
className="h-8 rounded-md border border-border bg-background px-2 text-xs"
>
<option value="">Alege...</option>
{Object.entries(FREQ_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</td>
</tr>
);
}
function AddRuleDialog({ counties, onClose, onCreated }: {
counties: string[];
onClose: () => void;
onCreated: () => void;
}) {
const [ruleType, setRuleType] = useState<"uat" | "county">("county");
const [siruta, setSiruta] = useState("");
const [county, setCounty] = useState("");
const [frequency, setFrequency] = useState("daily");
const [syncEnrich, setSyncEnrich] = useState(false);
const [syncNoGeom, setSyncNoGeom] = useState(false);
const [priority, setPriority] = useState(5);
const [label, setLabel] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
// UAT search — load all once, filter client-side
const [uatSearch, setUatSearch] = useState("");
const [allUats, setAllUats] = useState<Array<{ siruta: string; name: string }>>([]);
const [uatName, setUatName] = useState("");
useEffect(() => {
void (async () => {
try {
const res = await fetch("/api/eterra/uats");
if (res.ok) {
const d = (await res.json()) as { uats?: Array<{ siruta: string; name: string }> };
setAllUats(d.uats ?? []);
}
} catch { /* noop */ }
})();
}, []);
const uatResults = uatSearch.length >= 2
? allUats
.filter((u) => {
const q = uatSearch.toLowerCase();
return u.name.toLowerCase().includes(q) || u.siruta.includes(q);
})
.slice(0, 10)
: [];
const handleSubmit = async () => {
setError("");
setSaving(true);
const body: Record<string, unknown> = {
frequency,
syncEnrich,
syncNoGeom,
priority,
label: label.trim() || null,
};
if (ruleType === "uat") {
if (!siruta) { setError("Selecteaza un UAT"); setSaving(false); return; }
body.siruta = siruta;
} else {
if (!county) { setError("Selecteaza un judet"); setSaving(false); return; }
body.county = county;
}
try {
const res = await fetch("/api/eterra/sync-rules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const d = (await res.json()) as { rule?: SyncRule; error?: string };
if (!res.ok) {
setError(d.error ?? "Eroare");
setSaving(false);
return;
}
onCreated();
} catch {
setError("Eroare retea");
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="bg-card border border-border rounded-lg p-6 w-full max-w-md space-y-4"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold">Adauga regula de sync</h2>
{/* Rule type toggle */}
<div className="flex gap-2">
<button
onClick={() => setRuleType("county")}
className={`flex-1 py-2 rounded-md text-sm font-medium transition-colors ${
ruleType === "county" ? "bg-primary text-primary-foreground" : "bg-muted"
}`}
>
Judet
</button>
<button
onClick={() => setRuleType("uat")}
className={`flex-1 py-2 rounded-md text-sm font-medium transition-colors ${
ruleType === "uat" ? "bg-primary text-primary-foreground" : "bg-muted"
}`}
>
UAT specific
</button>
</div>
{/* Scope selection */}
{ruleType === "county" ? (
<select
value={county}
onChange={(e) => setCounty(e.target.value)}
className="h-9 w-full 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 className="relative">
<input
type="text"
value={uatSearch}
onChange={(e) => { setUatSearch(e.target.value); setSiruta(""); setUatName(""); }}
placeholder="Cauta UAT (nume sau SIRUTA)..."
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
/>
{uatName && (
<div className="text-xs text-green-400 mt-1">
Selectat: {uatName} ({siruta})
</div>
)}
{uatResults.length > 0 && !siruta && (
<div className="absolute top-full left-0 right-0 mt-1 bg-card border border-border rounded-md shadow-lg max-h-40 overflow-y-auto z-10">
{uatResults.map((u) => (
<button
key={u.siruta}
onClick={() => {
setSiruta(u.siruta);
setUatName(u.name);
setUatSearch("");
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-muted/50 transition-colors"
>
{u.name} <span className="text-muted-foreground">({u.siruta})</span>
</button>
))}
</div>
)}
</div>
)}
{/* Frequency */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">Frecventa</label>
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value)}
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
>
{Object.entries(FREQ_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
{/* Sync steps */}
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={syncEnrich}
onChange={(e) => setSyncEnrich(e.target.checked)}
className="rounded"
/>
Enrichment
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={syncNoGeom}
onChange={(e) => setSyncNoGeom(e.target.checked)}
className="rounded"
/>
No-geom parcels
</label>
</div>
{/* Priority */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
Prioritate (1=cea mai mare, 10=cea mai mica)
</label>
<input
type="number"
min={1}
max={10}
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
className="h-9 w-20 rounded-md border border-border bg-background px-3 text-sm"
/>
</div>
{/* Label */}
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Nota (optional)"
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
/>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex justify-end gap-3 pt-2">
<button
onClick={onClose}
className="px-4 py-2 rounded-md border border-border text-sm hover:bg-muted/50"
>
Anuleaza
</button>
<button
onClick={() => void handleSubmit()}
disabled={saving}
className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-50"
>
{saving ? "Se salveaza..." : "Creeaza"}
</button>
</div>
</div>
</div>
);
}
/* ─── Helpers ────────────────────────────────────────────── */
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const abs = Math.abs(diff);
const future = diff < 0;
const s = Math.floor(abs / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
const d = Math.floor(h / 24);
let str: string;
if (d > 0) str = `${d}z`;
else if (h > 0) str = `${h}h`;
else if (m > 0) str = `${m}m`;
else str = `${s}s`;
return future ? `in ${str}` : `acum ${str}`;
}
@@ -0,0 +1,95 @@
/**
* PATCH /api/eterra/sync-rules/[id] — Update a sync rule
* DELETE /api/eterra/sync-rules/[id] — Delete a sync rule
*/
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"];
function computeNextDue(frequency: string, lastSyncAt: Date | null): Date | null {
if (frequency === "manual") return null;
const base = lastSyncAt ?? new Date();
const ms: Record<string, number> = {
"3x-daily": 8 * 3600_000,
daily: 24 * 3600_000,
weekly: 7 * 24 * 3600_000,
monthly: 30 * 24 * 3600_000,
};
return ms[frequency] ? new Date(base.getTime() + ms[frequency]!) : null;
}
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
const existing = await prisma.gisSyncRule.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: "Regula nu exista" }, { status: 404 });
}
const body = (await req.json()) as Record<string, unknown>;
// Validate frequency if provided
if (body.frequency && !VALID_FREQUENCIES.includes(body.frequency as string)) {
return NextResponse.json({ error: "Frecventa invalida" }, { status: 400 });
}
// Build update data — only include provided fields
const data: Record<string, unknown> = {};
const fields = [
"frequency", "syncTerenuri", "syncCladiri", "syncNoGeom", "syncEnrich",
"priority", "enabled", "allowedHoursStart", "allowedHoursEnd",
"allowedDays", "label",
];
for (const f of fields) {
if (f in body) data[f] = body[f];
}
// Recompute nextDueAt if frequency changed
if (body.frequency) {
data.nextDueAt = computeNextDue(
body.frequency as string,
existing.lastSyncAt,
);
}
// If enabled changed to true and no nextDueAt, compute it
if (body.enabled === true && !existing.nextDueAt && !data.nextDueAt) {
const freq = (body.frequency as string) ?? existing.frequency;
data.nextDueAt = computeNextDue(freq, existing.lastSyncAt);
}
const updated = await prisma.gisSyncRule.update({
where: { id },
data,
});
return NextResponse.json({ rule: updated });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
await prisma.gisSyncRule.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* POST /api/eterra/sync-rules/bulk — Bulk operations on sync rules
*
* Actions:
* - set-county-frequency: Create or update a county-level rule
* - enable/disable: Toggle multiple rules by IDs
* - delete: Delete multiple rules by IDs
*/
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"];
function computeNextDue(frequency: string, lastSyncAt: Date | null): Date | null {
if (frequency === "manual") return null;
const base = lastSyncAt ?? new Date();
const ms: Record<string, number> = {
"3x-daily": 8 * 3600_000,
daily: 24 * 3600_000,
weekly: 7 * 24 * 3600_000,
monthly: 30 * 24 * 3600_000,
};
return ms[frequency] ? new Date(base.getTime() + ms[frequency]!) : null;
}
type BulkBody = {
action: string;
county?: string;
frequency?: string;
syncEnrich?: boolean;
syncNoGeom?: boolean;
ruleIds?: string[];
};
export async function POST(req: Request) {
try {
const body = (await req.json()) as BulkBody;
switch (body.action) {
case "set-county-frequency": {
if (!body.county || !body.frequency) {
return NextResponse.json({ error: "county si frequency obligatorii" }, { status: 400 });
}
if (!VALID_FREQUENCIES.includes(body.frequency)) {
return NextResponse.json({ error: "Frecventa invalida" }, { status: 400 });
}
// Upsert county-level rule
const existing = await prisma.gisSyncRule.findFirst({
where: { county: body.county, siruta: null },
});
const rule = existing
? await prisma.gisSyncRule.update({
where: { id: existing.id },
data: {
frequency: body.frequency,
syncEnrich: body.syncEnrich ?? existing.syncEnrich,
syncNoGeom: body.syncNoGeom ?? existing.syncNoGeom,
nextDueAt: computeNextDue(body.frequency, existing.lastSyncAt),
},
})
: await prisma.gisSyncRule.create({
data: {
county: body.county,
frequency: body.frequency,
syncEnrich: body.syncEnrich ?? false,
syncNoGeom: body.syncNoGeom ?? false,
nextDueAt: computeNextDue(body.frequency, null),
},
});
return NextResponse.json({ rule, action: "set-county-frequency" });
}
case "enable":
case "disable": {
if (!body.ruleIds?.length) {
return NextResponse.json({ error: "ruleIds obligatorii" }, { status: 400 });
}
const result = await prisma.gisSyncRule.updateMany({
where: { id: { in: body.ruleIds } },
data: { enabled: body.action === "enable" },
});
return NextResponse.json({ updated: result.count, action: body.action });
}
case "delete": {
if (!body.ruleIds?.length) {
return NextResponse.json({ error: "ruleIds obligatorii" }, { status: 400 });
}
const result = await prisma.gisSyncRule.deleteMany({
where: { id: { in: body.ruleIds } },
});
return NextResponse.json({ deleted: result.count, action: "delete" });
}
default:
return NextResponse.json({ error: `Actiune necunoscuta: ${body.action}` }, { status: 400 });
}
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
@@ -0,0 +1,47 @@
/**
* GET /api/eterra/sync-rules/global-default — Get global default frequency
* PATCH /api/eterra/sync-rules/global-default — Set global default frequency
*/
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const NAMESPACE = "sync-management";
const KEY = "global-default";
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"];
export async function GET() {
try {
const row = await prisma.keyValueStore.findUnique({
where: { namespace_key: { namespace: NAMESPACE, key: KEY } },
});
const val = row?.value as { frequency?: string } | null;
return NextResponse.json({ frequency: val?.frequency ?? "monthly" });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PATCH(req: Request) {
try {
const body = (await req.json()) as { frequency?: string };
if (!body.frequency || !VALID_FREQUENCIES.includes(body.frequency)) {
return NextResponse.json({ error: "Frecventa invalida" }, { status: 400 });
}
await prisma.keyValueStore.upsert({
where: { namespace_key: { namespace: NAMESPACE, key: KEY } },
update: { value: { frequency: body.frequency } },
create: { namespace: NAMESPACE, key: KEY, value: { frequency: body.frequency } },
});
return NextResponse.json({ frequency: body.frequency });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+171
View File
@@ -0,0 +1,171 @@
/**
* GET /api/eterra/sync-rules — List all sync rules, enriched with UAT/county names
* POST /api/eterra/sync-rules — Create a new sync rule
*/
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"] as const;
/** Compute nextDueAt from lastSyncAt + frequency interval */
function computeNextDue(frequency: string, lastSyncAt: Date | null): Date | null {
if (frequency === "manual") return null;
const base = lastSyncAt ?? new Date();
const ms = {
"3x-daily": 8 * 3600_000,
daily: 24 * 3600_000,
weekly: 7 * 24 * 3600_000,
monthly: 30 * 24 * 3600_000,
}[frequency];
if (!ms) return null;
return new Date(base.getTime() + ms);
}
export async function GET() {
try {
const rules = await prisma.gisSyncRule.findMany({
orderBy: [{ priority: "asc" }, { createdAt: "desc" }],
});
// Enrich with UAT names for UAT-specific rules
const sirutas = rules
.map((r) => r.siruta)
.filter((s): s is string => s != null);
const uatMap = new Map<string, string>();
if (sirutas.length > 0) {
const uats = await prisma.gisUat.findMany({
where: { siruta: { in: sirutas } },
select: { siruta: true, name: true },
});
for (const u of uats) uatMap.set(u.siruta, u.name);
}
// For county rules, get UAT count per county
const counties = rules
.map((r) => r.county)
.filter((c): c is string => c != null);
const countyCountMap = new Map<string, number>();
if (counties.length > 0) {
const counts = await prisma.gisUat.groupBy({
by: ["county"],
where: { county: { in: counties } },
_count: true,
});
for (const c of counts) {
if (c.county) countyCountMap.set(c.county, c._count);
}
}
const enriched = rules.map((r) => ({
...r,
uatName: r.siruta ? (uatMap.get(r.siruta) ?? null) : null,
uatCount: r.county ? (countyCountMap.get(r.county) ?? 0) : r.siruta ? 1 : 0,
}));
// Get global default
const globalDefault = await prisma.keyValueStore.findUnique({
where: { namespace_key: { namespace: "sync-management", key: "global-default" } },
});
return NextResponse.json({
rules: enriched,
globalDefault: (globalDefault?.value as { frequency?: string })?.frequency ?? "monthly",
});
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function POST(req: Request) {
try {
const body = (await req.json()) as {
siruta?: string;
county?: string;
frequency?: string;
syncTerenuri?: boolean;
syncCladiri?: boolean;
syncNoGeom?: boolean;
syncEnrich?: boolean;
priority?: number;
enabled?: boolean;
allowedHoursStart?: number | null;
allowedHoursEnd?: number | null;
allowedDays?: string | null;
label?: string | null;
};
if (!body.siruta && !body.county) {
return NextResponse.json({ error: "Trebuie specificat siruta sau judetul" }, { status: 400 });
}
if (!body.frequency || !VALID_FREQUENCIES.includes(body.frequency as typeof VALID_FREQUENCIES[number])) {
return NextResponse.json(
{ error: `Frecventa invalida. Valori permise: ${VALID_FREQUENCIES.join(", ")}` },
{ status: 400 },
);
}
// Validate siruta exists
if (body.siruta) {
const uat = await prisma.gisUat.findUnique({ where: { siruta: body.siruta } });
if (!uat) {
return NextResponse.json({ error: `UAT ${body.siruta} nu exista` }, { status: 404 });
}
}
// Validate county has UATs
if (body.county && !body.siruta) {
const count = await prisma.gisUat.count({ where: { county: body.county } });
if (count === 0) {
return NextResponse.json({ error: `Niciun UAT in judetul ${body.county}` }, { status: 404 });
}
}
// Check for existing rule with same scope
const existing = await prisma.gisSyncRule.findFirst({
where: {
siruta: body.siruta ?? null,
county: body.siruta ? null : (body.county ?? null),
},
});
if (existing) {
return NextResponse.json(
{ error: "Exista deja o regula pentru acest scope", existingId: existing.id },
{ status: 409 },
);
}
const nextDueAt = computeNextDue(body.frequency, null);
const rule = await prisma.gisSyncRule.create({
data: {
siruta: body.siruta ?? null,
county: body.siruta ? null : (body.county ?? null),
frequency: body.frequency,
syncTerenuri: body.syncTerenuri ?? true,
syncCladiri: body.syncCladiri ?? true,
syncNoGeom: body.syncNoGeom ?? false,
syncEnrich: body.syncEnrich ?? false,
priority: body.priority ?? 5,
enabled: body.enabled ?? true,
allowedHoursStart: body.allowedHoursStart ?? null,
allowedHoursEnd: body.allowedHoursEnd ?? null,
allowedDays: body.allowedDays ?? null,
label: body.label ?? null,
nextDueAt,
},
});
return NextResponse.json({ rule }, { status: 201 });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
@@ -0,0 +1,72 @@
/**
* GET /api/eterra/sync-rules/scheduler — Scheduler status
*
* Returns current scheduler state from KeyValueStore + computed stats.
*/
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
// Get scheduler state from KV (will be populated by the scheduler in Phase 2)
const kvState = await prisma.keyValueStore.findUnique({
where: {
namespace_key: { namespace: "sync-management", key: "scheduler-state" },
},
});
// Compute rule stats
const [totalRules, activeRules, dueNow, withErrors] = await Promise.all([
prisma.gisSyncRule.count(),
prisma.gisSyncRule.count({ where: { enabled: true } }),
prisma.gisSyncRule.count({
where: { enabled: true, nextDueAt: { lte: new Date() } },
}),
prisma.gisSyncRule.count({
where: { lastSyncStatus: "error" },
}),
]);
// Frequency distribution
const freqDist = await prisma.gisSyncRule.groupBy({
by: ["frequency"],
where: { enabled: true },
_count: true,
});
// County coverage
const totalCounties = await prisma.gisUat.groupBy({
by: ["county"],
where: { county: { not: null } },
_count: true,
});
const countiesWithRules = await prisma.gisSyncRule.groupBy({
by: ["county"],
where: { county: { not: null } },
_count: true,
});
return NextResponse.json({
scheduler: kvState?.value ?? { status: "not-started" },
stats: {
totalRules,
activeRules,
dueNow,
withErrors,
frequencyDistribution: Object.fromEntries(
freqDist.map((f) => [f.frequency, f._count]),
),
totalCounties: totalCounties.length,
countiesWithRules: countiesWithRules.length,
},
});
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}