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:
@@ -19,6 +19,36 @@ model KeyValueStore {
|
|||||||
@@index([namespace])
|
@@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 ────────────────────────────────────────
|
// ─── GIS: eTerra ParcelSync ────────────────────────────────────────
|
||||||
|
|
||||||
model GisFeature {
|
model GisFeature {
|
||||||
|
|||||||
@@ -447,7 +447,15 @@ export default function MonitorPage() {
|
|||||||
|
|
||||||
{/* Sync actions */}
|
{/* Sync actions */}
|
||||||
<div>
|
<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">
|
<div className="flex flex-wrap gap-3">
|
||||||
<SyncTestButton
|
<SyncTestButton
|
||||||
label="Sync All Romania"
|
label="Sync All Romania"
|
||||||
|
|||||||
@@ -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 "Adauga regula" 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user