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])
|
||||
}
|
||||
|
||||
// ─── 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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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