Compare commits
86 Commits
c012adaa77
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 265e1c934b | |||
| ddf27d9b17 | |||
| 377b88c48d | |||
| b356e70148 | |||
| 708e550d06 | |||
| 0cce1c8170 | |||
| 34be6c58bc | |||
| 7bc9e67e96 | |||
| 93b3904755 | |||
| f44d57629f | |||
| 8222be2f0e | |||
| 177f2104c1 | |||
| f106a2bb02 | |||
| 27960c9a43 | |||
| fc7a1f9787 | |||
| ef3719187d | |||
| 7a93a28055 | |||
| f822509169 | |||
| d76c49fb9e | |||
| 9e7abfafc8 | |||
| 4d1883b459 | |||
| 5bcf65ff02 | |||
| 89e7d08d19 | |||
| 126a121056 | |||
| 31877fde9e | |||
| 0a38b2c374 | |||
| b8061ae31f | |||
| 145aa11c55 | |||
| 730eee6c8a | |||
| 4410e968db | |||
| 82a225de67 | |||
| adc0b0a0d0 | |||
| 9bf79a15ed | |||
| b46eb7a70f | |||
| ee86af6183 | |||
| 870e1bd4c2 | |||
| c269d8b296 | |||
| aac93678bb | |||
| c00d4fe157 | |||
| f5c8cf5fdc | |||
| b33fe35c4b | |||
| 73456c1424 | |||
| 9eb2b12fea | |||
| dfb5ceb926 | |||
| 91fb23bc53 | |||
| 58442da355 | |||
| 9bab9db4df | |||
| c82e234d6c | |||
| ecf61e7e1d | |||
| dafb3555d7 | |||
| 0d5fcf909c | |||
| 236635fbf4 | |||
| 0572097fb2 | |||
| 938aa2c6d3 | |||
| 8ebd7e4ee2 | |||
| 536b3659bb | |||
| 67f3237761 | |||
| 675b1e51dd | |||
| a83f9e63b9 | |||
| a75d0e1adc | |||
| e42eeb6324 | |||
| 9d45799900 | |||
| 946723197e | |||
| 3ea57f00b6 | |||
| 311f63e812 | |||
| 1d233fdc19 | |||
| c6eb1a9450 | |||
| 49a239006d | |||
| 6c5aa61f09 | |||
| 4c1ffe3d01 | |||
| 4e67c29267 | |||
| acb9be8345 | |||
| 189e9a218a | |||
| c4516c6f23 | |||
| 798b3e4f6b | |||
| a6d7e1d87f | |||
| 54d9a36686 | |||
| 24b565f5ea | |||
| bde25d8d84 | |||
| 8b6d6ba1d0 | |||
| e5da0301de | |||
| 318cb6037e | |||
| 3b456eb481 | |||
| 8f65efd5d1 | |||
| eab465b8c3 | |||
| 0c4b91707f |
+2
-2
@@ -49,8 +49,8 @@ AUTHENTIK_CLIENT_ID=your-authentik-client-id
|
|||||||
AUTHENTIK_CLIENT_SECRET=your-authentik-client-secret
|
AUTHENTIK_CLIENT_SECRET=your-authentik-client-secret
|
||||||
AUTHENTIK_ISSUER=https://auth.beletage.ro/application/o/architools/
|
AUTHENTIK_ISSUER=https://auth.beletage.ro/application/o/architools/
|
||||||
|
|
||||||
# N8N automation (future)
|
# PMTiles rebuild webhook (pmtiles-webhook systemd service on satra)
|
||||||
# N8N_WEBHOOK_URL=http://10.10.10.166:5678/webhook
|
N8N_WEBHOOK_URL=http://10.10.10.166:9876
|
||||||
|
|
||||||
# External tool URLs (displayed in dashboard)
|
# External tool URLs (displayed in dashboard)
|
||||||
NEXT_PUBLIC_GITEA_URL=http://10.10.10.166:3002
|
NEXT_PUBLIC_GITEA_URL=http://10.10.10.166:3002
|
||||||
|
|||||||
@@ -1,463 +1,197 @@
|
|||||||
# ArchiTools — Project Context for AI Assistants
|
# ArchiTools — Project Context for AI Assistants
|
||||||
|
|
||||||
> This file provides all context needed for Claude Code, Sonnet, or any AI model to work on this project from scratch.
|
## Quick Start
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
npm install
|
||||||
## Quick Start
|
npm run dev # http://localhost:3000
|
||||||
|
npx next build # verify zero errors before pushing
|
||||||
```bash
|
git push origin main # manual redeploy via Portainer UI
|
||||||
npm install
|
```
|
||||||
npm run dev # http://localhost:3000
|
|
||||||
npx next build # verify zero errors before pushing
|
---
|
||||||
git push origin main # auto-deploys via Portainer webhook
|
|
||||||
```
|
## Project Overview
|
||||||
|
|
||||||
---
|
**ArchiTools** is a modular internal web dashboard for 3 architecture/engineering companies:
|
||||||
|
**Beletage** (architecture), **Urban Switch** (urbanism), **Studii de Teren** (geotechnics).
|
||||||
## Project Overview
|
Production: `tools.beletage.ro` — Docker on-premise, Portainer CE, Traefik v3 proxy.
|
||||||
|
|
||||||
**ArchiTools** is a modular internal web dashboard for an architecture/engineering office group of 3 companies:
|
### Stack
|
||||||
|
|
||||||
- **Beletage** (architecture)
|
| Layer | Technology |
|
||||||
- **Urban Switch** (urbanism)
|
| ---------- | ------------------------------------------------------- |
|
||||||
- **Studii de Teren** (geotechnics)
|
| Framework | Next.js 16.x, App Router, TypeScript (strict) |
|
||||||
|
| Styling | Tailwind CSS v4, shadcn/ui |
|
||||||
It runs on two on-premise servers, containerized with Docker, managed via Portainer CE.
|
| Database | PostgreSQL + PostGIS via Prisma v6 ORM |
|
||||||
|
| Storage | `DatabaseStorageAdapter` (Prisma), localStorage fallback |
|
||||||
### Stack
|
| Files | MinIO (S3-compatible object storage) |
|
||||||
|
| Auth | NextAuth v4 + Authentik OIDC (auth.beletage.ro) |
|
||||||
| Layer | Technology |
|
| Deploy | Docker multi-stage → Portainer CE → Traefik v3 + SSL |
|
||||||
| ------------ | ---------------------------------------------------------------------------- |
|
| Repo | Gitea at `git.beletage.ro/gitadmin/ArchiTools` |
|
||||||
| Framework | Next.js 16.x, App Router, TypeScript (strict) |
|
| Language | Code: **English**, UI: **Romanian** |
|
||||||
| Styling | Tailwind CSS v4, shadcn/ui |
|
|
||||||
| Database | PostgreSQL (10.10.10.166:5432) via Prisma v6 ORM |
|
### Architecture Principles
|
||||||
| Storage | `DatabaseStorageAdapter` (PostgreSQL) — localStorage fallback available |
|
|
||||||
| File Storage | MinIO (10.10.10.166:9002 API / 9003 UI) — client configured, adapter pending |
|
- **Module platform** — each module isolated: own types/services/hooks/components
|
||||||
| Auth | NextAuth v4 + Authentik OIDC (auth.beletage.ro) |
|
- **Feature flags** gate loading (disabled = zero bundle cost)
|
||||||
| Proxy | Traefik v3 on `10.10.10.199` (proxy server), SSL via Let's Encrypt |
|
- **Storage abstraction** via `StorageService` interface + adapters
|
||||||
| Deploy | Docker multi-stage, Portainer CE on `10.10.10.166` (satra) |
|
- **Auth via Authentik SSO** — group → role/company mapping
|
||||||
| Repo | Gitea at `https://git.beletage.ro/gitadmin/ArchiTools` |
|
- **All entities** include `visibility` / `createdBy` from day one
|
||||||
| Language | Code in **English**, UI in **Romanian** |
|
|
||||||
|
---
|
||||||
### Architecture Principles
|
|
||||||
|
## Repository Structure
|
||||||
- **Module platform, not monolith** — each module isolated with own types/services/hooks/components
|
|
||||||
- **Feature flags** gate module loading (disabled = zero bundle cost)
|
```
|
||||||
- **Storage abstraction**: `StorageService` interface with adapters (database default via Prisma, localStorage fallback)
|
src/
|
||||||
- **Cross-module tagging system** as shared service
|
├── app/(modules)/ # Route pages (thin wrappers)
|
||||||
- **Auth via Authentik SSO** — NextAuth v4 + OIDC, group→role/company mapping
|
├── core/ # Platform: auth, storage, flags, tagging, i18n, theme
|
||||||
- **All entities** include `visibility` / `createdBy` fields from day one
|
├── modules/<name>/ # Module business logic (see MODULE-MAP.md)
|
||||||
- **Company logos** — theme-aware (light/dark variants), dual-rendered for SSR safety
|
│ ├── components/ # UI components
|
||||||
|
│ ├── hooks/ # Module hooks
|
||||||
---
|
│ ├── services/ # Business logic
|
||||||
|
│ ├── types.ts # Interfaces
|
||||||
## Repository Structure
|
│ ├── config.ts # Module metadata
|
||||||
|
│ └── index.ts # Public exports
|
||||||
```
|
├── shared/components/ # ui/ (shadcn), layout/ (sidebar/header), common/
|
||||||
src/
|
├── config/ # modules.ts, flags.ts, navigation.ts, companies.ts
|
||||||
├── app/ # Routing only (thin wrappers)
|
docs/ # Architecture, guides, module deep-dives
|
||||||
│ ├── (modules)/ # Module route pages
|
```
|
||||||
│ └── layout.tsx # App shell
|
|
||||||
├── core/ # Platform services
|
---
|
||||||
│ ├── module-registry/ # Module registration + types
|
|
||||||
│ ├── feature-flags/ # Flag evaluation + env override
|
## Modules (17 total)
|
||||||
│ ├── storage/ # StorageService + adapters
|
|
||||||
│ │ └── adapters/ # localStorage adapter (+ future DB/MinIO)
|
| Module | Route | Key Features |
|
||||||
│ ├── tagging/ # Cross-module tag service
|
| ------------------ | ------------------- | --------------------------------------------------- |
|
||||||
│ ├── i18n/ # Romanian translations
|
| Dashboard | `/` | KPI cards, activity feed, module grid |
|
||||||
│ ├── theme/ # Light/dark theme
|
| Email Signature | `/email-signature` | Multi-company, live preview, copy/download |
|
||||||
│ └── auth/ # Auth types + stub (future Authentik)
|
| Word XML | `/word-xml` | Category-based XML, simple/advanced, ZIP export |
|
||||||
├── modules/ # Module business logic
|
| Registratura | `/registratura` | Registry CRUD, legal deadlines, notifications, NAS |
|
||||||
│ ├── <module-name>/
|
| Tag Manager | `/tag-manager` | Tags CRUD, ManicTime sync |
|
||||||
│ │ ├── components/ # Module UI components
|
| IT Inventory | `/it-inventory` | Equipment, rack visualization, filters |
|
||||||
│ │ ├── hooks/ # Module-specific hooks
|
| Address Book | `/address-book` | Contacts, vCard, Registratura integration |
|
||||||
│ │ ├── services/ # Module business logic
|
| Password Vault | `/password-vault` | AES-256-GCM encrypted, WiFi QR, multi-user |
|
||||||
│ │ ├── types.ts # Module types
|
| Mini Utilities | `/mini-utilities` | 12+ tools: PDF compress, OCR, converters, calc |
|
||||||
│ │ ├── config.ts # Module metadata
|
| Prompt Generator | `/prompt-generator` | 18 templates, text + image targets |
|
||||||
│ │ └── index.ts # Public exports
|
| Digital Signatures | `/digital-signatures` | Assets CRUD, file upload, tags |
|
||||||
│ └── ...
|
| Word Templates | `/word-templates` | Template library, .docx placeholder detection |
|
||||||
├── shared/ # Shared UI
|
| AI Chat | `/ai-chat` | Multi-provider (OpenAI/Claude/Ollama) |
|
||||||
│ ├── components/
|
| Hot Desk | `/hot-desk` | 4 desks, week calendar, room layout |
|
||||||
│ │ ├── ui/ # shadcn/ui primitives
|
| ParcelSync | `/parcel-sync` | eTerra ANCPI, PostGIS, enrichment, ePay ordering |
|
||||||
│ │ ├── layout/ # Sidebar, Header
|
| Geoportal | `/geoportal` | MapLibre viewer, parcel search, UAT layers |
|
||||||
│ │ └── common/ # Reusable app components
|
| Visual CoPilot | `/visual-copilot` | Placeholder — separate repo |
|
||||||
│ ├── hooks/ # Shared hooks
|
|
||||||
│ └── lib/ # Utils (cn, etc.)
|
See `docs/MODULE-MAP.md` for entry points, API routes, and cross-module deps.
|
||||||
├── config/ # Global config
|
|
||||||
│ ├── modules.ts # Module registry entries
|
---
|
||||||
│ ├── flags.ts # Default feature flags
|
|
||||||
│ ├── navigation.ts # Sidebar nav structure
|
## Development Rules
|
||||||
│ └── companies.ts # Company definitions
|
|
||||||
docs/ # 16 internal technical docs
|
### TypeScript Strict Mode Gotchas
|
||||||
legacy/ # Original HTML tools for reference
|
|
||||||
```
|
- `arr[0]` is `T | undefined` even after length check — assign to const first
|
||||||
|
- `Record<string, T>[key]` returns `T | undefined` — always null-check
|
||||||
---
|
- Spread of possibly-undefined: `{ ...obj[key] }` — check existence first
|
||||||
|
- lucide-react Icons: cast through `unknown` → `React.ComponentType<{ className?: string }>`
|
||||||
## Implemented Modules (16 total — 14 original + 2 new)
|
- Prisma `$queryRaw` returns `unknown[]` — cast with `as Array<{ field: type }>`
|
||||||
|
- `?? ""` on `{}` field produces `{}` not `string` — use `typeof` check
|
||||||
| # | Module | Route | Version | Key Features |
|
|
||||||
| --- | ---------------------- | --------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
### Conventions
|
||||||
| 1 | **Dashboard** | `/` | 0.1.0 | KPI cards (6), activity feed (last 20), module grid, external tools |
|
|
||||||
| 2 | **Email Signature** | `/email-signature` | 0.1.0 | Multi-company branding, address toggle (BTG/US/SDT), live preview, zoom/copy/download |
|
- **Code**: English | **UI text**: Romanian | **IDs**: uuid v4
|
||||||
| 3 | **Word XML Generator** | `/word-xml` | 0.1.0 | Category-based XML gen, simple/advanced mode, ZIP export |
|
- **Dates**: ISO strings (`YYYY-MM-DD` display, full ISO timestamps)
|
||||||
| 4 | **Registratura** | `/registratura` | 0.5.0 | CRUD registry, dynamic doc types, bidirectional Address Book, threads, backdating, **legal deadline tracking** (6 categories, 18 types), recipient registration, document expiry, **NAS network path attachments** (A/O/P/T drives, copy-to-clipboard), **detail sheet side panel**, **configurable column visibility**, **QuickLook attachment preview** (images: zoom/pan, PDFs: native viewer, multi-file navigation), **email notifications** (Brevo SMTP daily digest, per-user preferences), **compact registry numbers** (single-letter company badge + direction arrow + plain number) |
|
- **Components**: functional, `'use client'` where needed
|
||||||
| 5 | **Tag Manager** | `/tag-manager` | 0.2.0 | CRUD tags, category/scope/color, US/SDT seeds, mandatory categories, **ManicTime bidirectional sync** |
|
- **No emojis** in code or UI
|
||||||
| 6 | **IT Inventory** | `/it-inventory` | 0.2.0 | Dynamic equipment types, rented status (purple pulse), **42U rack visualization**, type/status/company filters |
|
|
||||||
| 7 | **Address Book** | `/address-book` | 0.2.0 | CRUD contacts (person OR institution), card grid, vCard export, Registratura reverse lookup, **dynamic types (creatable)**, **name OR company required** (flexible validation), **ContactPerson with department field**, **quick contact from Registratura** (persons + institutions) |
|
### Storage Performance (CRITICAL)
|
||||||
| 8 | **Password Vault** | `/password-vault` | 0.4.0 | CRUD credentials, 9 categorii cu iconițe, **WiFi QR code real**, context-aware form, strength meter, company scope, **AES-256-GCM encryption**, **utilizatori multipli per intrare** (VaultUser[]: username/password/email/notes, colapsibil în form, badge în list) |
|
|
||||||
| 9 | **Mini Utilities** | `/mini-utilities` | 0.4.0 | Text case, char counter, percentage, **TVA calculator (cotă configurabilă: 5/9/19/21% + custom)**, area converter, U→R, num→text, artifact cleaner, MDLPA, **PDF compression** (qpdf local lossless + iLovePDF API cloud lossy, streaming upload for large files), PDF unlock, **DWG→DXF**, OCR, color palette, **paste on all drop zones**, **thermal drag-drop reorder**, **Calculator scară desen** (real cm↔desen mm, 7 preseturi 1:20..1:5000 + custom) |
|
- **NEVER** `storage.list()` + `storage.get()` in loop — N+1 bug
|
||||||
| 10 | **Prompt Generator** | `/prompt-generator` | 0.2.0 | Template-driven prompt builder, **18 templates** (14 text + 4 image), search bar, target type filter |
|
- **ALWAYS** use `storage.exportAll()` or `storage.export(namespace)` for batch-load
|
||||||
| 11 | **Digital Signatures** | `/digital-signatures` | 0.1.0 | CRUD assets, drag-and-drop file upload, tag chips |
|
- **NEVER** store base64 files in entity JSON — use `lightweight: true` for listing
|
||||||
| 12 | **Word Templates** | `/word-templates` | 0.1.0 | Template library, 8 categories, version tracking, .docx placeholder auto-detection |
|
- After mutations: optimistic update OR single `refresh()` — never both
|
||||||
| 13 | **AI Chat** | `/ai-chat` | 0.2.0 | Multi-provider (OpenAI/Claude/Ollama/demo), **project linking via Tag Manager**, provider status badge |
|
|
||||||
| 14 | **Hot Desk** | `/hot-desk` | 0.1.1 | 4 desks, week-ahead calendar, room layout (window+door proportioned), reserve/cancel |
|
### Middleware & Large Uploads
|
||||||
| 15 | **ParcelSync** | `/parcel-sync` | 0.6.0 | eTerra ANCPI integration, **PostGIS database**, background sync, 23-layer catalog, enrichment pipeline, owner search, **per-UAT analytics dashboard**, **health check + maintenance detection**, **ANCPI ePay CF extract ordering** (batch orders, MinIO PDF storage, dedup protection, credit tracking), **static WORKSPACE_TO_COUNTY mapping**, **GisUat geometry select optimization**, **feature count cache (5-min TTL)** |
|
|
||||||
| 16 | **Visual Copilot** | `/visual-copilot` | 0.1.0 | AI-powered image analysis — **developed in separate repo** (`https://git.beletage.ro/gitadmin/vim`), placeholder in ArchiTools, will be merged as module later |
|
- Middleware buffers entire body — exclude large-upload routes from matcher
|
||||||
|
- Excluded routes: `api/auth|api/notifications/digest|api/compress-pdf|api/address-book|api/projects`
|
||||||
### Registratura — Legal Deadline Tracking (Termene Legale)
|
- Excluded routes use `requireAuth()` from `auth-check.ts` instead
|
||||||
|
- To add new upload route: (1) exclude from middleware, (2) add `requireAuth()`
|
||||||
The Registratura module includes a full legal deadline tracking engine for Romanian construction permitting:
|
|
||||||
|
### eTerra / ANCPI Rules
|
||||||
- **18 deadline types** across 6 categories (Certificat, Avize, Completari, Urbanism, Autorizare, Litigii)
|
|
||||||
- **Working days vs calendar days** with Romanian public holiday support (including Orthodox Easter via Meeus algorithm)
|
- ArcGIS: paginate with `resultOffset`/`resultRecordCount` (max 1000)
|
||||||
- **Chain deadlines** (resolving one prompts adding the next — e.g., CU analiza → emitere, PUZ/PUD analiza → post-CTATU → emitere)
|
- Sessions expire ~10min — cache TTL 9min, auto-relogin on 401
|
||||||
- **Tacit approval** (auto-detected when overdue + applicable type)
|
- Health check detects maintenance — block login when down
|
||||||
- **Tabbed UI**: "Registru" tab (existing registry) + "Termene legale" tab (deadline dashboard)
|
- `WORKSPACE_TO_COUNTY` (42 entries in `county-refresh.ts`) is authoritative
|
||||||
- **Email notifications**: daily digest via Brevo SMTP, per-user opt-in/opt-out preferences, N8N cron trigger
|
- `GisUat.geometry` is huge — always `select` to exclude in list queries
|
||||||
|
- Feature counts cached 5-min TTL
|
||||||
Key files:
|
- ePay: form-urlencoded body, OpenAM auth, MinIO metadata must be ASCII
|
||||||
|
|
||||||
- `services/working-days.ts` — Romanian holidays, `addWorkingDays()`, `isWorkingDay()`
|
### Before Pushing
|
||||||
- `services/deadline-catalog.ts` — 18 `DeadlineTypeDef` entries across 6 categories
|
|
||||||
- `services/deadline-service.ts` — `createTrackedDeadline()`, `resolveDeadline()`, `aggregateDeadlines()`
|
1. `npx next build` — zero errors
|
||||||
- `components/attachment-preview.tsx` — QuickLook-style fullscreen preview (images: zoom/pan, PDFs: blob URL iframe, multi-file nav)
|
2. Test on `localhost:3000`
|
||||||
- `components/deadline-dashboard.tsx` — Stats + filters + table
|
3. Commit with descriptive message
|
||||||
- `components/deadline-add-dialog.tsx` — 3-step wizard (category → type → date preview)
|
4. `git push origin main` → manual Portainer redeploy
|
||||||
- `components/notification-preferences.tsx` — Bell button + dialog with per-type toggles
|
|
||||||
- `components/registry-table.tsx` — `CompactNumber` component: single-letter company badge (B/U/S/G), direction arrow (↓ intrat / ↑ iesit), plain number
|
---
|
||||||
|
|
||||||
### Address Book — Flexible Contact Model
|
## Common Pitfalls (Top 10)
|
||||||
|
|
||||||
The Address Book supports both persons and institutions:
|
1. **Middleware body buffering** — upload routes >10MB must be excluded from matcher
|
||||||
|
2. **N+1 storage queries** — use `exportAll()`, never `list()` + `get()` loop
|
||||||
- **Flexible validation**: either `name` OR `company` required (not both mandatory)
|
3. **GisUat geometry in queries** — exclude with `select`, or 50ms → 5+ seconds
|
||||||
- **Auto-type detection**: when only company is set via quick-create, type defaults to "institution"
|
4. **Enrichment data loss on re-sync** — upsert must preserve enrichment field
|
||||||
- **ContactPerson sub-entities**: each has `name`, `department`, `role`, `email`, `phone`
|
5. **Ghostscript corrupts fonts** — use qpdf for PDF compression, never GS
|
||||||
- **Quick contact creation from Registratura**: inline dialog with name + company + phone + email
|
6. **eTerra timeout too low** — geometry pages need 60-90s; default 120s
|
||||||
- **Display logic**: if no name, company shows as primary; if both, shows "Name (Company)"
|
7. **Traefik 60s readTimeout** — must set 600s in static config for uploads
|
||||||
- **Creatable types**: dropdown with defaults (client/supplier/institution/collaborator/internal) + user-created custom types
|
8. **Portainer CE can't inject env vars** — all env in docker-compose.yml
|
||||||
|
9. **`@prisma/client` in dependencies** (not devDeps) — runtime requirement
|
||||||
Key files:
|
10. **`output: 'standalone'`** in next.config.ts — required for Docker
|
||||||
|
|
||||||
- `modules/address-book/types.ts` — `AddressContact`, `ContactPerson` interfaces
|
---
|
||||||
- `modules/address-book/components/address-book-module.tsx` — Full UI (cards, detail dialog, form)
|
|
||||||
- `modules/address-book/hooks/use-contacts.ts` — Storage hook with search/filter
|
## Infrastructure Quick Reference
|
||||||
- `modules/address-book/services/vcard-export.ts` — vCard 3.0 export
|
|
||||||
- `modules/registratura/components/quick-contact-dialog.tsx` — Quick create from registry
|
| Service | Address | Purpose |
|
||||||
|
| ----------- | ------------------------ | -------------------------- |
|
||||||
### PDF Compression — Dual Mode (Local + Cloud)
|
| App | 10.10.10.166:3000 | ArchiTools (tools.beletage.ro) |
|
||||||
|
| PostgreSQL | 10.10.10.166:5432 | Database (Prisma) |
|
||||||
Two compression routes, both with streaming upload support for large files (tested up to 287MB):
|
| MinIO | 10.10.10.166:9002/9003 | Object storage |
|
||||||
|
| Authentik | 10.10.10.166:9100 | SSO (auth.beletage.ro) |
|
||||||
- **Local (qpdf)**: lossless structural optimization — stream compression, object dedup, linearization. Safe, no font corruption. Typical reduction: 3-15%.
|
| Portainer | 10.10.10.166:9000 | Docker management |
|
||||||
- **Cloud (iLovePDF API)**: lossy image re-compression via iLovePDF REST API. Levels: extreme/recommended/low. Typical reduction: 50-91%. Requires `ILOVEPDF_PUBLIC_KEY` env var.
|
| Gitea | 10.10.10.166:3002 | Git (git.beletage.ro) |
|
||||||
|
| Traefik | 10.10.10.199 | Reverse proxy + SSL |
|
||||||
**Architecture** (zero-memory for any file size):
|
| N8N | 10.10.10.166:5678 | Workflow automation |
|
||||||
1. `parseMultipartUpload()` streams request body to disk (constant 64KB memory)
|
| Stirling PDF | 10.10.10.166:8087 | PDF tools (needs env vars!) |
|
||||||
2. Scans raw file for multipart boundaries using `findInFile()` with 64KB sliding window
|
|
||||||
3. Stream-copies PDF bytes to separate file
|
## Company IDs
|
||||||
4. Route handler processes (qpdf exec or iLovePDF API) and streams response back
|
|
||||||
|
| ID | Name | Prefix |
|
||||||
**Critical gotchas**:
|
| ----------------- | --------------- | ------ |
|
||||||
- Middleware body buffering: `api/compress-pdf` routes are **excluded from middleware matcher** (middleware buffers entire body at 10MB default)
|
| `beletage` | Beletage | B |
|
||||||
- Auth: route-level `requireAuth()` instead of middleware (in `auth-check.ts`)
|
| `urban-switch` | Urban Switch | US |
|
||||||
- Unicode filenames: `Content-Disposition` header uses `encodeURIComponent()` to avoid ByteString errors with Romanian chars (Ș, Ț, etc.)
|
| `studii-de-teren` | Studii de Teren | SDT |
|
||||||
- Ghostscript `-sDEVICE=pdfwrite` destroys font encodings — **never use GS for compression**, only qpdf
|
| `group` | Grup | G |
|
||||||
|
|
||||||
Key files:
|
---
|
||||||
|
|
||||||
- `app/api/compress-pdf/parse-upload.ts` — Streaming multipart parser (zero memory)
|
## Documentation
|
||||||
- `app/api/compress-pdf/extreme/route.ts` — qpdf local compression
|
|
||||||
- `app/api/compress-pdf/cloud/route.ts` — iLovePDF API integration
|
| Doc | Path |
|
||||||
- `app/api/compress-pdf/auth-check.ts` — Shared auth for routes excluded from middleware
|
| ------------------- | ------------------------------------------ |
|
||||||
|
| Module Map | `docs/MODULE-MAP.md` |
|
||||||
### Email Notifications (Brevo SMTP)
|
| Architecture Quick | `docs/ARCHITECTURE-QUICK.md` |
|
||||||
|
| System Architecture | `docs/architecture/SYSTEM-ARCHITECTURE.md` |
|
||||||
Platform-level notification service for daily email digests:
|
| Module System | `docs/architecture/MODULE-SYSTEM.md` |
|
||||||
|
| Feature Flags | `docs/architecture/FEATURE-FLAGS.md` |
|
||||||
- **Brevo SMTP relay** via nodemailer (port 587, STARTTLS)
|
| Storage Layer | `docs/architecture/STORAGE-LAYER.md` |
|
||||||
- **N8N cron**: weekdays 8:00 → POST `/api/notifications/digest` with Bearer token
|
| Security & Roles | `docs/architecture/SECURITY-AND-ROLES.md` |
|
||||||
- **Per-user preferences**: stored in KeyValueStore (`notifications` namespace), toggle global opt-out + 3 notification types
|
| Module Dev Guide | `docs/guides/MODULE-DEVELOPMENT.md` |
|
||||||
- **Digest content**: urgent deadlines (<=5 days), overdue deadlines, expiring documents (CU/AC)
|
| Docker Deployment | `docs/guides/DOCKER-DEPLOYMENT.md` |
|
||||||
- **HTML email**: inline-styled table layout, color-coded rows (red/yellow/blue), per-company grouping
|
| Coding Standards | `docs/guides/CODING-STANDARDS.md` |
|
||||||
- **Sender**: "Alerte Termene" <noreply@beletage.ro>, test mode via `?test=true` query param
|
| Data Model | `docs/DATA-MODEL.md` |
|
||||||
|
|
||||||
Key files:
|
For module-specific deep dives, see `docs/modules/`.
|
||||||
|
|
||||||
- `src/core/notifications/types.ts` — `NotificationType`, `NotificationPreference`, `DigestSection`, `DigestItem`
|
|
||||||
- `src/core/notifications/email-service.ts` — Nodemailer transport singleton (Brevo SMTP)
|
|
||||||
- `src/core/notifications/notification-service.ts` — `runDigest()`, `buildCompanyDigest()`, `renderDigestHtml()`, preference CRUD
|
|
||||||
- `src/app/api/notifications/digest/route.ts` — POST endpoint (N8N cron, Bearer auth)
|
|
||||||
- `src/app/api/notifications/preferences/route.ts` — GET/PUT (user session auth)
|
|
||||||
|
|
||||||
### ParcelSync — eTerra ANCPI GIS Integration
|
|
||||||
|
|
||||||
The ParcelSync module connects to Romania's national eTerra/ANCPI cadastral system:
|
|
||||||
|
|
||||||
- **eTerra API client** (`eterra-client.ts`): form-post auth, JSESSIONID cookie jar, session caching (9min TTL), auto-relogin, paginated fetching with `maxRecordCount=1000` + fallback page sizes (500, 200)
|
|
||||||
- **23-layer catalog** (`eterra-layers.ts`): TERENURI_ACTIVE, CLADIRI_ACTIVE, LIMITE_UAT, etc. organized in 6 categories
|
|
||||||
- **PostGIS storage**: `GisFeature` model with geometry column, SIRUTA-based partitioning, `enrichment` JSONB field
|
|
||||||
- **Background sync**: long-running jobs via server singleton, progress polling (2s), phase tracking (fetch → save → enrich)
|
|
||||||
- **Enrichment pipeline** (`enrich-service.ts`): hits eTerra `/api/immovable/list` per parcel to extract NR_CAD, NR_CF, PROPRIETARI, SUPRAFATA, INTRAVILAN, CATEGORIE_FOLOSINTA, HAS_BUILDING, etc.
|
|
||||||
- **Owner search**: DB-first (ILIKE on enrichment JSON) with eTerra API fallback
|
|
||||||
- **Per-UAT dashboard**: SQL aggregates (area stats, intravilan/extravilan, land use, top owners), CSS-only visualizations (donut ring, bar charts)
|
|
||||||
- **Health check** (`eterra-health.ts`): pings `eterra.ancpi.ro` every 3min, detects maintenance by keywords in HTML response, blocks login when down, UI shows amber "Mentenanță" state
|
|
||||||
- **ANCPI ePay CF extract ordering**: batch orders via `epay-client.ts`, PDF storage to MinIO, dedup protection (queue + API level), credit tracking
|
|
||||||
- **Static county mapping**: `WORKSPACE_TO_COUNTY` in `county-refresh.ts` — 42 verified entries, preferred over unreliable nomenclature API
|
|
||||||
- **Performance**: GisUat queries use `select` to exclude geometry column; feature counts cached 5-min TTL
|
|
||||||
- **Test UAT**: Feleacu (SIRUTA 57582, ~30k immovables, ~8k GIS features)
|
|
||||||
|
|
||||||
Key files:
|
|
||||||
|
|
||||||
- `services/eterra-client.ts` — API client (~1000 lines), session cache, pagination, retry
|
|
||||||
- `services/eterra-layers.ts` — 23-layer catalog with categories
|
|
||||||
- `services/sync-service.ts` — Layer sync engine with progress tracking
|
|
||||||
- `services/enrich-service.ts` — Enrichment pipeline (FeatureEnrichment type)
|
|
||||||
- `services/eterra-health.ts` — Health check singleton, maintenance detection
|
|
||||||
- `services/session-store.ts` — Server-side session management
|
|
||||||
- `services/epay-client.ts` — ePay HTTP client (login, cart, metadata, submit, poll, download)
|
|
||||||
- `services/epay-queue.ts` — Batch queue with dedup protection
|
|
||||||
- `services/epay-storage.ts` — MinIO storage helpers for CF extract PDFs
|
|
||||||
- `services/epay-counties.ts` — County index mapping (eTerra county name → ePay alphabetical index 0-41)
|
|
||||||
- `app/api/eterra/session/county-refresh.ts` — Static `WORKSPACE_TO_COUNTY` mapping, LIMITE_UAT geometry refresh
|
|
||||||
- `components/parcel-sync-module.tsx` — Main UI (~4100 lines), 5 tabs (Export/Layers/Search/DB/Extrase CF)
|
|
||||||
- `components/uat-dashboard.tsx` — Per-UAT analytics dashboard (CSS-only charts)
|
|
||||||
- `components/epay-tab.tsx` — CF extract ordering tab
|
|
||||||
- `components/epay-connect.tsx` — ePay connection widget
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Infrastructure
|
|
||||||
|
|
||||||
### Server: `satra` — 10.10.10.166 (Ubuntu, app server)
|
|
||||||
|
|
||||||
| Service | Port | Purpose |
|
|
||||||
| ----------------------- | ---------------------- | ----------------------------------- |
|
|
||||||
| **ArchiTools** | 3000 | This app (tools.beletage.ro) |
|
|
||||||
| **Gitea** | 3002 | Git hosting (git.beletage.ro) |
|
|
||||||
| **PostgreSQL** | 5432 | App database (Prisma ORM) |
|
|
||||||
| **Portainer CE** | 9000 | Docker management + deploy |
|
|
||||||
| **Uptime Kuma** | 3001 | Service monitoring |
|
|
||||||
| **MinIO** | 9002 (API) / 9003 (UI) | Object storage |
|
|
||||||
| **Authentik** | 9100 | SSO (auth.beletage.ro) — **active** |
|
|
||||||
| **N8N** | 5678 | Workflow automation (daily digest) |
|
|
||||||
| **Stirling PDF** | 8087 | PDF tools |
|
|
||||||
| **IT-Tools** | 8085 | Developer utilities |
|
|
||||||
| **FileBrowser** | 8086 | File management |
|
|
||||||
| **Netdata** | 19999 | System monitoring |
|
|
||||||
| **Dozzle** | 9999 | Docker log viewer |
|
|
||||||
| **CrowdSec** | 8088 | Security |
|
|
||||||
|
|
||||||
### Server: `proxy` — 10.10.10.199 (Traefik reverse proxy)
|
|
||||||
|
|
||||||
| Config | Path / Value |
|
|
||||||
| ----------------------- | ---------------------------------------- |
|
|
||||||
| **Static config** | `/opt/traefik/traefik.yml` |
|
|
||||||
| **Dynamic configs** | `/opt/traefik/dynamic/` (file provider, `watch: true`) |
|
|
||||||
| **ArchiTools route** | `/opt/traefik/dynamic/tools.yml` |
|
|
||||||
| **SSL** | Let's Encrypt ACME, HTTP challenge |
|
|
||||||
| **Timeouts** | `readTimeout: 600s`, `writeTimeout: 600s`, `idleTimeout: 600s` on `websecure` entrypoint |
|
|
||||||
| **Response forwarding** | `flushInterval: 100ms` (streaming support) |
|
|
||||||
|
|
||||||
**IMPORTANT**: Default Traefik v2.11+ has 60s `readTimeout` — breaks large file uploads. Must set explicitly in static config.
|
|
||||||
|
|
||||||
### Deployment Pipeline
|
|
||||||
|
|
||||||
```
|
|
||||||
git push origin main
|
|
||||||
→ Gitea webhook fires
|
|
||||||
→ Portainer CE: Stacks → architools → "Pull and redeploy"
|
|
||||||
→ Toggle "Re-pull image and redeploy" ON → click "Update"
|
|
||||||
→ Portainer re-clones git repo + Docker multi-stage build (~2 min)
|
|
||||||
→ Container starts on :3000
|
|
||||||
→ Traefik routes tools.beletage.ro → http://10.10.10.166:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
**Portainer CE deploy**: NOT automatic. Must manually click "Pull and redeploy" in Portainer UI after each push. The stack is configured from git repo `http://10.10.10.166:3002/gitadmin/ArchiTools`.
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
|
|
||||||
- `Dockerfile`: 3-stage build (deps → builder → runner), `node:22-alpine`, non-root user
|
|
||||||
- Runner stage installs: `gdal gdal-tools ghostscript qpdf` (for PDF compression, GIS)
|
|
||||||
- `Dockerfile` includes `npx prisma generate` before build step
|
|
||||||
- `docker-compose.yml`: single service, port 3000, **all env vars hardcoded** (Portainer CE can't inject env vars)
|
|
||||||
- `output: 'standalone'` in `next.config.ts` is **required**
|
|
||||||
- `@prisma/client` must be in `dependencies` (not devDependencies) for runtime
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development Rules
|
|
||||||
|
|
||||||
### TypeScript Strict Mode Gotchas
|
|
||||||
|
|
||||||
- `array.split()[0]` returns `string | undefined` — use `.slice(0, 10)` instead
|
|
||||||
- `Record<string, T>[key]` returns `T | undefined` — always guard with null check
|
|
||||||
- Spread of possibly-undefined objects: `{ ...obj[key], field }` — check existence first
|
|
||||||
- lucide-react Icons: cast through `unknown` → `React.ComponentType<{ className?: string }>`
|
|
||||||
- `arr[0]` is `T | undefined` even after `arr.length > 0` check — assign to const first: `const first = arr[0]; if (first) { ... }`
|
|
||||||
- Prisma `$queryRaw` returns `unknown[]` — always cast with `as Array<{ field: type }>` and guard access
|
|
||||||
- `?? ""` on an object field typed `{}` produces `{}` not `string` — use explicit `typeof x === 'string'` or `'number'` check
|
|
||||||
|
|
||||||
### Conventions
|
|
||||||
|
|
||||||
- **Code**: English
|
|
||||||
- **UI text**: Romanian
|
|
||||||
- **Components**: functional, `'use client'` directive where needed
|
|
||||||
- **State**: localStorage via `useStorage('module-name')` hook
|
|
||||||
- **IDs**: `uuid v4`
|
|
||||||
- **Dates**: ISO strings (`YYYY-MM-DD` for display, full ISO for timestamps)
|
|
||||||
- **No emojis** in code or UI unless explicitly requested
|
|
||||||
|
|
||||||
### Storage Performance Rules
|
|
||||||
|
|
||||||
- **NEVER** use `storage.list()` followed by `storage.get()` in a loop — this is an N+1 query bug
|
|
||||||
- `list()` fetches ALL items (keys+values) from DB but discards values, then each `get()` re-fetches individually
|
|
||||||
- **ALWAYS** use `storage.exportAll()` (namespaced) or `storage.export(namespace)` (service-level) to batch-load
|
|
||||||
- Filter items client-side after a single fetch: `for (const [key, value] of Object.entries(all)) { ... }`
|
|
||||||
- After mutations (add/update), either do optimistic local state update or a single `refresh()` — never both
|
|
||||||
- **NEVER store large binary data (base64 files) inside entity JSON** — this makes list loading transfer tens of MB
|
|
||||||
- For modules with attachments: use `exportAll({ lightweight: true })` for listing, `storage.get()` for single-entry full load
|
|
||||||
- The API `?lightweight=true` parameter strips `data`/`fileData` strings >1KB from JSON values server-side
|
|
||||||
- Future: move file data to MinIO; only store metadata (name, size, type, url) in the entity JSON
|
|
||||||
|
|
||||||
### Module Development Pattern
|
|
||||||
|
|
||||||
Every module follows:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/modules/<name>/
|
|
||||||
├── components/ # React components
|
|
||||||
├── hooks/ # Custom hooks (use-<name>.ts)
|
|
||||||
├── services/ # Business logic (pure functions)
|
|
||||||
├── types.ts # TypeScript interfaces
|
|
||||||
├── config.ts # ModuleConfig metadata
|
|
||||||
└── index.ts # Public exports
|
|
||||||
```
|
|
||||||
|
|
||||||
### Middleware & Large Upload Routes
|
|
||||||
|
|
||||||
- Next.js middleware buffers the **entire request body** even if it only reads cookies/headers
|
|
||||||
- Default middleware body limit is 10MB — any upload route handling large files MUST be excluded
|
|
||||||
- Excluded routes pattern in `src/middleware.ts` matcher: `api/auth|api/notifications/digest|api/compress-pdf`
|
|
||||||
- Excluded routes handle auth via `requireAuth()` helper (`src/app/api/compress-pdf/auth-check.ts`)
|
|
||||||
- To add a new large-upload route: (1) add to middleware matcher exclusion, (2) add `requireAuth()` call in route handler
|
|
||||||
- `next.config.ts` has `experimental: { middlewareClientMaxBodySize: '500mb' }` but this is unreliable with `output: 'standalone'`
|
|
||||||
|
|
||||||
### eTerra / External API Rules
|
|
||||||
|
|
||||||
- **ArcGIS REST API** has `maxRecordCount=1000` — always paginate with `resultOffset`/`resultRecordCount`
|
|
||||||
- **eTerra sessions expire after ~10min** — session cache TTL is 9min, auto-relogin on 401/redirect
|
|
||||||
- **eTerra goes into maintenance regularly** — health check must detect and block login attempts
|
|
||||||
- **Never hardcode timeouts too low** — eTerra 1000-feature geometry pages can take 60-90s; default is 120s
|
|
||||||
- **CookieJar + axios-cookiejar-support** required for eTerra auth (JSESSIONID tracking)
|
|
||||||
- **Page size fallbacks**: if 1000 fails, retry with 500, then 200
|
|
||||||
- **WORKSPACE_TO_COUNTY is the authoritative county mapping** — static 42-entry map in `county-refresh.ts`, preferred over `fetchCounties()` which 404s intermittently
|
|
||||||
- **GisUat.geometry is huge** — always use Prisma `select` to exclude it in list queries; forgetting this turns 50ms into 5+ seconds
|
|
||||||
- **Feature counts are expensive** — cached in global with 5-min TTL in UATs route; returns stale data while refreshing
|
|
||||||
|
|
||||||
### ANCPI ePay Rules
|
|
||||||
|
|
||||||
- **ePay county IDs = eTerra WORKSPACE_IDs** (CLUJ=127, ALBA=10) — zero discovery calls needed
|
|
||||||
- **ePay UAT IDs = SIRUTA codes** — use `GisUat.workspacePk` + `siruta` directly
|
|
||||||
- **EpayJsonInterceptor uses form-urlencoded** (NOT JSON body) — `reqType=nomenclatorUAT&countyId=127`
|
|
||||||
- **saveProductMetadataForBasketItem uses multipart/form-data** (form-data npm package)
|
|
||||||
- **Document IDs are HTML-encoded** in ShowOrderDetails — `"idDocument":47301767` must be decoded before JSON parse
|
|
||||||
- **ePay auth is OpenAM** — gets `AMAuthCookie`, then navigate to `http://` (not https) for JSESSIONID
|
|
||||||
- **MinIO metadata must be ASCII** — strip diacritics from values before storing
|
|
||||||
- Env vars: `ANCPI_USERNAME`, `ANCPI_PASSWORD`, `ANCPI_BASE_URL`, `ANCPI_LOGIN_URL`, `ANCPI_DEFAULT_SOLICITANT_ID`, `MINIO_BUCKET_ANCPI`
|
|
||||||
|
|
||||||
### Before Pushing
|
|
||||||
|
|
||||||
1. `npx next build` — must pass with zero errors
|
|
||||||
2. Test the feature manually on `localhost:3000`
|
|
||||||
3. Commit with descriptive message
|
|
||||||
4. `git push origin main` — Portainer auto-deploys
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Company IDs
|
|
||||||
|
|
||||||
| ID | Name | Prefix |
|
|
||||||
| ----------------- | --------------- | ------ |
|
|
||||||
| `beletage` | Beletage | B |
|
|
||||||
| `urban-switch` | Urban Switch | US |
|
|
||||||
| `studii-de-teren` | Studii de Teren | SDT |
|
|
||||||
| `group` | Grup | G |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current Integrations
|
|
||||||
|
|
||||||
| Feature | Status | Notes |
|
|
||||||
| -------------------- | ---------------------- | ------------------------------------------------------------------------------------------ |
|
|
||||||
| **Authentik SSO** | ✅ Active | NextAuth v4 + OIDC, group→role/company mapping |
|
|
||||||
| **PostgreSQL** | ✅ Active | Prisma ORM, `KeyValueStore` model, `/api/storage` route |
|
|
||||||
| **MinIO** | Client configured | 10.10.10.166:9002, bucket `tools`, adapter pending |
|
|
||||||
| **AI Chat API** | ✅ Multi-provider | `/api/ai-chat` — OpenAI/Claude/Ollama/demo; needs API key env |
|
|
||||||
| **Vault Encryption** | ✅ Active | AES-256-GCM server-side, `/api/vault`, ENCRYPTION_SECRET env |
|
|
||||||
| **ManicTime Sync** | ✅ Implemented | `/api/manictime` — bidirectional Tags.txt sync, needs SMB mount |
|
|
||||||
| **NAS Paths** | ✅ Active | `\\newamun` (10.10.10.10), drives A/O/P/T, hostname+IP fallback, `src/config/nas-paths.ts` |
|
|
||||||
| **eTerra ANCPI** | ✅ Active | ParcelSync module, `eterra-client.ts`, health check + maintenance detection |
|
|
||||||
| **ANCPI ePay** | ✅ Active | CF extract ordering, `epay-client.ts`, MinIO PDF storage, batch queue + dedup, `/api/ancpi/*` routes |
|
|
||||||
| **PostGIS** | ✅ Active | `GisFeature` model, geometry storage, spatial queries, used by ParcelSync |
|
|
||||||
| **Email Notifications** | ✅ Implemented | Brevo SMTP daily digest, `/api/notifications/digest` + `/preferences`, N8N cron trigger |
|
|
||||||
| **N8N automations** | ✅ Active (digest cron) | Daily digest cron `0 8 * * 1-5`, Bearer token auth, future: backups, workflows |
|
|
||||||
| **iLovePDF API** | ✅ Active | Cloud PDF compression, `ILOVEPDF_PUBLIC_KEY` env, free tier 250 files/month |
|
|
||||||
| **qpdf** | ✅ Active | Local lossless PDF optimization, installed in Docker image (`apk add qpdf`) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Model Recommendations
|
|
||||||
|
|
||||||
| Task Type | Claude | OpenAI | Google | Notes |
|
|
||||||
| ----------------------------- | -------------- | ------------- | ---------------- | ----------------------------------------------- |
|
|
||||||
| **Bug fixes, config** | Haiku 4.5 | GPT-4o-mini | Gemini 2.5 Flash | Fast, cheap |
|
|
||||||
| **Features, tests, UI** | **Sonnet 4.6** | GPT-5.2 | Gemini 3 Flash | Best value — Opus-class quality at Sonnet price |
|
|
||||||
| **New modules, architecture** | Opus 4.6 | GPT-5.3-Codex | Gemini 3 Pro | Complex multi-file, business logic |
|
|
||||||
|
|
||||||
**Default: Sonnet 4.6** for most work. See `ROADMAP.md` for per-task recommendations.
|
|
||||||
|
|
||||||
### Session Handoff Tips
|
|
||||||
|
|
||||||
- Read this `CLAUDE.md` first — it has all context
|
|
||||||
- Read `ROADMAP.md` for the complete task list with dependencies
|
|
||||||
- Check `docs/` for deep dives on specific systems
|
|
||||||
- Check `src/modules/<name>/types.ts` before modifying any module
|
|
||||||
- Always run `npx next build` before committing
|
|
||||||
- Push to `main` → Portainer auto-deploys via Gitea webhook
|
|
||||||
- The 16 docs in `docs/` total ~10,600 lines — search them for architecture questions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Documentation Index
|
|
||||||
|
|
||||||
| Doc | Path | Content |
|
|
||||||
| ------------------- | ------------------------------------------ | -------------------------------------------- |
|
|
||||||
| System Architecture | `docs/architecture/SYSTEM-ARCHITECTURE.md` | Overall architecture, module platform design |
|
|
||||||
| Module System | `docs/architecture/MODULE-SYSTEM.md` | Module registry, lifecycle, config format |
|
|
||||||
| Feature Flags | `docs/architecture/FEATURE-FLAGS.md` | Flag system, env overrides |
|
|
||||||
| Storage Layer | `docs/architecture/STORAGE-LAYER.md` | StorageService interface, adapters |
|
|
||||||
| Tagging System | `docs/architecture/TAGGING-SYSTEM.md` | Cross-module tags |
|
|
||||||
| Security & Roles | `docs/architecture/SECURITY-AND-ROLES.md` | Visibility, auth, roles |
|
|
||||||
| Module Dev Guide | `docs/guides/MODULE-DEVELOPMENT.md` | How to create a new module |
|
|
||||||
| HTML Integration | `docs/guides/HTML-TOOL-INTEGRATION.md` | Legacy tool migration |
|
|
||||||
| UI Design System | `docs/guides/UI-DESIGN-SYSTEM.md` | Design tokens, component patterns |
|
|
||||||
| Docker Deployment | `docs/guides/DOCKER-DEPLOYMENT.md` | Full Docker/Portainer/Nginx guide |
|
|
||||||
| Coding Standards | `docs/guides/CODING-STANDARDS.md` | TS strict, naming, patterns |
|
|
||||||
| Testing Strategy | `docs/guides/TESTING-STRATEGY.md` | Testing approach |
|
|
||||||
| Configuration | `docs/guides/CONFIGURATION.md` | Env vars, flags, companies |
|
|
||||||
| Data Model | `docs/DATA-MODEL.md` | All entity schemas |
|
|
||||||
| Repo Structure | `docs/REPO-STRUCTURE.md` | Directory layout |
|
|
||||||
| Prompt Generator | `docs/modules/PROMPT-GENERATOR.md` | Prompt module deep dive |
|
|
||||||
|
|||||||
+6
-1
@@ -24,9 +24,13 @@ COPY . .
|
|||||||
ARG NEXT_PUBLIC_STORAGE_ADAPTER=database
|
ARG NEXT_PUBLIC_STORAGE_ADAPTER=database
|
||||||
ARG NEXT_PUBLIC_APP_NAME=ArchiTools
|
ARG NEXT_PUBLIC_APP_NAME=ArchiTools
|
||||||
ARG NEXT_PUBLIC_APP_URL=https://tools.beletage.ro
|
ARG NEXT_PUBLIC_APP_URL=https://tools.beletage.ro
|
||||||
|
ARG NEXT_PUBLIC_MARTIN_URL=https://tools.beletage.ro/tiles
|
||||||
|
ARG NEXT_PUBLIC_PMTILES_URL=
|
||||||
ENV NEXT_PUBLIC_STORAGE_ADAPTER=${NEXT_PUBLIC_STORAGE_ADAPTER}
|
ENV NEXT_PUBLIC_STORAGE_ADAPTER=${NEXT_PUBLIC_STORAGE_ADAPTER}
|
||||||
ENV NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME}
|
ENV NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME}
|
||||||
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
|
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
|
||||||
|
ENV NEXT_PUBLIC_MARTIN_URL=${NEXT_PUBLIC_MARTIN_URL}
|
||||||
|
ENV NEXT_PUBLIC_PMTILES_URL=${NEXT_PUBLIC_PMTILES_URL}
|
||||||
|
|
||||||
# Increase memory for Next.js build if VM has limited RAM
|
# Increase memory for Next.js build if VM has limited RAM
|
||||||
ENV NODE_OPTIONS="--max-old-space-size=2048"
|
ENV NODE_OPTIONS="--max-old-space-size=2048"
|
||||||
@@ -37,9 +41,10 @@ FROM node:22-alpine AS runner
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ENV TZ=Europe/Bucharest
|
||||||
|
|
||||||
# Install system deps + create user in a single layer
|
# Install system deps + create user in a single layer
|
||||||
RUN apk add --no-cache gdal gdal-tools ghostscript qpdf \
|
RUN apk add --no-cache gdal gdal-tools ghostscript qpdf tzdata \
|
||||||
&& addgroup --system --gid 1001 nodejs \
|
&& addgroup --system --gid 1001 nodejs \
|
||||||
&& adduser --system --uid 1001 nextjs
|
&& adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
|||||||
+50
-6
@@ -1,5 +1,3 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
architools:
|
architools:
|
||||||
build:
|
build:
|
||||||
@@ -8,6 +6,8 @@ services:
|
|||||||
- NEXT_PUBLIC_STORAGE_ADAPTER=${NEXT_PUBLIC_STORAGE_ADAPTER:-database}
|
- NEXT_PUBLIC_STORAGE_ADAPTER=${NEXT_PUBLIC_STORAGE_ADAPTER:-database}
|
||||||
- NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME:-ArchiTools}
|
- NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME:-ArchiTools}
|
||||||
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-https://tools.beletage.ro}
|
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-https://tools.beletage.ro}
|
||||||
|
- NEXT_PUBLIC_MARTIN_URL=https://tools.beletage.ro/tiles
|
||||||
|
- NEXT_PUBLIC_PMTILES_URL=/tiles/pmtiles/overview.pmtiles
|
||||||
container_name: architools
|
container_name: architools
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -51,10 +51,15 @@ services:
|
|||||||
- ANCPI_LOGIN_URL=https://oassl.ancpi.ro/openam/UI/Login
|
- ANCPI_LOGIN_URL=https://oassl.ancpi.ro/openam/UI/Login
|
||||||
- ANCPI_DEFAULT_SOLICITANT_ID=14452
|
- ANCPI_DEFAULT_SOLICITANT_ID=14452
|
||||||
- MINIO_BUCKET_ANCPI=ancpi-documente
|
- MINIO_BUCKET_ANCPI=ancpi-documente
|
||||||
|
# Stirling PDF (local PDF tools)
|
||||||
|
- STIRLING_PDF_URL=http://10.10.10.166:8087
|
||||||
|
- STIRLING_PDF_API_KEY=cd829f62-6eef-43eb-a64d-c91af727b53a
|
||||||
# iLovePDF cloud compression (free: 250 files/month)
|
# iLovePDF cloud compression (free: 250 files/month)
|
||||||
- ILOVEPDF_PUBLIC_KEY=${ILOVEPDF_PUBLIC_KEY:-}
|
- ILOVEPDF_PUBLIC_KEY=${ILOVEPDF_PUBLIC_KEY:-}
|
||||||
# Martin vector tile server (geoportal)
|
# Martin vector tile server (geoportal)
|
||||||
- NEXT_PUBLIC_MARTIN_URL=https://tools.beletage.ro/tiles
|
- NEXT_PUBLIC_MARTIN_URL=https://tools.beletage.ro/tiles
|
||||||
|
# PMTiles overview tiles — proxied through tile-cache nginx (HTTPS, no mixed-content)
|
||||||
|
- NEXT_PUBLIC_PMTILES_URL=/tiles/pmtiles/overview.pmtiles
|
||||||
# DWG-to-DXF sidecar
|
# DWG-to-DXF sidecar
|
||||||
- DWG2DXF_URL=http://dwg2dxf:5001
|
- DWG2DXF_URL=http://dwg2dxf:5001
|
||||||
# Email notifications (Brevo SMTP)
|
# Email notifications (Brevo SMTP)
|
||||||
@@ -65,6 +70,12 @@ services:
|
|||||||
- NOTIFICATION_FROM_EMAIL=noreply@beletage.ro
|
- NOTIFICATION_FROM_EMAIL=noreply@beletage.ro
|
||||||
- NOTIFICATION_FROM_NAME=Alerte Termene
|
- NOTIFICATION_FROM_NAME=Alerte Termene
|
||||||
- NOTIFICATION_CRON_SECRET=1547a198feca43af6c05622588c6d3b820bad5163b8c20175b2b5bbf8fc1a987
|
- NOTIFICATION_CRON_SECRET=1547a198feca43af6c05622588c6d3b820bad5163b8c20175b2b5bbf8fc1a987
|
||||||
|
# Weekend Deep Sync email reports (comma-separated for multiple recipients)
|
||||||
|
- WEEKEND_SYNC_EMAIL=${WEEKEND_SYNC_EMAIL:-}
|
||||||
|
# PMTiles rebuild webhook (pmtiles-webhook systemd service on host)
|
||||||
|
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-http://10.10.10.166:9876}
|
||||||
|
# Portal-only users (comma-separated, redirected to /portal)
|
||||||
|
- PORTAL_ONLY_USERS=dtiurbe,d.tiurbe
|
||||||
# Address Book API (inter-service auth for external tools)
|
# Address Book API (inter-service auth for external tools)
|
||||||
- ADDRESSBOOK_API_KEY=abook-7f3e9a2b4c1d8e5f6a0b3c7d9e2f4a1b5c8d0e3f6a9b2c5d8e1f4a7b0c3d6e
|
- ADDRESSBOOK_API_KEY=abook-7f3e9a2b4c1d8e5f6a0b3c7d9e2f4a1b5c8d0e3f6a9b2c5d8e1f4a7b0c3d6e
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -95,11 +106,44 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
martin:
|
martin:
|
||||||
image: ghcr.io/maplibre/martin:v0.15.0
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: martin.Dockerfile
|
||||||
container_name: martin
|
container_name: martin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
# No host port — only accessible via tile-cache nginx proxy
|
||||||
- "3010:3000"
|
command: ["--config", "/config/martin.yaml"]
|
||||||
command: ["--default-srid", "3844"]
|
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://architools_user:stictMyFon34!_gonY@10.10.10.166:5432/architools_db
|
- DATABASE_URL=postgresql://architools_user:stictMyFon34!_gonY@10.10.10.166:5432/architools_db
|
||||||
|
|
||||||
|
tile-cache:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: tile-cache.Dockerfile
|
||||||
|
container_name: tile-cache
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3010:80"
|
||||||
|
depends_on:
|
||||||
|
- martin
|
||||||
|
volumes:
|
||||||
|
- tile-cache-data:/var/cache/nginx/tiles
|
||||||
|
|
||||||
|
tippecanoe:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: tippecanoe.Dockerfile
|
||||||
|
container_name: tippecanoe
|
||||||
|
profiles: ["tools"]
|
||||||
|
environment:
|
||||||
|
- DB_HOST=10.10.10.166
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_NAME=architools_db
|
||||||
|
- DB_USER=architools_user
|
||||||
|
- DB_PASS=stictMyFon34!_gonY
|
||||||
|
- MINIO_ENDPOINT=http://10.10.10.166:9002
|
||||||
|
- MINIO_ACCESS_KEY=admin
|
||||||
|
- MINIO_SECRET_KEY=MinioStrongPass123
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
tile-cache-data:
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# ArchiTools — Architecture Quick Reference
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser → Traefik (tools.beletage.ro) → Next.js :3000
|
||||||
|
├── App Router (pages)
|
||||||
|
├── API Routes (/api/*)
|
||||||
|
│ ├── Prisma → PostgreSQL + PostGIS
|
||||||
|
│ ├── MinIO (file storage)
|
||||||
|
│ ├── eTerra ANCPI (external GIS API)
|
||||||
|
│ └── Brevo SMTP (email notifications)
|
||||||
|
└── Auth: NextAuth → Authentik OIDC
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
registratura ←→ address-book (bidirectional: contacts + reverse lookup)
|
||||||
|
parcel-sync → geoportal (map components reuse)
|
||||||
|
geoportal → PostGIS (spatial queries, vector tiles)
|
||||||
|
parcel-sync → eTerra API (external: ANCPI cadastral data)
|
||||||
|
parcel-sync → ePay API (external: ANCPI CF extract ordering)
|
||||||
|
parcel-sync → MinIO (CF extract PDF storage)
|
||||||
|
notifications → registratura (deadline digest data)
|
||||||
|
all modules → core/storage (KeyValueStore via Prisma)
|
||||||
|
all modules → core/auth (Authentik SSO session)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical API Routes (Write Operations)
|
||||||
|
|
||||||
|
| Route | Method | What it does | Auth |
|
||||||
|
| ---------------------------------- | ------ | ----------------------------------- | --------- |
|
||||||
|
| `/api/storage` | PUT/DELETE | KeyValueStore CRUD | Middleware |
|
||||||
|
| `/api/registratura` | POST/PUT/DELETE | Registry entries + audit | Middleware + Bearer |
|
||||||
|
| `/api/registratura/reserved` | POST | Reserve future registry slots | Middleware |
|
||||||
|
| `/api/registratura/debug-sequences`| POST/PATCH | Reset sequence counters | Admin only |
|
||||||
|
| `/api/vault` | PUT/DELETE | Encrypted vault entries | Middleware |
|
||||||
|
| `/api/address-book` | PUT/DELETE | Contact CRUD | Middleware + Bearer |
|
||||||
|
| `/api/eterra/sync-background` | POST | Start GIS sync job | Middleware |
|
||||||
|
| `/api/eterra/uats` | POST/PATCH | UAT management + county refresh | Middleware |
|
||||||
|
| `/api/ancpi/order` | POST | ePay CF extract order | Middleware |
|
||||||
|
| `/api/notifications/digest` | POST | Trigger email digest | Bearer |
|
||||||
|
| `/api/notifications/preferences` | PUT | User notification prefs | Middleware |
|
||||||
|
| `/api/compress-pdf/*` | POST | PDF compression/unlock | requireAuth |
|
||||||
|
|
||||||
|
## Storage Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
KeyValueStore (Prisma) GisFeature (PostGIS) MinIO
|
||||||
|
├── namespace: module-id ├── layerId + objectId ├── bucket: tools
|
||||||
|
├── key: entity UUID ├── geometry (GeoJSON) ├── bucket: ancpi-cf
|
||||||
|
└── value: JSON blob ├── enrichment (JSONB) └── PDF files
|
||||||
|
└── geom (native PostGIS)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User → /auth/signin → Authentik OIDC → callback → NextAuth session
|
||||||
|
├── Middleware: checks JWT token, redirects if unauthenticated
|
||||||
|
├── Portal-only users: env PORTAL_ONLY_USERS → redirected to /portal
|
||||||
|
└── API routes excluded from middleware: use requireAuth() or Bearer token
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables (Critical)
|
||||||
|
|
||||||
|
| Var | Required | Used by |
|
||||||
|
| ---------------------- | -------- | -------------------------- |
|
||||||
|
| `DATABASE_URL` | Yes | Prisma |
|
||||||
|
| `NEXTAUTH_SECRET` | Yes | NextAuth JWT |
|
||||||
|
| `NEXTAUTH_URL` | Yes | Auth redirects |
|
||||||
|
| `ENCRYPTION_SECRET` | Yes | Password Vault AES-256 |
|
||||||
|
| `STIRLING_PDF_URL` | Yes | PDF compression/unlock |
|
||||||
|
| `STIRLING_PDF_API_KEY` | Yes | Stirling PDF auth |
|
||||||
|
| `NOTIFICATION_CRON_SECRET` | Yes | Digest endpoint Bearer |
|
||||||
|
| `MINIO_*` | Yes | MinIO connection |
|
||||||
|
| `ANCPI_*` | For ePay | ePay CF ordering |
|
||||||
|
| `ILOVEPDF_PUBLIC_KEY` | Optional | Cloud PDF compression |
|
||||||
|
| `PORTAL_ONLY_USERS` | Optional | Comma-separated usernames |
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# ArchiTools — Module Map
|
||||||
|
|
||||||
|
Quick reference: entry points, key files, API routes, and cross-module dependencies.
|
||||||
|
|
||||||
|
## Module Index
|
||||||
|
|
||||||
|
| Module | Entry Point | Config | Types |
|
||||||
|
| ------ | ----------- | ------ | ----- |
|
||||||
|
| [Dashboard](#dashboard) | `modules/dashboard/index.ts` | — | `types.ts` |
|
||||||
|
| [Email Signature](#email-signature) | `modules/email-signature/index.ts` | `config.ts` | `types.ts` |
|
||||||
|
| [Word XML](#word-xml) | `modules/word-xml/index.ts` | `config.ts` | `types.ts` |
|
||||||
|
| [Registratura](#registratura) | `modules/registratura/index.ts` | `config.ts` | `types.ts` |
|
||||||
|
| [Tag Manager](#tag-manager) | `modules/tag-manager/index.ts` | `config.ts` | `types.ts` |
|
||||||
|
| [IT Inventory](#it-inventory) | `modules/it-inventory/index.ts` | `config.ts` | `types.ts` |
|
||||||
|
| [Address Book](#address-book) | `modules/address-book/index.ts` | `config.ts` | `types.ts` |
|
||||||
|
| [Password Vault](#password-vault) | `modules/password-vault/index.ts` | `config.ts` | `types.ts` |
|
||||||
|
| [Mini Utilities](#mini-utilities) | `modules/mini-utilities/index.ts` | `config.ts` | `types.ts` |
|
||||||
|
| [Prompt Generator](#prompt-generator) | `modules/prompt-generator/index.ts` | `config.ts` | `types.ts` |
|
||||||
|
| [Digital Signatures](#digital-signatures) | `modules/digital-signatures/index.ts` | `config.ts` | `types.ts` |
|
||||||
|
| [Word Templates](#word-templates) | `modules/word-templates/index.ts` | `config.ts` | `types.ts` |
|
||||||
|
| [AI Chat](#ai-chat) | `modules/ai-chat/index.ts` | `config.ts` | `types.ts` |
|
||||||
|
| [Hot Desk](#hot-desk) | `modules/hot-desk/index.ts` | `config.ts` | `types.ts` |
|
||||||
|
| [ParcelSync](#parcel-sync) | `modules/parcel-sync/index.ts` | `config.ts` | `types.ts` |
|
||||||
|
| [Geoportal](#geoportal) | `modules/geoportal/index.ts` | `config.ts` | `types.ts` |
|
||||||
|
| [Visual CoPilot](#visual-copilot) | `modules/visual-copilot/index.ts` | `config.ts` | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module Details
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
- **Route**: `/`
|
||||||
|
- **Main component**: `app/(modules)/page.tsx` (home page, not a registered module)
|
||||||
|
- **API routes**: none (reads via storage API)
|
||||||
|
- **Cross-deps**: none
|
||||||
|
|
||||||
|
### Email Signature
|
||||||
|
- **Route**: `/email-signature`
|
||||||
|
- **Main component**: `components/email-signature-module.tsx`
|
||||||
|
- **API routes**: none (client-only)
|
||||||
|
- **Cross-deps**: none
|
||||||
|
|
||||||
|
### Word XML
|
||||||
|
- **Route**: `/word-xml`
|
||||||
|
- **Main component**: `components/word-xml-module.tsx`
|
||||||
|
- **Services**: `services/xml-builder.ts`, `services/zip-export.ts`
|
||||||
|
- **API routes**: none (client-only)
|
||||||
|
- **Cross-deps**: none
|
||||||
|
|
||||||
|
### Registratura
|
||||||
|
- **Route**: `/registratura`
|
||||||
|
- **Main component**: `components/registratura-module.tsx`
|
||||||
|
- **Key services**: `services/registry-service.ts` (numbering, advisory locks), `services/working-days.ts` (Romanian holidays), `services/deadline-catalog.ts` (18 legal deadline types), `services/deadline-service.ts`
|
||||||
|
- **API routes**: `/api/registratura` (CRUD + audit), `/api/registratura/reserved`, `/api/registratura/debug-sequences`, `/api/registratura/audit`, `/api/registratura/status-check`
|
||||||
|
- **Cross-deps**: **address-book** (quick contact, reverse lookup), **notifications** (deadline digest)
|
||||||
|
|
||||||
|
### Tag Manager
|
||||||
|
- **Route**: `/tag-manager`
|
||||||
|
- **Main component**: `components/tag-manager-module.tsx`
|
||||||
|
- **Services**: `services/manictime-sync.ts`
|
||||||
|
- **API routes**: `/api/manictime`
|
||||||
|
- **Cross-deps**: core/tagging
|
||||||
|
|
||||||
|
### IT Inventory
|
||||||
|
- **Route**: `/it-inventory`
|
||||||
|
- **Main component**: `components/it-inventory-module.tsx`
|
||||||
|
- **API routes**: none (via storage API)
|
||||||
|
- **Cross-deps**: none
|
||||||
|
|
||||||
|
### Address Book
|
||||||
|
- **Route**: `/address-book`
|
||||||
|
- **Main component**: `components/address-book-module.tsx`
|
||||||
|
- **Services**: `services/vcard-export.ts`
|
||||||
|
- **API routes**: `/api/address-book` (CRUD, Bearer token support)
|
||||||
|
- **Cross-deps**: **registratura** (reverse lookup via `useRegistry`)
|
||||||
|
|
||||||
|
### Password Vault
|
||||||
|
- **Route**: `/password-vault`
|
||||||
|
- **Main component**: `components/password-vault-module.tsx`
|
||||||
|
- **API routes**: `/api/vault` (AES-256-GCM encrypt/decrypt)
|
||||||
|
- **Cross-deps**: none
|
||||||
|
|
||||||
|
### Mini Utilities
|
||||||
|
- **Route**: `/mini-utilities`
|
||||||
|
- **Main component**: `components/mini-utilities-module.tsx` (monolithic, tab-based)
|
||||||
|
- **API routes**: `/api/compress-pdf/*` (local qpdf + cloud iLovePDF), `/api/compress-pdf/unlock`
|
||||||
|
- **Cross-deps**: none
|
||||||
|
|
||||||
|
### Prompt Generator
|
||||||
|
- **Route**: `/prompt-generator`
|
||||||
|
- **Main component**: `components/prompt-generator-module.tsx`
|
||||||
|
- **Services**: `services/prompt-templates.ts` (18 templates)
|
||||||
|
- **API routes**: none (client-only)
|
||||||
|
- **Cross-deps**: none
|
||||||
|
|
||||||
|
### Digital Signatures
|
||||||
|
- **Route**: `/digital-signatures`
|
||||||
|
- **Main component**: `components/digital-signatures-module.tsx`
|
||||||
|
- **API routes**: none (via storage API)
|
||||||
|
- **Cross-deps**: none
|
||||||
|
|
||||||
|
### Word Templates
|
||||||
|
- **Route**: `/word-templates`
|
||||||
|
- **Main component**: `components/word-templates-module.tsx`
|
||||||
|
- **Services**: `services/docx-analyzer.ts`
|
||||||
|
- **API routes**: none (via storage API)
|
||||||
|
- **Cross-deps**: none
|
||||||
|
|
||||||
|
### AI Chat
|
||||||
|
- **Route**: `/ai-chat`
|
||||||
|
- **Main component**: `components/ai-chat-module.tsx`
|
||||||
|
- **API routes**: `/api/ai-chat` (multi-provider proxy)
|
||||||
|
- **Cross-deps**: tag-manager (project linking)
|
||||||
|
|
||||||
|
### Hot Desk
|
||||||
|
- **Route**: `/hot-desk`
|
||||||
|
- **Main component**: `components/hot-desk-module.tsx`
|
||||||
|
- **Services**: `services/desk-layout.ts`
|
||||||
|
- **API routes**: none (via storage API)
|
||||||
|
- **Cross-deps**: none
|
||||||
|
|
||||||
|
### ParcelSync
|
||||||
|
- **Route**: `/parcel-sync`
|
||||||
|
- **Main component**: `components/parcel-sync-module.tsx` (~4100 lines, 5 tabs)
|
||||||
|
- **Key services**: `services/eterra-client.ts` (~1000 lines, eTerra API), `services/sync-service.ts`, `services/enrich-service.ts`, `services/eterra-health.ts`, `services/epay-client.ts`, `services/epay-queue.ts`, `services/epay-storage.ts`, `services/no-geom-sync.ts`
|
||||||
|
- **API routes**: `/api/eterra/*` (login, sync, search, features, UATs, health), `/api/ancpi/*` (order, test), `/api/geoportal/*` (search, boundaries, setup)
|
||||||
|
- **Cross-deps**: **geoportal** (map components via map-tab.tsx), **MinIO** (CF extract PDFs), **PostGIS** (GisFeature, GisUat)
|
||||||
|
|
||||||
|
### Geoportal
|
||||||
|
- **Route**: `/geoportal`
|
||||||
|
- **Main component**: `components/geoportal-module.tsx`
|
||||||
|
- **Key components**: `components/map-viewer.tsx` (MapLibre, PMTiles protocol), `components/basemap-switcher.tsx`, `components/selection-toolbar.tsx`, `components/feature-info-panel.tsx`
|
||||||
|
- **Tile infrastructure**: Martin v1.4.0 (live MVT) -> nginx tile-cache (7d TTL) -> Traefik; PMTiles (z0-z18, MinIO) for pre-generated overview tiles
|
||||||
|
- **Monitor page**: `/monitor` — nginx/Martin/PMTiles status, rebuild + warm-cache actions
|
||||||
|
- **API routes**: `/api/geoportal/*` (search, boundary-check, uat-bounds, setup-views, monitor)
|
||||||
|
- **Cross-deps**: **parcel-sync** (declared dependency — uses PostGIS data), **MinIO** (PMTiles storage), **N8N** (rebuild webhook)
|
||||||
|
|
||||||
|
### Visual CoPilot
|
||||||
|
- **Route**: `/visual-copilot`
|
||||||
|
- **Status**: Placeholder (iframe to separate repo `git.beletage.ro/gitadmin/vim`)
|
||||||
|
- **API routes**: none
|
||||||
|
- **Cross-deps**: none
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Geoportal Continuous Improvement — Mega Prompt
|
||||||
|
|
||||||
|
Use this prompt to start a new session focused on geoportal tile serving improvements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context Prompt (copy-paste to Claude)
|
||||||
|
|
||||||
|
```
|
||||||
|
Scopul acestei sesiuni este imbunatatirea continua a tile serving-ului pentru modulul Geoportal din ArchiTools.
|
||||||
|
|
||||||
|
Citeste aceste fisiere INAINTE de orice:
|
||||||
|
- CLAUDE.md (project conventions)
|
||||||
|
- geoportal/TILE-SERVER-EVALUATION.md (current architecture + roadmap)
|
||||||
|
- src/modules/geoportal/components/map-viewer.tsx (MapLibre + PMTiles integration)
|
||||||
|
- martin.yaml (Martin tile server config)
|
||||||
|
- docker-compose.yml (infrastructure stack)
|
||||||
|
- scripts/rebuild-overview-tiles.sh (PMTiles generation pipeline)
|
||||||
|
- src/app/api/geoportal/monitor/route.ts (monitoring API)
|
||||||
|
- src/app/(modules)/monitor/page.tsx (monitoring dashboard)
|
||||||
|
|
||||||
|
## Arhitectura curenta (2026-03-28):
|
||||||
|
|
||||||
|
Pipeline: Browser → PMTiles (MinIO, z0-z18, ~1-2 GB) | Martin (PostGIS) doar pentru gis_terenuri_status + gis_cladiri_status
|
||||||
|
Cache: nginx tile-cache (7d TTL) in fata Martin | Browser cache 24h | PMTiles servit direct din MinIO
|
||||||
|
|
||||||
|
Stack:
|
||||||
|
- PMTiles: overview.pmtiles pe MinIO (10.10.10.166:9002/tiles/overview.pmtiles)
|
||||||
|
- nginx tile-cache: port 3010, proxy_cache 2GB, 7d TTL
|
||||||
|
- Martin v1.4: port intern 3000, config baked in image, pool_size 8
|
||||||
|
- tippecanoe Docker: one-shot rebuild, profiles: ["tools"]
|
||||||
|
- N8N webhook: auto-rebuild dupa weekend deep sync
|
||||||
|
|
||||||
|
Rebuild PMTiles: ~45-60 min (565K+ features, z0-z18)
|
||||||
|
Server: VM satra (10.10.10.166), 6 CPU, 16 GB RAM, Docker, Portainer CE
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- NEXT_PUBLIC_* vars TREBUIE declarate ca ARG+ENV in Dockerfile (altfel webpack nu le vede)
|
||||||
|
- Portainer CE nu monteaza fisiere din repo — bake configs in Docker images
|
||||||
|
- Dupa schimbari la Dockerfile/NEXT_PUBLIC_: docker compose build --no-cache architools
|
||||||
|
|
||||||
|
Comenzi server (SSH bulibasa@10.10.10.166):
|
||||||
|
cd /tmp/ArchiTools && git pull && docker compose --profile tools build tippecanoe && docker compose --profile tools run --rm tippecanoe
|
||||||
|
docker compose build --no-cache architools && docker compose up -d architools
|
||||||
|
bash /tmp/ArchiTools/scripts/warm-tile-cache.sh http://10.10.10.166:3010
|
||||||
|
|
||||||
|
Monitor dashboard: https://tools.beletage.ro/monitor
|
||||||
|
N8N: http://n8n.beletage.ro (workflow "PMTiles Rebuild")
|
||||||
|
|
||||||
|
npx next build TREBUIE sa treaca dupa fiecare schimbare.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist periodic (lunar):
|
||||||
|
|
||||||
|
### 1. Check MLT Production Readiness
|
||||||
|
```
|
||||||
|
Verifica daca Martin suporta generare MLT din PostGIS (nu doar servire din MBTiles).
|
||||||
|
Cauta:
|
||||||
|
- Martin releases: https://github.com/maplibre/martin/releases
|
||||||
|
- Martin MLT PR: https://github.com/maplibre/martin/pull/2512
|
||||||
|
- PostGIS MLT: cauta "ST_AsMLT" in PostGIS development
|
||||||
|
- MapLibre GL JS MLT: https://maplibre.org/maplibre-tile-spec/implementation-status/
|
||||||
|
|
||||||
|
Daca Martin poate genera MLT din PostGIS live:
|
||||||
|
1. Testeaza pe un layer (gis_terenuri) cu encoding: "mlt" in map-viewer
|
||||||
|
2. Compara tile sizes MVT vs MLT
|
||||||
|
3. Daca merge, aplica pe toate layerele Martin
|
||||||
|
|
||||||
|
Status curent (2026-03-28): NU e viabil. Martin doar serveste MLT pre-generat, nu transcodeaza din PostGIS.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. mvt-rs Parallel Evaluation
|
||||||
|
```
|
||||||
|
Evalueaza mvt-rs ca alternativa Martin pentru deployment multi-tenant.
|
||||||
|
|
||||||
|
Prompt gata de folosit:
|
||||||
|
|
||||||
|
"Deployeaza mvt-rs v0.16+ in parallel cu Martin pe ArchiTools.
|
||||||
|
|
||||||
|
Context:
|
||||||
|
- PostgreSQL: 10.10.10.166:5432, db architools_db, user architools_user
|
||||||
|
- Martin actual: martin.yaml cu 9 surse PostGIS (EPSG:3844)
|
||||||
|
- Docker stack: Portainer CE, Traefik v3
|
||||||
|
- Scopul: per-layer access control pentru clienti externi
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Adauga mvt-rs in docker-compose.yml pe port 3011
|
||||||
|
2. Configureaza aceleasi layere ca martin.yaml
|
||||||
|
3. Test: toate proprietatile apar in MVT? Performance vs Martin?
|
||||||
|
4. Admin UI: creeaza user test, asigneaza permisiuni per layer
|
||||||
|
5. Decision matrix: cand trecem de la Martin la mvt-rs
|
||||||
|
|
||||||
|
NU modifica setup-ul Martin existent. Evaluare paralela doar.
|
||||||
|
mvt-rs repo: https://github.com/mvt-proj/mvt-rs
|
||||||
|
Citeste CLAUDE.md si geoportal/TILE-SERVER-EVALUATION.md inainte."
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. PMTiles Rebuild Optimization
|
||||||
|
```
|
||||||
|
Daca rebuild dureaza >60 min sau fisierul >3 GB:
|
||||||
|
- Evalueaza tile-join pentru rebuild partial (doar layerul modificat)
|
||||||
|
- Evalueaza --no-tile-size-limit vs --drop-densest-as-needed trade-off
|
||||||
|
- Evalueaza split: un PMTiles per UAT sincronizat (rebuild doar orasul modificat)
|
||||||
|
- Evalueaza cron nightly vs rebuild per sync event
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Issues & Limitations
|
||||||
|
- tippecanoe `--drop-densest-as-needed` poate pierde features in zone dense la zoom mic
|
||||||
|
- PMTiles data e statica — parcele noi nu apar pana la rebuild
|
||||||
|
- MinIO CORS headers necesita Range + Content-Range exposed
|
||||||
|
- Martin `pool_size: 8` — nu creste fara upgrade PostgreSQL
|
||||||
|
- Portainer CE nu injecteaza env vars la build — toate in docker-compose.yml
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
# Tile Server Evaluation — ArchiTools Geoportal (March 2026)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ArchiTools Geoportal serves vector tiles (MVT) from PostgreSQL 16 + PostGIS 3 via Martin.
|
||||||
|
Data: ~330K GIS features (parcels, buildings, admin boundaries) in EPSG:3844 (Stereo70), growing to 1M+.
|
||||||
|
Frontend: MapLibre GL JS 5.21, Next.js 16, Docker self-hosted via Portainer CE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
1. Martin v0.15.0 was running in **auto-discovery mode** — the existing `martin.yaml` config was never mounted
|
||||||
|
2. Building labels (`cadastral_ref`) missing from MVT tiles despite the view exposing them
|
||||||
|
3. Performance concerns at scale (330K -> 1M+ features)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Solutions Evaluated (7 options + emerging tech)
|
||||||
|
|
||||||
|
### 1. Martin (Fix + Upgrade) — WINNER
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|---|---|
|
||||||
|
| Root cause | `martin.yaml` not mounted in docker-compose — Martin ran in auto-discovery mode |
|
||||||
|
| Fix | Bake config into custom image via Dockerfile + upgrade v0.15 -> v1.4.0 |
|
||||||
|
| Performance | Fastest tile server benchmarked (2-3x faster than #2 Tegola) |
|
||||||
|
| EPSG:3844 | Native support via `default_srid: 3844` |
|
||||||
|
| New in v1.4 | ZSTD compression, MLT format, materialized views, better logging |
|
||||||
|
|
||||||
|
**Status: IMPLEMENTED AND VERIFIED IN PRODUCTION** (2026-03-27)
|
||||||
|
|
||||||
|
### 2. pg_tileserv (CrunchyData)
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|---|---|
|
||||||
|
| Architecture | Go binary, zero-config, delegates ST_AsMVT to PostGIS |
|
||||||
|
| Property control | Auto from schema + URL `?properties=` parameter |
|
||||||
|
| Performance | 2-3x slower than Martin (Rechsteiner benchmark) |
|
||||||
|
| EPSG:3844 | Supported (auto-reprojects via ST_Transform) |
|
||||||
|
| Killer feature | Function-based sources (full SQL tile functions) |
|
||||||
|
| Dealbreaker | View extent estimation bug (#156) affects all our views, development stagnant (last release Feb 2025) |
|
||||||
|
|
||||||
|
**Verdict: NO** — slower, buggy with views, stagnant development.
|
||||||
|
|
||||||
|
### 3. Tegola (Go-based)
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|---|---|
|
||||||
|
| Architecture | Go, TOML config, explicit per-layer SQL |
|
||||||
|
| Performance | 2nd in benchmarks, but 2-3x slower than Martin |
|
||||||
|
| Built-in cache | File, S3/MinIO, Redis — with seed/purge CLI |
|
||||||
|
| EPSG:3844 | **NOT SUPPORTED** (only 3857/4326) — requires ST_Transform in every query |
|
||||||
|
| Killer feature | Built-in tile seeding and cache purging |
|
||||||
|
|
||||||
|
**Verdict: NO** — EPSG:3844 not supported, dealbreaker for our data.
|
||||||
|
|
||||||
|
### 4. t-rex (Rust-based)
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|---|---|
|
||||||
|
| Status | **Abandoned/unmaintained** — no releases since 2023 |
|
||||||
|
|
||||||
|
**Verdict: NO** — dead project.
|
||||||
|
|
||||||
|
### 5. GeoJSON Direct from Next.js API
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|---|---|
|
||||||
|
| 330K features | ~270 MB uncompressed, 800 MB-1.4 GB browser memory |
|
||||||
|
| Browser impact | 10-30s main thread freeze, mobile crash |
|
||||||
|
| Pan/zoom | Full re-fetch on every viewport change, flickering |
|
||||||
|
| Viable range | Only at zoom 16+ with <500 features in viewport |
|
||||||
|
|
||||||
|
**Verdict: NO** — does not scale beyond ~20K features.
|
||||||
|
|
||||||
|
### 6. PMTiles (Pre-generated)
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|---|---|
|
||||||
|
| Architecture | Single-file tile archive, HTTP Range Requests, no server needed |
|
||||||
|
| Performance | ~5ms per tile (vs 200-2000ms for Martin on low-zoom) |
|
||||||
|
| Property control | tippecanoe gives explicit include/exclude per property |
|
||||||
|
| Update strategy | Full rebuild required (~3-7 min for 330K features) |
|
||||||
|
| EPSG:3844 | Requires reprojection to 4326 via ogr2ogr before tippecanoe |
|
||||||
|
| MinIO serving | Yes — direct HTTP Range Requests with CORS |
|
||||||
|
|
||||||
|
**Verdict: YES as hybrid complement** — excellent for static UAT overview layers (z0-z12), Martin for live detail.
|
||||||
|
|
||||||
|
### 7. Emerging Solutions
|
||||||
|
|
||||||
|
| Solution | Status | Relevance |
|
||||||
|
|---|---|---|
|
||||||
|
| **mvt-rs** (Rust) | v0.16.2, active | Admin UI, auth per layer, cache — good for multi-tenant |
|
||||||
|
| **MLT format** | Stable Jan 2026 | 6x compression, 4x faster decode — Martin v1.3+ supports it |
|
||||||
|
| **BBOX** | Maturing | Similar to Tegola performance, unified raster+vector |
|
||||||
|
| **DuckDB tiles** | Early | Not PostGIS replacement, interesting for GeoParquet |
|
||||||
|
| **FlatGeobuf** | Stable | Good for <100K features, not a tile replacement |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Benchmark Reference (Rechsteiner, April 2025)
|
||||||
|
|
||||||
|
| Rank | Server | Language | Relative Speed |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Martin** | Rust | 1x (fastest) |
|
||||||
|
| 2 | Tegola | Go | 2-3x slower |
|
||||||
|
| 3 | BBOX | Rust | ~same as Tegola |
|
||||||
|
| 4 | pg_tileserv | Go | ~4x slower |
|
||||||
|
| 5 | TiPg | Python | Slower |
|
||||||
|
| 6 | ldproxy | Java | 4-70x slower |
|
||||||
|
|
||||||
|
Source: [github.com/FabianRechsteiner/vector-tiles-benchmark](https://github.com/FabianRechsteiner/vector-tiles-benchmark)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Roadmap
|
||||||
|
|
||||||
|
### Phase 1: Martin Fix — DONE (2026-03-27)
|
||||||
|
|
||||||
|
Changes applied:
|
||||||
|
- `martin.Dockerfile`: custom image that COPY-s `martin.yaml` into `/config/`
|
||||||
|
- `docker-compose.yml`: Martin v0.15 -> v1.4.0, build from Dockerfile, `--config` flag
|
||||||
|
- `martin.yaml`: comment updated to reflect v1.4
|
||||||
|
- `map-viewer.tsx`: building labels layer activated (`cladiriLabel` at minzoom 16)
|
||||||
|
|
||||||
|
#### Deployment Lessons Learned
|
||||||
|
|
||||||
|
1. **Docker image tag format changed at v1.0**: old tags use `v` prefix (`v0.15.0`), new tags do not (`1.4.0`). The tag `ghcr.io/maplibre/martin:v1.4.0` does NOT exist — correct is `ghcr.io/maplibre/martin:1.4.0`.
|
||||||
|
|
||||||
|
2. **Portainer CE volume mount pitfall**: volume `./martin.yaml:/config/martin.yaml:ro` fails because Portainer deploys only the docker-compose.yml content, not the full git repo. Docker silently creates an empty directory instead of failing. Solution: bake config into a custom image with a 2-line Dockerfile:
|
||||||
|
```dockerfile
|
||||||
|
FROM ghcr.io/maplibre/martin:1.4.0
|
||||||
|
COPY martin.yaml /config/martin.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Martin config format is stable**: YAML format unchanged from v0.15 to v1.4 — `postgres.tables`, `connection_string`, `auto_publish`, `properties` map all work identically. No migration needed.
|
||||||
|
|
||||||
|
4. **PostGIS view geometry type**: Martin logs `UNKNOWN GEOMETRY TYPE` for all views — this is normal for nested views (`SELECT * FROM parent_view`). Views don't register specific geometry types in `geometry_columns`. Does not affect tile generation or property inclusion.
|
||||||
|
|
||||||
|
### Phase 2A: nginx Tile Cache — DONE (2026-03-27)
|
||||||
|
|
||||||
|
**Impact**: 10-100x faster on repeat requests, zero PostGIS load for cached tiles.
|
||||||
|
|
||||||
|
Changes applied:
|
||||||
|
- `nginx/tile-cache.conf`: proxy_cache config with 2GB cache zone, 7-day TTL, stale serving
|
||||||
|
- `tile-cache.Dockerfile`: bakes nginx config into custom image (Portainer CE pattern)
|
||||||
|
- `docker-compose.yml`: `tile-cache` container, Martin no longer exposed on host
|
||||||
|
- Gzip passthrough (Martin already compresses), browser caching via Cache-Control headers
|
||||||
|
- CORS headers for cross-origin tile requests
|
||||||
|
|
||||||
|
### Phase 2B: PMTiles — DONE (2026-03-27)
|
||||||
|
|
||||||
|
**Impact**: Sub-10ms overview tiles, zero PostGIS load for z0-z18.
|
||||||
|
|
||||||
|
Changes applied:
|
||||||
|
- `scripts/rebuild-overview-tiles.sh`: ogr2ogr export (3844->4326) + tippecanoe generation
|
||||||
|
- PMTiles archive: z0-z18, ~1-2 GB, includes all terenuri, cladiri, UATs, and administrativ layers
|
||||||
|
- `map-viewer.tsx`: pmtiles:// protocol registered on MapLibre, hybrid source switching
|
||||||
|
- MinIO bucket `tiles` with public read + CORS for Range Requests
|
||||||
|
- N8N webhook trigger for rebuild (via monitor page)
|
||||||
|
- Monitor page (`/monitor`): rebuild + warm-cache actions with live status polling
|
||||||
|
|
||||||
|
### Phase 2C: MLT Format — DEFERRED
|
||||||
|
|
||||||
|
Martin v1.4 advertises MLT support, but it cannot generate MLT from PostGIS live queries.
|
||||||
|
MLT generation requires pre-built tile archives (tippecanoe does not output MLT either).
|
||||||
|
No actionable path until Martin or tippecanoe adds MLT output from PostGIS sources.
|
||||||
|
|
||||||
|
### Phase 2D: mvt-rs Evaluation — FUTURE (Multi-Tenant)
|
||||||
|
|
||||||
|
**Impact**: Built-in auth, admin UI, per-layer access control.
|
||||||
|
**Effort**: 1-2 days for evaluation + migration.
|
||||||
|
|
||||||
|
Reserved for when external client access to the geoportal is needed.
|
||||||
|
mvt-rs (v0.16.2+, Rust, Salvo framework) provides per-layer auth and admin UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Current Architecture (as of 2026-03-27)
|
||||||
|
|
||||||
|
Full tile-serving pipeline in production:
|
||||||
|
|
||||||
|
```
|
||||||
|
PostGIS (EPSG:3844)
|
||||||
|
|
|
||||||
|
+--> Martin v1.4.0 (live MVT from 9 PostGIS views)
|
||||||
|
| |
|
||||||
|
| +--> tile-cache (nginx reverse proxy, 2GB disk, 7d TTL)
|
||||||
|
| |
|
||||||
|
| +--> Traefik (tools.beletage.ro/tiles)
|
||||||
|
|
|
||||||
|
+--> ogr2ogr (3844->4326) + tippecanoe (z0-z18)
|
||||||
|
|
|
||||||
|
+--> PMTiles archive (~1-2 GB)
|
||||||
|
|
|
||||||
|
+--> MinIO bucket "tiles" (HTTP Range Requests)
|
||||||
|
|
|
||||||
|
+--> MapLibre (pmtiles:// protocol)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hybrid strategy**:
|
||||||
|
- PMTiles serves pre-generated overview tiles (all zoom levels, all layers)
|
||||||
|
- Martin serves live detail tiles (real-time PostGIS data)
|
||||||
|
- nginx tile-cache sits in front of Martin to absorb repeat requests
|
||||||
|
- Rebuild triggered via N8N webhook from the `/monitor` page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operational Commands
|
||||||
|
|
||||||
|
### Rebuild PMTiles
|
||||||
|
|
||||||
|
Trigger from the Monitor page (`/monitor` -> "Rebuild PMTiles" button), which sends a webhook to N8N.
|
||||||
|
N8N runs `scripts/rebuild-overview-tiles.sh` on the server.
|
||||||
|
|
||||||
|
Manual rebuild (SSH to 10.10.10.166):
|
||||||
|
```bash
|
||||||
|
cd /path/to/architools
|
||||||
|
bash scripts/rebuild-overview-tiles.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Warm nginx Cache
|
||||||
|
|
||||||
|
Trigger from the Monitor page (`/monitor` -> "Warm Cache" button).
|
||||||
|
Pre-loads frequently accessed tiles into the nginx disk cache.
|
||||||
|
|
||||||
|
### Purge nginx Tile Cache
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec tile-cache rm -rf /var/cache/nginx/tiles/*
|
||||||
|
docker exec tile-cache nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart Martin (after PostGIS view changes)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker restart martin
|
||||||
|
```
|
||||||
|
|
||||||
|
Martin caches source schema at startup — must restart after DDL changes to pick up new columns.
|
||||||
|
|
||||||
|
### Check PMTiles Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check file size and last modified in MinIO
|
||||||
|
docker exec minio mc stat local/tiles/overview.pmtiles
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Technical Details
|
||||||
|
|
||||||
|
### Martin v1.4.0 Deployment Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Gitea repo (martin.yaml + martin.Dockerfile)
|
||||||
|
-> Portainer CE builds custom image: FROM martin:1.4.0, COPY martin.yaml
|
||||||
|
-> Container starts with --config /config/martin.yaml
|
||||||
|
-> Reads DATABASE_URL from environment
|
||||||
|
-> Serves 9 PostGIS view sources on port 3000
|
||||||
|
-> Host maps 3010:3000
|
||||||
|
-> Traefik proxies tools.beletage.ro/tiles -> host:3010
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical**: Do NOT use volume mounts for config files in Portainer CE stacks.
|
||||||
|
Always bake configs into custom images via Dockerfile COPY.
|
||||||
|
|
||||||
|
### Martin Config (validated compatible v0.15 through v1.4)
|
||||||
|
|
||||||
|
The `martin.yaml` at project root defines 9 sources with explicit properties.
|
||||||
|
Config format unchanged from v0.15 to v1.4 — no migration needed.
|
||||||
|
|
||||||
|
Key config features used:
|
||||||
|
- `auto_publish: false` — only explicitly listed sources are served
|
||||||
|
- `default_srid: 3844` — all sources use Stereo70
|
||||||
|
- `properties:` map per source — explicit column name + PostgreSQL type
|
||||||
|
- `minzoom/maxzoom` per source — controls tile generation range
|
||||||
|
- `bounds: [20.2, 43.5, 30.0, 48.3]` — approximate Romania extent
|
||||||
|
|
||||||
|
### Docker Image Tag Convention
|
||||||
|
|
||||||
|
Martin changed tag format at v1.0:
|
||||||
|
- Pre-1.0: `ghcr.io/maplibre/martin:v0.15.0` (with `v` prefix)
|
||||||
|
- Post-1.0: `ghcr.io/maplibre/martin:1.4.0` (no `v` prefix)
|
||||||
|
- Also available: `latest`, `nightly`
|
||||||
|
|
||||||
|
### PostGIS View Chain
|
||||||
|
|
||||||
|
```
|
||||||
|
GisFeature table (Prisma) -> gis_features view -> gis_terenuri / gis_cladiri / gis_administrativ
|
||||||
|
-> gis_terenuri_status / gis_cladiri_status (with JOINs)
|
||||||
|
GisUat table -> gis_uats_z0/z5/z8/z12 (with ST_SimplifyPreserveTopology)
|
||||||
|
```
|
||||||
|
|
||||||
|
### MapLibre Layer Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Sources (Martin): gis_uats_z0, z5, z8, z12, administrativ, terenuri, cladiri
|
||||||
|
Layers per source: fill + line + label (where applicable)
|
||||||
|
Selection: Separate highlight layers on terenuri source
|
||||||
|
Drawing: GeoJSON source for freehand/rect polygon
|
||||||
|
Building labels: cladiriLabel layer, cadastral_ref at minzoom 16
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Pitfalls (Discovered During Implementation)
|
||||||
|
|
||||||
|
1. **Portainer CE does not expose repo files to containers at runtime.** Volume mounts like `./file.conf:/etc/file.conf:ro` fail silently — Docker creates an empty directory. Always bake config files into custom images via Dockerfile COPY.
|
||||||
|
|
||||||
|
2. **Martin Docker tag format change at v1.0.** `v1.4.0` does not exist, `1.4.0` does. Always check [ghcr.io/maplibre/martin](https://github.com/maplibre/martin/pkgs/container/martin) for actual tags.
|
||||||
|
|
||||||
|
3. **Martin logs `UNKNOWN GEOMETRY TYPE` for PostGIS views.** This is normal — nested views don't register geometry types in `geometry_columns`. Does not affect functionality.
|
||||||
|
|
||||||
|
4. **Martin auto-discovery mode is unreliable for property inclusion.** Always use explicit config with `auto_publish: false` and per-source `properties:` definitions.
|
||||||
|
|
||||||
|
5. **Martin caches source schema at startup.** After PostGIS view DDL changes (e.g., adding columns to gis_features), Martin must be restarted to pick up new columns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Martin Documentation](https://maplibre.org/martin/)
|
||||||
|
- [Martin Releases](https://github.com/maplibre/martin/releases)
|
||||||
|
- [Martin Container Registry](https://github.com/maplibre/martin/pkgs/container/martin)
|
||||||
|
- [Vector Tiles Benchmark (Rechsteiner 2025)](https://github.com/FabianRechsteiner/vector-tiles-benchmark)
|
||||||
|
- [PMTiles Specification](https://github.com/protomaps/PMTiles)
|
||||||
|
- [tippecanoe (Felt)](https://github.com/felt/tippecanoe)
|
||||||
|
- [MLT Format Announcement](https://maplibre.org/news/2026-01-23-mlt-release/)
|
||||||
|
- [mvt-rs](https://github.com/mvt-proj/mvt-rs)
|
||||||
|
- [pg_tileserv](https://github.com/CrunchyData/pg_tileserv)
|
||||||
|
- [Tegola](https://github.com/go-spatial/tegola)
|
||||||
|
- [Serving Vector Tiles Fast (Spatialists)](https://spatialists.ch/posts/2025/04/05-serving-vector-tiles-fast/)
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
# Skill: MapLibre GL JS Performance for Large GIS Datasets
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
When building web maps with MapLibre GL JS that display large spatial datasets (>10K features). Covers source type selection, layer optimization, label rendering, and client-side performance tuning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source Type Decision Matrix
|
||||||
|
|
||||||
|
| Dataset Size | Recommended Source | Reason |
|
||||||
|
|---|---|---|
|
||||||
|
| <2K features | GeoJSON | Simple, full property access, smooth |
|
||||||
|
| 2K-20K features | GeoJSON (careful) | Works but `setData()` updates lag 200-400ms |
|
||||||
|
| 20K-100K features | Vector tiles (MVT) | GeoJSON causes multi-second freezes |
|
||||||
|
| 100K+ features | Vector tiles (MVT) | GeoJSON crashes mobile, 1GB+ memory on desktop |
|
||||||
|
| Static/archival | PMTiles | Pre-generated, ~5ms per tile, zero server load |
|
||||||
|
|
||||||
|
### GeoJSON Memory Profile
|
||||||
|
|
||||||
|
| Features (polygons, ~20 coords each) | JSON Size | Browser Memory | Load Time |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1K | 0.8 MB | ~50 MB | <1s |
|
||||||
|
| 10K | 8 MB | ~200 MB | 1-3s |
|
||||||
|
| 50K | 41 MB | ~600 MB | 5-15s freeze |
|
||||||
|
| 100K | 82 MB | ~1.2 GB | 15-30s freeze |
|
||||||
|
| 330K | 270 MB | ~1.5 GB+ | Crash |
|
||||||
|
|
||||||
|
The bottleneck is `JSON.stringify` on the main thread when data is transferred to the Web Worker for `geojson-vt` tiling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vector Tile Source Configuration
|
||||||
|
|
||||||
|
### Zoom-Dependent Source Loading
|
||||||
|
|
||||||
|
Don't load data you don't need. Set `minzoom`/`maxzoom` on sources and layers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: only request tiles in useful zoom range
|
||||||
|
map.addSource('parcels', {
|
||||||
|
type: 'vector',
|
||||||
|
tiles: ['https://tiles.example.com/parcels/{z}/{x}/{y}'],
|
||||||
|
minzoom: 10, // don't request below z10
|
||||||
|
maxzoom: 18, // server maxzoom (tiles overzoom beyond this)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Layer: only render when meaningful
|
||||||
|
map.addLayer({
|
||||||
|
id: 'parcels-fill',
|
||||||
|
type: 'fill',
|
||||||
|
source: 'parcels',
|
||||||
|
'source-layer': 'parcels',
|
||||||
|
minzoom: 13, // visible from z13 (even if source loads from z10)
|
||||||
|
maxzoom: 20, // render up to z20 (overzooming tiles from z18)
|
||||||
|
paint: { ... },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Sources at Different Detail Levels
|
||||||
|
|
||||||
|
For large datasets, serve simplified versions at low zoom:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Simplified overview (server: ST_Simplify, fewer properties)
|
||||||
|
map.addSource('parcels-overview', {
|
||||||
|
type: 'vector',
|
||||||
|
tiles: ['https://tiles.example.com/parcels_simplified/{z}/{x}/{y}'],
|
||||||
|
minzoom: 6, maxzoom: 14,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Full detail
|
||||||
|
map.addSource('parcels-detail', {
|
||||||
|
type: 'vector',
|
||||||
|
tiles: ['https://tiles.example.com/parcels/{z}/{x}/{y}'],
|
||||||
|
minzoom: 14, maxzoom: 18,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Layers with zoom handoff
|
||||||
|
map.addLayer({
|
||||||
|
id: 'parcels-overview-fill', source: 'parcels-overview',
|
||||||
|
minzoom: 10, maxzoom: 14, // disappears at z14
|
||||||
|
...
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: 'parcels-detail-fill', source: 'parcels-detail',
|
||||||
|
minzoom: 14, // appears at z14
|
||||||
|
...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Label Rendering Best Practices
|
||||||
|
|
||||||
|
### Text Labels on Polygons
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
map.addLayer({
|
||||||
|
id: 'parcel-labels',
|
||||||
|
type: 'symbol',
|
||||||
|
source: 'parcels',
|
||||||
|
'source-layer': 'parcels',
|
||||||
|
minzoom: 16, // only show labels at high zoom
|
||||||
|
layout: {
|
||||||
|
'text-field': ['coalesce', ['get', 'cadastral_ref'], ''],
|
||||||
|
'text-font': ['Noto Sans Regular'],
|
||||||
|
'text-size': 10,
|
||||||
|
'text-anchor': 'center',
|
||||||
|
'text-allow-overlap': false, // prevent label collisions
|
||||||
|
'text-max-width': 8, // wrap long labels (in ems)
|
||||||
|
'text-optional': true, // label is optional — feature renders without it
|
||||||
|
'symbol-placement': 'point', // placed at polygon centroid
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': '#1e3a5f',
|
||||||
|
'text-halo-color': '#ffffff',
|
||||||
|
'text-halo-width': 1, // readability on any background
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Tips for Labels
|
||||||
|
|
||||||
|
- **`text-allow-overlap: false`** — essential for dense datasets, MapLibre auto-removes colliding labels
|
||||||
|
- **`text-optional: true`** — allow symbol layer to show icon without text if text collides
|
||||||
|
- **High `minzoom`** (16+) — labels are expensive to render, only show when meaningful
|
||||||
|
- **`text-font`** — use fonts available in the basemap style. Custom fonts require glyph server.
|
||||||
|
- **`symbol-sort-key`** — prioritize which labels show first (e.g., larger parcels)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
layout: {
|
||||||
|
'symbol-sort-key': ['*', -1, ['get', 'area_value']], // larger areas get priority
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Selection and Interaction Patterns
|
||||||
|
|
||||||
|
### Click Selection (single feature)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
map.on('click', 'parcels-fill', (e) => {
|
||||||
|
const feature = e.features?.[0];
|
||||||
|
if (!feature) return;
|
||||||
|
const props = feature.properties;
|
||||||
|
|
||||||
|
// Highlight via filter
|
||||||
|
map.setFilter('selection-highlight', ['==', 'object_id', props.object_id]);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### queryRenderedFeatures for Box/Polygon Selection
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Rectangle selection
|
||||||
|
const features = map.queryRenderedFeatures(
|
||||||
|
[[x1, y1], [x2, y2]], // pixel bbox
|
||||||
|
{ layers: ['parcels-fill'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Features are from rendered tiles — properties may be limited
|
||||||
|
// For full properties, fetch from API by ID
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** `queryRenderedFeatures` only returns features currently rendered in the viewport tiles. Properties in MVT tiles may be a subset of the full database record. For detailed properties, use a separate API endpoint.
|
||||||
|
|
||||||
|
### Highlight Layer Pattern
|
||||||
|
|
||||||
|
Dedicated layer with dynamic filter for selection highlighting:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Add once during map setup
|
||||||
|
map.addLayer({
|
||||||
|
id: 'selection-fill',
|
||||||
|
type: 'fill',
|
||||||
|
source: 'parcels',
|
||||||
|
'source-layer': 'parcels',
|
||||||
|
filter: ['==', 'object_id', '__NONE__'], // show nothing initially
|
||||||
|
paint: { 'fill-color': '#f59e0b', 'fill-opacity': 0.5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update filter on selection
|
||||||
|
const ids = Array.from(selectedIds);
|
||||||
|
map.setFilter('selection-fill',
|
||||||
|
ids.length > 0
|
||||||
|
? ['in', ['to-string', ['get', 'object_id']], ['literal', ids]]
|
||||||
|
: ['==', 'object_id', '__NONE__']
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Basemap Management
|
||||||
|
|
||||||
|
### Multiple Basemap Support
|
||||||
|
|
||||||
|
Switching basemaps requires recreating the map (MapLibre limitation). Preserve view state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const viewStateRef = useRef({ center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM });
|
||||||
|
|
||||||
|
// Save on every move
|
||||||
|
map.on('moveend', () => {
|
||||||
|
viewStateRef.current = {
|
||||||
|
center: map.getCenter().toArray(),
|
||||||
|
zoom: map.getZoom(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// On basemap switch: destroy map, recreate with saved view state
|
||||||
|
// All sources + layers must be re-added after style load
|
||||||
|
```
|
||||||
|
|
||||||
|
### Raster Basemaps
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const style: StyleSpecification = {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
basemap: {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: ['https://mt0.google.com/vt/lyrs=s&x={x}&y={y}&z={z}'],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution: '© Google',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [{
|
||||||
|
id: 'basemap', type: 'raster', source: 'basemap',
|
||||||
|
minzoom: 0, maxzoom: 20,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vector Basemaps (OpenFreeMap, MapTiler)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Style URL — includes all sources + layers
|
||||||
|
const map = new maplibregl.Map({
|
||||||
|
style: 'https://tiles.openfreemap.org/styles/liberty',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide unwanted built-in layers (e.g., admin boundaries you'll replace)
|
||||||
|
for (const layer of map.getStyle().layers) {
|
||||||
|
if (/boundar|admin/i.test(layer.id)) {
|
||||||
|
map.setLayoutProperty(layer.id, 'visibility', 'none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Checklist
|
||||||
|
|
||||||
|
### Server Side
|
||||||
|
- [ ] Spatial index (GiST) on geometry column
|
||||||
|
- [ ] Zoom-dependent simplified views for overview levels
|
||||||
|
- [ ] `minzoom`/`maxzoom` per tile source to prevent pathological tiles
|
||||||
|
- [ ] HTTP cache (nginx proxy_cache / Varnish) in front of tile server
|
||||||
|
- [ ] PMTiles for static layers (no DB hit)
|
||||||
|
- [ ] Exclude large geometry columns from list queries
|
||||||
|
|
||||||
|
### Client Side
|
||||||
|
- [ ] Set `minzoom` on layers to avoid rendering at useless zoom levels
|
||||||
|
- [ ] `text-allow-overlap: false` on all symbol layers
|
||||||
|
- [ ] Use `text-optional: true` for labels
|
||||||
|
- [ ] Don't add GeoJSON sources for >20K features
|
||||||
|
- [ ] Use `queryRenderedFeatures` (not `querySourceFeatures`) for interaction
|
||||||
|
- [ ] Preserve view state across basemap switches (ref, not state)
|
||||||
|
- [ ] Debounce viewport-dependent API calls (search, feature loading)
|
||||||
|
|
||||||
|
### Memory Management
|
||||||
|
- [ ] Remove unused sources/layers when switching views
|
||||||
|
- [ ] Clear GeoJSON sources with `setData(emptyFeatureCollection)` before removing
|
||||||
|
- [ ] Use `map.remove()` in cleanup (useEffect return)
|
||||||
|
- [ ] Don't store large GeoJSON in React state (use refs)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **GeoJSON `setData()` freezes main thread** — `JSON.stringify` runs synchronously for every update
|
||||||
|
2. **`queryRenderedFeatures` returns simplified geometry** — don't use for area/distance calculations
|
||||||
|
3. **Vector tile properties may be truncated** — tile servers can drop properties to fit tile size limits
|
||||||
|
4. **Basemap switch requires full map recreation** — save/restore view state and re-add all overlay layers
|
||||||
|
5. **`text-font` must match basemap fonts** — if using vector basemap, use its font stack; if raster, you need a glyph server
|
||||||
|
6. **Popup/tooltip on dense data causes flicker** — debounce mousemove handlers
|
||||||
|
7. **Large fill layers without `minzoom` tank performance** — 100K polygons at z0 is pathological
|
||||||
|
8. **`map.setFilter` with huge ID lists is slow** — for >1000 selected features, consider a separate GeoJSON source
|
||||||
|
9. **MapLibre CSS must be loaded manually in SSR frameworks** — inject `<link>` in `useEffect` or import statically
|
||||||
|
10. **React strict mode double-mounts effects** — guard map initialization with ref check
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
# Skill: PMTiles Generation Pipeline from PostGIS
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
When you need to pre-generate vector tiles from PostGIS data for fast static serving. Ideal for overview/boundary layers that change infrequently, serving from S3/MinIO/CDN without a tile server, or eliminating database load for tile serving.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complete Pipeline
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
| Tool | Purpose | Install |
|
||||||
|
|---|---|---|
|
||||||
|
| ogr2ogr (GDAL) | PostGIS export + reprojection | `apt install gdal-bin` or Docker |
|
||||||
|
| tippecanoe | MVT tile generation → PMTiles | `ghcr.io/felt/tippecanoe` Docker image |
|
||||||
|
| mc (MinIO client) | Upload to MinIO/S3 | `brew install minio/stable/mc` |
|
||||||
|
|
||||||
|
### Step 1: Export from PostGIS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Single layer — FlatGeobuf is fastest for tippecanoe input
|
||||||
|
ogr2ogr -f FlatGeobuf \
|
||||||
|
-s_srs EPSG:3844 \ # source SRID (your data)
|
||||||
|
-t_srs EPSG:4326 \ # tippecanoe REQUIRES WGS84
|
||||||
|
parcels.fgb \
|
||||||
|
"PG:host=10.10.10.166 dbname=mydb user=myuser password=mypass" \
|
||||||
|
-sql "SELECT id, name, area, geom FROM my_view WHERE geom IS NOT NULL"
|
||||||
|
|
||||||
|
# Multiple layers in parallel
|
||||||
|
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||||
|
parcels.fgb "PG:..." -sql "SELECT ... FROM gis_terenuri" &
|
||||||
|
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||||
|
buildings.fgb "PG:..." -sql "SELECT ... FROM gis_cladiri" &
|
||||||
|
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||||
|
uats.fgb "PG:..." -sql "SELECT ... FROM gis_uats_z12" &
|
||||||
|
wait
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why FlatGeobuf over GeoJSON:**
|
||||||
|
- Binary columnar format — tippecanoe reads it 3-5x faster
|
||||||
|
- No JSON parsing overhead
|
||||||
|
- Streaming read (no need to load entire file in memory)
|
||||||
|
- tippecanoe native support since v2.17+
|
||||||
|
|
||||||
|
### Step 2: Generate PMTiles with tippecanoe
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Single layer
|
||||||
|
tippecanoe \
|
||||||
|
-o parcels.pmtiles \
|
||||||
|
--name="Parcels" \
|
||||||
|
--layer="parcels" \
|
||||||
|
--minimum-zoom=6 \
|
||||||
|
--maximum-zoom=15 \
|
||||||
|
--base-zoom=15 \
|
||||||
|
--drop-densest-as-needed \
|
||||||
|
--extend-zooms-if-still-dropping \
|
||||||
|
--detect-shared-borders \
|
||||||
|
--simplification=10 \
|
||||||
|
--hilbert \
|
||||||
|
--force \
|
||||||
|
parcels.fgb
|
||||||
|
|
||||||
|
# Multi-layer (combined file)
|
||||||
|
tippecanoe \
|
||||||
|
-o combined.pmtiles \
|
||||||
|
--named-layer=parcels:parcels.fgb \
|
||||||
|
--named-layer=buildings:buildings.fgb \
|
||||||
|
--named-layer=uats:uats.fgb \
|
||||||
|
--minimum-zoom=0 \
|
||||||
|
--maximum-zoom=15 \
|
||||||
|
--drop-densest-as-needed \
|
||||||
|
--detect-shared-borders \
|
||||||
|
--hilbert \
|
||||||
|
--force
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Key tippecanoe Flags
|
||||||
|
|
||||||
|
| Flag | Purpose | When to Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--minimum-zoom=N` | Lowest zoom level | Always set |
|
||||||
|
| `--maximum-zoom=N` | Highest zoom level (full detail) | Always set |
|
||||||
|
| `--base-zoom=N` | Zoom where ALL features kept (no dropping) | Set to max-zoom |
|
||||||
|
| `--drop-densest-as-needed` | Drop features in dense areas at low zoom | Large polygon datasets |
|
||||||
|
| `--extend-zooms-if-still-dropping` | Auto-increase max zoom if needed | Safety net |
|
||||||
|
| `--detect-shared-borders` | Prevent gaps between adjacent polygons | **Critical for parcels/admin boundaries** |
|
||||||
|
| `--coalesce-densest-as-needed` | Merge small features at low zoom | Building footprints |
|
||||||
|
| `--simplification=N` | Pixel tolerance for geometry simplification | Reduce tile size at low zoom |
|
||||||
|
| `--hilbert` | Hilbert curve ordering | Better compression, always use |
|
||||||
|
| `-y col1 -y col2` | Include ONLY these properties | Reduce tile size |
|
||||||
|
| `-x col1 -x col2` | Exclude these properties | Remove large/unnecessary fields |
|
||||||
|
| `--force` | Overwrite existing output | Scripts |
|
||||||
|
| `--no-feature-limit` | No limit per tile | When density matters |
|
||||||
|
| `--no-tile-size-limit` | No tile byte limit | When completeness matters |
|
||||||
|
|
||||||
|
#### Property Control
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Include only specific properties (whitelist)
|
||||||
|
tippecanoe -o out.pmtiles -y name -y area -y type parcels.fgb
|
||||||
|
|
||||||
|
# Exclude specific properties (blacklist)
|
||||||
|
tippecanoe -o out.pmtiles -x raw_json -x internal_id parcels.fgb
|
||||||
|
|
||||||
|
# Zoom-dependent properties (different attributes per zoom)
|
||||||
|
# Use tippecanoe-json format with per-feature "tippecanoe" key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Upload to MinIO (Atomic Swap)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Upload to temp name first
|
||||||
|
mc cp combined.pmtiles myminio/tiles/combined_new.pmtiles
|
||||||
|
|
||||||
|
# Atomic rename (zero-downtime swap)
|
||||||
|
mc mv myminio/tiles/combined_new.pmtiles myminio/tiles/combined.pmtiles
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: MinIO CORS Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required for browser-direct Range Requests
|
||||||
|
mc admin config set myminio api cors_allow_origin="https://tools.beletage.ro"
|
||||||
|
|
||||||
|
# Or bucket policy for public read
|
||||||
|
mc anonymous set download myminio/tiles
|
||||||
|
```
|
||||||
|
|
||||||
|
MinIO CORS must expose Range/Content-Range headers:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"CORSRules": [{
|
||||||
|
"AllowedOrigins": ["https://your-domain.com"],
|
||||||
|
"AllowedMethods": ["GET", "HEAD"],
|
||||||
|
"AllowedHeaders": ["Range", "If-None-Match"],
|
||||||
|
"ExposeHeaders": ["Content-Range", "Content-Length", "ETag"],
|
||||||
|
"MaxAgeSeconds": 3600
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MapLibre GL JS Integration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install pmtiles
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
import { Protocol } from 'pmtiles';
|
||||||
|
|
||||||
|
// Register ONCE at app initialization
|
||||||
|
const protocol = new Protocol();
|
||||||
|
maplibregl.addProtocol('pmtiles', protocol.tile);
|
||||||
|
|
||||||
|
// Add source to map
|
||||||
|
map.addSource('my-tiles', {
|
||||||
|
type: 'vector',
|
||||||
|
url: 'pmtiles://https://minio.example.com/tiles/combined.pmtiles',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add layers
|
||||||
|
map.addLayer({
|
||||||
|
id: 'parcels-fill',
|
||||||
|
type: 'fill',
|
||||||
|
source: 'my-tiles',
|
||||||
|
'source-layer': 'parcels', // layer name from tippecanoe --layer or --named-layer
|
||||||
|
minzoom: 10,
|
||||||
|
maxzoom: 16,
|
||||||
|
paint: { 'fill-color': '#22c55e', 'fill-opacity': 0.15 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
maplibregl.removeProtocol('pmtiles');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hybrid Architecture (PMTiles + Live Tile Server)
|
||||||
|
|
||||||
|
```
|
||||||
|
Zoom 0-14: PMTiles from MinIO (pre-generated, ~5ms, zero DB load)
|
||||||
|
Zoom 14+: Martin from PostGIS (live, always-current, ~50-200ms)
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// PMTiles for overview
|
||||||
|
map.addSource('overview', {
|
||||||
|
type: 'vector',
|
||||||
|
url: 'pmtiles://https://minio/tiles/overview.pmtiles',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Martin for detail
|
||||||
|
map.addSource('detail', {
|
||||||
|
type: 'vector',
|
||||||
|
tiles: ['https://tiles.example.com/{source}/{z}/{x}/{y}'],
|
||||||
|
minzoom: 14,
|
||||||
|
maxzoom: 18,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Layers with zoom handoff
|
||||||
|
map.addLayer({
|
||||||
|
id: 'parcels-overview', source: 'overview', 'source-layer': 'parcels',
|
||||||
|
minzoom: 6, maxzoom: 14, // PMTiles handles low zoom
|
||||||
|
...
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: 'parcels-detail', source: 'detail', 'source-layer': 'gis_terenuri',
|
||||||
|
minzoom: 14, // Martin handles high zoom
|
||||||
|
...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rebuild Strategies
|
||||||
|
|
||||||
|
### Nightly Cron
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# crontab -e
|
||||||
|
0 2 * * * /opt/scripts/rebuild-tiles.sh >> /var/log/tile-rebuild.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
### After Data Sync (webhook/API trigger)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Call from sync completion handler
|
||||||
|
curl -X POST http://n8n:5678/webhook/rebuild-tiles
|
||||||
|
```
|
||||||
|
|
||||||
|
### Partial Rebuild (single layer update)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rebuild just parcels, then merge with existing layers
|
||||||
|
tippecanoe -o parcels_new.pmtiles ... parcels.fgb
|
||||||
|
tile-join -o combined_new.pmtiles --force \
|
||||||
|
parcels_new.pmtiles \
|
||||||
|
buildings_existing.pmtiles \
|
||||||
|
uats_existing.pmtiles
|
||||||
|
mc cp combined_new.pmtiles myminio/tiles/combined.pmtiles
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build Time Estimates
|
||||||
|
|
||||||
|
| Features | Type | Zoom Range | Time | Output Size |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 500 | Polygons (UAT) | z0-z12 | <5s | 10-30 MB |
|
||||||
|
| 100K | Polygons (buildings) | z12-z15 | 30-90s | 100-200 MB |
|
||||||
|
| 330K | Polygons (parcels) | z6-z15 | 2-5 min | 200-400 MB |
|
||||||
|
| 1M | Polygons (mixed) | z0-z15 | 8-15 min | 500 MB-1 GB |
|
||||||
|
|
||||||
|
tippecanoe is highly optimized and uses parallel processing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **tippecanoe only accepts WGS84 (EPSG:4326)** — always reproject with ogr2ogr first
|
||||||
|
2. **`--detect-shared-borders` is critical for parcels** — without it, gaps appear between adjacent polygons
|
||||||
|
3. **GeoJSON input is slow** — use FlatGeobuf for 3-5x faster reads
|
||||||
|
4. **No incremental updates** — must rebuild entire file (use `tile-join` for layer-level replacement)
|
||||||
|
5. **MinIO needs CORS for browser-direct access** — Range + Content-Range headers must be exposed
|
||||||
|
6. **Large properties bloat tile size** — use `-y`/`-x` flags to control what goes into tiles
|
||||||
|
7. **`--no-tile-size-limit` can produce huge tiles** — use with `--drop-densest-as-needed` safety valve
|
||||||
|
8. **Atomic upload prevents serving partial files** — always upload as temp name then rename
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# Skill: Vector Tile Serving from PostGIS
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
When building a web map that serves vector tiles from PostgreSQL/PostGIS data. Applies to any project using MapLibre GL JS, Mapbox GL JS, or OpenLayers with MVT tiles from a spatial database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Architecture Decision
|
||||||
|
|
||||||
|
**Always use a dedicated tile server over GeoJSON for datasets >20K features.**
|
||||||
|
|
||||||
|
GeoJSON limits:
|
||||||
|
- 20K polygons: visible jank on `setData()`, 200-400ms freezes
|
||||||
|
- 50K polygons: multi-second freezes, 500MB+ browser memory
|
||||||
|
- 100K+ polygons: crashes mobile browsers, 1-2GB memory on desktop
|
||||||
|
- `JSON.stringify` runs on main thread — blocks UI proportional to data size
|
||||||
|
|
||||||
|
Vector tiles (MVT) solve this:
|
||||||
|
- Only visible tiles loaded (~50-200KB per viewport)
|
||||||
|
- Incremental pan/zoom (no re-fetch)
|
||||||
|
- ~100-200MB client memory regardless of total dataset size
|
||||||
|
- Works on mobile
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tile Server Rankings (Rechsteiner Benchmark, April 2025)
|
||||||
|
|
||||||
|
| Rank | Server | Language | Speed | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | **Martin** | Rust | 1x | Clear winner, 95-122ms range |
|
||||||
|
| 2 | Tegola | Go | 2-3x slower | Only supports SRID 3857/4326 |
|
||||||
|
| 3 | BBOX | Rust | ~same as Tegola | Unified raster+vector |
|
||||||
|
| 4 | pg_tileserv | Go | ~4x slower | Zero-config but limited control |
|
||||||
|
| 5 | TiPg | Python | Slower | Not for production scale |
|
||||||
|
| 6 | ldproxy | Java | 4-70x slower | Enterprise/OGC compliance |
|
||||||
|
|
||||||
|
Source: [github.com/FabianRechsteiner/vector-tiles-benchmark](https://github.com/FabianRechsteiner/vector-tiles-benchmark)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Martin: Best Practices
|
||||||
|
|
||||||
|
### Always use explicit config (not auto-discovery)
|
||||||
|
|
||||||
|
Auto-discovery can drop properties, misdetect SRIDs, and behave unpredictably with nested views.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
postgres:
|
||||||
|
connection_string: ${DATABASE_URL}
|
||||||
|
default_srid: 3844 # your source SRID
|
||||||
|
auto_publish: false # explicit sources only
|
||||||
|
tables:
|
||||||
|
my_layer:
|
||||||
|
schema: public
|
||||||
|
table: my_view_name
|
||||||
|
geometry_column: geom
|
||||||
|
srid: 3844
|
||||||
|
bounds: [20.2, 43.5, 30.0, 48.3] # approximate extent
|
||||||
|
minzoom: 10
|
||||||
|
maxzoom: 18
|
||||||
|
properties:
|
||||||
|
object_id: text # explicit column name: pg_type
|
||||||
|
name: text
|
||||||
|
area: float8
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker image tags
|
||||||
|
|
||||||
|
Martin changed tag format at v1.0:
|
||||||
|
- Pre-1.0: `ghcr.io/maplibre/martin:v0.15.0` (with `v` prefix)
|
||||||
|
- Post-1.0: `ghcr.io/maplibre/martin:1.4.0` (no `v` prefix)
|
||||||
|
|
||||||
|
### Docker deployment
|
||||||
|
|
||||||
|
**If your orchestrator has access to the full repo** (docker-compose CLI, Docker Swarm with repo checkout):
|
||||||
|
```yaml
|
||||||
|
martin:
|
||||||
|
image: ghcr.io/maplibre/martin:1.4.0
|
||||||
|
command: ["--config", "/config/martin.yaml"]
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||||
|
volumes:
|
||||||
|
- ./martin.yaml:/config/martin.yaml:ro
|
||||||
|
ports:
|
||||||
|
- "3010:3000"
|
||||||
|
```
|
||||||
|
|
||||||
|
**If using Portainer CE or any system that only sees docker-compose.yml** (not full repo):
|
||||||
|
Volume mounts for repo files fail silently — Docker creates an empty directory instead.
|
||||||
|
Bake config into a custom image:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# martin.Dockerfile
|
||||||
|
FROM ghcr.io/maplibre/martin:1.4.0
|
||||||
|
COPY martin.yaml /config/martin.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
martin:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: martin.Dockerfile
|
||||||
|
command: ["--config", "/config/martin.yaml"]
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||||
|
ports:
|
||||||
|
- "3010:3000"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom SRID handling
|
||||||
|
|
||||||
|
Martin handles non-4326/3857 SRIDs natively. Set `default_srid` globally or `srid` per source. Martin reprojects to Web Mercator (3857) internally for tile envelope calculations. Your PostGIS spatial indexes on the source SRID are used correctly.
|
||||||
|
|
||||||
|
### Zoom-dependent simplification
|
||||||
|
|
||||||
|
Create separate views per zoom range with `ST_SimplifyPreserveTopology`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- z0-5: heavy simplification (2000m tolerance)
|
||||||
|
CREATE VIEW my_layer_z0 AS
|
||||||
|
SELECT id, name, ST_SimplifyPreserveTopology(geom, 2000) AS geom
|
||||||
|
FROM my_table;
|
||||||
|
|
||||||
|
-- z8-12: moderate (50m)
|
||||||
|
CREATE VIEW my_layer_z8 AS
|
||||||
|
SELECT id, name, ST_SimplifyPreserveTopology(geom, 50) AS geom
|
||||||
|
FROM my_table;
|
||||||
|
|
||||||
|
-- z12+: full precision
|
||||||
|
CREATE VIEW my_layer_z12 AS
|
||||||
|
SELECT * FROM my_table;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance at 1M+ features
|
||||||
|
|
||||||
|
- Set `minzoom` per source to avoid pathological low-zoom tiles
|
||||||
|
- Buildings: minzoom 14 (skip at overview levels)
|
||||||
|
- Use zoom-dependent simplified views for boundaries
|
||||||
|
- Add HTTP cache (nginx proxy_cache) in front of Martin
|
||||||
|
- Consider PMTiles for static overview layers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PMTiles: Pre-generated Tile Archives
|
||||||
|
|
||||||
|
Best for: static/rarely-changing layers, overview zoom levels, eliminating DB load.
|
||||||
|
|
||||||
|
### Pipeline
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Export from PostGIS, reproject to WGS84
|
||||||
|
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||||
|
layer.fgb "PG:dbname=mydb" \
|
||||||
|
-sql "SELECT id, name, geom FROM my_table"
|
||||||
|
|
||||||
|
# 2. Generate PMTiles
|
||||||
|
tippecanoe -o output.pmtiles \
|
||||||
|
--layer="my_layer" layer.fgb \
|
||||||
|
--minimum-zoom=0 --maximum-zoom=14 \
|
||||||
|
--drop-densest-as-needed \
|
||||||
|
--detect-shared-borders \
|
||||||
|
--hilbert --force
|
||||||
|
|
||||||
|
# 3. Serve from any HTTP server with Range request support (MinIO, nginx, CDN)
|
||||||
|
```
|
||||||
|
|
||||||
|
### MapLibre integration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Protocol } from 'pmtiles';
|
||||||
|
maplibregl.addProtocol('pmtiles', new Protocol().tile);
|
||||||
|
|
||||||
|
// Add source
|
||||||
|
map.addSource('overview', {
|
||||||
|
type: 'vector',
|
||||||
|
url: 'pmtiles://https://my-server/tiles/overview.pmtiles',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hybrid approach (recommended for large datasets)
|
||||||
|
|
||||||
|
- PMTiles for overview (z0-z14): pre-generated, ~5ms serving, zero DB load
|
||||||
|
- Martin for detail (z14+): live from PostGIS, always-current data
|
||||||
|
- Rebuild PMTiles on schedule (nightly) or after data sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MLT (MapLibre Tiles) — Next-Gen Format (2026)
|
||||||
|
|
||||||
|
- 6x better compression than MVT (column-oriented layout)
|
||||||
|
- 3.7-4.4x faster client decode (SIMD-friendly)
|
||||||
|
- Martin v1.3+ supports serving MLT
|
||||||
|
- MapLibre GL JS 5.x supports decoding MLT
|
||||||
|
- Spec: [github.com/maplibre/maplibre-tile-spec](https://github.com/maplibre/maplibre-tile-spec)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Martin auto-discovery drops properties** — always use explicit config with `auto_publish: false`
|
||||||
|
2. **Martin Docker tag format changed at v1.0** — `v0.15.0` (with v) but `1.4.0` (without v). Check actual tags at ghcr.io.
|
||||||
|
3. **Portainer CE volume mounts fail silently** — Docker creates empty directory instead of file. Bake configs into images via Dockerfile COPY.
|
||||||
|
4. **Martin logs `UNKNOWN GEOMETRY TYPE` for views** — normal for nested views, does not affect tile generation
|
||||||
|
5. **Nested views lose SRID metadata** — cast geometry: `geom::geometry(Geometry, 3844)`
|
||||||
|
6. **GisUat.geometry is huge** — always `select` to exclude in list queries
|
||||||
|
7. **Low-zoom tiles scan entire dataset** — use zoom-dependent simplified views
|
||||||
|
8. **No tile cache by default** — add nginx/Varnish in front of any tile server
|
||||||
|
9. **tippecanoe requires WGS84** — reproject from custom SRID before generating PMTiles
|
||||||
|
10. **PMTiles not incrementally updatable** — full rebuild required on data change
|
||||||
|
11. **Tegola doesn't support custom SRIDs** — only 3857/4326, requires ST_Transform everywhere
|
||||||
|
12. **pg_tileserv `ST_Estimated_Extent` fails on views** — use materialized views or function layers
|
||||||
|
13. **Martin caches source schema at startup** — restart after view DDL changes
|
||||||
@@ -1,456 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ro">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Configurator semnatura e-mail</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { font-family: 'Inter', sans-serif; }
|
|
||||||
.no-select { -webkit-user-select: none; -ms-user-select: none; user-select: none; }
|
|
||||||
|
|
||||||
input[type=range] {
|
|
||||||
-webkit-appearance: none; appearance: none; width: 100%; height: 4px;
|
|
||||||
background: #e5e7eb; border-radius: 5px; outline: none; transition: background 0.2s ease;
|
|
||||||
}
|
|
||||||
input[type=range]:hover { background: #d1d5db; }
|
|
||||||
input[type=range]::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none; appearance: none; width: 12px; height: 20px;
|
|
||||||
background: #22B5AB; cursor: pointer; border-radius: 4px;
|
|
||||||
margin-top: -8px; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
|
||||||
transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
|
|
||||||
}
|
|
||||||
input[type=range]::-webkit-slider-thumb:active { transform: scale(1.15); box-shadow: 0 2px 6px rgba(0,0,0,0.3); }
|
|
||||||
input[type=range]::-moz-range-thumb {
|
|
||||||
width: 12px; height: 20px; background: #22B5AB; cursor: pointer;
|
|
||||||
border-radius: 4px; border: none; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
#preview-wrapper { transition: transform 0.2s ease-in-out; transform-origin: top left; }
|
|
||||||
.color-swatch {
|
|
||||||
width: 24px; height: 24px; border-radius: 9999px; cursor: pointer;
|
|
||||||
border: 2px solid transparent; transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
.color-swatch.active { border-color: #22B5AB; transform: scale(1.1); box-shadow: 0 0 0 2px white, 0 0 0 4px #22B5AB; }
|
|
||||||
.collapsible-content { max-height: 0; overflow: hidden; transition: max-height 0.3s ease-out; }
|
|
||||||
.collapsible-content.open { max-height: 1000px; /* Valoare mare pentru a permite extinderea */ }
|
|
||||||
.collapsible-trigger svg { transition: transform 0.3s ease; }
|
|
||||||
.collapsible-trigger.open svg { transform: rotate(90deg); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-50 text-gray-800 no-select">
|
|
||||||
|
|
||||||
<div class="container mx-auto p-4 md:p-8">
|
|
||||||
<header class="text-center mb-10">
|
|
||||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-900">Configurator semnatura e-mail</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="flex flex-col lg:flex-row gap-8">
|
|
||||||
|
|
||||||
<!-- Panoul de control -->
|
|
||||||
<aside class="lg:w-2/5 bg-white p-6 rounded-2xl shadow-lg border border-gray-200">
|
|
||||||
<div id="controls">
|
|
||||||
<!-- Secțiunea Date Personale -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-800 border-b pb-2 mb-4">Date Personale</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label for="input-prefix" class="block text-sm font-medium text-gray-700 mb-1">Titulatură (prefix)</label>
|
|
||||||
<input type="text" id="input-prefix" value="arh." class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="input-name" class="block text-sm font-medium text-gray-700 mb-1">Nume și Prenume</label>
|
|
||||||
<input type="text" id="input-name" value="Marius TĂRĂU" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="input-title" class="block text-sm font-medium text-gray-700 mb-1">Funcția</label>
|
|
||||||
<input type="text" id="input-title" value="Arhitect • Beletage SRL" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="input-phone" class="block text-sm font-medium text-gray-700 mb-1">Telefon (format 07xxxxxxxx)</label>
|
|
||||||
<input type="tel" id="input-phone" value="0785123433" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Culori Text (Collapsible) -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="collapsible-trigger flex justify-between items-center cursor-pointer border-b pb-2 mb-2">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-800">Culori Text</h3>
|
|
||||||
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
|
||||||
</div>
|
|
||||||
<div class="collapsible-content">
|
|
||||||
<div id="color-controls" class="space-y-2 pt-2"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Secțiunea Stil & Aranjare (Collapsible) -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="collapsible-trigger flex justify-between items-center cursor-pointer border-b pb-2 mb-2">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-800">Stil & Aranjare</h3>
|
|
||||||
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
|
||||||
</div>
|
|
||||||
<div class="collapsible-content">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-3 pt-2">
|
|
||||||
<div>
|
|
||||||
<label for="green-line-width" class="block text-sm font-medium text-gray-700 mb-2">Lungime linie verde (<span id="green-line-value">97</span>px)</label>
|
|
||||||
<input id="green-line-width" type="range" min="50" max="300" value="97">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="section-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. secțiuni (<span id="section-spacing-value">10</span>px)</label>
|
|
||||||
<input id="section-spacing" type="range" min="0" max="30" value="10">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="logo-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. Logo (<span id="logo-spacing-value">10</span>px)</label>
|
|
||||||
<input id="logo-spacing" type="range" min="0" max="30" value="10">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="title-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. funcție (<span id="title-spacing-value">2</span>px)</label>
|
|
||||||
<input id="title-spacing" type="range" min="0" max="20" value="2">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="b-gutter-width" class="block text-sm font-medium text-gray-700 mb-2">Aliniere contact (<span id="b-gutter-value">13</span>px)</label>
|
|
||||||
<input id="b-gutter-width" type="range" min="0" max="150" value="13">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="icon-text-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiu Icon-Text (<span id="icon-text-spacing-value">5</span>px)</label>
|
|
||||||
<input id="icon-text-spacing" type="range" min="-10" max="30" value="5">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="icon-vertical-pos" class="block text-sm font-medium text-gray-700 mb-2">Aliniere vert. iconițe (<span id="icon-vertical-value">1</span>px)</label>
|
|
||||||
<input id="icon-vertical-pos" type="range" min="-10" max="10" value="1">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="motto-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. motto (<span id="motto-spacing-value">3</span>px)</label>
|
|
||||||
<input id="motto-spacing" type="range" min="0" max="20" value="3">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Opțiuni -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-800 border-b pb-2 mb-4">Opțiuni</h3>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label class="flex items-center space-x-3 cursor-pointer">
|
|
||||||
<input type="checkbox" id="reply-variant-checkbox" class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500">
|
|
||||||
<span class="text-sm font-medium text-gray-700">Variantă simplă (fără logo/adresă)</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center space-x-3 cursor-pointer">
|
|
||||||
<input type="checkbox" id="super-reply-variant-checkbox" class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500">
|
|
||||||
<span class="text-sm font-medium text-gray-700">Super-simplă (doar nume/telefon)</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center space-x-3 cursor-pointer">
|
|
||||||
<input type="checkbox" id="use-svg-checkbox" class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500">
|
|
||||||
<span class="text-sm font-medium text-gray-700">Folosește imagini SVG (calitate maximă)</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Buton de Export -->
|
|
||||||
<div class="mt-8 pt-6 border-t">
|
|
||||||
<button id="export-btn" class="w-full bg-teal-600 text-white font-bold py-3 px-4 rounded-lg hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-teal-500 transition-all duration-300 ease-in-out transform hover:scale-105">
|
|
||||||
Descarcă HTML
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Previzualizare Live -->
|
|
||||||
<main class="lg:w-3/5 bg-white p-6 rounded-2xl shadow-lg border border-gray-200 overflow-hidden">
|
|
||||||
<div class="flex justify-between items-center border-b pb-3 mb-4">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900">Previzualizare Live</h2>
|
|
||||||
<button id="zoom-btn" class="text-sm bg-gray-200 text-gray-700 px-3 py-1 rounded-md hover:bg-gray-300">Zoom 100%</button>
|
|
||||||
</div>
|
|
||||||
<div id="preview-wrapper" class="overflow-auto">
|
|
||||||
<div id="preview-container">
|
|
||||||
<!-- Aici este inserat codul HTML al semnăturii -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const controls = {
|
|
||||||
prefix: document.getElementById('input-prefix'),
|
|
||||||
name: document.getElementById('input-name'),
|
|
||||||
title: document.getElementById('input-title'),
|
|
||||||
phone: document.getElementById('input-phone'),
|
|
||||||
greenLine: document.getElementById('green-line-width'),
|
|
||||||
gutter: document.getElementById('b-gutter-width'),
|
|
||||||
iconTextSpacing: document.getElementById('icon-text-spacing'),
|
|
||||||
iconVertical: document.getElementById('icon-vertical-pos'),
|
|
||||||
mottoSpacing: document.getElementById('motto-spacing'),
|
|
||||||
sectionSpacing: document.getElementById('section-spacing'),
|
|
||||||
titleSpacing: document.getElementById('title-spacing'),
|
|
||||||
logoSpacing: document.getElementById('logo-spacing'),
|
|
||||||
replyCheckbox: document.getElementById('reply-variant-checkbox'),
|
|
||||||
superReplyCheckbox: document.getElementById('super-reply-variant-checkbox'),
|
|
||||||
useSvgCheckbox: document.getElementById('use-svg-checkbox'),
|
|
||||||
exportBtn: document.getElementById('export-btn'),
|
|
||||||
zoomBtn: document.getElementById('zoom-btn'),
|
|
||||||
colorControls: document.getElementById('color-controls')
|
|
||||||
};
|
|
||||||
|
|
||||||
const values = {
|
|
||||||
greenLine: document.getElementById('green-line-value'),
|
|
||||||
gutter: document.getElementById('b-gutter-value'),
|
|
||||||
iconTextSpacing: document.getElementById('icon-text-spacing-value'),
|
|
||||||
iconVertical: document.getElementById('icon-vertical-value'),
|
|
||||||
mottoSpacing: document.getElementById('motto-spacing-value'),
|
|
||||||
sectionSpacing: document.getElementById('section-spacing-value'),
|
|
||||||
titleSpacing: document.getElementById('title-spacing-value'),
|
|
||||||
logoSpacing: document.getElementById('logo-spacing-value')
|
|
||||||
};
|
|
||||||
|
|
||||||
const previewContainer = document.getElementById('preview-container');
|
|
||||||
const previewWrapper = document.getElementById('preview-wrapper');
|
|
||||||
|
|
||||||
const imageSets = {
|
|
||||||
png: {
|
|
||||||
logo: 'https://beletage.ro/img/Semnatura-Logo.png',
|
|
||||||
greySlash: 'https://beletage.ro/img/Grey-slash.png',
|
|
||||||
greenSlash: 'https://beletage.ro/img/Green-slash.png'
|
|
||||||
},
|
|
||||||
svg: {
|
|
||||||
logo: 'https://beletage.ro/img/Logo-Beletage.svg',
|
|
||||||
greySlash: 'https://beletage.ro/img/Grey-slash.svg',
|
|
||||||
greenSlash: 'https://beletage.ro/img/Green-slash.svg'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const beletageColors = {
|
|
||||||
verde: '#22B5AB',
|
|
||||||
griInchis: '#54504F',
|
|
||||||
griDeschis: '#A7A9AA',
|
|
||||||
negru: '#323232'
|
|
||||||
};
|
|
||||||
|
|
||||||
const colorConfig = {
|
|
||||||
prefix: { label: 'Titulatură', default: beletageColors.griInchis },
|
|
||||||
name: { label: 'Nume', default: beletageColors.griInchis },
|
|
||||||
title: { label: 'Funcție', default: beletageColors.griDeschis },
|
|
||||||
address: { label: 'Adresă', default: beletageColors.griDeschis },
|
|
||||||
phone: { label: 'Telefon', default: beletageColors.griInchis },
|
|
||||||
website: { label: 'Website', default: beletageColors.griInchis },
|
|
||||||
motto: { label: 'Motto', default: beletageColors.verde }
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentColors = {};
|
|
||||||
|
|
||||||
function createColorPickers() {
|
|
||||||
for (const [key, config] of Object.entries(colorConfig)) {
|
|
||||||
currentColors[key] = config.default;
|
|
||||||
const controlRow = document.createElement('div');
|
|
||||||
controlRow.className = 'flex items-center justify-between';
|
|
||||||
const label = document.createElement('span');
|
|
||||||
label.className = 'text-sm font-medium text-gray-700';
|
|
||||||
label.textContent = config.label;
|
|
||||||
controlRow.appendChild(label);
|
|
||||||
const swatchesContainer = document.createElement('div');
|
|
||||||
swatchesContainer.className = 'flex items-center space-x-2';
|
|
||||||
swatchesContainer.dataset.controlKey = key;
|
|
||||||
for (const color of Object.values(beletageColors)) {
|
|
||||||
const swatch = document.createElement('div');
|
|
||||||
swatch.className = 'color-swatch';
|
|
||||||
swatch.style.backgroundColor = color;
|
|
||||||
swatch.dataset.color = color;
|
|
||||||
if (color === config.default) swatch.classList.add('active');
|
|
||||||
swatchesContainer.appendChild(swatch);
|
|
||||||
}
|
|
||||||
controlRow.appendChild(swatchesContainer);
|
|
||||||
controls.colorControls.appendChild(controlRow);
|
|
||||||
}
|
|
||||||
|
|
||||||
controls.colorControls.addEventListener('click', (e) => {
|
|
||||||
if (e.target.classList.contains('color-swatch')) {
|
|
||||||
const key = e.target.parentElement.dataset.controlKey;
|
|
||||||
currentColors[key] = e.target.dataset.color;
|
|
||||||
e.target.parentElement.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('active'));
|
|
||||||
e.target.classList.add('active');
|
|
||||||
updatePreview();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateSignatureHTML(data) {
|
|
||||||
const {
|
|
||||||
prefix, name, title, phone, phoneLink, greenLineWidth, gutterWidth,
|
|
||||||
iconTextSpacing, iconVerticalOffset, mottoSpacing, sectionSpacing, titleSpacing, logoSpacing,
|
|
||||||
isReply, isSuperReply, colors, images
|
|
||||||
} = data;
|
|
||||||
|
|
||||||
const hideTitle = isReply || isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
|
|
||||||
const hideLogoAddress = isReply || isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
|
|
||||||
const hideBottom = isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
|
|
||||||
const hidePhoneIcon = isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
|
|
||||||
|
|
||||||
const spacerWidth = Math.max(0, iconTextSpacing);
|
|
||||||
const textPaddingLeft = Math.max(0, -iconTextSpacing);
|
|
||||||
|
|
||||||
const prefixHTML = prefix ? `<span style="font-size:13px; color:${colors.prefix};">${prefix} </span>` : '';
|
|
||||||
const logoWidth = controls.useSvgCheckbox.checked ? 162 : 162;
|
|
||||||
const logoHeight = controls.useSvgCheckbox.checked ? 24 : 24;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-family: Arial, Helvetica, sans-serif; color:#333333; font-size:14px; line-height:18px;">
|
|
||||||
<tbody>
|
|
||||||
<tr><td style="padding:0 0 ${titleSpacing}px 0;">${prefixHTML}<span style="font-size:15px; color:${colors.name}; font-weight:700;">${name}</span></td></tr>
|
|
||||||
<tr style="${hideTitle}"><td style="padding:0 0 8px 0;"><span style="font-size:12px; color:${colors.title};">${title}</span></td></tr>
|
|
||||||
<tr style="${hideBottom}">
|
|
||||||
<td style="padding:0; font-size:0; line-height:0;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540">
|
|
||||||
<tr>
|
|
||||||
<td width="${greenLineWidth}" height="2" bgcolor="${beletageColors.verde}" style="font-size:0; line-height:0; height:2px;"></td>
|
|
||||||
<td width="${540 - greenLineWidth}" height="2" style="font-size:0; line-height:0; height:2px;"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="${hideLogoAddress}"><td style="padding:${logoSpacing}px 0 ${parseInt(logoSpacing, 10) + 2}px 0;">
|
|
||||||
<a href="https://www.beletage.ro" style="text-decoration:none; border:0;">
|
|
||||||
<img src="${images.logo}" alt="Beletage" style="display:block; border:0; height:${logoHeight}px; width:${logoWidth}px;" height="${logoHeight}" width="${logoWidth}">
|
|
||||||
</a>
|
|
||||||
</td></tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding-top:${hideLogoAddress ? '0' : sectionSpacing}px;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-size:13px; line-height:18px;">
|
|
||||||
<tbody>
|
|
||||||
<tr style="${hideLogoAddress}">
|
|
||||||
<td width="${gutterWidth}" style="width:${gutterWidth}px; font-size:0; line-height:0;"></td>
|
|
||||||
<td width="11" style="width:11px; vertical-align:top; padding-top:${4 + iconVerticalOffset}px;">
|
|
||||||
<img src="${images.greySlash}" alt="" width="11" height="11" style="display: block; border:0;">
|
|
||||||
</td>
|
|
||||||
<td width="${spacerWidth}" style="width:${spacerWidth}px; font-size:0; line-height:0;"></td>
|
|
||||||
<td style="vertical-align:top; padding:0 0 0 ${textPaddingLeft}px;">
|
|
||||||
<a href="https://maps.google.com/?q=str.%20Unirii%203%2C%20ap.%2026%2C%20Cluj-Napoca%20400417%2C%20Rom%C3%A2nia" style="color:${colors.address}; text-decoration:none;"><span style="color:${colors.address}; text-decoration:none;">str. Unirii, nr. 3, ap. 26<br>Cluj-Napoca, Cluj 400417<br>România</span></a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td width="${gutterWidth}" style="width:${gutterWidth}px; font-size:0; line-height:0;"></td>
|
|
||||||
<td width="11" style="width:11px; vertical-align:top; padding-top:${12 + iconVerticalOffset}px; ${hidePhoneIcon}">
|
|
||||||
<img src="${images.greenSlash}" alt="" width="11" height="7" style="display: block; border:0;">
|
|
||||||
</td>
|
|
||||||
<td width="${isSuperReply ? 0 : spacerWidth}" style="width:${isSuperReply ? 0 : spacerWidth}px; font-size:0; line-height:0;"></td>
|
|
||||||
<td style="vertical-align:top; padding:8px 0 0 ${isSuperReply ? 0 : textPaddingLeft}px;">
|
|
||||||
<a href="${phoneLink}" style="color:${colors.phone}; text-decoration:none;"><span style="color:${colors.phone}; text-decoration:none;">${phone}</span></a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="${hideBottom}"><td style="padding:${sectionSpacing}px 0 ${mottoSpacing}px 0;"><a href="https://www.beletage.ro" style="color:${colors.website}; text-decoration:none;"><span style="color:${colors.website}; text-decoration:none;">www.beletage.ro</span></a></td></tr>
|
|
||||||
<tr style="${hideBottom}">
|
|
||||||
<td style="padding:0; font-size:0; line-height:0;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540">
|
|
||||||
<tr>
|
|
||||||
<td width="${greenLineWidth}" height="1" bgcolor="${beletageColors.verde}" style="font-size:0; line-height:0; height:1px;"></td>
|
|
||||||
<td width="${540 - greenLineWidth}" height="1" style="font-size:0; line-height:0; height:1px;"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="${hideBottom}"><td style="padding:${mottoSpacing}px 0 0 0;"><span style="font-size:12px; color:${colors.motto}; font-style:italic;">we make complex simple</span></td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePreview() {
|
|
||||||
const phoneRaw = controls.phone.value.replace(/\s/g, '');
|
|
||||||
let formattedPhone = controls.phone.value;
|
|
||||||
let phoneLink = `tel:${phoneRaw}`;
|
|
||||||
|
|
||||||
if (phoneRaw.length === 10 && phoneRaw.startsWith('07')) {
|
|
||||||
formattedPhone = `+40 ${phoneRaw.substring(1, 4)} ${phoneRaw.substring(4, 7)} ${phoneRaw.substring(7, 10)}`;
|
|
||||||
phoneLink = `tel:+40${phoneRaw.substring(1)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (controls.superReplyCheckbox.checked) {
|
|
||||||
controls.replyCheckbox.checked = true;
|
|
||||||
controls.replyCheckbox.disabled = true;
|
|
||||||
} else {
|
|
||||||
controls.replyCheckbox.disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
prefix: controls.prefix.value,
|
|
||||||
name: controls.name.value,
|
|
||||||
title: controls.title.value,
|
|
||||||
phone: formattedPhone,
|
|
||||||
phoneLink: phoneLink,
|
|
||||||
greenLineWidth: controls.greenLine.value,
|
|
||||||
gutterWidth: controls.gutter.value,
|
|
||||||
iconTextSpacing: controls.iconTextSpacing.value,
|
|
||||||
iconVerticalOffset: parseInt(controls.iconVertical.value, 10),
|
|
||||||
mottoSpacing: controls.mottoSpacing.value,
|
|
||||||
sectionSpacing: controls.sectionSpacing.value,
|
|
||||||
titleSpacing: controls.titleSpacing.value,
|
|
||||||
logoSpacing: controls.logoSpacing.value,
|
|
||||||
isReply: controls.replyCheckbox.checked,
|
|
||||||
isSuperReply: controls.superReplyCheckbox.checked,
|
|
||||||
colors: { ...currentColors },
|
|
||||||
images: controls.useSvgCheckbox.checked ? imageSets.svg : imageSets.png
|
|
||||||
};
|
|
||||||
|
|
||||||
values.greenLine.textContent = data.greenLineWidth;
|
|
||||||
values.gutter.textContent = data.gutterWidth;
|
|
||||||
values.iconTextSpacing.textContent = data.iconTextSpacing;
|
|
||||||
values.iconVertical.textContent = data.iconVerticalOffset;
|
|
||||||
values.mottoSpacing.textContent = data.mottoSpacing;
|
|
||||||
values.sectionSpacing.textContent = data.sectionSpacing;
|
|
||||||
values.titleSpacing.textContent = data.titleSpacing;
|
|
||||||
values.logoSpacing.textContent = data.logoSpacing;
|
|
||||||
|
|
||||||
previewContainer.innerHTML = generateSignatureHTML(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Inițializare ---
|
|
||||||
createColorPickers();
|
|
||||||
|
|
||||||
Object.values(controls).forEach(control => {
|
|
||||||
if (control.id !== 'export-btn' && control.id !== 'zoom-btn') {
|
|
||||||
control.addEventListener('input', updatePreview);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('.collapsible-trigger').forEach(trigger => {
|
|
||||||
trigger.addEventListener('click', () => {
|
|
||||||
const content = trigger.nextElementSibling;
|
|
||||||
trigger.classList.toggle('open');
|
|
||||||
content.classList.toggle('open');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
controls.zoomBtn.addEventListener('click', () => {
|
|
||||||
const isZoomed = previewWrapper.style.transform === 'scale(2)';
|
|
||||||
if (isZoomed) {
|
|
||||||
previewWrapper.style.transform = 'scale(1)';
|
|
||||||
controls.zoomBtn.textContent = 'Zoom 200%';
|
|
||||||
} else {
|
|
||||||
previewWrapper.style.transform = 'scale(2)';
|
|
||||||
controls.zoomBtn.textContent = 'Zoom 100%';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
controls.exportBtn.addEventListener('click', () => {
|
|
||||||
const finalHTML = previewContainer.innerHTML;
|
|
||||||
const blob = new Blob([finalHTML], { type: 'text/html' });
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = 'semnatura-beletage.html';
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
});
|
|
||||||
|
|
||||||
updatePreview();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
Pauza de masa
|
|
||||||
Timp personal
|
|
||||||
|
|
||||||
Concediu
|
|
||||||
Compensare overtime
|
|
||||||
|
|
||||||
Beletage
|
|
||||||
Ofertare
|
|
||||||
Configurari
|
|
||||||
Organizare initiala
|
|
||||||
Pregatire Portofoliu
|
|
||||||
Website
|
|
||||||
Documentare
|
|
||||||
Design grafic
|
|
||||||
Design interior
|
|
||||||
Design exterior
|
|
||||||
|
|
||||||
Releveu
|
|
||||||
Reclama
|
|
||||||
|
|
||||||
000 Farmacie
|
|
||||||
002 Cladire birouri Stratec
|
|
||||||
003 PUZ Bellavista
|
|
||||||
007 Design Apartament Teodora
|
|
||||||
010 Casa Doinei
|
|
||||||
016 Duplex Eremia
|
|
||||||
024 Bloc Petofi
|
|
||||||
028 PUZ Borhanci-Sopor
|
|
||||||
033 Mansardare Branului
|
|
||||||
039 Cabinete Stoma Scala
|
|
||||||
041 Imobil mixt Progresului
|
|
||||||
045 Casa Andrei Muresanu
|
|
||||||
052 PUZ Carpenului
|
|
||||||
059 PUZ Nordului
|
|
||||||
064 Casa Salicea
|
|
||||||
066 Terasa Gherase
|
|
||||||
070 Bloc Fanatelor
|
|
||||||
073 Case Frumoasa
|
|
||||||
074 PUG Cosbuc
|
|
||||||
076 Casa Copernicus
|
|
||||||
077 PUZ Schimbare destinatie Brancusi
|
|
||||||
078 Service auto Linistei
|
|
||||||
079 Amenajare drum Servitute Eremia
|
|
||||||
080 Bloc Tribunul
|
|
||||||
081 Extindere casa Gherase
|
|
||||||
083 Modificari casa Zsigmund 18
|
|
||||||
084 Mansardare Petofi 21
|
|
||||||
085 Container CT Spital Tabacarilor
|
|
||||||
086 Imprejmuire casa sat Gheorgheni
|
|
||||||
087 Duplex Oasului fn
|
|
||||||
089 PUZ A-Liu Sopor
|
|
||||||
090 VR MedEvents
|
|
||||||
091 Reclama Caparol
|
|
||||||
092 Imobil birouri 13 Septembrie
|
|
||||||
093 Casa Salistea Noua
|
|
||||||
094 PUD Casa Rediu
|
|
||||||
095 Duplex Vanatorului
|
|
||||||
096 Design apartament Sopor
|
|
||||||
097 Cabana Gilau
|
|
||||||
101 PUZ Gilau
|
|
||||||
102 PUZ Ghimbav
|
|
||||||
103 Piscine Lunca Noua
|
|
||||||
104 PUZ REGHIN
|
|
||||||
105 CUT&Crust
|
|
||||||
106 PUZ Mihai Romanu Nord
|
|
||||||
108 Reabilitare Bloc Beiusului
|
|
||||||
109 Case Samboleni
|
|
||||||
110 Penny Crasna
|
|
||||||
111 Anexa Piscina Borhanci
|
|
||||||
112 PUZ Blocuri Bistrita
|
|
||||||
113 PUZ VARATEC-FIRIZA
|
|
||||||
114 PUG Husi
|
|
||||||
115 PUG Josenii Bargaului
|
|
||||||
116 PUG Monor
|
|
||||||
117 Schimbare Destinatie Mihai Viteazu 2
|
|
||||||
120 Anexa Brasov
|
|
||||||
121 Imprejurare imobil Mesterul Manole 9
|
|
||||||
122 Fastfood Bashar
|
|
||||||
123 PUD Rediu 2
|
|
||||||
127 Casa Socaciu Ciurila
|
|
||||||
128 Schimbare de destinatie Danubius
|
|
||||||
129 (re) Casa Sarca-Sorescu
|
|
||||||
130 Casa Suta-Wonderland
|
|
||||||
131 PUD Oasului Hufi
|
|
||||||
132 Reabilitare Camin Cultural Baciu
|
|
||||||
133 PUG Feldru
|
|
||||||
134 DALI Blocuri Murfatlar
|
|
||||||
135 Case de vacanta Dianei
|
|
||||||
136 PUG BROSTENI
|
|
||||||
139 Casa Turda
|
|
||||||
140 Releveu Bistrita (Morariu)
|
|
||||||
141 PUZ Janovic Jeno
|
|
||||||
142 Penny Borhanci
|
|
||||||
143 Pavilion Politie Radauti
|
|
||||||
149 Duplex Sorescu 31-33
|
|
||||||
150 DALI SF Scoala Baciu
|
|
||||||
151 Casa Alexandru Bohatiel 17
|
|
||||||
152 PUZ Penny Tautii Magheraus
|
|
||||||
153 PUG Banita
|
|
||||||
155 PT Scoala Floresti
|
|
||||||
156 Case Sorescu
|
|
||||||
157 Gradi-Cresa Baciu
|
|
||||||
158 Duplex Sorescu 21-23
|
|
||||||
159 Amenajare Spatiu Grenke PBC
|
|
||||||
160 Etajare Primaria Baciu
|
|
||||||
161 Extindere Ap Baciu
|
|
||||||
164 SD salon Aurel Vlaicu
|
|
||||||
165 Reclama Marasti
|
|
||||||
166 Catei Apahida
|
|
||||||
167 Apartament Mircea Zaciu 13-15
|
|
||||||
169 Casa PETRILA 37
|
|
||||||
170 Cabana Campeni AB
|
|
||||||
171 Camin Apahida
|
|
||||||
L089 PUZ TUSA-BOJAN
|
|
||||||
172 Design casa Iugoslaviei 18
|
|
||||||
173 Reabilitare spitale Sighetu
|
|
||||||
174 StudX UMFST
|
|
||||||
176 - 2025 - ReAC Ansamblu rezi Bibescu
|
|
||||||
|
|
||||||
|
|
||||||
CU
|
|
||||||
Schita
|
|
||||||
Avize
|
|
||||||
PUD
|
|
||||||
AO
|
|
||||||
PUZ
|
|
||||||
PUG
|
|
||||||
DTAD
|
|
||||||
DTAC
|
|
||||||
PT
|
|
||||||
Detalii de Executie
|
|
||||||
Studii de fundamentare
|
|
||||||
|
|
||||||
Regulament
|
|
||||||
Parte desenata
|
|
||||||
Parte scrisa
|
|
||||||
Consultanta client
|
|
||||||
Macheta
|
|
||||||
Consultanta receptie
|
|
||||||
|
|
||||||
Redactare
|
|
||||||
Depunere
|
|
||||||
Ridicare
|
|
||||||
Verificare proiect
|
|
||||||
|
|
||||||
Vizita santier
|
|
||||||
|
|
||||||
Master MATDR
|
|
||||||
@@ -1,694 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ro">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Beletage – Word XML Data Engine</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<!-- JSZip pentru arhivă ZIP -->
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"
|
|
||||||
integrity="sha512-FGv7V3GpCr3C6wz6Q4z8F1v8y4mZohwPqhwKiPfz0btvAvOE0tfLOgvBcFQncn1C3KW0y5fN9c7v1sQW8vGfMQ=="
|
|
||||||
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
padding: 1.5rem;
|
|
||||||
background: #020617;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 1.7rem;
|
|
||||||
margin-bottom: .25rem;
|
|
||||||
}
|
|
||||||
.subtitle {
|
|
||||||
font-size: .9rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: #020617;
|
|
||||||
border-radius: 1rem;
|
|
||||||
padding: 1.1rem 1.3rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border: 1px solid #1e293b;
|
|
||||||
box-shadow: 0 15px 35px rgba(0,0,0,.45);
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
font-size: .8rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
display: block;
|
|
||||||
margin-bottom: .2rem;
|
|
||||||
}
|
|
||||||
input, textarea, select {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: .5rem .6rem;
|
|
||||||
border-radius: .5rem;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
background: #020617;
|
|
||||||
color: #e5e7eb;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: .9rem;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
input:focus, textarea:focus, select:focus {
|
|
||||||
border-color: #38bdf8;
|
|
||||||
box-shadow: 0 0 0 1px #38bdf8;
|
|
||||||
}
|
|
||||||
textarea { min-height: 140px; resize: vertical; }
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
.col-3 { flex: 1 1 220px; }
|
|
||||||
.col-6 { flex: 1 1 320px; }
|
|
||||||
.col-9 { flex: 3 1 420px; }
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: .55rem 1.1rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: none;
|
|
||||||
background: linear-gradient(135deg, #38bdf8, #6366f1);
|
|
||||||
color: #fff;
|
|
||||||
font-size: .9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 12px 25px rgba(37,99,235,.4);
|
|
||||||
}
|
|
||||||
button:hover { filter: brightness(1.05); transform: translateY(-1px); }
|
|
||||||
button:active { transform: translateY(0); box-shadow: 0 8px 18px rgba(37,99,235,.6); }
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #4b5563;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: #020617;
|
|
||||||
box-shadow: 0 8px 20px rgba(0,0,0,.6);
|
|
||||||
}
|
|
||||||
.btn-small {
|
|
||||||
font-size: .8rem;
|
|
||||||
padding: .35rem .8rem;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: .4rem;
|
|
||||||
font-size: .8rem;
|
|
||||||
color: #cbd5f5;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.toggle input { width: auto; }
|
|
||||||
|
|
||||||
.pill-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: .4rem;
|
|
||||||
margin-bottom: .4rem;
|
|
||||||
}
|
|
||||||
.pill {
|
|
||||||
padding: .25rem .7rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
font-size: .8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
background: #020617;
|
|
||||||
color: #e5e7eb;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: .35rem;
|
|
||||||
}
|
|
||||||
.pill.active {
|
|
||||||
background: linear-gradient(135deg, #38bdf8, #6366f1);
|
|
||||||
border-color: transparent;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
.pill span.remove {
|
|
||||||
font-size: .8rem;
|
|
||||||
opacity: .7;
|
|
||||||
}
|
|
||||||
.pill span.remove:hover { opacity: 1; }
|
|
||||||
|
|
||||||
.small {
|
|
||||||
font-size: .8rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
margin-top: .25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
background: #020617;
|
|
||||||
border-radius: .75rem;
|
|
||||||
padding: .7rem .8rem;
|
|
||||||
border: 1px solid #1f2937;
|
|
||||||
overflow: auto;
|
|
||||||
font-size: .8rem;
|
|
||||||
max-height: 340px;
|
|
||||||
}
|
|
||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: .15rem .45rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: .7rem;
|
|
||||||
background: rgba(148,163,184,.18);
|
|
||||||
margin-right: .4rem;
|
|
||||||
margin-bottom: .25rem;
|
|
||||||
}
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
body { padding: 1rem; }
|
|
||||||
.card { padding: 1rem; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>Beletage – Word XML Data Engine</h1>
|
|
||||||
<p class="subtitle">
|
|
||||||
Generator de <strong>Custom XML Parts</strong> pentru Word, pe categorii (Beneficiar, Proiect, Suprafete, Meta etc.),
|
|
||||||
cu mod <em>Simple</em> / <em>Advanced</em> și câmpuri derivate (Short, Upper, Initials) + POT/CUT pregătite.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- SETĂRI GLOBALE -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-6">
|
|
||||||
<label for="baseNs">Bază Namespace (se completează automat cu /Categorie)</label>
|
|
||||||
<input id="baseNs" type="text" value="http://schemas.beletage.ro/contract">
|
|
||||||
<div class="small">Ex: <code>http://schemas.beletage.ro/contract</code> → pentru categoria „Proiect” devine
|
|
||||||
<code>http://schemas.beletage.ro/contract/Proiect</code>.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-3">
|
|
||||||
<label>Mod generare câmpuri</label>
|
|
||||||
<div class="pill-row">
|
|
||||||
<div class="pill active" id="modeSimplePill" onclick="setMode('simple')">Simple</div>
|
|
||||||
<div class="pill" id="modeAdvancedPill" onclick="setMode('advanced')">Advanced</div>
|
|
||||||
</div>
|
|
||||||
<div class="small">
|
|
||||||
<strong>Simple</strong>: doar câmpurile tale.<br>
|
|
||||||
<strong>Advanced</strong>: + Short / Upper / Lower / Initials / First pentru fiecare câmp.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-3">
|
|
||||||
<label>Opțiuni extra</label>
|
|
||||||
<div class="small" style="margin-top:.25rem;">
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" id="computeMetrics" checked>
|
|
||||||
<span>Adaugă câmpuri POT / CUT în categoria Suprafete</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CATEGORII -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-3">
|
|
||||||
<label>Categorii de date</label>
|
|
||||||
<div id="categoryPills" class="pill-row"></div>
|
|
||||||
<button class="btn-secondary btn-small" onclick="addCategoryPrompt()">+ Adaugă categorie</button>
|
|
||||||
<div class="small">
|
|
||||||
Exemple de organizare: <code>Beneficiar</code>, <code>Proiect</code>, <code>Suprafete</code>, <code>Meta</code>.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-9">
|
|
||||||
<label>Câmpuri pentru categoria selectată</label>
|
|
||||||
<textarea id="fieldsArea"></textarea>
|
|
||||||
<div class="small">
|
|
||||||
Un câmp pe linie. Poți edita lista. Butonul „Reset categorie la preset” reîncarcă valorile default pentru
|
|
||||||
categoria curentă (dacă există).
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:.5rem; display:flex; gap:.5rem; flex-wrap:wrap;">
|
|
||||||
<button class="btn-secondary btn-small" onclick="resetCategoryToPreset()">Reset categorie la preset</button>
|
|
||||||
<button class="btn-secondary btn-small" onclick="clearCategoryFields()">Curăță câmpurile</button>
|
|
||||||
</div>
|
|
||||||
<div class="small" id="nsRootInfo" style="margin-top:.6rem;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- GENERARE & DOWNLOAD -->
|
|
||||||
<div class="card">
|
|
||||||
<div style="display:flex; flex-wrap:wrap; gap:.5rem; align-items:center; margin-bottom:.5rem;">
|
|
||||||
<button onclick="generateAll()">Generează XML pentru toate categoriile</button>
|
|
||||||
<button class="btn-secondary" onclick="downloadCurrentXml()">Descarcă XML categorie curentă</button>
|
|
||||||
<button class="btn-secondary" onclick="downloadZipAll()">Descarcă ZIP cu toate XML-urile</button>
|
|
||||||
</div>
|
|
||||||
<div class="small">
|
|
||||||
<span class="badge">Tip</span>
|
|
||||||
În Word, fiecare fișier generat devine un Custom XML Part separat (ex: <code>BeneficiarData.xml</code>,
|
|
||||||
<code>ProiectData.xml</code> etc.), perfect pentru organizarea mapping-urilor.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- PREVIEW -->
|
|
||||||
<div class="card">
|
|
||||||
<h3 style="margin-top:0;">Preview XML & XPaths</h3>
|
|
||||||
<div class="small" style="margin-bottom:.4rem;">
|
|
||||||
Selectează o categorie pentru a vedea XML-ul și XPaths-urile aferente.
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-6">
|
|
||||||
<div class="badge">XML categorie curentă</div>
|
|
||||||
<pre id="xmlPreview"></pre>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<div class="badge">XPaths categorie curentă</div>
|
|
||||||
<pre id="xpathPreview"></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// --- PRESETURI CATEGORII ---
|
|
||||||
const defaultPresets = {
|
|
||||||
"Beneficiar": [
|
|
||||||
"NumeClient",
|
|
||||||
"Adresa",
|
|
||||||
"CUI",
|
|
||||||
"CNP",
|
|
||||||
"Reprezentant",
|
|
||||||
"Email",
|
|
||||||
"Telefon"
|
|
||||||
],
|
|
||||||
"Proiect": [
|
|
||||||
"TitluProiect",
|
|
||||||
"AdresaImobil",
|
|
||||||
"NrCadastral",
|
|
||||||
"NrCF",
|
|
||||||
"Localitate",
|
|
||||||
"Judet"
|
|
||||||
],
|
|
||||||
"Suprafete": [
|
|
||||||
"SuprafataTeren",
|
|
||||||
"SuprafataConstruitaLaSol",
|
|
||||||
"SuprafataDesfasurata",
|
|
||||||
"SuprafataUtila"
|
|
||||||
],
|
|
||||||
"Meta": [
|
|
||||||
"NrContract",
|
|
||||||
"DataContract",
|
|
||||||
"Responsabil",
|
|
||||||
"VersiuneDocument",
|
|
||||||
"DataGenerarii"
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- STATE ---
|
|
||||||
let categories = {}; // { Categorie: { fieldsText: "..." } }
|
|
||||||
let currentCategory = "Beneficiar";
|
|
||||||
let mode = "advanced"; // "simple" | "advanced"
|
|
||||||
const xmlParts = {}; // { Categorie: xmlString }
|
|
||||||
const xpathParts = {}; // { Categorie: xpathString }
|
|
||||||
|
|
||||||
// --- UTILITARE ---
|
|
||||||
function sanitizeName(name) {
|
|
||||||
if (!name) return null;
|
|
||||||
let n = name.trim();
|
|
||||||
if (!n) return null;
|
|
||||||
n = n.replace(/\s+/g, "_").replace(/[^A-Za-z0-9_.-]/g, "");
|
|
||||||
if (!/^[A-Za-z_]/.test(n)) n = "_" + n;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initialsFromLabel(label) {
|
|
||||||
if (!label) return "";
|
|
||||||
return label.trim().split(/\s+/).map(s => s.charAt(0).toUpperCase() + ".").join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstToken(label) {
|
|
||||||
if (!label) return "";
|
|
||||||
return label.trim().split(/\s+/)[0] || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBaseNamespace() {
|
|
||||||
const val = document.getElementById("baseNs").value.trim();
|
|
||||||
return val || "http://schemas.beletage.ro/contract";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCategoryNamespace(cat) {
|
|
||||||
const base = getBaseNamespace();
|
|
||||||
const safeCat = sanitizeName(cat) || cat;
|
|
||||||
return base.replace(/\/+$/,"") + "/" + safeCat;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCategoryRoot(cat) {
|
|
||||||
const safeCat = sanitizeName(cat) || cat;
|
|
||||||
return safeCat + "Data";
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- MOD SIMPLE/ADVANCED ---
|
|
||||||
function setMode(m) {
|
|
||||||
mode = m === "advanced" ? "advanced" : "simple";
|
|
||||||
document.getElementById("modeSimplePill").classList.toggle("active", mode === "simple");
|
|
||||||
document.getElementById("modeAdvancedPill").classList.toggle("active", mode === "advanced");
|
|
||||||
// regenerăm previw dacă avem ceva
|
|
||||||
generateAll(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- CATEGORII: INIT, UI, STORAGE ---
|
|
||||||
function initCategories() {
|
|
||||||
// încarcă din localStorage, altfel default
|
|
||||||
const saved = window.localStorage.getItem("beletage_xml_categories");
|
|
||||||
if (saved) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(saved);
|
|
||||||
categories = parsed.categories || {};
|
|
||||||
currentCategory = parsed.currentCategory || "Beneficiar";
|
|
||||||
} catch(e) {
|
|
||||||
Object.keys(defaultPresets).forEach(cat => {
|
|
||||||
categories[cat] = { fieldsText: defaultPresets[cat].join("\n") };
|
|
||||||
});
|
|
||||||
currentCategory = "Beneficiar";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Object.keys(defaultPresets).forEach(cat => {
|
|
||||||
categories[cat] = { fieldsText: defaultPresets[cat].join("\n") };
|
|
||||||
});
|
|
||||||
currentCategory = "Beneficiar";
|
|
||||||
}
|
|
||||||
|
|
||||||
renderCategoryPills();
|
|
||||||
loadCategoryToUI(currentCategory);
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistCategories() {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem("beletage_xml_categories", JSON.stringify({
|
|
||||||
categories,
|
|
||||||
currentCategory
|
|
||||||
}));
|
|
||||||
} catch(e){}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCategoryPills() {
|
|
||||||
const container = document.getElementById("categoryPills");
|
|
||||||
container.innerHTML = "";
|
|
||||||
Object.keys(categories).forEach(cat => {
|
|
||||||
const pill = document.createElement("div");
|
|
||||||
pill.className = "pill" + (cat === currentCategory ? " active" : "");
|
|
||||||
pill.onclick = () => switchCategory(cat);
|
|
||||||
pill.textContent = cat;
|
|
||||||
|
|
||||||
// nu permitem ștergerea preset-urilor de bază direct (doar la custom)
|
|
||||||
if (!defaultPresets[cat]) {
|
|
||||||
const remove = document.createElement("span");
|
|
||||||
remove.className = "remove";
|
|
||||||
remove.textContent = "×";
|
|
||||||
remove.onclick = (ev) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
deleteCategory(cat);
|
|
||||||
};
|
|
||||||
pill.appendChild(remove);
|
|
||||||
}
|
|
||||||
container.appendChild(pill);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchCategory(cat) {
|
|
||||||
saveCurrentCategoryFields();
|
|
||||||
currentCategory = cat;
|
|
||||||
renderCategoryPills();
|
|
||||||
loadCategoryToUI(cat);
|
|
||||||
updateNsRootInfo();
|
|
||||||
showPreview(cat);
|
|
||||||
persistCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadCategoryToUI(cat) {
|
|
||||||
const area = document.getElementById("fieldsArea");
|
|
||||||
area.value = categories[cat]?.fieldsText || "";
|
|
||||||
updateNsRootInfo();
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveCurrentCategoryFields() {
|
|
||||||
const area = document.getElementById("fieldsArea");
|
|
||||||
if (!categories[currentCategory]) {
|
|
||||||
categories[currentCategory] = { fieldsText: "" };
|
|
||||||
}
|
|
||||||
categories[currentCategory].fieldsText = area.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteCategory(cat) {
|
|
||||||
if (!confirm(`Sigur ștergi categoria "${cat}"?`)) return;
|
|
||||||
delete categories[cat];
|
|
||||||
const keys = Object.keys(categories);
|
|
||||||
currentCategory = keys[0] || "Beneficiar";
|
|
||||||
renderCategoryPills();
|
|
||||||
loadCategoryToUI(currentCategory);
|
|
||||||
updateNsRootInfo();
|
|
||||||
persistCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addCategoryPrompt() {
|
|
||||||
const name = prompt("Nume categorie nouă (ex: Urbanism, Fiscal, Altele):");
|
|
||||||
if (!name) return;
|
|
||||||
const trimmed = name.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
if (categories[trimmed]) {
|
|
||||||
alert("Categoria există deja.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
categories[trimmed] = { fieldsText: "" };
|
|
||||||
currentCategory = trimmed;
|
|
||||||
renderCategoryPills();
|
|
||||||
loadCategoryToUI(currentCategory);
|
|
||||||
updateNsRootInfo();
|
|
||||||
persistCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetCategoryToPreset() {
|
|
||||||
if (!defaultPresets[currentCategory]) {
|
|
||||||
alert("Categoria curentă nu are preset definit.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!confirm("Resetezi lista de câmpuri la presetul standard pentru această categorie?")) return;
|
|
||||||
categories[currentCategory].fieldsText = defaultPresets[currentCategory].join("\n");
|
|
||||||
loadCategoryToUI(currentCategory);
|
|
||||||
persistCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearCategoryFields() {
|
|
||||||
categories[currentCategory].fieldsText = "";
|
|
||||||
loadCategoryToUI(currentCategory);
|
|
||||||
persistCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateNsRootInfo() {
|
|
||||||
const ns = getCategoryNamespace(currentCategory);
|
|
||||||
const root = getCategoryRoot(currentCategory);
|
|
||||||
document.getElementById("nsRootInfo").innerHTML =
|
|
||||||
`<strong>Namespace:</strong> <code>${ns}</code><br>` +
|
|
||||||
`<strong>Root element:</strong> <code><${root}></code>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- GENERARE XML PENTRU O CATEGORIE ---
|
|
||||||
function generateCategory(cat) {
|
|
||||||
const entry = categories[cat];
|
|
||||||
if (!entry) return { xml: "", xpaths: "" };
|
|
||||||
|
|
||||||
const raw = (entry.fieldsText || "").split(/\r?\n/)
|
|
||||||
.map(l => l.trim())
|
|
||||||
.filter(l => l.length > 0);
|
|
||||||
|
|
||||||
if (raw.length === 0) {
|
|
||||||
return { xml: "", xpaths: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ns = getCategoryNamespace(cat);
|
|
||||||
const root = getCategoryRoot(cat);
|
|
||||||
const computeMetrics = document.getElementById("computeMetrics").checked;
|
|
||||||
|
|
||||||
const usedNames = new Set();
|
|
||||||
const fields = []; // { label, baseName, variants: [] }
|
|
||||||
|
|
||||||
for (const label of raw) {
|
|
||||||
const base = sanitizeName(label);
|
|
||||||
if (!base) continue;
|
|
||||||
|
|
||||||
let baseName = base;
|
|
||||||
let idx = 2;
|
|
||||||
while (usedNames.has(baseName)) {
|
|
||||||
baseName = base + "_" + idx;
|
|
||||||
idx++;
|
|
||||||
}
|
|
||||||
usedNames.add(baseName);
|
|
||||||
|
|
||||||
const variants = [baseName];
|
|
||||||
if (mode === "advanced") {
|
|
||||||
const advCandidates = [
|
|
||||||
baseName + "Short",
|
|
||||||
baseName + "Upper",
|
|
||||||
baseName + "Lower",
|
|
||||||
baseName + "Initials",
|
|
||||||
baseName + "First"
|
|
||||||
];
|
|
||||||
for (let v of advCandidates) {
|
|
||||||
let vn = v;
|
|
||||||
let k = 2;
|
|
||||||
while (usedNames.has(vn)) {
|
|
||||||
vn = v + "_" + k;
|
|
||||||
k++;
|
|
||||||
}
|
|
||||||
usedNames.add(vn);
|
|
||||||
variants.push(vn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fields.push({ label, baseName, variants });
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectăm câmpuri pentru metrici (în special categoria Suprafete)
|
|
||||||
const extraMetricFields = [];
|
|
||||||
if (computeMetrics && cat.toLowerCase().includes("suprafete")) {
|
|
||||||
const hasTeren = fields.some(f => f.baseName.toLowerCase().includes("suprafatateren"));
|
|
||||||
const hasLaSol = fields.some(f => f.baseName.toLowerCase().includes("suprafataconstruitalasol"));
|
|
||||||
const hasDesf = fields.some(f => f.baseName.toLowerCase().includes("suprafatadesfasurata"));
|
|
||||||
|
|
||||||
if (hasTeren && hasLaSol) {
|
|
||||||
if (!usedNames.has("POT")) {
|
|
||||||
usedNames.add("POT");
|
|
||||||
extraMetricFields.push({ label: "Procent Ocupare Teren", baseName: "POT", variants: ["POT"] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasTeren && hasDesf) {
|
|
||||||
if (!usedNames.has("CUT")) {
|
|
||||||
usedNames.add("CUT");
|
|
||||||
extraMetricFields.push({ label: "Coeficient Utilizare Teren", baseName: "CUT", variants: ["CUT"] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// generăm XML
|
|
||||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
||||||
xml += `<${root} xmlns="${ns}">\n`;
|
|
||||||
|
|
||||||
const allFieldEntries = fields.concat(extraMetricFields);
|
|
||||||
|
|
||||||
for (const f of allFieldEntries) {
|
|
||||||
for (const v of f.variants) {
|
|
||||||
xml += ` <${v}></${v}>\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
xml += `</${root}>\n`;
|
|
||||||
|
|
||||||
// generăm XPaths
|
|
||||||
let xp = `Categorie: ${cat}\nNamespace: ${ns}\nRoot: /${root}\n\n`;
|
|
||||||
for (const f of fields) {
|
|
||||||
xp += `# ${f.label}\n`;
|
|
||||||
for (const v of f.variants) {
|
|
||||||
xp += `/${root}/${v}\n`;
|
|
||||||
}
|
|
||||||
xp += `\n`;
|
|
||||||
}
|
|
||||||
if (extraMetricFields.length > 0) {
|
|
||||||
xp += `# Metrici auto (POT / CUT)\n`;
|
|
||||||
for (const f of extraMetricFields) {
|
|
||||||
for (const v of f.variants) {
|
|
||||||
xp += `/${root}/${v}\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
xp += `\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { xml, xpaths: xp };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- GENERARE PENTRU TOATE CATEGORIILE ---
|
|
||||||
function generateAll(showForCurrent = true) {
|
|
||||||
saveCurrentCategoryFields();
|
|
||||||
Object.keys(categories).forEach(cat => {
|
|
||||||
const { xml, xpaths } = generateCategory(cat);
|
|
||||||
xmlParts[cat] = xml;
|
|
||||||
xpathParts[cat] = xpaths;
|
|
||||||
});
|
|
||||||
if (showForCurrent) {
|
|
||||||
showPreview(currentCategory);
|
|
||||||
}
|
|
||||||
persistCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- PREVIEW ---
|
|
||||||
function showPreview(cat) {
|
|
||||||
document.getElementById("xmlPreview").textContent = xmlParts[cat] || "<!-- Niciun XML generat încă pentru această categorie. -->";
|
|
||||||
document.getElementById("xpathPreview").textContent = xpathParts[cat] || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- DOWNLOAD: XML CATEGORIE ---
|
|
||||||
function downloadCurrentXml() {
|
|
||||||
generateAll(false);
|
|
||||||
const xml = xmlParts[currentCategory];
|
|
||||||
if (!xml) {
|
|
||||||
alert("Nu există XML generat pentru categoria curentă. Apasă întâi „Generează XML pentru toate categoriile”.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const root = getCategoryRoot(currentCategory);
|
|
||||||
const fileName = root + ".xml";
|
|
||||||
const blob = new Blob([xml], { type: "application/xml" });
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = fileName;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(a.href);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- DOWNLOAD: ZIP CU TOATE XML-URILE ---
|
|
||||||
async function downloadZipAll() {
|
|
||||||
generateAll(false);
|
|
||||||
const cats = Object.keys(categories);
|
|
||||||
if (cats.length === 0) {
|
|
||||||
alert("Nu există categorii.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const zip = new JSZip();
|
|
||||||
const folder = zip.folder("customXmlParts");
|
|
||||||
|
|
||||||
let hasAny = false;
|
|
||||||
for (const cat of cats) {
|
|
||||||
const xml = xmlParts[cat];
|
|
||||||
if (!xml) continue;
|
|
||||||
hasAny = true;
|
|
||||||
const root = getCategoryRoot(cat);
|
|
||||||
const fileName = root + ".xml";
|
|
||||||
folder.file(fileName, xml);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasAny) {
|
|
||||||
alert("Nu există XML generat încă. Apasă întâi „Generează XML pentru toate categoriile”.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = await zip.generateAsync({ type: "blob" });
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = URL.createObjectURL(content);
|
|
||||||
a.download = "beletage_custom_xml_parts.zip";
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(a.href);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- INIT ---
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
|
||||||
initCategories();
|
|
||||||
updateNsRootInfo();
|
|
||||||
generateAll();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ro">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Generator XML Word – Versiune Extinsă</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
padding: 1.5rem;
|
|
||||||
background: #0f172a;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: #020617;
|
|
||||||
border-radius: 1rem;
|
|
||||||
padding: 1.25rem;
|
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
|
||||||
border: 1px solid #1e293b;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
label { font-size: .85rem; color: #94a3b8; }
|
|
||||||
input, textarea {
|
|
||||||
width: 100%; padding: .55rem .7rem;
|
|
||||||
border-radius: .5rem; border: 1px solid #334155;
|
|
||||||
background: #020617; color: #e5e7eb;
|
|
||||||
}
|
|
||||||
textarea { min-height: 120px; }
|
|
||||||
button {
|
|
||||||
padding: .6rem 1.2rem; border-radius: 999px; border: none;
|
|
||||||
background: linear-gradient(135deg,#38bdf8,#6366f1);
|
|
||||||
font-weight: 600; color: white; cursor: pointer;
|
|
||||||
}
|
|
||||||
pre {
|
|
||||||
background: #000; padding: .8rem; border-radius: .7rem;
|
|
||||||
border: 1px solid #1e293b; max-height: 350px; overflow: auto;
|
|
||||||
font-size: .85rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<h1>Generator Word XML – Varianta Extinsă (cu Short / Upper / Lower / Initials)</h1>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<label>Namespace URI</label>
|
|
||||||
<input id="nsUri" value="http://schemas.beletage.ro/word/contract">
|
|
||||||
|
|
||||||
<label style="margin-top:1rem;">Element rădăcină</label>
|
|
||||||
<input id="rootElement" value="ContractData">
|
|
||||||
|
|
||||||
<label style="margin-top:1rem;">Lista de câmpuri (unul pe linie)</label>
|
|
||||||
<textarea id="fieldList">NumeClient
|
|
||||||
TitluProiect
|
|
||||||
Adresa</textarea>
|
|
||||||
|
|
||||||
<button onclick="generateXML()" style="margin-top:1rem;">Generează XML complet</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h3>Custom XML Part (item1.xml)</h3>
|
|
||||||
<pre id="xmlOutput"></pre>
|
|
||||||
<button onclick="downloadXML()">Descarcă XML</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h3>XPaths pentru mapping</h3>
|
|
||||||
<pre id="xpathOutput"></pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function sanitize(name) {
|
|
||||||
if (!name) return null;
|
|
||||||
let n = name.trim();
|
|
||||||
if (!n) return null;
|
|
||||||
n = n.replace(/\s+/g,"_").replace(/[^A-Za-z0-9_.-]/g,"");
|
|
||||||
if (!/^[A-Za-z_]/.test(n)) n = "_" + n;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initials(str) {
|
|
||||||
return str.split(/\s+/).map(s => s[0]?.toUpperCase() + ".").join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateXML() {
|
|
||||||
const ns = document.getElementById("nsUri").value.trim();
|
|
||||||
const root = sanitize(document.getElementById("rootElement").value) || "Root";
|
|
||||||
const fieldRaw = document.getElementById("fieldList").value;
|
|
||||||
|
|
||||||
const lines = fieldRaw.split(/\r?\n/)
|
|
||||||
.map(l => l.trim()).filter(l => l.length);
|
|
||||||
|
|
||||||
const fields = [];
|
|
||||||
|
|
||||||
for (let l of lines) {
|
|
||||||
const base = sanitize(l);
|
|
||||||
if (!base) continue;
|
|
||||||
|
|
||||||
fields.push({
|
|
||||||
base,
|
|
||||||
variants: [
|
|
||||||
base, // original
|
|
||||||
base + "Short", // prescurtat
|
|
||||||
base + "Upper", // caps
|
|
||||||
base + "Lower", // lowercase
|
|
||||||
base + "Initials", // inițiale
|
|
||||||
base + "First" // primul cuvânt
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// === GENERĂM XML ===
|
|
||||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
||||||
xml += `<${root} xmlns="${ns}">\n`;
|
|
||||||
|
|
||||||
for (const f of fields) {
|
|
||||||
for (const v of f.variants) {
|
|
||||||
xml += ` <${v}></${v}>\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
xml += `</${root}>`;
|
|
||||||
|
|
||||||
document.getElementById("xmlOutput").textContent = xml;
|
|
||||||
|
|
||||||
// === GENERĂM XPATHS ===
|
|
||||||
let xp = `Namespace: ${ns}\nRoot: /${root}\n\n`;
|
|
||||||
for (const f of fields) {
|
|
||||||
xp += `# ${f.base}\n`;
|
|
||||||
xp += `/${root}/${f.base}\n`;
|
|
||||||
xp += `/${root}/${f.base}Short\n`;
|
|
||||||
xp += `/${root}/${f.base}Upper\n`;
|
|
||||||
xp += `/${root}/${f.base}Lower\n`;
|
|
||||||
xp += `/${root}/${f.base}Initials\n`;
|
|
||||||
xp += `/${root}/${f.base}First\n\n`;
|
|
||||||
}
|
|
||||||
document.getElementById("xpathOutput").textContent = xp;
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadXML() {
|
|
||||||
const text = document.getElementById("xmlOutput").textContent;
|
|
||||||
const blob = new Blob([text], { type: "application/xml" });
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = "item1.xml";
|
|
||||||
a.click();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ro">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Generator Word XML Custom Part</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
padding: 1.5rem;
|
|
||||||
background: #0f172a;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 1.6rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: #020617;
|
|
||||||
border-radius: 1rem;
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
box-shadow: 0 15px 40px rgba(0,0,0,0.35);
|
|
||||||
border: 1px solid #1f2937;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
input, textarea {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 0.5rem 0.6rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
border: 1px solid #374151;
|
|
||||||
background: #020617;
|
|
||||||
color: #e5e7eb;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
input:focus, textarea:focus {
|
|
||||||
border-color: #38bdf8;
|
|
||||||
box-shadow: 0 0 0 1px #38bdf8;
|
|
||||||
}
|
|
||||||
textarea {
|
|
||||||
min-height: 140px;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
.col-6 {
|
|
||||||
flex: 1 1 260px;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: none;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
background: linear-gradient(135deg, #38bdf8, #6366f1);
|
|
||||||
color: white;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 0 10px 25px rgba(37, 99, 235, 0.4);
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
filter: brightness(1.05);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
button:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
box-shadow: 0 6px 18px rgba(37,99,235,0.6);
|
|
||||||
}
|
|
||||||
pre {
|
|
||||||
background: #020617;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
overflow: auto;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
border: 1px solid #1f2937;
|
|
||||||
max-height: 360px;
|
|
||||||
}
|
|
||||||
.subtitle {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(148, 163, 184, 0.2);
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
.pill span {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
.small {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
}
|
|
||||||
.btn-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
.btn-secondary {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #4b5563;
|
|
||||||
box-shadow: none;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: #111827;
|
|
||||||
box-shadow: 0 8px 18px rgba(0,0,0,0.5);
|
|
||||||
}
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
body {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>Generator XML pentru Word Custom XML Part</h1>
|
|
||||||
<p class="subtitle">
|
|
||||||
Introdu câmpurile (unul pe linie) și obții XML pentru <strong>Custom XML Part</strong>, plus XPaths pentru mapping în Word.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-6">
|
|
||||||
<label for="nsUri">Namespace URI (obligatoriu)</label>
|
|
||||||
<input id="nsUri" type="text"
|
|
||||||
value="http://schemas.beletage.ro/word/data">
|
|
||||||
<div class="small">
|
|
||||||
Exemplu: <code>http://schemas.firma-ta.ro/word/contract</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label for="rootElement">Nume element rădăcină</label>
|
|
||||||
<input id="rootElement" type="text" value="Root">
|
|
||||||
<div class="small">
|
|
||||||
Exemplu: <code>ContractData</code>, <code>ClientInfo</code> etc.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top:1rem;">
|
|
||||||
<label for="fieldList">Lista de câmpuri (unul pe linie)</label>
|
|
||||||
<textarea id="fieldList" placeholder="Exemplu:
|
|
||||||
NumeClient
|
|
||||||
Adresa
|
|
||||||
DataContract
|
|
||||||
ValoareTotala"></textarea>
|
|
||||||
<div class="small">
|
|
||||||
Numele va fi curățat automat pentru a fi valid ca nume de element XML
|
|
||||||
(spațiile devin <code>_</code>, caracterele ciudate se elimină).
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-row">
|
|
||||||
<button type="button" onclick="generateXML()">Generează XML</button>
|
|
||||||
<button type="button" class="btn-secondary" onclick="fillDemo()">Exemplu demo</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="pill"><strong>1</strong><span>Custom XML Part (item1.xml)</span></div>
|
|
||||||
<pre id="xmlOutput"></pre>
|
|
||||||
<div class="btn-row">
|
|
||||||
<button type="button" class="btn-secondary" onclick="copyToClipboard('xmlOutput')">
|
|
||||||
Copiază XML
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn-secondary" onclick="downloadXML()">
|
|
||||||
Descarcă item1.xml
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="pill"><strong>2</strong><span>XPaths pentru mapping în Word</span></div>
|
|
||||||
<pre id="xpathOutput"></pre>
|
|
||||||
<button type="button" class="btn-secondary" onclick="copyToClipboard('xpathOutput')">
|
|
||||||
Copiază XPaths
|
|
||||||
</button>
|
|
||||||
<p class="small">
|
|
||||||
În Word → <strong>Developer</strong> → <strong>XML Mapping Pane</strong> → alegi Custom XML Part-ul
|
|
||||||
→ pentru fiecare câmp, click dreapta → <em>Insert Content Control</em> → tipul dorit.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function sanitizeXmlName(name) {
|
|
||||||
if (!name) return null;
|
|
||||||
let n = name.trim();
|
|
||||||
if (!n) return null;
|
|
||||||
|
|
||||||
// înlocuim spații cu underscore
|
|
||||||
n = n.replace(/\s+/g, "_");
|
|
||||||
|
|
||||||
// eliminăm caractere invalide pentru nume de element XML
|
|
||||||
n = n.replace(/[^A-Za-z0-9_.-]/g, "");
|
|
||||||
|
|
||||||
// numele XML nu are voie să înceapă cu cifră sau punct sau cratimă
|
|
||||||
if (!/^[A-Za-z_]/.test(n)) {
|
|
||||||
n = "_" + n;
|
|
||||||
}
|
|
||||||
|
|
||||||
return n || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateXML() {
|
|
||||||
const nsUri = document.getElementById("nsUri").value.trim();
|
|
||||||
const root = sanitizeXmlName(document.getElementById("rootElement").value) || "Root";
|
|
||||||
const fieldRaw = document.getElementById("fieldList").value;
|
|
||||||
const xmlOutput = document.getElementById("xmlOutput");
|
|
||||||
const xpathOutput = document.getElementById("xpathOutput");
|
|
||||||
|
|
||||||
if (!nsUri) {
|
|
||||||
alert("Te rog completează Namespace URI.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = fieldRaw.split(/\r?\n/)
|
|
||||||
.map(l => l.trim())
|
|
||||||
.filter(l => l.length > 0);
|
|
||||||
|
|
||||||
const fields = [];
|
|
||||||
const used = new Set();
|
|
||||||
|
|
||||||
for (let line of lines) {
|
|
||||||
const clean = sanitizeXmlName(line);
|
|
||||||
if (!clean) continue;
|
|
||||||
let finalName = clean;
|
|
||||||
let idx = 2;
|
|
||||||
while (used.has(finalName)) {
|
|
||||||
finalName = clean + "_" + idx;
|
|
||||||
idx++;
|
|
||||||
}
|
|
||||||
used.add(finalName);
|
|
||||||
fields.push({ original: line, xmlName: finalName });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fields.length === 0) {
|
|
||||||
xmlOutput.textContent = "<!-- Niciun câmp valid. Completează lista de câmpuri. -->";
|
|
||||||
xpathOutput.textContent = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generăm XML-ul pentru Custom XML Part
|
|
||||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
||||||
xml += `<${root} xmlns="${nsUri}">\n`;
|
|
||||||
for (const f of fields) {
|
|
||||||
xml += ` <${f.xmlName}></${f.xmlName}>\n`;
|
|
||||||
}
|
|
||||||
xml += `</${root}>\n`;
|
|
||||||
|
|
||||||
xmlOutput.textContent = xml;
|
|
||||||
|
|
||||||
// Generăm lista de XPaths
|
|
||||||
let xpaths = `Namespace: ${nsUri}\nRoot: /${root}\n\nCâmpuri:\n`;
|
|
||||||
for (const f of fields) {
|
|
||||||
xpaths += `- ${f.original} => /${root}/${f.xmlName}\n`;
|
|
||||||
}
|
|
||||||
xpathOutput.textContent = xpaths;
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyToClipboard(elementId) {
|
|
||||||
const el = document.getElementById(elementId);
|
|
||||||
if (!el || !el.textContent) return;
|
|
||||||
navigator.clipboard.writeText(el.textContent)
|
|
||||||
.then(() => alert("Copiat în clipboard."))
|
|
||||||
.catch(() => alert("Nu am reușit să copiez în clipboard."));
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadXML() {
|
|
||||||
const xmlText = document.getElementById("xmlOutput").textContent;
|
|
||||||
if (!xmlText || xmlText.startsWith("<!--")) {
|
|
||||||
alert("Nu există XML valid de descărcat.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const blob = new Blob([xmlText], { type: "application/xml" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = "item1.xml";
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillDemo() {
|
|
||||||
document.getElementById("nsUri").value = "http://schemas.beletage.ro/word/contract";
|
|
||||||
document.getElementById("rootElement").value = "ContractData";
|
|
||||||
document.getElementById("fieldList").value = [
|
|
||||||
"NumeClient",
|
|
||||||
"AdresaClient",
|
|
||||||
"Proiect",
|
|
||||||
"DataContract",
|
|
||||||
"ValoareTotala",
|
|
||||||
"Moneda",
|
|
||||||
"TermenExecutie"
|
|
||||||
].join("\n");
|
|
||||||
generateXML();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
FROM ghcr.io/maplibre/martin:1.4.0
|
||||||
|
COPY martin.yaml /config/martin.yaml
|
||||||
+4
-3
@@ -1,9 +1,10 @@
|
|||||||
# Martin v0.15 configuration — optimized tile sources for ArchiTools Geoportal
|
# Martin v1.4 configuration — optimized tile sources for ArchiTools Geoportal
|
||||||
# All geometries are EPSG:3844 (Stereo70). Bounds are approximate Romania extent.
|
# All geometries are EPSG:3844 (Stereo70). Bounds are approximate Romania extent.
|
||||||
# Original table data is NEVER modified — views compute simplification on-the-fly.
|
# Original table data is NEVER modified — views compute simplification on-the-fly.
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
connection_string: ${DATABASE_URL}
|
connection_string: ${DATABASE_URL}
|
||||||
|
pool_size: 8
|
||||||
default_srid: 3844
|
default_srid: 3844
|
||||||
auto_publish: false
|
auto_publish: false
|
||||||
tables:
|
tables:
|
||||||
@@ -83,7 +84,7 @@ postgres:
|
|||||||
geometry_column: geom
|
geometry_column: geom
|
||||||
srid: 3844
|
srid: 3844
|
||||||
bounds: [20.2, 43.5, 30.0, 48.3]
|
bounds: [20.2, 43.5, 30.0, 48.3]
|
||||||
minzoom: 10
|
minzoom: 17
|
||||||
maxzoom: 18
|
maxzoom: 18
|
||||||
properties:
|
properties:
|
||||||
object_id: text
|
object_id: text
|
||||||
@@ -138,7 +139,7 @@ postgres:
|
|||||||
geometry_column: geom
|
geometry_column: geom
|
||||||
srid: 3844
|
srid: 3844
|
||||||
bounds: [20.2, 43.5, 30.0, 48.3]
|
bounds: [20.2, 43.5, 30.0, 48.3]
|
||||||
minzoom: 12
|
minzoom: 17
|
||||||
maxzoom: 18
|
maxzoom: 18
|
||||||
properties:
|
properties:
|
||||||
object_id: text
|
object_id: text
|
||||||
|
|||||||
@@ -6,6 +6,19 @@ const nextConfig: NextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
middlewareClientMaxBodySize: '500mb',
|
middlewareClientMaxBodySize: '500mb',
|
||||||
},
|
},
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/(.*)',
|
||||||
|
headers: [
|
||||||
|
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
|
||||||
|
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||||
|
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||||
|
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
const martinUrl = process.env.MARTIN_URL || 'http://martin:3000';
|
const martinUrl = process.env.MARTIN_URL || 'http://martin:3000';
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# nginx tile cache for Martin vector tile server
|
||||||
|
# Proxy-cache layer: 10-100x faster on repeat requests, zero PostGIS load for cached tiles
|
||||||
|
|
||||||
|
proxy_cache_path /var/cache/nginx/tiles
|
||||||
|
levels=1:2
|
||||||
|
keys_zone=tiles:64m
|
||||||
|
max_size=2g
|
||||||
|
inactive=7d
|
||||||
|
use_temp_path=off;
|
||||||
|
|
||||||
|
# Log format with cache status for monitoring (docker logs tile-cache | grep HIT/MISS)
|
||||||
|
log_format tiles '$remote_addr [$time_local] "$request" $status '
|
||||||
|
'cache=$upstream_cache_status size=$body_bytes_sent '
|
||||||
|
'time=$request_time';
|
||||||
|
|
||||||
|
server {
|
||||||
|
access_log /var/log/nginx/access.log tiles;
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
location = /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "ok\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
# nginx status (active connections, request counts) — for monitoring
|
||||||
|
location = /status {
|
||||||
|
access_log off;
|
||||||
|
stub_status on;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Martin catalog endpoint (no cache)
|
||||||
|
location = /catalog {
|
||||||
|
proxy_pass http://martin:3000/catalog;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# PMTiles from MinIO — HTTPS proxy for browser access (avoids mixed-content block)
|
||||||
|
# Browser fetches: /pmtiles/overview.pmtiles → MinIO http://10.10.10.166:9002/tiles/overview.pmtiles
|
||||||
|
location /pmtiles/ {
|
||||||
|
proxy_pass http://10.10.10.166:9002/tiles/;
|
||||||
|
proxy_set_header Host 10.10.10.166:9002;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
# Range requests — essential for PMTiles (byte-range tile lookups)
|
||||||
|
proxy_set_header Range $http_range;
|
||||||
|
proxy_set_header If-Range $http_if_range;
|
||||||
|
proxy_pass_request_headers on;
|
||||||
|
|
||||||
|
# Browser cache — file changes only on rebuild (~weekly)
|
||||||
|
add_header Cache-Control "public, max-age=3600, stale-while-revalidate=86400" always;
|
||||||
|
|
||||||
|
# CORS — PMTiles loaded from tools.beletage.ro page
|
||||||
|
add_header Access-Control-Allow-Origin "*" always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
|
||||||
|
add_header Access-Control-Allow-Headers "Range, If-None-Match, If-Range, Accept-Encoding" always;
|
||||||
|
add_header Access-Control-Expose-Headers "Content-Range, Content-Length, ETag, Accept-Ranges" always;
|
||||||
|
|
||||||
|
# Preflight
|
||||||
|
if ($request_method = OPTIONS) {
|
||||||
|
add_header Access-Control-Allow-Origin "*";
|
||||||
|
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS";
|
||||||
|
add_header Access-Control-Allow-Headers "Range, If-Range";
|
||||||
|
add_header Access-Control-Max-Age 86400;
|
||||||
|
add_header Content-Length 0;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tile requests — cache aggressively
|
||||||
|
location / {
|
||||||
|
proxy_pass http://martin:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|
||||||
|
# Cache config — tiles change only on sync (weekly), long TTL is safe
|
||||||
|
proxy_cache tiles;
|
||||||
|
proxy_cache_key "$request_uri";
|
||||||
|
proxy_cache_valid 200 7d;
|
||||||
|
proxy_cache_valid 204 1h;
|
||||||
|
proxy_cache_valid 404 1m;
|
||||||
|
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
|
||||||
|
proxy_cache_lock on;
|
||||||
|
proxy_cache_lock_timeout 5s;
|
||||||
|
|
||||||
|
# Browser caching — tiles are immutable between syncs
|
||||||
|
add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800" always;
|
||||||
|
|
||||||
|
# Pass cache status header (useful for debugging)
|
||||||
|
add_header X-Cache-Status $upstream_cache_status always;
|
||||||
|
|
||||||
|
# CORS headers for tile requests
|
||||||
|
add_header Access-Control-Allow-Origin "*" always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
|
||||||
|
add_header Access-Control-Allow-Headers "Range, If-None-Match, Accept-Encoding" always;
|
||||||
|
add_header Access-Control-Expose-Headers "Content-Range, Content-Length, ETag, X-Cache-Status" always;
|
||||||
|
|
||||||
|
# Handle preflight
|
||||||
|
if ($request_method = OPTIONS) {
|
||||||
|
add_header Access-Control-Allow-Origin "*";
|
||||||
|
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS";
|
||||||
|
add_header Access-Control-Max-Age 86400;
|
||||||
|
add_header Content-Length 0;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Let Martin gzip natively — pass compressed response through to client and cache
|
||||||
|
gzip off;
|
||||||
|
|
||||||
|
# Timeouts (Martin can be slow on low-zoom tiles)
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10
@@ -25,6 +25,7 @@
|
|||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nodemailer": "^7.0.13",
|
"nodemailer": "^7.0.13",
|
||||||
|
"pmtiles": "^4.4.0",
|
||||||
"proj4": "^2.20.3",
|
"proj4": "^2.20.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
@@ -10806,6 +10807,15 @@
|
|||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pmtiles": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-tCLI1C5134MR54i8izUWhse0QUtO/EC33n9yWp1N5dYLLvyc197U0fkF5gAJhq1TdWO9Tvl+9hgvFvM0fR27Zg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"fflate": "^0.8.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pngjs": {
|
"node_modules/pngjs": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nodemailer": "^7.0.13",
|
"nodemailer": "^7.0.13",
|
||||||
|
"pmtiles": "^4.4.0",
|
||||||
"proj4": "^2.20.3",
|
"proj4": "^2.20.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ WHERE geometry IS NOT NULL AND geom IS NULL;
|
|||||||
CREATE INDEX IF NOT EXISTS gis_feature_geom_idx
|
CREATE INDEX IF NOT EXISTS gis_feature_geom_idx
|
||||||
ON "GisFeature" USING GIST (geom);
|
ON "GisFeature" USING GIST (geom);
|
||||||
|
|
||||||
|
-- B-tree index on layerId for view filtering (LIKE 'TERENURI%', 'CLADIRI%')
|
||||||
|
CREATE INDEX IF NOT EXISTS gis_feature_layer_id_idx
|
||||||
|
ON "GisFeature" ("layerId");
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 7. QGIS-friendly views
|
-- 7. QGIS-friendly views
|
||||||
-- - Clean snake_case column names
|
-- - Clean snake_case column names
|
||||||
|
|||||||
+31
-1
@@ -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 {
|
||||||
@@ -42,7 +72,7 @@ model GisFeature {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
syncRun GisSyncRun? @relation(fields: [syncRunId], references: [id])
|
syncRun GisSyncRun? @relation(fields: [syncRunId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@unique([layerId, objectId])
|
@@unique([layerId, objectId])
|
||||||
@@index([siruta])
|
@@index([siruta])
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# rebuild-overview-tiles.sh — Export all overview layers from PostGIS, generate PMTiles, upload to MinIO
|
||||||
|
# Includes: UAT boundaries, administrativ, simplified terenuri (z10-z14), simplified cladiri (z12-z14)
|
||||||
|
# Usage: ./scripts/rebuild-overview-tiles.sh
|
||||||
|
# Dependencies: ogr2ogr (GDAL), tippecanoe, mc (MinIO client)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Configuration ──
|
||||||
|
DB_HOST="${DB_HOST:-10.10.10.166}"
|
||||||
|
DB_PORT="${DB_PORT:-5432}"
|
||||||
|
DB_NAME="${DB_NAME:-architools_db}"
|
||||||
|
DB_USER="${DB_USER:-architools_user}"
|
||||||
|
DB_PASS="${DB_PASS:-stictMyFon34!_gonY}"
|
||||||
|
|
||||||
|
MINIO_ALIAS="${MINIO_ALIAS:-myminio}"
|
||||||
|
MINIO_BUCKET="${MINIO_BUCKET:-tiles}"
|
||||||
|
MINIO_ENDPOINT="${MINIO_ENDPOINT:-http://10.10.10.166:9002}"
|
||||||
|
MINIO_ACCESS_KEY="${MINIO_ACCESS_KEY:-admin}"
|
||||||
|
MINIO_SECRET_KEY="${MINIO_SECRET_KEY:-MinioStrongPass123}"
|
||||||
|
|
||||||
|
TMPDIR="${TMPDIR:-/tmp/tile-rebuild}"
|
||||||
|
OUTPUT_FILE="overview.pmtiles"
|
||||||
|
|
||||||
|
PG_CONN="PG:host=${DB_HOST} port=${DB_PORT} dbname=${DB_NAME} user=${DB_USER} password=${DB_PASS}"
|
||||||
|
|
||||||
|
echo "[$(date -Iseconds)] Starting overview tile rebuild..."
|
||||||
|
|
||||||
|
# ── Setup ──
|
||||||
|
mkdir -p "$TMPDIR"
|
||||||
|
cd "$TMPDIR"
|
||||||
|
|
||||||
|
# ── Step 1: Export views from PostGIS (parallel) ──
|
||||||
|
echo "[$(date -Iseconds)] Exporting PostGIS views to FlatGeobuf..."
|
||||||
|
|
||||||
|
# UAT boundaries (4 zoom levels)
|
||||||
|
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||||
|
uats_z0.fgb "$PG_CONN" \
|
||||||
|
-sql "SELECT name, siruta, geom FROM gis_uats_z0 WHERE geom IS NOT NULL" &
|
||||||
|
|
||||||
|
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||||
|
uats_z5.fgb "$PG_CONN" \
|
||||||
|
-sql "SELECT name, siruta, geom FROM gis_uats_z5 WHERE geom IS NOT NULL" &
|
||||||
|
|
||||||
|
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||||
|
uats_z8.fgb "$PG_CONN" \
|
||||||
|
-sql "SELECT name, siruta, county, geom FROM gis_uats_z8 WHERE geom IS NOT NULL" &
|
||||||
|
|
||||||
|
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||||
|
uats_z12.fgb "$PG_CONN" \
|
||||||
|
-sql "SELECT name, siruta, county, geom FROM gis_uats_z12 WHERE geom IS NOT NULL" &
|
||||||
|
|
||||||
|
# Administrativ (intravilan, arii speciale)
|
||||||
|
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||||
|
administrativ.fgb "$PG_CONN" \
|
||||||
|
-sql "SELECT object_id, siruta, layer_id, cadastral_ref, geom FROM gis_administrativ WHERE geom IS NOT NULL" &
|
||||||
|
|
||||||
|
# Terenuri for overview — let tippecanoe handle simplification
|
||||||
|
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||||
|
terenuri_overview.fgb "$PG_CONN" \
|
||||||
|
-sql "SELECT object_id, siruta, cadastral_ref, area_value, layer_id, geom
|
||||||
|
FROM gis_terenuri WHERE geom IS NOT NULL" &
|
||||||
|
|
||||||
|
# Cladiri for overview — let tippecanoe handle simplification
|
||||||
|
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||||
|
cladiri_overview.fgb "$PG_CONN" \
|
||||||
|
-sql "SELECT object_id, siruta, cadastral_ref, area_value, layer_id, geom
|
||||||
|
FROM gis_cladiri WHERE geom IS NOT NULL" &
|
||||||
|
|
||||||
|
wait
|
||||||
|
echo "[$(date -Iseconds)] Export complete."
|
||||||
|
|
||||||
|
# ── Step 2: Generate PMTiles with tippecanoe ──
|
||||||
|
echo "[$(date -Iseconds)] Generating PMTiles..."
|
||||||
|
|
||||||
|
# Per-layer zoom ranges — avoids processing features at zoom levels where they won't appear
|
||||||
|
# UAT boundaries: only at their respective zoom bands (saves processing z13-z18 for simple polygons)
|
||||||
|
# Terenuri/Cladiri: only z13+/z14+ (the expensive layers skip z0-z12 entirely)
|
||||||
|
tippecanoe \
|
||||||
|
-o "$OUTPUT_FILE" \
|
||||||
|
-L'{"layer":"gis_uats_z0","file":"uats_z0.fgb","minzoom":0,"maxzoom":5}' \
|
||||||
|
-L'{"layer":"gis_uats_z5","file":"uats_z5.fgb","minzoom":5,"maxzoom":8}' \
|
||||||
|
-L'{"layer":"gis_uats_z8","file":"uats_z8.fgb","minzoom":8,"maxzoom":12}' \
|
||||||
|
-L'{"layer":"gis_uats_z12","file":"uats_z12.fgb","minzoom":12,"maxzoom":14}' \
|
||||||
|
-L'{"layer":"gis_administrativ","file":"administrativ.fgb","minzoom":10,"maxzoom":16}' \
|
||||||
|
-L'{"layer":"gis_terenuri","file":"terenuri_overview.fgb","minzoom":13,"maxzoom":18}' \
|
||||||
|
-L'{"layer":"gis_cladiri","file":"cladiri_overview.fgb","minzoom":14,"maxzoom":18}' \
|
||||||
|
--base-zoom=18 \
|
||||||
|
--drop-densest-as-needed \
|
||||||
|
--detect-shared-borders \
|
||||||
|
--no-tile-stats \
|
||||||
|
--hilbert \
|
||||||
|
--force
|
||||||
|
|
||||||
|
echo "[$(date -Iseconds)] PMTiles generated: $(du -h "$OUTPUT_FILE" | cut -f1)"
|
||||||
|
|
||||||
|
# ── Step 3: Upload to MinIO (atomic swap) ──
|
||||||
|
echo "[$(date -Iseconds)] Uploading to MinIO..."
|
||||||
|
|
||||||
|
# Configure MinIO client alias (idempotent)
|
||||||
|
mc alias set "$MINIO_ALIAS" "$MINIO_ENDPOINT" "$MINIO_ACCESS_KEY" "$MINIO_SECRET_KEY" --api S3v4 2>/dev/null || true
|
||||||
|
|
||||||
|
# Ensure bucket exists
|
||||||
|
mc mb --ignore-existing "${MINIO_ALIAS}/${MINIO_BUCKET}" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Upload as temp file first
|
||||||
|
mc cp "$OUTPUT_FILE" "${MINIO_ALIAS}/${MINIO_BUCKET}/overview_new.pmtiles"
|
||||||
|
|
||||||
|
# Atomic rename (zero-downtime swap)
|
||||||
|
mc mv "${MINIO_ALIAS}/${MINIO_BUCKET}/overview_new.pmtiles" "${MINIO_ALIAS}/${MINIO_BUCKET}/overview.pmtiles"
|
||||||
|
|
||||||
|
echo "[$(date -Iseconds)] Upload complete."
|
||||||
|
|
||||||
|
# ── Step 4: Cleanup ──
|
||||||
|
rm -f uats_z0.fgb uats_z5.fgb uats_z8.fgb uats_z12.fgb administrativ.fgb \
|
||||||
|
terenuri_overview.fgb cladiri_overview.fgb "$OUTPUT_FILE"
|
||||||
|
echo "[$(date -Iseconds)] Rebuild finished successfully."
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# tile-cache-stats.sh — Show tile cache hit/miss statistics
|
||||||
|
# Usage: ./scripts/tile-cache-stats.sh [MINUTES]
|
||||||
|
# Reads recent nginx logs from tile-cache container.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MINUTES="${1:-60}"
|
||||||
|
|
||||||
|
echo "=== Tile Cache Stats (last ${MINUTES}min) ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Get nginx status (active connections)
|
||||||
|
echo "--- Connections ---"
|
||||||
|
curl -s "http://10.10.10.166:3010/status" 2>/dev/null || echo "(status endpoint unavailable)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Parse recent logs for cache hit/miss ratio
|
||||||
|
echo "--- Cache Performance ---"
|
||||||
|
docker logs tile-cache --since "${MINUTES}m" 2>/dev/null | \
|
||||||
|
grep -oP 'cache=\K\w+' | sort | uniq -c | sort -rn || echo "(no logs in timeframe)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- Cache Size ---"
|
||||||
|
docker exec tile-cache du -sh /var/cache/nginx/tiles/ 2>/dev/null || echo "(cannot read cache dir)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- Slowest Tiles (>1s) ---"
|
||||||
|
docker logs tile-cache --since "${MINUTES}m" 2>/dev/null | \
|
||||||
|
grep -oP 'time=\K[0-9.]+' | awk '$1 > 1.0 {print $1"s"}' | sort -rn | head -5 || echo "(none)"
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# warm-tile-cache.sh — Pre-populate nginx tile cache with common tiles
|
||||||
|
# Usage: ./scripts/warm-tile-cache.sh [BASE_URL]
|
||||||
|
# Run after deploy or cache purge to ensure fast first-load for users.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE="${1:-http://10.10.10.166:3010}"
|
||||||
|
PARALLEL="${PARALLEL:-8}"
|
||||||
|
TOTAL=0
|
||||||
|
HITS=0
|
||||||
|
|
||||||
|
echo "[$(date -Iseconds)] Warming tile cache at $BASE ..."
|
||||||
|
|
||||||
|
# ── Helper: fetch a range of tiles ──
|
||||||
|
fetch_tiles() {
|
||||||
|
local source="$1" z="$2" x_min="$3" x_max="$4" y_min="$5" y_max="$6"
|
||||||
|
for x in $(seq "$x_min" "$x_max"); do
|
||||||
|
for y in $(seq "$y_min" "$y_max"); do
|
||||||
|
echo "${BASE}/${source}/${z}/${x}/${y}"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Romania bounding box at various zoom levels ──
|
||||||
|
# Lon: 20.2-30.0, Lat: 43.5-48.3
|
||||||
|
# Tile coords computed from slippy map formula
|
||||||
|
|
||||||
|
# z5: UATs coarse (2 tiles)
|
||||||
|
fetch_tiles gis_uats_z5 5 17 18 11 11
|
||||||
|
|
||||||
|
# z7: UATs moderate (12 tiles)
|
||||||
|
fetch_tiles gis_uats_z8 7 69 73 44 46
|
||||||
|
|
||||||
|
# z8: UATs + labels (40 tiles)
|
||||||
|
fetch_tiles gis_uats_z8 8 139 147 88 92
|
||||||
|
|
||||||
|
# z9: UATs labels (100 tiles — major cities area)
|
||||||
|
fetch_tiles gis_uats_z8 9 279 288 177 185
|
||||||
|
|
||||||
|
# z10: Administrativ + terenuri sources start loading
|
||||||
|
# Focus on major metro areas: Bucharest, Cluj, Timisoara, Iasi, Brasov
|
||||||
|
# Bucharest area (z12)
|
||||||
|
fetch_tiles gis_terenuri 12 2300 2310 1490 1498
|
||||||
|
fetch_tiles gis_cladiri 12 2300 2310 1490 1498
|
||||||
|
# Cluj area (z12)
|
||||||
|
fetch_tiles gis_terenuri 12 2264 2270 1460 1465
|
||||||
|
fetch_tiles gis_cladiri 12 2264 2270 1460 1465
|
||||||
|
|
||||||
|
echo "[$(date -Iseconds)] Fetching tiles ($PARALLEL concurrent)..."
|
||||||
|
|
||||||
|
# Pipe all URLs through xargs+curl for parallel fetching
|
||||||
|
fetch_tiles gis_uats_z5 5 17 18 11 11
|
||||||
|
fetch_tiles gis_uats_z8 7 69 73 44 46
|
||||||
|
fetch_tiles gis_uats_z8 8 139 147 88 92
|
||||||
|
fetch_tiles gis_uats_z8 9 279 288 177 185
|
||||||
|
fetch_tiles gis_terenuri 12 2300 2310 1490 1498
|
||||||
|
fetch_tiles gis_cladiri 12 2300 2310 1490 1498
|
||||||
|
fetch_tiles gis_terenuri 12 2264 2270 1460 1465
|
||||||
|
fetch_tiles gis_cladiri 12 2264 2270 1460 1465
|
||||||
|
|
||||||
|
# Actually execute all fetches
|
||||||
|
{
|
||||||
|
fetch_tiles gis_uats_z5 5 17 18 11 11
|
||||||
|
fetch_tiles gis_uats_z8 7 69 73 44 46
|
||||||
|
fetch_tiles gis_uats_z8 8 139 147 88 92
|
||||||
|
fetch_tiles gis_uats_z8 9 279 288 177 185
|
||||||
|
fetch_tiles gis_terenuri 12 2300 2310 1490 1498
|
||||||
|
fetch_tiles gis_cladiri 12 2300 2310 1490 1498
|
||||||
|
fetch_tiles gis_terenuri 12 2264 2270 1460 1465
|
||||||
|
fetch_tiles gis_cladiri 12 2264 2270 1460 1465
|
||||||
|
} | xargs -P "$PARALLEL" -I {} curl -sf -o /dev/null {} 2>/dev/null
|
||||||
|
|
||||||
|
echo "[$(date -Iseconds)] Cache warming complete."
|
||||||
@@ -0,0 +1,736 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
|
||||||
|
type MonitorData = {
|
||||||
|
timestamp: string;
|
||||||
|
nginx?: { activeConnections?: number; requests?: number; reading?: number; writing?: number; waiting?: number; error?: string };
|
||||||
|
martin?: { status?: string; sources?: string[]; sourceCount?: number; error?: string };
|
||||||
|
pmtiles?: { url?: string; status?: string; size?: string; lastModified?: string; error?: string };
|
||||||
|
cacheTests?: { tile: string; status: string; cache: string }[];
|
||||||
|
config?: { martinUrl?: string; pmtilesUrl?: string; n8nWebhook?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type EterraSessionStatus = {
|
||||||
|
connected: boolean;
|
||||||
|
username?: string;
|
||||||
|
connectedAt?: string;
|
||||||
|
activeJobCount: number;
|
||||||
|
eterraAvailable?: boolean;
|
||||||
|
eterraMaintenance?: boolean;
|
||||||
|
eterraHealthMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GisStats = {
|
||||||
|
totalUats: number;
|
||||||
|
totalFeatures: number;
|
||||||
|
totalTerenuri: number;
|
||||||
|
totalCladiri: number;
|
||||||
|
totalEnriched: number;
|
||||||
|
totalNoGeom: number;
|
||||||
|
countiesWithData: number;
|
||||||
|
lastSyncAt: string | null;
|
||||||
|
dbSizeMb: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MonitorPage() {
|
||||||
|
const [data, setData] = useState<MonitorData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionLoading, setActionLoading] = useState("");
|
||||||
|
const [logs, setLogs] = useState<{ time: string; type: "info" | "ok" | "error" | "wait"; msg: string }[]>([]);
|
||||||
|
const [counties, setCounties] = useState<string[]>([]);
|
||||||
|
const [selectedCounty, setSelectedCounty] = useState("");
|
||||||
|
const [eterraSession, setEterraSession] = useState<EterraSessionStatus>({ connected: false, activeJobCount: 0 });
|
||||||
|
const [eterraConnecting, setEterraConnecting] = useState(false);
|
||||||
|
const [showLoginForm, setShowLoginForm] = useState(false);
|
||||||
|
const [eterraUser, setEterraUser] = useState("");
|
||||||
|
const [eterraPwd, setEterraPwd] = useState("");
|
||||||
|
const [gisStats, setGisStats] = useState<GisStats | null>(null);
|
||||||
|
const rebuildPrevRef = useRef<string | null>(null);
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/geoportal/monitor");
|
||||||
|
if (res.ok) setData(await res.json());
|
||||||
|
} catch { /* noop */ }
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
|
// Auto-refresh every 30s
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(refresh, 30_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const addLog = useCallback((type: "info" | "ok" | "error" | "wait", msg: string) => {
|
||||||
|
setLogs((prev) => [{ time: new Date().toLocaleTimeString("ro-RO"), type, msg }, ...prev.slice(0, 49)]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch counties for sync selector
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/eterra/counties")
|
||||||
|
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||||
|
.then((d: { counties: string[] }) => setCounties(d.counties ?? []))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// eTerra session status — poll every 30s
|
||||||
|
const fetchEterraSession = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/eterra/session");
|
||||||
|
if (res.ok) setEterraSession(await res.json() as EterraSessionStatus);
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchEterraSession();
|
||||||
|
const interval = setInterval(() => void fetchEterraSession(), 30_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchEterraSession]);
|
||||||
|
|
||||||
|
// GIS stats — poll every 30s
|
||||||
|
const fetchGisStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/eterra/stats");
|
||||||
|
if (res.ok) setGisStats(await res.json() as GisStats);
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchGisStats();
|
||||||
|
const interval = setInterval(() => void fetchGisStats(), 30_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchGisStats]);
|
||||||
|
|
||||||
|
const handleEterraConnect = async () => {
|
||||||
|
setEterraConnecting(true);
|
||||||
|
try {
|
||||||
|
const payload: Record<string, string> = { action: "connect" };
|
||||||
|
if (eterraUser.trim()) payload.username = eterraUser.trim();
|
||||||
|
if (eterraPwd.trim()) payload.password = eterraPwd.trim();
|
||||||
|
const res = await fetch("/api/eterra/session", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const d = await res.json() as { success?: boolean; error?: string };
|
||||||
|
if (d.success) {
|
||||||
|
await fetchEterraSession();
|
||||||
|
addLog("ok", "eTerra conectat");
|
||||||
|
setShowLoginForm(false);
|
||||||
|
setEterraPwd("");
|
||||||
|
} else {
|
||||||
|
addLog("error", `eTerra: ${d.error ?? "Eroare conectare"}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
addLog("error", "eTerra: eroare retea");
|
||||||
|
}
|
||||||
|
setEterraConnecting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEterraDisconnect = async () => {
|
||||||
|
const res = await fetch("/api/eterra/session", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "disconnect" }),
|
||||||
|
});
|
||||||
|
const d = await res.json() as { success?: boolean; error?: string };
|
||||||
|
if (d.success) {
|
||||||
|
setEterraSession({ connected: false, activeJobCount: 0 });
|
||||||
|
addLog("info", "eTerra deconectat");
|
||||||
|
} else {
|
||||||
|
addLog("error", `Deconectare: ${d.error ?? "Eroare"}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup poll on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const triggerRebuild = async () => {
|
||||||
|
setActionLoading("rebuild");
|
||||||
|
addLog("info", "Se trimite webhook la N8N...");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/geoportal/monitor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "rebuild" }),
|
||||||
|
});
|
||||||
|
const result = await res.json() as { ok?: boolean; error?: string; alreadyRunning?: boolean; previousPmtiles?: { lastModified: string } };
|
||||||
|
if (!result.ok) {
|
||||||
|
addLog("error", result.error ?? "Eroare necunoscuta");
|
||||||
|
setActionLoading("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addLog("ok", result.alreadyRunning
|
||||||
|
? "Rebuild deja in curs. Se monitorizeaza..."
|
||||||
|
: "Webhook trimis. Rebuild pornit...");
|
||||||
|
rebuildPrevRef.current = result.previousPmtiles?.lastModified ?? null;
|
||||||
|
// Poll every 15s to check if PMTiles was updated
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
pollRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const checkRes = await fetch("/api/geoportal/monitor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "check-rebuild", previousLastModified: rebuildPrevRef.current }),
|
||||||
|
});
|
||||||
|
const check = await checkRes.json() as { changed?: boolean; current?: { size: string; lastModified: string } };
|
||||||
|
if (check.changed) {
|
||||||
|
addLog("ok", `Rebuild finalizat! PMTiles: ${check.current?.size}, actualizat: ${check.current?.lastModified}`);
|
||||||
|
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||||
|
setActionLoading("");
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
} catch { /* continue polling */ }
|
||||||
|
}, 15_000);
|
||||||
|
// Timeout after 90 min (z18 builds can take 45-60 min)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (pollRef.current) {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
pollRef.current = null;
|
||||||
|
addLog("error", "Timeout: rebuild nu s-a finalizat in 90 minute");
|
||||||
|
setActionLoading("");
|
||||||
|
}
|
||||||
|
}, 90 * 60_000);
|
||||||
|
} catch {
|
||||||
|
addLog("error", "Nu s-a putut trimite webhook-ul");
|
||||||
|
setActionLoading("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerWarmCache = async () => {
|
||||||
|
setActionLoading("warm-cache");
|
||||||
|
addLog("info", "Se incarca tile-uri in cache...");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/geoportal/monitor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "warm-cache" }),
|
||||||
|
});
|
||||||
|
const result = await res.json() as { ok?: boolean; error?: string; total?: number; hits?: number; misses?: number; errors?: number; message?: string };
|
||||||
|
if (result.ok) {
|
||||||
|
addLog("ok", result.message ?? "Cache warming finalizat");
|
||||||
|
} else {
|
||||||
|
addLog("error", result.error ?? "Eroare");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
addLog("error", "Eroare la warm cache");
|
||||||
|
}
|
||||||
|
setActionLoading("");
|
||||||
|
setTimeout(refresh, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Tile Infrastructure Monitor</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{data?.timestamp ? `Ultima actualizare: ${new Date(data.timestamp).toLocaleTimeString("ro-RO")}` : "Se incarca..."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 rounded bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "..." : "Reincarca"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{/* Nginx Card */}
|
||||||
|
<Card title="nginx Tile Cache">
|
||||||
|
{data?.nginx?.error ? (
|
||||||
|
<StatusBadge status="error" label={data.nginx.error} />
|
||||||
|
) : data?.nginx ? (
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<StatusBadge status="ok" label="Online" />
|
||||||
|
<div className="grid grid-cols-2 gap-1 mt-2">
|
||||||
|
<Stat label="Conexiuni active" value={data.nginx.activeConnections} />
|
||||||
|
<Stat label="Total requests" value={data.nginx.requests?.toLocaleString()} />
|
||||||
|
<Stat label="Reading" value={data.nginx.reading} />
|
||||||
|
<Stat label="Writing" value={data.nginx.writing} />
|
||||||
|
<Stat label="Waiting" value={data.nginx.waiting} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : <Skeleton />}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Martin Card */}
|
||||||
|
<Card title="Martin Tile Server">
|
||||||
|
{data?.martin?.error ? (
|
||||||
|
<StatusBadge status="error" label={data.martin.error} />
|
||||||
|
) : data?.martin ? (
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<StatusBadge status="ok" label={`${data.martin.sourceCount} surse active`} />
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{data.martin.sources?.map((s) => (
|
||||||
|
<span key={s} className="inline-block mr-1 mb-1 px-2 py-0.5 rounded bg-muted text-xs">{s}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : <Skeleton />}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* PMTiles Card */}
|
||||||
|
<Card title="PMTiles Overview">
|
||||||
|
{data?.pmtiles?.error ? (
|
||||||
|
<StatusBadge status="error" label={data.pmtiles.error} />
|
||||||
|
) : data?.pmtiles?.status === "not configured" ? (
|
||||||
|
<StatusBadge status="warn" label="Nu e configurat" />
|
||||||
|
) : data?.pmtiles ? (
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<StatusBadge status="ok" label={data.pmtiles.size ?? "OK"} />
|
||||||
|
<Stat label="Ultima modificare" value={data.pmtiles.lastModified} />
|
||||||
|
</div>
|
||||||
|
) : <Skeleton />}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cache Test Results */}
|
||||||
|
<Card title="Cache Test">
|
||||||
|
{data?.cacheTests ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left">
|
||||||
|
<th className="py-2 pr-4">Tile</th>
|
||||||
|
<th className="py-2 pr-4">HTTP</th>
|
||||||
|
<th className="py-2">Cache</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.cacheTests.map((t, i) => (
|
||||||
|
<tr key={i} className="border-b border-border/50">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">{t.tile}</td>
|
||||||
|
<td className="py-2 pr-4">{t.status}</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||||
|
t.cache === "HIT" ? "bg-green-500/20 text-green-400" :
|
||||||
|
t.cache === "MISS" ? "bg-yellow-500/20 text-yellow-400" :
|
||||||
|
"bg-red-500/20 text-red-400"
|
||||||
|
}`}>
|
||||||
|
{t.cache}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : <Skeleton />}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* eTerra Connection + Live Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
{/* Connection card */}
|
||||||
|
<Card title="Conexiune eTerra">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${
|
||||||
|
eterraSession.eterraMaintenance ? "bg-yellow-400 animate-pulse" :
|
||||||
|
eterraSession.connected ? "bg-green-400" : "bg-red-400"
|
||||||
|
}`} />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{eterraSession.eterraMaintenance ? "Mentenanta" :
|
||||||
|
eterraSession.connected ? (eterraSession.username || "Conectat") : "Deconectat"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{eterraSession.connected && eterraSession.connectedAt && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Conectat de la {new Date(eterraSession.connectedAt).toLocaleTimeString("ro-RO")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{eterraSession.connected && eterraSession.activeJobCount > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||||
|
<span>{eterraSession.activeJobCount} {eterraSession.activeJobCount === 1 ? "job activ" : "joburi active"}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pt-1">
|
||||||
|
{eterraSession.connected ? (
|
||||||
|
<button
|
||||||
|
onClick={handleEterraDisconnect}
|
||||||
|
className="w-full text-xs px-3 py-1.5 rounded-md border border-border hover:bg-destructive/10 hover:text-destructive hover:border-destructive/30 transition-colors"
|
||||||
|
>
|
||||||
|
Deconecteaza
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLoginForm((v) => !v)}
|
||||||
|
className="w-full text-xs px-3 py-1.5 rounded-md border border-border hover:border-primary/50 hover:bg-primary/10 transition-colors"
|
||||||
|
>
|
||||||
|
{showLoginForm ? "Anuleaza" : "Conecteaza"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showLoginForm && !eterraSession.connected && (
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={eterraUser}
|
||||||
|
onChange={(e) => setEterraUser(e.target.value)}
|
||||||
|
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||||
|
placeholder="Utilizator eTerra"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={eterraPwd}
|
||||||
|
onChange={(e) => setEterraPwd(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") handleEterraConnect(); }}
|
||||||
|
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||||
|
placeholder="Parola"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleEterraConnect}
|
||||||
|
disabled={eterraConnecting || !eterraUser.trim() || !eterraPwd.trim()}
|
||||||
|
className="w-full h-8 rounded-md bg-primary text-primary-foreground text-xs hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{eterraConnecting ? "Se conecteaza..." : "Login"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Live stats cards */}
|
||||||
|
<StatCard
|
||||||
|
label="UAT-uri"
|
||||||
|
value={gisStats?.totalUats}
|
||||||
|
sub={gisStats?.countiesWithData ? `din ${gisStats.countiesWithData} judete` : undefined}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Parcele"
|
||||||
|
value={gisStats?.totalTerenuri}
|
||||||
|
sub={gisStats?.totalEnriched ? `${gisStats.totalEnriched.toLocaleString("ro-RO")} enriched` : undefined}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Cladiri"
|
||||||
|
value={gisStats?.totalCladiri}
|
||||||
|
sub={gisStats?.dbSizeMb ? `DB: ${gisStats.dbSizeMb >= 1024 ? `${(gisStats.dbSizeMb / 1024).toFixed(1)} GB` : `${gisStats.dbSizeMb} MB`}` : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gisStats?.lastSyncAt && (
|
||||||
|
<div className="text-xs text-muted-foreground text-right -mt-2">
|
||||||
|
Ultimul sync: {new Date(gisStats.lastSyncAt).toLocaleString("ro-RO")} — auto-refresh 30s
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Card title="Actiuni">
|
||||||
|
{/* Tile infrastructure actions */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Tile-uri</h3>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<ActionButton
|
||||||
|
label="Rebuild PMTiles"
|
||||||
|
description="Regenereaza tile-urile overview din PostGIS (~45-60 min)"
|
||||||
|
loading={actionLoading === "rebuild"}
|
||||||
|
onClick={triggerRebuild}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
label="Warm Cache"
|
||||||
|
description="Pre-incarca tile-uri frecvente in nginx cache"
|
||||||
|
loading={actionLoading === "warm-cache"}
|
||||||
|
onClick={triggerWarmCache}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sync actions */}
|
||||||
|
<div>
|
||||||
|
<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"
|
||||||
|
description={`Toate judetele (${gisStats?.countiesWithData ?? "?"} jud, ${gisStats?.totalUats ?? "?"} UAT) — poate dura ore`}
|
||||||
|
siruta=""
|
||||||
|
mode="base"
|
||||||
|
includeNoGeometry={false}
|
||||||
|
actionKey="sync-all-counties"
|
||||||
|
actionLoading={actionLoading}
|
||||||
|
setActionLoading={setActionLoading}
|
||||||
|
addLog={addLog}
|
||||||
|
pollRef={pollRef}
|
||||||
|
customEndpoint="/api/eterra/sync-all-counties"
|
||||||
|
/>
|
||||||
|
<SyncTestButton
|
||||||
|
label="Refresh ALL UATs"
|
||||||
|
description={`Delta sync pe toate ${gisStats?.totalUats ?? "?"} UAT-urile din DB`}
|
||||||
|
siruta=""
|
||||||
|
mode="base"
|
||||||
|
includeNoGeometry={false}
|
||||||
|
actionKey="refresh-all"
|
||||||
|
actionLoading={actionLoading}
|
||||||
|
setActionLoading={setActionLoading}
|
||||||
|
addLog={addLog}
|
||||||
|
pollRef={pollRef}
|
||||||
|
customEndpoint="/api/eterra/refresh-all"
|
||||||
|
/>
|
||||||
|
<SyncTestButton
|
||||||
|
label="Test Delta — Cluj-Napoca"
|
||||||
|
description="Parcele + cladiri existente, fara magic (54975)"
|
||||||
|
siruta="54975"
|
||||||
|
mode="base"
|
||||||
|
includeNoGeometry={false}
|
||||||
|
actionKey="delta-cluj-base"
|
||||||
|
actionLoading={actionLoading}
|
||||||
|
setActionLoading={setActionLoading}
|
||||||
|
addLog={addLog}
|
||||||
|
pollRef={pollRef}
|
||||||
|
/>
|
||||||
|
<SyncTestButton
|
||||||
|
label="Test Delta — Feleacu"
|
||||||
|
description="Magic + no-geom, cu enrichment (57582)"
|
||||||
|
siruta="57582"
|
||||||
|
mode="magic"
|
||||||
|
includeNoGeometry={true}
|
||||||
|
actionKey="delta-feleacu-magic"
|
||||||
|
actionLoading={actionLoading}
|
||||||
|
setActionLoading={setActionLoading}
|
||||||
|
addLog={addLog}
|
||||||
|
pollRef={pollRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* County sync */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Sync pe judet</h3>
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<select
|
||||||
|
value={selectedCounty}
|
||||||
|
onChange={(e) => setSelectedCounty(e.target.value)}
|
||||||
|
className="h-9 w-52 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>
|
||||||
|
<SyncTestButton
|
||||||
|
label={selectedCounty ? `Sync ${selectedCounty}` : "Sync Judet"}
|
||||||
|
description="TERENURI + CLADIRI + INTRAVILAN pentru tot judetul"
|
||||||
|
siruta=""
|
||||||
|
mode="base"
|
||||||
|
includeNoGeometry={false}
|
||||||
|
actionKey="sync-county"
|
||||||
|
actionLoading={actionLoading}
|
||||||
|
setActionLoading={setActionLoading}
|
||||||
|
addLog={addLog}
|
||||||
|
pollRef={pollRef}
|
||||||
|
customEndpoint="/api/eterra/sync-county"
|
||||||
|
customBody={{ county: selectedCounty }}
|
||||||
|
disabled={!selectedCounty}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Activity Log */}
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 bg-muted/50 border-b border-border">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Log activitate</span>
|
||||||
|
<button onClick={() => setLogs([])} className="text-xs text-muted-foreground hover:text-foreground transition-colors">Sterge</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-56 overflow-y-auto">
|
||||||
|
{logs.map((log, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 px-4 py-1.5 text-xs border-b border-border/30 last:border-0">
|
||||||
|
<span className="text-muted-foreground shrink-0 font-mono">{log.time}</span>
|
||||||
|
<span className={`shrink-0 ${
|
||||||
|
log.type === "ok" ? "text-green-400" :
|
||||||
|
log.type === "error" ? "text-red-400" :
|
||||||
|
log.type === "wait" ? "text-yellow-400" :
|
||||||
|
"text-blue-400"
|
||||||
|
}`}>
|
||||||
|
{log.type === "ok" ? "[OK]" : log.type === "error" ? "[ERR]" : log.type === "wait" ? "[...]" : "[i]"}
|
||||||
|
</span>
|
||||||
|
<span>{log.msg}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Config */}
|
||||||
|
<Card title="Configuratie">
|
||||||
|
{data?.config ? (
|
||||||
|
<div className="space-y-1 text-sm font-mono">
|
||||||
|
<div><span className="text-muted-foreground">MARTIN_URL:</span> {data.config.martinUrl}</div>
|
||||||
|
<div><span className="text-muted-foreground">PMTILES_URL:</span> {data.config.pmtilesUrl}</div>
|
||||||
|
<div><span className="text-muted-foreground">N8N_WEBHOOK:</span> {data.config.n8nWebhook}</div>
|
||||||
|
</div>
|
||||||
|
) : <Skeleton />}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Sub-components ---- */
|
||||||
|
|
||||||
|
function Card({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground mb-3">{title}</h2>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status, label }: { status: "ok" | "error" | "warn"; label: string }) {
|
||||||
|
const colors = {
|
||||||
|
ok: "bg-green-500/20 text-green-400",
|
||||||
|
error: "bg-red-500/20 text-red-400",
|
||||||
|
warn: "bg-yellow-500/20 text-yellow-400",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium ${colors[status]}`}>
|
||||||
|
<span className={`w-2 h-2 rounded-full ${status === "ok" ? "bg-green-400" : status === "error" ? "bg-red-400" : "bg-yellow-400"}`} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value?: string | number | null }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-xs">{label}</div>
|
||||||
|
<div className="font-medium">{value ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Skeleton() {
|
||||||
|
return <div className="h-16 rounded bg-muted/50 animate-pulse" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, sub }: { label: string; value?: number | null; sub?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card p-4">
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">{label}</div>
|
||||||
|
<div className="text-2xl font-bold tabular-nums">
|
||||||
|
{value != null ? value.toLocaleString("ro-RO") : <span className="text-muted-foreground">--</span>}
|
||||||
|
</div>
|
||||||
|
{sub && <div className="text-xs text-muted-foreground mt-1">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({ label, description, loading, onClick }: {
|
||||||
|
label: string; description: string; loading: boolean; onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex flex-col items-start px-4 py-3 rounded-lg border border-border hover:border-primary/50 hover:bg-primary/5 transition-colors disabled:opacity-50 text-left"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-sm">{loading ? "Se ruleaza..." : label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{description}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, actionKey, actionLoading, setActionLoading, addLog, pollRef, customEndpoint, customBody, disabled }: {
|
||||||
|
label: string; description: string; siruta: string; mode: "base" | "magic";
|
||||||
|
includeNoGeometry: boolean; actionKey: string; actionLoading: string;
|
||||||
|
setActionLoading: (v: string) => void;
|
||||||
|
addLog: (type: "info" | "ok" | "error" | "wait", msg: string) => void;
|
||||||
|
pollRef: React.MutableRefObject<ReturnType<typeof setInterval> | null>;
|
||||||
|
customEndpoint?: string;
|
||||||
|
customBody?: Record<string, unknown>;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const startTimeRef = useRef<number>(0);
|
||||||
|
const formatElapsed = () => {
|
||||||
|
if (!startTimeRef.current) return "";
|
||||||
|
const s = Math.round((Date.now() - startTimeRef.current) / 1000);
|
||||||
|
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setActionLoading(actionKey);
|
||||||
|
startTimeRef.current = Date.now();
|
||||||
|
addLog("info", `[${label}] Pornire...`);
|
||||||
|
try {
|
||||||
|
const endpoint = customEndpoint ?? "/api/eterra/sync-background";
|
||||||
|
const body = customEndpoint ? (customBody ?? {}) : { siruta, mode, includeNoGeometry };
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const d = await res.json() as { jobId?: string; error?: string };
|
||||||
|
if (!res.ok) {
|
||||||
|
addLog("error", `[${label}] ${d.error ?? "Eroare start"}`);
|
||||||
|
setActionLoading(""); return;
|
||||||
|
}
|
||||||
|
addLog("ok", `[${label}] Job: ${d.jobId?.slice(0, 8)}`);
|
||||||
|
const jid = d.jobId;
|
||||||
|
let lastPhase = "";
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
pollRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const pr = await fetch(`/api/eterra/progress?jobId=${jid}`);
|
||||||
|
const pg = await pr.json() as { status?: string; phase?: string; downloaded?: number; total?: number; note?: string; message?: string };
|
||||||
|
const pct = pg.total ? Math.round(((pg.downloaded ?? 0) / pg.total) * 100) : 0;
|
||||||
|
const elapsed = formatElapsed();
|
||||||
|
const phaseChanged = pg.phase !== lastPhase;
|
||||||
|
if (phaseChanged) lastPhase = pg.phase ?? "";
|
||||||
|
// Only log phase changes and completion to keep log clean
|
||||||
|
if (phaseChanged || pg.status === "done" || pg.status === "error") {
|
||||||
|
const noteStr = pg.note ? ` — ${pg.note}` : "";
|
||||||
|
addLog(
|
||||||
|
pg.status === "done" ? "ok" : pg.status === "error" ? "error" : "wait",
|
||||||
|
`[${label}] ${elapsed} | ${pg.phase ?? "..."} (${pct}%)${noteStr}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pg.status === "done" || pg.status === "error") {
|
||||||
|
const totalTime = formatElapsed();
|
||||||
|
addLog(pg.status === "done" ? "ok" : "error",
|
||||||
|
`[${label}] TOTAL: ${totalTime}${pg.message ? " — " + pg.message : ""}`,
|
||||||
|
);
|
||||||
|
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||||
|
setActionLoading("");
|
||||||
|
}
|
||||||
|
} catch { /* continue */ }
|
||||||
|
}, 3000);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (pollRef.current) {
|
||||||
|
clearInterval(pollRef.current); pollRef.current = null;
|
||||||
|
addLog("error", `[${label}] Timeout 3h (${formatElapsed()})`);
|
||||||
|
setActionLoading("");
|
||||||
|
}
|
||||||
|
}, 3 * 60 * 60_000);
|
||||||
|
} catch { addLog("error", `[${label}] Eroare retea`); setActionLoading(""); }
|
||||||
|
}}
|
||||||
|
disabled={!!actionLoading || !!disabled}
|
||||||
|
className="flex flex-col items-start px-4 py-3 rounded-lg border border-border hover:border-primary/50 hover:bg-primary/5 transition-colors disabled:opacity-50 text-left"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-sm">{actionLoading === actionKey ? "Se ruleaza..." : label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{description}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,878 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/shared/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Terminal,
|
||||||
|
Bug,
|
||||||
|
Sparkles,
|
||||||
|
Shield,
|
||||||
|
TestTube,
|
||||||
|
Plug,
|
||||||
|
RefreshCw,
|
||||||
|
FileText,
|
||||||
|
Zap,
|
||||||
|
Rocket,
|
||||||
|
ListChecks,
|
||||||
|
Brain,
|
||||||
|
Wrench,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// ─── Types ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type PromptCategory = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
description: string;
|
||||||
|
prompts: PromptTemplate[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PromptTemplate = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
prompt: string;
|
||||||
|
tags: string[];
|
||||||
|
oneTime?: boolean;
|
||||||
|
variables?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Module list for variable substitution hints ───────────────────
|
||||||
|
|
||||||
|
const MODULES = [
|
||||||
|
"registratura", "address-book", "parcel-sync", "geoportal", "password-vault",
|
||||||
|
"mini-utilities", "email-signature", "word-xml", "word-templates", "tag-manager",
|
||||||
|
"it-inventory", "digital-signatures", "prompt-generator", "ai-chat", "hot-desk",
|
||||||
|
"visual-copilot", "dashboard",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ─── Prompt Templates ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CATEGORIES: PromptCategory[] = [
|
||||||
|
{
|
||||||
|
id: "module-work",
|
||||||
|
label: "Lucru pe modul",
|
||||||
|
icon: <Terminal className="size-4" />,
|
||||||
|
description: "Prompturi pentru lucru general, bugfix-uri si features pe module existente",
|
||||||
|
prompts: [
|
||||||
|
{
|
||||||
|
id: "module-work-general",
|
||||||
|
title: "Lucru general pe modul",
|
||||||
|
description: "Sesiune de lucru pe un modul specific — citeste contextul, propune, implementeaza",
|
||||||
|
tags: ["regular", "module"],
|
||||||
|
variables: ["MODULE_NAME"],
|
||||||
|
prompt: `Scopul acestei sesiuni este sa lucram pe modulul {MODULE_NAME} din ArchiTools.
|
||||||
|
|
||||||
|
Citeste CLAUDE.md si docs/MODULE-MAP.md inainte de orice.
|
||||||
|
Apoi citeste tipurile si componentele modulului:
|
||||||
|
- src/modules/{MODULE_NAME}/types.ts
|
||||||
|
- src/modules/{MODULE_NAME}/config.ts
|
||||||
|
- src/modules/{MODULE_NAME}/components/ (fisierele principale)
|
||||||
|
- src/modules/{MODULE_NAME}/services/ (daca exista)
|
||||||
|
|
||||||
|
Dupa ce ai inteles codul existent, intreaba-ma ce vreau sa fac.
|
||||||
|
Nu propune schimbari pana nu intelegi modulul complet.
|
||||||
|
npx next build TREBUIE sa treaca dupa fiecare schimbare.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bugfix",
|
||||||
|
title: "Bugfix pe modul",
|
||||||
|
description: "Investigheaza si rezolva un bug specific",
|
||||||
|
tags: ["regular", "bugfix"],
|
||||||
|
variables: ["MODULE_NAME", "BUG_DESCRIPTION"],
|
||||||
|
prompt: `Am un bug in modulul {MODULE_NAME}: {BUG_DESCRIPTION}
|
||||||
|
|
||||||
|
Citeste CLAUDE.md si docs/MODULE-MAP.md pentru context.
|
||||||
|
|
||||||
|
Pasi:
|
||||||
|
1. Citeste fisierele relevante ale modulului
|
||||||
|
2. Identifica cauza root — nu ghici, citeste codul
|
||||||
|
3. Propune fix-ul INAINTE sa-l aplici
|
||||||
|
4. Aplica fix-ul minimal (nu refactoriza alte lucruri)
|
||||||
|
5. Verifica ca npx next build trece
|
||||||
|
6. Explica ce s-a schimbat si de ce
|
||||||
|
|
||||||
|
Daca bug-ul e in interactiunea cu alt modul sau API, citeste si acel cod.
|
||||||
|
Nu adauga features sau "imbunatatiri" — doar fix bug-ul raportat.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "feature-existing",
|
||||||
|
title: "Feature nou in modul existent",
|
||||||
|
description: "Adauga o functionalitate noua intr-un modul care exista deja",
|
||||||
|
tags: ["regular", "feature"],
|
||||||
|
variables: ["MODULE_NAME", "FEATURE_DESCRIPTION"],
|
||||||
|
prompt: `Vreau sa adaug un feature in modulul {MODULE_NAME}: {FEATURE_DESCRIPTION}
|
||||||
|
|
||||||
|
Citeste CLAUDE.md si docs/MODULE-MAP.md pentru context.
|
||||||
|
|
||||||
|
Pasi obligatorii:
|
||||||
|
1. Citeste types.ts, config.ts si componentele existente ale modulului
|
||||||
|
2. Verifica daca feature-ul necesita schimbari de tip (types.ts)
|
||||||
|
3. Verifica daca necesita API route nou sau modificare la cel existent
|
||||||
|
4. Propune planul de implementare INAINTE de a scrie cod
|
||||||
|
5. Implementeaza pas cu pas, verificand build dupa fiecare fisier major
|
||||||
|
6. Pastreaza conventiile existente (English code, Romanian UI)
|
||||||
|
7. npx next build TREBUIE sa treaca
|
||||||
|
|
||||||
|
Reguli:
|
||||||
|
- Nu schimba structura modulului fara motiv
|
||||||
|
- Nu adauga dependinte noi daca nu e necesar
|
||||||
|
- Pastreaza compatibilitatea cu datele existente in storage/DB
|
||||||
|
- Daca trebuie migrare de date, propune-o separat`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "feature-new-module",
|
||||||
|
title: "Modul complet nou",
|
||||||
|
description: "Creeaza un modul nou de la zero urmand pattern-ul standard",
|
||||||
|
tags: ["one-time", "feature", "architecture"],
|
||||||
|
oneTime: true,
|
||||||
|
variables: ["MODULE_NAME", "MODULE_DESCRIPTION"],
|
||||||
|
prompt: `Vreau sa creez un modul nou: {MODULE_NAME} — {MODULE_DESCRIPTION}
|
||||||
|
|
||||||
|
Citeste CLAUDE.md, docs/MODULE-MAP.md si docs/guides/MODULE-DEVELOPMENT.md.
|
||||||
|
Studiaza un modul existent similar ca referinta (ex: it-inventory sau hot-desk pentru module simple, registratura pentru module complexe).
|
||||||
|
|
||||||
|
Creeaza structura standard:
|
||||||
|
src/modules/{MODULE_NAME}/
|
||||||
|
components/{MODULE_NAME}-module.tsx
|
||||||
|
hooks/use-{MODULE_NAME}.ts (daca e nevoie)
|
||||||
|
services/ (daca e nevoie)
|
||||||
|
types.ts
|
||||||
|
config.ts
|
||||||
|
index.ts
|
||||||
|
|
||||||
|
Plus:
|
||||||
|
- src/app/(modules)/{MODULE_NAME}/page.tsx (route page)
|
||||||
|
- Adauga config in src/config/modules.ts
|
||||||
|
- Adauga flag in src/config/flags.ts
|
||||||
|
- Adauga navigare in src/config/navigation.ts
|
||||||
|
|
||||||
|
Reguli:
|
||||||
|
- Urmeaza EXACT pattern-ul celorlalte module
|
||||||
|
- English code, Romanian UI text
|
||||||
|
- Feature flag enabled by default
|
||||||
|
- Storage via useStorage('{MODULE_NAME}') hook
|
||||||
|
- npx next build TREBUIE sa treaca
|
||||||
|
- Nu implementa mai mult decat MVP-ul — pot adauga dupa`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "api",
|
||||||
|
label: "API & Backend",
|
||||||
|
icon: <Plug className="size-4" />,
|
||||||
|
description: "Creare si modificare API routes, integrari externe, Prisma schema",
|
||||||
|
prompts: [
|
||||||
|
{
|
||||||
|
id: "api-new-route",
|
||||||
|
title: "API route nou",
|
||||||
|
description: "Creeaza un endpoint API nou cu auth, validare, error handling",
|
||||||
|
tags: ["regular", "api"],
|
||||||
|
variables: ["ROUTE_PATH", "ROUTE_DESCRIPTION"],
|
||||||
|
prompt: `Creeaza un API route nou: /api/{ROUTE_PATH} — {ROUTE_DESCRIPTION}
|
||||||
|
|
||||||
|
Citeste CLAUDE.md (sectiunea Middleware & Large Uploads) si docs/ARCHITECTURE-QUICK.md.
|
||||||
|
|
||||||
|
Cerinte obligatorii:
|
||||||
|
1. Auth: middleware coverage SAU requireAuth() pentru rute excluse
|
||||||
|
2. Input validation pe toate parametrii
|
||||||
|
3. Error handling: try/catch cu mesaje utile (nu stack traces)
|
||||||
|
4. Prisma queries: parametrizate ($queryRaw cu template literals, NU string concat)
|
||||||
|
5. TypeScript strict: toate return types explicit
|
||||||
|
|
||||||
|
Pattern de referinta — citeste un API route existent similar:
|
||||||
|
- CRUD simplu: src/app/api/storage/route.ts
|
||||||
|
- Cu Prisma raw: src/app/api/registratura/route.ts
|
||||||
|
- Cu external API: src/app/api/eterra/search/route.ts
|
||||||
|
|
||||||
|
Daca ruta accepta uploads mari:
|
||||||
|
- Exclude din middleware matcher (src/middleware.ts)
|
||||||
|
- Adauga requireAuth() manual
|
||||||
|
- Documenteaza in CLAUDE.md sectiunea Middleware
|
||||||
|
|
||||||
|
npx next build TREBUIE sa treaca.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prisma-schema",
|
||||||
|
title: "Modificare Prisma schema",
|
||||||
|
description: "Adauga model nou sau modifica schema existenta",
|
||||||
|
tags: ["regular", "database"],
|
||||||
|
variables: ["CHANGE_DESCRIPTION"],
|
||||||
|
prompt: `Vreau sa modific Prisma schema: {CHANGE_DESCRIPTION}
|
||||||
|
|
||||||
|
Citeste prisma/schema.prisma complet inainte.
|
||||||
|
|
||||||
|
Pasi:
|
||||||
|
1. Propune schimbarea de schema INAINTE de a o aplica
|
||||||
|
2. Verifica impactul asupra codului existent (grep pentru modelul afectat)
|
||||||
|
3. Aplica in schema.prisma
|
||||||
|
4. Ruleaza: npx prisma generate
|
||||||
|
5. Actualizeaza codul care foloseste modelul
|
||||||
|
6. npx next build TREBUIE sa treaca
|
||||||
|
|
||||||
|
Reguli:
|
||||||
|
- Adauga @@index pe coloane folosite in WHERE/ORDER BY
|
||||||
|
- Adauga @@unique pe combinatii care trebuie sa fie unice
|
||||||
|
- onDelete: SetNull sau Cascade — niciodata default (restrict)
|
||||||
|
- Foloseste Json? pentru campuri flexibile (enrichment pattern)
|
||||||
|
- DateTime cu @default(now()) pe createdAt, @updatedAt pe updatedAt
|
||||||
|
|
||||||
|
IMPORTANT: Aceasta schimbare necesita si migrare pe server (prisma migrate).
|
||||||
|
Nu face breaking changes fara plan de migrare.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "external-integration",
|
||||||
|
title: "Integrare API extern",
|
||||||
|
description: "Conectare la un serviciu extern (pattern eTerra/ePay)",
|
||||||
|
tags: ["one-time", "api", "architecture"],
|
||||||
|
oneTime: true,
|
||||||
|
variables: ["SERVICE_NAME", "SERVICE_DESCRIPTION"],
|
||||||
|
prompt: `Vreau sa integrez un serviciu extern: {SERVICE_NAME} — {SERVICE_DESCRIPTION}
|
||||||
|
|
||||||
|
Citeste CLAUDE.md sectiunile eTerra/ANCPI Rules si Middleware.
|
||||||
|
Studiaza pattern-ul din src/modules/parcel-sync/services/eterra-client.ts ca referinta.
|
||||||
|
|
||||||
|
Pattern obligatoriu pentru integrari externe:
|
||||||
|
1. Client class separat in services/ (nu inline in route)
|
||||||
|
2. Session/token caching cu TTL (global singleton pattern)
|
||||||
|
3. Periodic cleanup pe cache (setInterval)
|
||||||
|
4. Health check daca serviciul e instabil
|
||||||
|
5. Retry logic pentru erori tranziente (ECONNRESET, 500)
|
||||||
|
6. Timeout explicit pe toate request-urile
|
||||||
|
7. Error handling granular (nu catch-all generic)
|
||||||
|
8. Logging cu prefix: console.log("[{SERVICE_NAME}] ...")
|
||||||
|
|
||||||
|
Env vars:
|
||||||
|
- Adauga in docker-compose.yml
|
||||||
|
- NU hardcoda credentials in cod
|
||||||
|
- Documenteaza in docs/ARCHITECTURE-QUICK.md
|
||||||
|
|
||||||
|
npx next build TREBUIE sa treaca.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quality",
|
||||||
|
label: "Calitate & Securitate",
|
||||||
|
icon: <Shield className="size-4" />,
|
||||||
|
description: "Audituri de securitate, testing, performance, code review",
|
||||||
|
prompts: [
|
||||||
|
{
|
||||||
|
id: "security-audit",
|
||||||
|
title: "Audit securitate complet",
|
||||||
|
description: "Scanare completa de securitate pe tot codebase-ul",
|
||||||
|
tags: ["periodic", "security"],
|
||||||
|
prompt: `Scopul acestei sesiuni este un audit complet de securitate al ArchiTools.
|
||||||
|
Aplicatia este IN PRODUCTIE la https://tools.beletage.ro.
|
||||||
|
|
||||||
|
Citeste CLAUDE.md si docs/ARCHITECTURE-QUICK.md inainte de orice.
|
||||||
|
|
||||||
|
Scaneaza cu agenti in paralel:
|
||||||
|
|
||||||
|
1. API AUTH: Verifica ca TOATE rutele din src/app/api/ au auth check
|
||||||
|
(middleware matcher + requireAuth fallback)
|
||||||
|
2. SQL INJECTION: Cauta $queryRaw/$executeRaw cu string concatenation
|
||||||
|
3. INPUT VALIDATION: Verifica sanitizarea pe toate endpoint-urile
|
||||||
|
4. SECRETS: Cauta credentials hardcoded, env vars expuse in client
|
||||||
|
5. ERROR HANDLING: Catch goale, stack traces in responses
|
||||||
|
6. RACE CONDITIONS: Write operations concurente fara locks
|
||||||
|
7. DATA INTEGRITY: Upsert-uri care pot suprascrie date
|
||||||
|
|
||||||
|
Grupeaza in: CRITICAL / IMPORTANT / NICE-TO-HAVE
|
||||||
|
Pentru fiecare: fisier, linia, problema, solutia propusa.
|
||||||
|
NU aplica fix-uri fara sa le listezi mai intai.
|
||||||
|
npx next build TREBUIE sa treaca dupa fiecare fix.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "security-module",
|
||||||
|
title: "Audit securitate pe modul",
|
||||||
|
description: "Review de securitate focusat pe un singur modul",
|
||||||
|
tags: ["regular", "security"],
|
||||||
|
variables: ["MODULE_NAME"],
|
||||||
|
prompt: `Fa un audit de securitate pe modulul {MODULE_NAME}.
|
||||||
|
|
||||||
|
Citeste CLAUDE.md si docs/MODULE-MAP.md.
|
||||||
|
|
||||||
|
Verifica:
|
||||||
|
1. API routes folosite de modul — au auth? Input validation?
|
||||||
|
2. Prisma queries — SQL injection posibil?
|
||||||
|
3. User input — sanitizat inainte de stocare/afisare?
|
||||||
|
4. File uploads (daca exista) — validare tip/dimensiune?
|
||||||
|
5. Storage operations — race conditions la concurrent access?
|
||||||
|
6. Error handling — erori silentioase? Stack traces expuse?
|
||||||
|
7. Cross-module deps — sunt corecte si necesare?
|
||||||
|
|
||||||
|
Raporteaza gasirile cu: fisier, linia, severitate, fix propus.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "testing-hardcore",
|
||||||
|
title: "Testing hardcore",
|
||||||
|
description: "Edge cases, stress testing, error scenarios pentru un modul",
|
||||||
|
tags: ["periodic", "testing"],
|
||||||
|
variables: ["MODULE_NAME"],
|
||||||
|
prompt: `Vreau sa testez hardcore modulul {MODULE_NAME}.
|
||||||
|
|
||||||
|
Citeste codul modulului complet, apoi gandeste:
|
||||||
|
|
||||||
|
1. EDGE CASES: Ce se intampla cu input gol? Cu caractere speciale (diacritice, emoji)? Cu valori extreme (numar foarte mare, string foarte lung)?
|
||||||
|
|
||||||
|
2. CONCURRENT ACCESS: Ce se intampla daca 2 useri fac aceeasi operatie simultan? Race conditions la write/update/delete?
|
||||||
|
|
||||||
|
3. ERROR PATHS: Ce se intampla daca DB-ul e down? Daca API-ul extern nu raspunde? Daca sesiunea expira mid-operation?
|
||||||
|
|
||||||
|
4. DATA INTEGRITY: Pot pierde date? Pot crea duplicate? Pot suprascrie datele altcuiva?
|
||||||
|
|
||||||
|
5. UI STATE: Ce se intampla daca user-ul da click dublu pe buton? Daca navigheaza away in timpul unui save? Daca face refresh?
|
||||||
|
|
||||||
|
6. STORAGE: Ce se intampla cu date legacy (format vechi)? Cu valori null/undefined in JSON?
|
||||||
|
|
||||||
|
Pentru fiecare problema gasita: descrie scenariul, impactul, si propune fix.
|
||||||
|
Aplica doar ce e CRITICAL dupa aprobare.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "performance-audit",
|
||||||
|
title: "Audit performanta",
|
||||||
|
description: "Identificare bottleneck-uri, optimizare queries, bundle size",
|
||||||
|
tags: ["periodic", "performance"],
|
||||||
|
variables: ["MODULE_NAME"],
|
||||||
|
prompt: `Analizeaza performanta modulului {MODULE_NAME}.
|
||||||
|
|
||||||
|
Citeste CLAUDE.md (Storage Performance Rules) si codul modulului.
|
||||||
|
|
||||||
|
Verifica:
|
||||||
|
1. N+1 QUERIES: storage.list() + get() in loop? Ar trebui exportAll()
|
||||||
|
2. LARGE PAYLOADS: Se incarca date inutile? lightweight: true folosit?
|
||||||
|
3. RE-RENDERS: useEffect-uri care trigger-uiesc re-render excesiv?
|
||||||
|
4. BUNDLE SIZE: Import-uri heavy (librarii intregi vs tree-shaking)?
|
||||||
|
5. API CALLS: Request-uri redundante? Lipseste caching?
|
||||||
|
6. DB QUERIES: Lipsesc indexuri? SELECT * in loc de select specific?
|
||||||
|
7. MEMORY: Global singletons care cresc nelimitat? Cache fara TTL?
|
||||||
|
|
||||||
|
Propune optimizarile ordonate dupa impact.
|
||||||
|
Aplica doar dupa aprobare.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "session",
|
||||||
|
label: "Sesiune & Continuare",
|
||||||
|
icon: <RefreshCw className="size-4" />,
|
||||||
|
description: "Prompturi pentru inceperea sau continuarea sesiunilor de lucru",
|
||||||
|
prompts: [
|
||||||
|
{
|
||||||
|
id: "continue-tasklist",
|
||||||
|
title: "Continuare din sesiunea anterioara",
|
||||||
|
description: "Reia lucrul de unde am ramas, cu verificare task list",
|
||||||
|
tags: ["regular", "session"],
|
||||||
|
prompt: `Continuam din sesiunea anterioara.
|
||||||
|
|
||||||
|
Citeste CLAUDE.md, MEMORY.md si docs/MODULE-MAP.md.
|
||||||
|
Verifica memory/ pentru context despre ce s-a lucrat recent.
|
||||||
|
|
||||||
|
Apoi:
|
||||||
|
1. Citeste ROADMAP.md (daca exista) pentru task list-ul curent
|
||||||
|
2. Verifica git log --oneline -20 sa vezi ce s-a comis recent
|
||||||
|
3. Verifica git status sa vezi daca sunt schimbari uncommited
|
||||||
|
4. Rezuma ce s-a facut si ce a ramas
|
||||||
|
5. Intreaba-ma cum vreau sa continuam
|
||||||
|
|
||||||
|
Nu incepe sa lucrezi fara confirmare.
|
||||||
|
npx next build TREBUIE sa treaca inainte de orice schimbare.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fresh-session",
|
||||||
|
title: "Sesiune noua — orientare",
|
||||||
|
description: "Prima sesiune sau sesiune dupa pauza lunga — ia-ti bearings",
|
||||||
|
tags: ["regular", "session"],
|
||||||
|
prompt: `Sesiune noua pe ArchiTools.
|
||||||
|
|
||||||
|
Citeste in ordine:
|
||||||
|
1. CLAUDE.md (context proiect + reguli)
|
||||||
|
2. memory/MEMORY.md (index memorii)
|
||||||
|
3. Fiecare fisier din memory/ (context sesiuni anterioare)
|
||||||
|
4. git log --oneline -20 (activitate recenta)
|
||||||
|
5. git status (stare curenta)
|
||||||
|
6. docs/MODULE-MAP.md (harta module)
|
||||||
|
|
||||||
|
Dupa ce ai citit tot, da-mi un rezumat de 5-10 randuri:
|
||||||
|
- Ce s-a facut recent
|
||||||
|
- Ce e in progress / neterminat
|
||||||
|
- Ce probleme sunt cunoscute
|
||||||
|
- Recomandarea ta pentru ce sa facem azi
|
||||||
|
|
||||||
|
Asteapta confirmarea mea inainte de a incepe.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "review-refactor",
|
||||||
|
title: "Code review & refactoring",
|
||||||
|
description: "Review si curatare cod pe o zona specifica",
|
||||||
|
tags: ["periodic", "review"],
|
||||||
|
variables: ["TARGET"],
|
||||||
|
prompt: `Fa code review pe: {TARGET}
|
||||||
|
|
||||||
|
Citeste CLAUDE.md si codul tinta complet.
|
||||||
|
|
||||||
|
Verifica:
|
||||||
|
1. PATTERN COMPLIANCE: Urmeaza conventiile din CLAUDE.md?
|
||||||
|
2. TYPE SAFETY: TypeScript strict — sunt tipuri corecte? Null checks?
|
||||||
|
3. ERROR HANDLING: Catch blocks complete? Promise-uri handled?
|
||||||
|
4. NAMING: English code, Romanian UI? Consistent cu restul?
|
||||||
|
5. COMPLEXITY: Functii prea lungi? Logica duplicata?
|
||||||
|
6. SECURITY: Input validation? Auth checks?
|
||||||
|
7. PERFORMANCE: N+1 queries? Re-renders inutile?
|
||||||
|
|
||||||
|
Raporteaza gasirile ordonate dupa severitate.
|
||||||
|
NU aplica refactoring fara listarea schimbarilor propuse si aprobare.
|
||||||
|
Refactoring-ul trebuie sa fie minimal — nu rescrie ce functioneaza.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "full-audit",
|
||||||
|
title: "Audit complet codebase",
|
||||||
|
description: "Scanare completa: cod mort, consistenta, securitate, documentatie",
|
||||||
|
tags: ["periodic", "audit"],
|
||||||
|
oneTime: true,
|
||||||
|
prompt: `Scopul acestei sesiuni este un audit complet al codebase-ului ArchiTools cu 3 obiective:
|
||||||
|
|
||||||
|
1. REVIEW & CLEANUP: Cod mort, dependinte neutilizate, TODO/FIXME, consistenta module
|
||||||
|
2. SIGURANTA IN PRODUCTIE: SQL injection, auth gaps, race conditions, data integrity
|
||||||
|
3. DOCUMENTATIE: CLAUDE.md actualizat, docs/ la zi, memory/ updatat
|
||||||
|
|
||||||
|
Citeste CLAUDE.md si MEMORY.md inainte de orice.
|
||||||
|
Foloseste agenti Explore in paralel (minim 5 simultan) pentru scanare.
|
||||||
|
|
||||||
|
Grupeaza gasirile in: CRITICAL / IMPORTANT / NICE-TO-HAVE
|
||||||
|
NU modifica cod fara sa listezi mai intai toate schimbarile propuse.
|
||||||
|
npx next build TREBUIE sa treaca dupa fiecare schimbare.
|
||||||
|
Commit frecvent cu mesaje descriptive.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "docs",
|
||||||
|
label: "Documentatie & Meta",
|
||||||
|
icon: <FileText className="size-4" />,
|
||||||
|
description: "Update documentatie, CLAUDE.md, memory, si meta-prompting",
|
||||||
|
prompts: [
|
||||||
|
{
|
||||||
|
id: "update-claudemd",
|
||||||
|
title: "Actualizeaza CLAUDE.md",
|
||||||
|
description: "Sincronizeaza CLAUDE.md cu starea actuala a codului",
|
||||||
|
tags: ["periodic", "docs"],
|
||||||
|
prompt: `CLAUDE.md trebuie actualizat sa reflecte starea curenta a proiectului.
|
||||||
|
|
||||||
|
Pasi:
|
||||||
|
1. Citeste CLAUDE.md curent
|
||||||
|
2. Verifica fiecare sectiune contra codului real:
|
||||||
|
- Module table: sunt toate modulele? Versiuni corecte?
|
||||||
|
- Stack: versiuni la zi?
|
||||||
|
- Conventions: se respecta?
|
||||||
|
- Common Pitfalls: mai sunt relevante? Lipsesc altele noi?
|
||||||
|
- Infrastructure: porturi/servicii corecte?
|
||||||
|
3. Citeste docs/MODULE-MAP.md — e la zi?
|
||||||
|
4. Citeste docs/ARCHITECTURE-QUICK.md — e la zi?
|
||||||
|
|
||||||
|
Propune schimbarile necesare inainte de a le aplica.
|
||||||
|
Target: CLAUDE.md sub 200 linii, informatii derivabile din cod mutate in docs/.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "update-memory",
|
||||||
|
title: "Actualizeaza memory/",
|
||||||
|
description: "Curata memorii vechi si adauga context nou",
|
||||||
|
tags: ["periodic", "meta"],
|
||||||
|
prompt: `Verifica si actualizeaza memory/ files.
|
||||||
|
|
||||||
|
Citeste memory/MEMORY.md si fiecare fisier indexat.
|
||||||
|
|
||||||
|
Pentru fiecare memorie:
|
||||||
|
1. E inca relevanta? Daca nu, sterge-o.
|
||||||
|
2. Informatia e la zi? Daca nu, actualizeaz-o.
|
||||||
|
3. Informatia e derivabila din cod? Daca da, sterge-o (redundanta).
|
||||||
|
|
||||||
|
Adauga memorii NOI pentru:
|
||||||
|
- Decizii arhitecturale recente care nu sunt in CLAUDE.md
|
||||||
|
- Feedback-ul meu din aceasta sesiune (daca am corectat ceva)
|
||||||
|
- Starea task-urilor in progress
|
||||||
|
|
||||||
|
NU salva in memory: cod, structura fisierelor, git history — astea se pot citi direct.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "improve-prompts",
|
||||||
|
title: "Imbunatateste prompturile",
|
||||||
|
description: "Meta-prompt: analizeaza si rafineaza prompturile din aceasta pagina",
|
||||||
|
tags: ["periodic", "meta"],
|
||||||
|
prompt: `Citeste codul paginii /prompts (src/app/(modules)/prompts/page.tsx).
|
||||||
|
|
||||||
|
Analizeaza fiecare prompt din CATEGORIES:
|
||||||
|
1. E clar si specific? Lipseste context?
|
||||||
|
2. Pasii sunt in ordine logica?
|
||||||
|
3. Include safety nets (build check, aprobare)?
|
||||||
|
4. E prea lung/scurt?
|
||||||
|
5. Variabilele sunt utile?
|
||||||
|
|
||||||
|
Apoi gandeste: ce prompturi noi ar fi utile bazat pe:
|
||||||
|
- Tipurile de task-uri care apar frecvent in git log
|
||||||
|
- Module care sunt modificate des
|
||||||
|
- Greseli care se repeta (din memory/ feedback)
|
||||||
|
|
||||||
|
Propune imbunatatiri si prompturi noi. Aplica dupa aprobare.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quick",
|
||||||
|
label: "Quick Actions",
|
||||||
|
icon: <Zap className="size-4" />,
|
||||||
|
description: "Prompturi scurte pentru actiuni rapide si frecvente",
|
||||||
|
prompts: [
|
||||||
|
{
|
||||||
|
id: "quick-build",
|
||||||
|
title: "Verifica build",
|
||||||
|
description: "Build check rapid",
|
||||||
|
tags: ["quick"],
|
||||||
|
prompt: `Ruleaza npx next build si raporteaza rezultatul. Daca sunt erori, propune fix-uri.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "deploy-prep",
|
||||||
|
title: "Pregatire deploy",
|
||||||
|
description: "Checklist complet inainte de push la productie",
|
||||||
|
tags: ["regular", "deploy"],
|
||||||
|
prompt: `Pregateste deploy-ul pe productie (tools.beletage.ro).
|
||||||
|
|
||||||
|
Checklist:
|
||||||
|
1. git status — totul comis? Fisiere untracked suspecte?
|
||||||
|
2. npx next build — zero erori?
|
||||||
|
3. docker-compose.yml — env vars noi necesare?
|
||||||
|
4. prisma/schema.prisma — s-a schimbat? Necesita migrate?
|
||||||
|
5. middleware.ts — rute noi excluse daca e cazul?
|
||||||
|
6. Verifica ca nu sunt credentials hardcoded in cod
|
||||||
|
7. git log --oneline -5 — commit messages descriptive?
|
||||||
|
|
||||||
|
Daca totul e ok, confirma "Ready to push".
|
||||||
|
Daca sunt probleme, listeaza-le cu fix propus.
|
||||||
|
|
||||||
|
IMPORTANT: Dupa push, deploy-ul e MANUAL in Portainer.
|
||||||
|
Daca schema Prisma s-a schimbat, trebuie migrate pe server.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "debug-unknown",
|
||||||
|
title: "Debug eroare necunoscuta",
|
||||||
|
description: "Investigheaza o eroare fara cauza evidenta",
|
||||||
|
tags: ["regular", "debug"],
|
||||||
|
variables: ["ERROR_DESCRIPTION"],
|
||||||
|
prompt: `Am o eroare: {ERROR_DESCRIPTION}
|
||||||
|
|
||||||
|
Investigheaza:
|
||||||
|
1. Citeste stack trace-ul (daca exista) — gaseste fisierul root cause
|
||||||
|
2. Citeste codul relevant — nu ghici, verifica
|
||||||
|
3. Cauta pattern-uri similare in codebase (grep)
|
||||||
|
4. Verifica git log recent — s-a schimbat ceva care ar cauza asta?
|
||||||
|
5. Verifica env vars — lipseste ceva?
|
||||||
|
6. Verifica Prisma schema — model-ul e in sync?
|
||||||
|
|
||||||
|
Dupa investigatie:
|
||||||
|
- Explica cauza root (nu simptomul)
|
||||||
|
- Propune fix minim
|
||||||
|
- Aplica fix dupa aprobare
|
||||||
|
- npx next build TREBUIE sa treaca`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quick-deps",
|
||||||
|
title: "Update dependinte",
|
||||||
|
description: "Verifica si actualizeaza package.json",
|
||||||
|
tags: ["quick"],
|
||||||
|
prompt: `Verifica daca sunt update-uri disponibile pentru dependintele din package.json.
|
||||||
|
|
||||||
|
Ruleaza: npm outdated
|
||||||
|
Listeaza ce se poate actualiza safe (minor/patch).
|
||||||
|
NU actualiza major versions fara discutie.
|
||||||
|
Dupa update: npx next build TREBUIE sa treaca.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quick-git-cleanup",
|
||||||
|
title: "Git cleanup",
|
||||||
|
description: "Verifica starea repo-ului si curata",
|
||||||
|
tags: ["quick"],
|
||||||
|
prompt: `Verifica starea repo-ului:
|
||||||
|
1. git status — fisiere uncommited?
|
||||||
|
2. git log --oneline -10 — commit-uri recente ok?
|
||||||
|
3. Fisiere untracked suspecte? (.env, tmp files, build artifacts)
|
||||||
|
4. .gitignore — lipseste ceva?
|
||||||
|
|
||||||
|
Propune cleanup daca e nevoie.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quick-type-check",
|
||||||
|
title: "Verificare tipuri modul",
|
||||||
|
description: "Verifica typesafety pe un modul specific",
|
||||||
|
tags: ["quick"],
|
||||||
|
variables: ["MODULE_NAME"],
|
||||||
|
prompt: `Citeste src/modules/{MODULE_NAME}/types.ts si verifica:
|
||||||
|
1. Toate interfetele sunt folosite? (grep imports)
|
||||||
|
2. Sunt tipuri any sau unknown neutipizate?
|
||||||
|
3. Optional fields corect marcate cu ?
|
||||||
|
4. Consistenta cu Prisma schema (daca modulul foloseste DB direct)
|
||||||
|
Raporteaza rapid ce nu e in regula.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Copy button component ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function CopyButton({ text, className }: { text: string; className?: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={className}
|
||||||
|
title="Copiaza"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="size-3 text-green-500" /> : <Copy className="size-3" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Prompt Card ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PromptCard({ prompt }: { prompt: PromptTemplate }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
// Persist one-time completion in localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
if (prompt.oneTime) {
|
||||||
|
const stored = localStorage.getItem(`prompt-done-${prompt.id}`);
|
||||||
|
if (stored === "true") setDone(true);
|
||||||
|
}
|
||||||
|
}, [prompt.id, prompt.oneTime]);
|
||||||
|
|
||||||
|
const toggleDone = useCallback(() => {
|
||||||
|
const next = !done;
|
||||||
|
setDone(next);
|
||||||
|
localStorage.setItem(`prompt-done-${prompt.id}`, String(next));
|
||||||
|
}, [done, prompt.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`transition-all ${done ? "opacity-50" : ""}`}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
{prompt.oneTime && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={done}
|
||||||
|
onChange={toggleDone}
|
||||||
|
className="size-4 rounded accent-primary cursor-pointer"
|
||||||
|
title={done ? "Marcheaza ca nefacut" : "Marcheaza ca facut"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex items-center gap-1 text-sm font-semibold hover:text-primary transition-colors text-left"
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown className="size-3.5 shrink-0" /> : <ChevronRight className="size-3.5 shrink-0" />}
|
||||||
|
{prompt.title}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground ml-5">{prompt.description}</p>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2 ml-5">
|
||||||
|
{prompt.tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{prompt.variables?.map((v) => (
|
||||||
|
<Badge key={v} variant="outline" className="text-[10px] px-1.5 py-0 border-amber-500/50 text-amber-600 dark:text-amber-400">
|
||||||
|
{`{${v}}`}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CopyButton text={prompt.prompt} className="shrink-0 mt-0.5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-3 ml-5">
|
||||||
|
<div className="relative group">
|
||||||
|
<pre className="text-xs bg-muted/50 border rounded-md p-3 whitespace-pre-wrap font-mono leading-relaxed overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||||
|
{prompt.prompt}
|
||||||
|
</pre>
|
||||||
|
<CopyButton
|
||||||
|
text={prompt.prompt}
|
||||||
|
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stats bar ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StatsBar() {
|
||||||
|
const totalPrompts = CATEGORIES.reduce((s, c) => s + c.prompts.length, 0);
|
||||||
|
const oneTimePrompts = CATEGORIES.reduce(
|
||||||
|
(s, c) => s + c.prompts.filter((p) => p.oneTime).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1"><Brain className="size-3" /> {totalPrompts} prompturi</span>
|
||||||
|
<span className="flex items-center gap-1"><ListChecks className="size-3" /> {oneTimePrompts} one-time</span>
|
||||||
|
<span className="flex items-center gap-1"><Wrench className="size-3" /> {CATEGORIES.length} categorii</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Best practices sidebar ────────────────────────────────────────
|
||||||
|
|
||||||
|
const BEST_PRACTICES = [
|
||||||
|
{ icon: <Rocket className="size-3" />, text: "Incepe cu CLAUDE.md — da context inainte de task" },
|
||||||
|
{ icon: <Brain className="size-3" />, text: "Cere plan inainte de implementare" },
|
||||||
|
{ icon: <Shield className="size-3" />, text: "npx next build dupa fiecare schimbare" },
|
||||||
|
{ icon: <Bug className="size-3" />, text: "Nu refactoriza cand faci bugfix" },
|
||||||
|
{ icon: <Sparkles className="size-3" />, text: "O sesiune = un obiectiv clar" },
|
||||||
|
{ icon: <TestTube className="size-3" />, text: "Verifica-ti munca: teste, build, manual" },
|
||||||
|
{ icon: <ListChecks className="size-3" />, text: "Listeaza schimbarile inainte de a le aplica" },
|
||||||
|
{ icon: <RefreshCw className="size-3" />, text: "Actualizeaza memory/ la sfarsit de sesiune" },
|
||||||
|
{ icon: <Zap className="size-3" />, text: "/clear intre task-uri diferite" },
|
||||||
|
{ icon: <Terminal className="size-3" />, text: "Dupa 2 corectii → /clear + prompt mai bun" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Main Page ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function PromptsPage() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const filtered = CATEGORIES.map((cat) => ({
|
||||||
|
...cat,
|
||||||
|
prompts: cat.prompts.filter(
|
||||||
|
(p) =>
|
||||||
|
!search ||
|
||||||
|
p.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
p.description.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
p.tags.some((t) => t.toLowerCase().includes(search.toLowerCase())),
|
||||||
|
),
|
||||||
|
})).filter((cat) => cat.prompts.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Terminal className="size-6" />
|
||||||
|
Claude Code Prompts
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Biblioteca de prompturi optimizate pentru ArchiTools. Click pe titlu pentru a vedea, buton pentru a copia.
|
||||||
|
</p>
|
||||||
|
<StatsBar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Cauta prompt... (ex: bugfix, security, parcel-sync)"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="flex-1 h-9 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSearch("")}>
|
||||||
|
Sterge
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[1fr_240px] gap-6">
|
||||||
|
{/* Main content */}
|
||||||
|
<Tabs defaultValue={CATEGORIES[0]?.id} className="w-full">
|
||||||
|
<TabsList className="w-full flex flex-wrap h-auto gap-1 bg-transparent p-0 mb-4">
|
||||||
|
{filtered.map((cat) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={cat.id}
|
||||||
|
value={cat.id}
|
||||||
|
className="flex items-center gap-1.5 text-xs data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-md px-3 py-1.5 border"
|
||||||
|
>
|
||||||
|
{cat.icon}
|
||||||
|
{cat.label}
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1 py-0 ml-1">
|
||||||
|
{cat.prompts.length}
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{filtered.map((cat) => (
|
||||||
|
<TabsContent key={cat.id} value={cat.id} className="space-y-3 mt-0">
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">{cat.description}</p>
|
||||||
|
{cat.prompts.map((prompt) => (
|
||||||
|
<PromptCard key={prompt.id} prompt={prompt} />
|
||||||
|
))}
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<h3 className="text-sm font-semibold mb-3 flex items-center gap-1.5">
|
||||||
|
<Sparkles className="size-3.5" />
|
||||||
|
Best Practices
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{BEST_PRACTICES.map((bp, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="mt-0.5 shrink-0">{bp.icon}</span>
|
||||||
|
<span>{bp.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<h3 className="text-sm font-semibold mb-3 flex items-center gap-1.5">
|
||||||
|
<Terminal className="size-3.5" />
|
||||||
|
Module disponibile
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{MODULES.map((m) => (
|
||||||
|
<Badge key={m} variant="outline" className="text-[10px] px-1.5 py-0 cursor-pointer hover:bg-accent" onClick={() => {
|
||||||
|
navigator.clipboard.writeText(m);
|
||||||
|
}}>
|
||||||
|
{m}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-2">Click pe modul = copiaza numele</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,623 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useDeferredValue, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
RotateCcw,
|
||||||
|
Moon,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
MapPin,
|
||||||
|
Search,
|
||||||
|
AlertTriangle,
|
||||||
|
WifiOff,
|
||||||
|
Activity,
|
||||||
|
Play,
|
||||||
|
Download,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type StepName = "sync_terenuri" | "sync_cladiri" | "import_nogeom" | "enrich";
|
||||||
|
type StepStatus = "pending" | "done" | "error";
|
||||||
|
|
||||||
|
type CityState = {
|
||||||
|
siruta: string;
|
||||||
|
name: string;
|
||||||
|
county: string;
|
||||||
|
priority: number;
|
||||||
|
steps: Record<StepName, StepStatus>;
|
||||||
|
lastActivity?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
dbStats?: {
|
||||||
|
terenuri: number;
|
||||||
|
cladiri: number;
|
||||||
|
total: number;
|
||||||
|
enriched: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type QueueState = {
|
||||||
|
cities: CityState[];
|
||||||
|
lastSessionDate?: string;
|
||||||
|
totalSessions: number;
|
||||||
|
completedCycles: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SyncStatus = "running" | "error" | "waiting" | "idle";
|
||||||
|
|
||||||
|
type CurrentActivity = {
|
||||||
|
city: string;
|
||||||
|
step: string;
|
||||||
|
startedAt: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
const STEPS: StepName[] = [
|
||||||
|
"sync_terenuri",
|
||||||
|
"sync_cladiri",
|
||||||
|
"import_nogeom",
|
||||||
|
"enrich",
|
||||||
|
];
|
||||||
|
|
||||||
|
const STEP_LABELS: Record<StepName, string> = {
|
||||||
|
sync_terenuri: "Terenuri",
|
||||||
|
sync_cladiri: "Cladiri",
|
||||||
|
import_nogeom: "No-geom",
|
||||||
|
enrich: "Enrichment",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Auto-poll intervals */
|
||||||
|
const POLL_ACTIVE_MS = 15_000; // 15s when running
|
||||||
|
const POLL_IDLE_MS = 60_000; // 60s when idle
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Page */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export default function WeekendDeepSyncPage() {
|
||||||
|
const [state, setState] = useState<QueueState | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
|
||||||
|
// Live status
|
||||||
|
const [syncStatus, setSyncStatus] = useState<SyncStatus>("idle");
|
||||||
|
const [currentActivity, setCurrentActivity] = useState<CurrentActivity>(null);
|
||||||
|
const [inWeekendWindow, setInWeekendWindow] = useState(false);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
// UAT autocomplete for adding cities
|
||||||
|
type UatEntry = { siruta: string; name: string; county?: string };
|
||||||
|
const [uatData, setUatData] = useState<UatEntry[]>([]);
|
||||||
|
const [uatQuery, setUatQuery] = useState("");
|
||||||
|
const [uatResults, setUatResults] = useState<UatEntry[]>([]);
|
||||||
|
const [showUatResults, setShowUatResults] = useState(false);
|
||||||
|
const uatRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const fetchState = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/eterra/weekend-sync");
|
||||||
|
if (!res.ok) {
|
||||||
|
setFetchError(`Server: ${res.status} ${res.statusText}`);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
state: QueueState | null;
|
||||||
|
syncStatus?: SyncStatus;
|
||||||
|
currentActivity?: CurrentActivity;
|
||||||
|
inWeekendWindow?: boolean;
|
||||||
|
};
|
||||||
|
setState(data.state);
|
||||||
|
setSyncStatus(data.syncStatus ?? "idle");
|
||||||
|
setCurrentActivity(data.currentActivity ?? null);
|
||||||
|
setInWeekendWindow(data.inWeekendWindow ?? false);
|
||||||
|
setFetchError(null);
|
||||||
|
setLastRefresh(new Date());
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Conexiune esuata";
|
||||||
|
setFetchError(msg);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initial load + UAT list
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchState();
|
||||||
|
fetch("/api/eterra/uats")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { uats?: UatEntry[] }) => {
|
||||||
|
if (data.uats) setUatData(data.uats);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
fetch("/uat.json")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((fallback: UatEntry[]) => setUatData(fallback))
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
}, [fetchState]);
|
||||||
|
|
||||||
|
// Auto-poll: 15s when running, 60s otherwise
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = syncStatus === "running" ? POLL_ACTIVE_MS : POLL_IDLE_MS;
|
||||||
|
const timer = setInterval(() => void fetchState(), interval);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [fetchState, syncStatus]);
|
||||||
|
|
||||||
|
// UAT autocomplete filter
|
||||||
|
const normalizeText = (text: string) =>
|
||||||
|
text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
|
||||||
|
|
||||||
|
const deferredUatQuery = useDeferredValue(uatQuery);
|
||||||
|
useEffect(() => {
|
||||||
|
const raw = deferredUatQuery.trim();
|
||||||
|
if (raw.length < 2) { setUatResults([]); return; }
|
||||||
|
const isDigit = /^\d+$/.test(raw);
|
||||||
|
const query = normalizeText(raw);
|
||||||
|
const nameMatches: UatEntry[] = [];
|
||||||
|
const countyOnly: UatEntry[] = [];
|
||||||
|
for (const item of uatData) {
|
||||||
|
// Skip cities already in queue
|
||||||
|
if (state?.cities.some((c) => c.siruta === item.siruta)) continue;
|
||||||
|
if (isDigit) {
|
||||||
|
if (item.siruta.startsWith(raw)) nameMatches.push(item);
|
||||||
|
} else {
|
||||||
|
if (normalizeText(item.name).includes(query)) nameMatches.push(item);
|
||||||
|
else if (item.county && normalizeText(item.county).includes(query))
|
||||||
|
countyOnly.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUatResults([...nameMatches, ...countyOnly].slice(0, 10));
|
||||||
|
}, [deferredUatQuery, uatData, state?.cities]);
|
||||||
|
|
||||||
|
const doAction = async (body: Record<string, unknown>) => {
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/eterra/weekend-sync", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
setFetchError(data.error ?? `Eroare: ${res.status}`);
|
||||||
|
} else {
|
||||||
|
setFetchError(null);
|
||||||
|
}
|
||||||
|
await fetchState();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Actiune esuata";
|
||||||
|
setFetchError(msg);
|
||||||
|
}
|
||||||
|
setActionLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddUat = async (uat: UatEntry) => {
|
||||||
|
await doAction({
|
||||||
|
action: "add",
|
||||||
|
siruta: uat.siruta,
|
||||||
|
name: uat.name,
|
||||||
|
county: uat.county ?? "",
|
||||||
|
priority: 3,
|
||||||
|
});
|
||||||
|
setUatQuery("");
|
||||||
|
setUatResults([]);
|
||||||
|
setShowUatResults(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl py-12 text-center text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 mx-auto mb-3 animate-spin opacity-40" />
|
||||||
|
<p>Se incarca...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cities = state?.cities ?? [];
|
||||||
|
const totalSteps = cities.length * STEPS.length;
|
||||||
|
const doneSteps = cities.reduce(
|
||||||
|
(sum, c) => sum + STEPS.filter((s) => c.steps[s] === "done").length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const progressPct = totalSteps > 0 ? Math.round((doneSteps / totalSteps) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||||
|
<Moon className="h-6 w-6 text-indigo-500" />
|
||||||
|
Weekend Deep Sync
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Sincronizare Magic completa pentru municipii mari — Vin/Sam/Dum
|
||||||
|
23:00-04:00
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{lastRefresh && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{lastRefresh.toLocaleTimeString("ro-RO", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{syncStatus !== "running" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={actionLoading}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm("Descarca terenuri + cladiri pentru orasele pending?"))
|
||||||
|
void doAction({ action: "trigger", onlySteps: ["sync_terenuri", "sync_cladiri"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
Descarca parcele
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-indigo-600 border-indigo-300 hover:bg-indigo-50 dark:text-indigo-400 dark:border-indigo-700 dark:hover:bg-indigo-950/30"
|
||||||
|
disabled={actionLoading}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm("Pornesti sincronizarea completa? Va procesa toti pasii pending."))
|
||||||
|
void doAction({ action: "trigger" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-1" />
|
||||||
|
Sync complet
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void fetchState()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||||
|
Reincarca
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection error banner */}
|
||||||
|
{fetchError && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50 px-4 py-2.5 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950/30 dark:text-rose-400">
|
||||||
|
<WifiOff className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{fetchError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live status banner */}
|
||||||
|
{syncStatus === "running" && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-indigo-200 bg-indigo-50 px-4 py-2.5 text-sm text-indigo-700 dark:border-indigo-800 dark:bg-indigo-950/30 dark:text-indigo-400">
|
||||||
|
<Activity className="h-4 w-4 shrink-0 animate-pulse" />
|
||||||
|
<span className="font-medium">Sincronizarea ruleaza</span>
|
||||||
|
{currentActivity && (
|
||||||
|
<span>
|
||||||
|
— {currentActivity.city} / {STEP_LABELS[currentActivity.step as StepName] ?? currentActivity.step}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Loader2 className="h-3.5 w-3.5 ml-auto animate-spin opacity-50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{syncStatus === "error" && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50 px-4 py-2.5 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950/30 dark:text-rose-400">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="font-medium">Erori in ultimul ciclu</span>
|
||||||
|
<span>
|
||||||
|
— {cities.filter((c) => STEPS.some((s) => c.steps[s] === "error")).map((c) => c.name).join(", ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{syncStatus === "waiting" && !fetchError && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-4 py-2.5 text-sm text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-400">
|
||||||
|
<Clock className="h-4 w-4 shrink-0" />
|
||||||
|
<span>Fereastra weekend activa — se asteapta urmatorul slot de procesare</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats bar */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-3 px-4">
|
||||||
|
<div className="flex items-center gap-4 flex-wrap text-sm">
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold">{cities.length}</span> orase in
|
||||||
|
coada
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Progres ciclu:{" "}
|
||||||
|
<span className="font-semibold">{doneSteps}/{totalSteps}</span>{" "}
|
||||||
|
pasi ({progressPct}%)
|
||||||
|
</span>
|
||||||
|
{state?.totalSessions != null && state.totalSessions > 0 && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{state.totalSessions} sesiuni | {state.completedCycles ?? 0}{" "}
|
||||||
|
cicluri complete
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{state?.lastSessionDate && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Ultima sesiune: {state.lastSessionDate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{totalSteps > 0 && (
|
||||||
|
<div className="h-2 w-full rounded-full bg-muted mt-2">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full bg-indigo-500 transition-all duration-300"
|
||||||
|
style={{ width: `${Math.max(1, progressPct)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* City cards */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{cities
|
||||||
|
.sort((a, b) => a.priority - b.priority)
|
||||||
|
.map((city) => {
|
||||||
|
const doneCount = STEPS.filter(
|
||||||
|
(s) => city.steps[s] === "done",
|
||||||
|
).length;
|
||||||
|
const hasError = STEPS.some((s) => city.steps[s] === "error");
|
||||||
|
const allDone = doneCount === STEPS.length;
|
||||||
|
const isActive = currentActivity?.city === city.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={city.siruta}
|
||||||
|
className={cn(
|
||||||
|
"transition-colors",
|
||||||
|
isActive && "border-indigo-300 ring-1 ring-indigo-200 dark:border-indigo-700 dark:ring-indigo-800",
|
||||||
|
allDone && !isActive && "border-emerald-200 dark:border-emerald-800",
|
||||||
|
hasError && !isActive && "border-rose-200 dark:border-rose-800",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent className="py-3 px-4 space-y-2">
|
||||||
|
{/* City header */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-semibold">{city.name}</span>
|
||||||
|
{city.county && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
jud. {city.county}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] font-mono"
|
||||||
|
>
|
||||||
|
{city.siruta}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
|
P{city.priority}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* Status icon */}
|
||||||
|
{allDone ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-emerald-500 ml-auto" />
|
||||||
|
) : hasError ? (
|
||||||
|
<XCircle className="h-4 w-4 text-rose-500 ml-auto" />
|
||||||
|
) : doneCount > 0 ? (
|
||||||
|
<Clock className="h-4 w-4 text-amber-500 ml-auto" />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-1 ml-auto">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-[10px]"
|
||||||
|
disabled={actionLoading}
|
||||||
|
onClick={() =>
|
||||||
|
void doAction({
|
||||||
|
action: "reset",
|
||||||
|
siruta: city.siruta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
title="Reseteaza progresul"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-[10px] text-destructive"
|
||||||
|
disabled={actionLoading}
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Stergi ${city.name} din coada?`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
void doAction({
|
||||||
|
action: "remove",
|
||||||
|
siruta: city.siruta,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
title="Sterge din coada"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps progress */}
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{STEPS.map((step) => {
|
||||||
|
const status = city.steps[step];
|
||||||
|
const isRunning = isActive && currentActivity?.step === step;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-md border px-2 py-1.5 text-center text-[11px] transition-colors",
|
||||||
|
isRunning &&
|
||||||
|
"bg-indigo-50 border-indigo-300 text-indigo-700 dark:bg-indigo-950/30 dark:border-indigo-700 dark:text-indigo-400 animate-pulse",
|
||||||
|
!isRunning && status === "done" &&
|
||||||
|
"bg-emerald-50 border-emerald-200 text-emerald-700 dark:bg-emerald-950/30 dark:border-emerald-800 dark:text-emerald-400",
|
||||||
|
!isRunning && status === "error" &&
|
||||||
|
"bg-rose-50 border-rose-200 text-rose-700 dark:bg-rose-950/30 dark:border-rose-800 dark:text-rose-400",
|
||||||
|
!isRunning && status === "pending" &&
|
||||||
|
"bg-muted/30 border-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isRunning && <Loader2 className="h-3 w-3 inline mr-1 animate-spin" />}
|
||||||
|
{STEP_LABELS[step]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DB stats + error */}
|
||||||
|
<div className="flex items-center gap-3 text-[11px] text-muted-foreground">
|
||||||
|
{city.dbStats && city.dbStats.total > 0 && (
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
DB: {city.dbStats.terenuri.toLocaleString("ro")} ter.
|
||||||
|
+ {city.dbStats.cladiri.toLocaleString("ro")} clad.
|
||||||
|
</span>
|
||||||
|
{city.dbStats.enriched > 0 && (
|
||||||
|
<span className="text-teal-600 dark:text-teal-400">
|
||||||
|
{city.dbStats.enriched.toLocaleString("ro")}{" "}
|
||||||
|
enriched
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{city.lastActivity && (
|
||||||
|
<span>
|
||||||
|
Ultima activitate:{" "}
|
||||||
|
{new Date(city.lastActivity).toLocaleString("ro-RO", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{city.errorMessage && (
|
||||||
|
<span className="text-rose-500 truncate max-w-[300px]">
|
||||||
|
{city.errorMessage}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add city — UAT autocomplete */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Adauga oras in coada
|
||||||
|
</h3>
|
||||||
|
<div className="relative" ref={uatRef}>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
placeholder="Cauta UAT — scrie nume sau cod SIRUTA..."
|
||||||
|
value={uatQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUatQuery(e.target.value);
|
||||||
|
setShowUatResults(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setShowUatResults(true)}
|
||||||
|
onBlur={() => setTimeout(() => setShowUatResults(false), 150)}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{showUatResults && uatResults.length > 0 && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-60 overflow-auto">
|
||||||
|
{uatResults.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.siruta}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleAddUat(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{item.name}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
({item.siruta})
|
||||||
|
</span>
|
||||||
|
{item.county && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
— jud. {item.county}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Plus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Reset all button */}
|
||||||
|
{cities.length > 0 && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
disabled={actionLoading}
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
"Resetezi progresul pentru TOATE orasele? Se va reporni ciclul de la zero.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
void doAction({ action: "reset_all" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3 mr-1" />
|
||||||
|
Reseteaza tot
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info footer */}
|
||||||
|
<div className="text-xs text-muted-foreground space-y-1 pb-4">
|
||||||
|
<p>
|
||||||
|
Sincronizarea ruleaza automat Vineri, Sambata si Duminica noaptea
|
||||||
|
(23:00-04:00). Procesarea e intercalata intre orase si se reia de
|
||||||
|
unde a ramas. Pagina se actualizeaza automat la fiecare {syncStatus === "running" ? "15" : "60"} secunde.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Prioritate: P1 = primele procesate, P2 = urmatoarele, P3 = adaugate
|
||||||
|
manual. In cadrul aceleiasi prioritati, ordinea e aleatorie.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "./auth-check";
|
||||||
|
|
||||||
const STIRLING_PDF_URL =
|
const STIRLING_PDF_URL = process.env.STIRLING_PDF_URL;
|
||||||
process.env.STIRLING_PDF_URL ?? "http://10.10.10.166:8087";
|
const STIRLING_PDF_API_KEY = process.env.STIRLING_PDF_API_KEY;
|
||||||
const STIRLING_PDF_API_KEY =
|
|
||||||
process.env.STIRLING_PDF_API_KEY ?? "cd829f62-6eef-43eb-a64d-c91af727b53a";
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const authErr = await requireAuth(req);
|
||||||
|
if (authErr) return authErr;
|
||||||
|
|
||||||
|
if (!STIRLING_PDF_URL || !STIRLING_PDF_API_KEY) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Stirling PDF nu este configurat" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Buffer the full body then forward to Stirling — streaming passthrough
|
// Buffer the full body then forward to Stirling — streaming passthrough
|
||||||
// (req.body + duplex:half) is unreliable for large files in Next.js.
|
// (req.body + duplex:half) is unreliable for large files in Next.js.
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "../auth-check";
|
||||||
|
|
||||||
const STIRLING_PDF_URL =
|
const STIRLING_PDF_URL = process.env.STIRLING_PDF_URL;
|
||||||
process.env.STIRLING_PDF_URL ?? "http://10.10.10.166:8087";
|
const STIRLING_PDF_API_KEY = process.env.STIRLING_PDF_API_KEY;
|
||||||
const STIRLING_PDF_API_KEY =
|
|
||||||
process.env.STIRLING_PDF_API_KEY ?? "cd829f62-6eef-43eb-a64d-c91af727b53a";
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const authErr = await requireAuth(req);
|
||||||
|
if (authErr) return authErr;
|
||||||
|
|
||||||
|
if (!STIRLING_PDF_URL || !STIRLING_PDF_API_KEY) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Stirling PDF nu este configurat" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Stream body directly to Stirling — avoids FormData re-serialization
|
// Stream body directly to Stirling — avoids FormData re-serialization
|
||||||
// failure on large files ("Failed to parse body as FormData")
|
// failure on large files ("Failed to parse body as FormData")
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
||||||
|
import {
|
||||||
|
getLayerFreshness,
|
||||||
|
isFresh,
|
||||||
|
} from "@/modules/parcel-sync/services/enrich-service";
|
||||||
|
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const maxDuration = 300; // 5 min max — N8N handles overall timeout
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
type UatRefreshResult = {
|
||||||
|
siruta: string;
|
||||||
|
uatName: string;
|
||||||
|
action: "synced" | "fresh" | "error";
|
||||||
|
reason?: string;
|
||||||
|
terenuri?: { new: number; removed: number };
|
||||||
|
cladiri?: { new: number; removed: number };
|
||||||
|
durationMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/eterra/auto-refresh
|
||||||
|
*
|
||||||
|
* Server-to-server endpoint called by N8N cron to keep DB data fresh.
|
||||||
|
* Auth: Authorization: Bearer <NOTIFICATION_CRON_SECRET>
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* ?maxUats=5 — max UATs to process per run (default 5, max 10)
|
||||||
|
* ?maxAgeHours=168 — freshness threshold in hours (default 168 = 7 days)
|
||||||
|
* ?forceFullSync=true — force full re-download (for weekly deep sync)
|
||||||
|
* ?includeEnrichment=true — re-enrich UATs with partial enrichment
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
// ── Auth ──
|
||||||
|
const secret = process.env.NOTIFICATION_CRON_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "NOTIFICATION_CRON_SECRET not configured" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const authHeader = request.headers.get("Authorization");
|
||||||
|
const token = authHeader?.replace("Bearer ", "");
|
||||||
|
if (token !== secret) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parse params ──
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const maxUats = Math.min(
|
||||||
|
Number(url.searchParams.get("maxUats") ?? "5") || 5,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const maxAgeHours =
|
||||||
|
Number(url.searchParams.get("maxAgeHours") ?? "168") || 168;
|
||||||
|
const forceFullSync = url.searchParams.get("forceFullSync") === "true";
|
||||||
|
const includeEnrichment =
|
||||||
|
url.searchParams.get("includeEnrichment") === "true";
|
||||||
|
|
||||||
|
// ── Credentials ──
|
||||||
|
const username = process.env.ETERRA_USERNAME;
|
||||||
|
const password = process.env.ETERRA_PASSWORD;
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "ETERRA_USERNAME / ETERRA_PASSWORD not configured" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Health check ──
|
||||||
|
const health = await checkEterraHealthNow();
|
||||||
|
if (!health.available) {
|
||||||
|
return NextResponse.json({
|
||||||
|
processed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: 0,
|
||||||
|
duration: "0s",
|
||||||
|
message: `eTerra indisponibil: ${health.message ?? "maintenance"}`,
|
||||||
|
details: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Find UATs with data in DB ──
|
||||||
|
const uatGroups = await prisma.gisFeature.groupBy({
|
||||||
|
by: ["siruta"],
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve UAT names
|
||||||
|
const sirutas = uatGroups.map((g) => g.siruta);
|
||||||
|
const uatRecords = await prisma.gisUat.findMany({
|
||||||
|
where: { siruta: { in: sirutas } },
|
||||||
|
select: { siruta: true, name: true },
|
||||||
|
});
|
||||||
|
const nameMap = new Map(uatRecords.map((u) => [u.siruta, u.name]));
|
||||||
|
|
||||||
|
// ── Check freshness per UAT ──
|
||||||
|
type UatCandidate = {
|
||||||
|
siruta: string;
|
||||||
|
uatName: string;
|
||||||
|
featureCount: number;
|
||||||
|
terenuriStale: boolean;
|
||||||
|
cladiriStale: boolean;
|
||||||
|
enrichedCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stale: UatCandidate[] = [];
|
||||||
|
const fresh: string[] = [];
|
||||||
|
|
||||||
|
for (const group of uatGroups) {
|
||||||
|
const sir = group.siruta;
|
||||||
|
const [tStatus, cStatus] = await Promise.all([
|
||||||
|
getLayerFreshness(sir, "TERENURI_ACTIVE"),
|
||||||
|
getLayerFreshness(sir, "CLADIRI_ACTIVE"),
|
||||||
|
]);
|
||||||
|
const tFresh = isFresh(tStatus.lastSynced, maxAgeHours);
|
||||||
|
const cFresh = isFresh(cStatus.lastSynced, maxAgeHours);
|
||||||
|
|
||||||
|
if (forceFullSync || !tFresh || !cFresh) {
|
||||||
|
stale.push({
|
||||||
|
siruta: sir,
|
||||||
|
uatName: nameMap.get(sir) ?? sir,
|
||||||
|
featureCount: group._count.id,
|
||||||
|
terenuriStale: !tFresh || forceFullSync,
|
||||||
|
cladiriStale: !cFresh || forceFullSync,
|
||||||
|
enrichedCount: tStatus.enrichedCount,
|
||||||
|
totalCount: tStatus.featureCount + cStatus.featureCount,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fresh.push(sir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle stale UATs so we don't always process the same ones first
|
||||||
|
for (let i = stale.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[stale[i]!, stale[j]!] = [stale[j]!, stale[i]!];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toProcess = stale.slice(0, maxUats);
|
||||||
|
const startTime = Date.now();
|
||||||
|
const details: UatRefreshResult[] = [];
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
// ── Process stale UATs ──
|
||||||
|
for (let idx = 0; idx < toProcess.length; idx++) {
|
||||||
|
const uat = toProcess[idx]!;
|
||||||
|
|
||||||
|
// Random delay between UATs (30-120s) to spread load
|
||||||
|
if (idx > 0) {
|
||||||
|
const delay = 30_000 + Math.random() * 90_000;
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uatStart = Date.now();
|
||||||
|
console.log(
|
||||||
|
`[auto-refresh] Processing UAT ${uat.siruta} (${uat.uatName})...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let terenuriResult = { newFeatures: 0, removedFeatures: 0 };
|
||||||
|
let cladiriResult = { newFeatures: 0, removedFeatures: 0 };
|
||||||
|
|
||||||
|
if (uat.terenuriStale) {
|
||||||
|
const res = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", {
|
||||||
|
uatName: uat.uatName,
|
||||||
|
forceFullSync,
|
||||||
|
});
|
||||||
|
terenuriResult = { newFeatures: res.newFeatures, removedFeatures: res.removedFeatures };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uat.cladiriStale) {
|
||||||
|
const res = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", {
|
||||||
|
uatName: uat.uatName,
|
||||||
|
forceFullSync,
|
||||||
|
});
|
||||||
|
cladiriResult = { newFeatures: res.newFeatures, removedFeatures: res.removedFeatures };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: re-enrich if partial enrichment
|
||||||
|
if (includeEnrichment && uat.enrichedCount < uat.totalCount) {
|
||||||
|
try {
|
||||||
|
const { EterraClient } = await import(
|
||||||
|
"@/modules/parcel-sync/services/eterra-client"
|
||||||
|
);
|
||||||
|
const { enrichFeatures } = await import(
|
||||||
|
"@/modules/parcel-sync/services/enrich-service"
|
||||||
|
);
|
||||||
|
const enrichClient = await EterraClient.create(username, password);
|
||||||
|
await enrichFeatures(enrichClient, uat.siruta);
|
||||||
|
} catch (enrichErr) {
|
||||||
|
console.warn(
|
||||||
|
`[auto-refresh] Enrichment failed for ${uat.siruta}:`,
|
||||||
|
enrichErr instanceof Error ? enrichErr.message : enrichErr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = Date.now() - uatStart;
|
||||||
|
console.log(
|
||||||
|
`[auto-refresh] UAT ${uat.siruta}: terenuri +${terenuriResult.newFeatures}/-${terenuriResult.removedFeatures}, cladiri +${cladiriResult.newFeatures}/-${cladiriResult.removedFeatures} (${(durationMs / 1000).toFixed(1)}s)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
details.push({
|
||||||
|
siruta: uat.siruta,
|
||||||
|
uatName: uat.uatName,
|
||||||
|
action: "synced",
|
||||||
|
terenuri: { new: terenuriResult.newFeatures, removed: terenuriResult.removedFeatures },
|
||||||
|
cladiri: { new: cladiriResult.newFeatures, removed: cladiriResult.removedFeatures },
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorCount++;
|
||||||
|
const msg = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
console.error(`[auto-refresh] Error on UAT ${uat.siruta}: ${msg}`);
|
||||||
|
details.push({
|
||||||
|
siruta: uat.siruta,
|
||||||
|
uatName: uat.uatName,
|
||||||
|
action: "error",
|
||||||
|
reason: msg,
|
||||||
|
durationMs: Date.now() - uatStart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDuration = Date.now() - startTime;
|
||||||
|
const durationStr =
|
||||||
|
totalDuration > 60_000
|
||||||
|
? `${Math.floor(totalDuration / 60_000)}m ${Math.round((totalDuration % 60_000) / 1000)}s`
|
||||||
|
: `${Math.round(totalDuration / 1000)}s`;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[auto-refresh] Completed ${toProcess.length}/${stale.length} UATs, ${errorCount} errors (${durationStr})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
processed: toProcess.length,
|
||||||
|
skipped: fresh.length,
|
||||||
|
staleTotal: stale.length,
|
||||||
|
errors: errorCount,
|
||||||
|
duration: durationStr,
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/eterra/counties
|
||||||
|
*
|
||||||
|
* Returns distinct county names from GisUat, sorted alphabetically.
|
||||||
|
*/
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/core/storage/prisma";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const rows = await prisma.gisUat.findMany({
|
||||||
|
where: { county: { not: null } },
|
||||||
|
select: { county: true },
|
||||||
|
distinct: ["county"],
|
||||||
|
orderBy: { county: "asc" },
|
||||||
|
});
|
||||||
|
const counties = rows.map((r) => r.county).filter(Boolean) as string[];
|
||||||
|
return NextResponse.json({ counties });
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Eroare la interogare judete";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/eterra/debug-fields?siruta=161829&cadRef=77102
|
||||||
|
*
|
||||||
|
* Diagnostic endpoint — shows all available fields from eTerra + local DB
|
||||||
|
* for a specific parcel and its buildings.
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const siruta = url.searchParams.get("siruta") ?? "161829";
|
||||||
|
const cadRef = url.searchParams.get("cadRef") ?? "77102";
|
||||||
|
|
||||||
|
const username = process.env.ETERRA_USERNAME;
|
||||||
|
const password = process.env.ETERRA_PASSWORD;
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json({ error: "ETERRA creds missing" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
|
query: { siruta, cadRef },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await EterraClient.create(username, password);
|
||||||
|
|
||||||
|
// 1. GIS layer: TERENURI_ACTIVE — raw attributes
|
||||||
|
const terenuri = await client.listLayerByWhere(
|
||||||
|
{ id: "TERENURI_ACTIVE", name: "TERENURI_ACTIVE", endpoint: "aut" },
|
||||||
|
`ADMIN_UNIT_ID=${siruta} AND IS_ACTIVE=1 AND NATIONAL_CADASTRAL_REFERENCE='${cadRef}'`,
|
||||||
|
{ limit: 1, outFields: "*" },
|
||||||
|
);
|
||||||
|
const parcelAttrs = terenuri[0]?.attributes ?? null;
|
||||||
|
result.gis_parcela = {
|
||||||
|
found: !!parcelAttrs,
|
||||||
|
fields: parcelAttrs
|
||||||
|
? Object.entries(parcelAttrs)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([k, v]) => ({ field: k, value: v, type: typeof v }))
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. GIS layer: CLADIRI_ACTIVE — buildings on this parcel
|
||||||
|
const cladiri = await client.listLayerByWhere(
|
||||||
|
{ id: "CLADIRI_ACTIVE", name: "CLADIRI_ACTIVE", endpoint: "aut" },
|
||||||
|
`ADMIN_UNIT_ID=${siruta} AND IS_ACTIVE=1 AND NATIONAL_CADASTRAL_REFERENCE LIKE '${cadRef}-%'`,
|
||||||
|
{ limit: 20, outFields: "*" },
|
||||||
|
);
|
||||||
|
result.gis_cladiri = {
|
||||||
|
count: cladiri.length,
|
||||||
|
buildings: cladiri.map((c) => {
|
||||||
|
const a = c.attributes;
|
||||||
|
return {
|
||||||
|
cadastralRef: a.NATIONAL_CADASTRAL_REFERENCE,
|
||||||
|
fields: Object.entries(a)
|
||||||
|
.sort(([x], [y]) => x.localeCompare(y))
|
||||||
|
.filter(([, v]) => v != null && v !== "" && v !== 0)
|
||||||
|
.map(([k, v]) => ({ field: k, value: v, type: typeof v })),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Immovable details (enrichment source)
|
||||||
|
const immId = parcelAttrs?.IMMOVABLE_ID;
|
||||||
|
const wsId = parcelAttrs?.WORKSPACE_ID;
|
||||||
|
if (immId && wsId) {
|
||||||
|
try {
|
||||||
|
const details = await client.fetchImmovableParcelDetails(
|
||||||
|
wsId as string | number,
|
||||||
|
immId as string | number,
|
||||||
|
);
|
||||||
|
result.immovable_parcel_details = {
|
||||||
|
count: details.length,
|
||||||
|
items: details,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
result.immovable_parcel_details = {
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Immovable list entry (address source)
|
||||||
|
try {
|
||||||
|
const listResponse = await client.fetchImmovableListByAdminUnit(
|
||||||
|
wsId as number,
|
||||||
|
siruta,
|
||||||
|
0,
|
||||||
|
5,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const items = (listResponse?.content ?? []) as Record<string, unknown>[];
|
||||||
|
// Find our specific immovable
|
||||||
|
const match = items.find(
|
||||||
|
(item) => String(item.immovablePk) === String(immId) ||
|
||||||
|
String(item.identifierDetails ?? "").includes(cadRef),
|
||||||
|
);
|
||||||
|
result.immovable_list_entry = {
|
||||||
|
totalInUat: listResponse?.totalElements ?? "?",
|
||||||
|
matchFound: !!match,
|
||||||
|
entry: match ?? null,
|
||||||
|
note: "Acest obiect contine campul immovableAddresses cu adresa completa",
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
result.immovable_list_entry = {
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Documentation data (owner source)
|
||||||
|
try {
|
||||||
|
const docResponse = await client.fetchDocumentationData(
|
||||||
|
wsId as number,
|
||||||
|
[String(immId)],
|
||||||
|
);
|
||||||
|
const immovables = docResponse?.immovables ?? [];
|
||||||
|
const regs = docResponse?.partTwoRegs ?? [];
|
||||||
|
result.documentation_data = {
|
||||||
|
immovablesCount: immovables.length,
|
||||||
|
immovables: immovables.slice(0, 3),
|
||||||
|
registrationsCount: regs.length,
|
||||||
|
registrations: regs.slice(0, 10),
|
||||||
|
note: "partTwoRegs contine proprietarii (nodeType=P, nodeStatus=-1=radiat)",
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
result.documentation_data = {
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Local DB data (what we have stored)
|
||||||
|
const dbParcel = await prisma.gisFeature.findFirst({
|
||||||
|
where: { layerId: "TERENURI_ACTIVE", siruta, cadastralRef: cadRef },
|
||||||
|
select: {
|
||||||
|
objectId: true,
|
||||||
|
cadastralRef: true,
|
||||||
|
areaValue: true,
|
||||||
|
isActive: true,
|
||||||
|
enrichment: true,
|
||||||
|
enrichedAt: true,
|
||||||
|
geometrySource: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const dbBuildings = await prisma.gisFeature.findMany({
|
||||||
|
where: {
|
||||||
|
layerId: "CLADIRI_ACTIVE",
|
||||||
|
siruta,
|
||||||
|
cadastralRef: { startsWith: `${cadRef}-` },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
objectId: true,
|
||||||
|
cadastralRef: true,
|
||||||
|
areaValue: true,
|
||||||
|
attributes: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
result.local_db = {
|
||||||
|
parcel: dbParcel
|
||||||
|
? {
|
||||||
|
objectId: dbParcel.objectId,
|
||||||
|
cadastralRef: dbParcel.cadastralRef,
|
||||||
|
areaValue: dbParcel.areaValue,
|
||||||
|
enrichedAt: dbParcel.enrichedAt,
|
||||||
|
geometrySource: dbParcel.geometrySource,
|
||||||
|
enrichment: dbParcel.enrichment,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
buildings: dbBuildings.map((b) => ({
|
||||||
|
objectId: b.objectId,
|
||||||
|
cadastralRef: b.cadastralRef,
|
||||||
|
areaValue: b.areaValue,
|
||||||
|
is_legal: (b.attributes as Record<string, unknown>)?.IS_LEGAL,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
result.error = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(result, {
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -250,6 +250,20 @@ export async function POST(req: Request) {
|
|||||||
pushProgress();
|
pushProgress();
|
||||||
updatePhaseProgress(2, 2);
|
updatePhaseProgress(2, 2);
|
||||||
}
|
}
|
||||||
|
// Sync admin layers (lightweight, non-fatal)
|
||||||
|
for (const adminLayer of ["LIMITE_INTRAV_DYNAMIC", "LIMITE_UAT"]) {
|
||||||
|
try {
|
||||||
|
await syncLayer(
|
||||||
|
validated.username,
|
||||||
|
validated.password,
|
||||||
|
validated.siruta,
|
||||||
|
adminLayer,
|
||||||
|
{ jobId, isSubStep: true },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// admin layers are best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
finishPhase();
|
finishPhase();
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════════════════ */
|
/* ══════════════════════════════════════════════════════════ */
|
||||||
@@ -548,6 +562,19 @@ export async function POST(req: Request) {
|
|||||||
zip.file("terenuri.gpkg", terenuriGpkg);
|
zip.file("terenuri.gpkg", terenuriGpkg);
|
||||||
zip.file("cladiri.gpkg", cladiriGpkg);
|
zip.file("cladiri.gpkg", cladiriGpkg);
|
||||||
|
|
||||||
|
// DXF versions (non-fatal)
|
||||||
|
try {
|
||||||
|
const { gpkgToDxf } = await import(
|
||||||
|
"@/modules/parcel-sync/services/gpkg-export"
|
||||||
|
);
|
||||||
|
const tDxf = await gpkgToDxf(terenuriGpkg, "TERENURI_ACTIVE");
|
||||||
|
if (tDxf) zip.file("terenuri.dxf", tDxf);
|
||||||
|
const cDxf = await gpkgToDxf(cladiriGpkg, "CLADIRI_ACTIVE");
|
||||||
|
if (cDxf) zip.file("cladiri.dxf", cDxf);
|
||||||
|
} catch {
|
||||||
|
// DXF conversion not available — skip silently
|
||||||
|
}
|
||||||
|
|
||||||
// ── Comprehensive quality analysis ──
|
// ── Comprehensive quality analysis ──
|
||||||
const withGeomRecords = dbTerenuri.filter(
|
const withGeomRecords = dbTerenuri.filter(
|
||||||
(r) =>
|
(r) =>
|
||||||
@@ -671,6 +698,15 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
if (validated.mode === "magic" && magicGpkg && csvContent) {
|
if (validated.mode === "magic" && magicGpkg && csvContent) {
|
||||||
zip.file("terenuri_magic.gpkg", magicGpkg);
|
zip.file("terenuri_magic.gpkg", magicGpkg);
|
||||||
|
try {
|
||||||
|
const { gpkgToDxf } = await import(
|
||||||
|
"@/modules/parcel-sync/services/gpkg-export"
|
||||||
|
);
|
||||||
|
const mDxf = await gpkgToDxf(magicGpkg, "TERENURI_MAGIC");
|
||||||
|
if (mDxf) zip.file("terenuri_magic.dxf", mDxf);
|
||||||
|
} catch {
|
||||||
|
// DXF conversion not available
|
||||||
|
}
|
||||||
zip.file("terenuri_complet.csv", csvContent);
|
zip.file("terenuri_complet.csv", csvContent);
|
||||||
report.magic = {
|
report.magic = {
|
||||||
csvRows: csvContent.split("\n").length - 1,
|
csvRows: csvContent.split("\n").length - 1,
|
||||||
|
|||||||
@@ -182,6 +182,15 @@ async function buildFullZip(siruta: string, mode: "base" | "magic") {
|
|||||||
zip.file("terenuri.gpkg", terenuriGpkg);
|
zip.file("terenuri.gpkg", terenuriGpkg);
|
||||||
zip.file("cladiri.gpkg", cladiriGpkg);
|
zip.file("cladiri.gpkg", cladiriGpkg);
|
||||||
|
|
||||||
|
// DXF versions (non-fatal — ogr2ogr may not be available)
|
||||||
|
const { gpkgToDxf } = await import(
|
||||||
|
"@/modules/parcel-sync/services/gpkg-export"
|
||||||
|
);
|
||||||
|
const terenuriDxf = await gpkgToDxf(terenuriGpkg, "TERENURI_ACTIVE");
|
||||||
|
if (terenuriDxf) zip.file("terenuri.dxf", terenuriDxf);
|
||||||
|
const cladiriDxf = await gpkgToDxf(cladiriGpkg, "CLADIRI_ACTIVE");
|
||||||
|
if (cladiriDxf) zip.file("cladiri.dxf", cladiriDxf);
|
||||||
|
|
||||||
if (mode === "magic") {
|
if (mode === "magic") {
|
||||||
// ── Magic: enrichment-merged GPKG + CSV + quality report ──
|
// ── Magic: enrichment-merged GPKG + CSV + quality report ──
|
||||||
const headers = [
|
const headers = [
|
||||||
@@ -295,6 +304,8 @@ async function buildFullZip(siruta: string, mode: "base" | "magic") {
|
|||||||
});
|
});
|
||||||
|
|
||||||
zip.file("terenuri_magic.gpkg", magicGpkg);
|
zip.file("terenuri_magic.gpkg", magicGpkg);
|
||||||
|
const magicDxf = await gpkgToDxf(magicGpkg, "TERENURI_MAGIC");
|
||||||
|
if (magicDxf) zip.file("terenuri_magic.dxf", magicDxf);
|
||||||
zip.file("terenuri_complet.csv", csvRows.join("\n"));
|
zip.file("terenuri_complet.csv", csvRows.join("\n"));
|
||||||
|
|
||||||
// ── Quality analysis ──
|
// ── Quality analysis ──
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/eterra/refresh-all
|
||||||
|
*
|
||||||
|
* Runs delta sync on ALL UATs in DB sequentially.
|
||||||
|
* UATs with >30% enrichment → magic mode (sync + enrichment).
|
||||||
|
* UATs with ≤30% enrichment → base mode (sync only).
|
||||||
|
*
|
||||||
|
* Returns immediately with jobId — progress via /api/eterra/progress.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
setProgress,
|
||||||
|
clearProgress,
|
||||||
|
type SyncProgress,
|
||||||
|
} from "@/modules/parcel-sync/services/progress-store";
|
||||||
|
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
||||||
|
import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service";
|
||||||
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||||
|
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const username = process.env.ETERRA_USERNAME ?? "";
|
||||||
|
const password = process.env.ETERRA_PASSWORD ?? "";
|
||||||
|
if (!username || !password) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "ETERRA_USERNAME / ETERRA_PASSWORD not configured" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = crypto.randomUUID();
|
||||||
|
setProgress({
|
||||||
|
jobId,
|
||||||
|
downloaded: 0,
|
||||||
|
total: 100,
|
||||||
|
status: "running",
|
||||||
|
phase: "Pregătire refresh complet",
|
||||||
|
});
|
||||||
|
|
||||||
|
void runRefreshAll(jobId, username, password);
|
||||||
|
|
||||||
|
return Response.json({ jobId, message: "Refresh complet pornit" }, { status: 202 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRefreshAll(jobId: string, username: string, password: string) {
|
||||||
|
const push = (p: Partial<SyncProgress>) =>
|
||||||
|
setProgress({ jobId, downloaded: 0, total: 100, status: "running", ...p } as SyncProgress);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Health check
|
||||||
|
const health = await checkEterraHealthNow();
|
||||||
|
if (!health.available) {
|
||||||
|
setProgress({ jobId, downloaded: 0, total: 100, status: "error", phase: "eTerra indisponibil", message: health.message ?? "maintenance" });
|
||||||
|
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all UATs with features + enrichment ratio
|
||||||
|
const uats = await prisma.$queryRawUnsafe<
|
||||||
|
Array<{ siruta: string; name: string | null; total: number; enriched: number }>
|
||||||
|
>(
|
||||||
|
`SELECT f.siruta, u.name, COUNT(*)::int as total,
|
||||||
|
COUNT(*) FILTER (WHERE f."enrichedAt" IS NOT NULL)::int as enriched
|
||||||
|
FROM "GisFeature" f LEFT JOIN "GisUat" u ON f.siruta = u.siruta
|
||||||
|
WHERE f."layerId" IN ('TERENURI_ACTIVE','CLADIRI_ACTIVE') AND f."objectId" > 0
|
||||||
|
GROUP BY f.siruta, u.name ORDER BY total DESC`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uats.length === 0) {
|
||||||
|
setProgress({ jobId, downloaded: 100, total: 100, status: "done", phase: "Niciun UAT in DB" });
|
||||||
|
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: Array<{ siruta: string; name: string; mode: string; duration: number; note: string }> = [];
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < uats.length; i++) {
|
||||||
|
const uat = uats[i]!;
|
||||||
|
const uatName = uat.name ?? uat.siruta;
|
||||||
|
const ratio = uat.total > 0 ? uat.enriched / uat.total : 0;
|
||||||
|
const isMagic = ratio > 0.3;
|
||||||
|
const mode = isMagic ? "magic" : "base";
|
||||||
|
const pct = Math.round(((i) / uats.length) * 100);
|
||||||
|
|
||||||
|
push({
|
||||||
|
downloaded: pct,
|
||||||
|
total: 100,
|
||||||
|
phase: `[${i + 1}/${uats.length}] ${uatName} (${mode})`,
|
||||||
|
note: results.length > 0 ? `Ultimul: ${results[results.length - 1]!.name} — ${results[results.length - 1]!.note}` : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uatStart = Date.now();
|
||||||
|
try {
|
||||||
|
// Sync TERENURI + CLADIRI (quick-count + VALID_FROM delta)
|
||||||
|
const tRes = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", { uatName });
|
||||||
|
const cRes = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", { uatName });
|
||||||
|
|
||||||
|
let enrichNote = "";
|
||||||
|
if (isMagic) {
|
||||||
|
const client = await EterraClient.create(username, password, { timeoutMs: 120_000 });
|
||||||
|
const eRes = await enrichFeatures(client, uat.siruta);
|
||||||
|
enrichNote = eRes.status === "done"
|
||||||
|
? ` | enrich: ${eRes.enrichedCount}/${eRes.totalFeatures ?? "?"}`
|
||||||
|
: ` | enrich err: ${eRes.error}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dur = Math.round((Date.now() - uatStart) / 1000);
|
||||||
|
const parts = [
|
||||||
|
tRes.newFeatures > 0 || (tRes.validFromUpdated ?? 0) > 0
|
||||||
|
? `T:+${tRes.newFeatures}/${tRes.validFromUpdated ?? 0}vf`
|
||||||
|
: "T:ok",
|
||||||
|
cRes.newFeatures > 0 || (cRes.validFromUpdated ?? 0) > 0
|
||||||
|
? `C:+${cRes.newFeatures}/${cRes.validFromUpdated ?? 0}vf`
|
||||||
|
: "C:ok",
|
||||||
|
];
|
||||||
|
const note = `${parts.join(", ")}${enrichNote} (${dur}s)`;
|
||||||
|
results.push({ siruta: uat.siruta, name: uatName, mode, duration: dur, note });
|
||||||
|
console.log(`[refresh-all] ${i + 1}/${uats.length} ${uatName}: ${note}`);
|
||||||
|
} catch (err) {
|
||||||
|
errors++;
|
||||||
|
const dur = Math.round((Date.now() - uatStart) / 1000);
|
||||||
|
const msg = err instanceof Error ? err.message : "Unknown";
|
||||||
|
results.push({ siruta: uat.siruta, name: uatName, mode, duration: dur, note: `ERR: ${msg}` });
|
||||||
|
console.error(`[refresh-all] ${uatName}: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDur = results.reduce((s, r) => s + r.duration, 0);
|
||||||
|
const summary = `${uats.length} UATs, ${errors} erori, ${totalDur}s total`;
|
||||||
|
setProgress({
|
||||||
|
jobId,
|
||||||
|
downloaded: 100,
|
||||||
|
total: 100,
|
||||||
|
status: "done",
|
||||||
|
phase: "Refresh complet finalizat",
|
||||||
|
message: summary,
|
||||||
|
note: results.map((r) => `${r.name}: ${r.note}`).join("\n"),
|
||||||
|
});
|
||||||
|
console.log(`[refresh-all] Done: ${summary}`);
|
||||||
|
setTimeout(() => clearProgress(jobId), 6 * 3_600_000);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Unknown";
|
||||||
|
setProgress({ jobId, downloaded: 0, total: 100, status: "error", phase: "Eroare", message: msg });
|
||||||
|
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/eterra/stats
|
||||||
|
*
|
||||||
|
* Lightweight endpoint for the monitor page — returns aggregate counts
|
||||||
|
* suitable for polling every 30s without heavy DB load.
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* {
|
||||||
|
* totalUats: number,
|
||||||
|
* totalFeatures: number,
|
||||||
|
* totalTerenuri: number,
|
||||||
|
* totalCladiri: number,
|
||||||
|
* totalEnriched: number,
|
||||||
|
* totalNoGeom: number,
|
||||||
|
* countiesWithData: number,
|
||||||
|
* lastSyncAt: string | null,
|
||||||
|
* dbSizeMb: number | null,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const [
|
||||||
|
totalUats,
|
||||||
|
totalFeatures,
|
||||||
|
totalTerenuri,
|
||||||
|
totalCladiri,
|
||||||
|
totalEnriched,
|
||||||
|
totalNoGeom,
|
||||||
|
countyAgg,
|
||||||
|
lastSync,
|
||||||
|
dbSize,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.gisUat.count(),
|
||||||
|
prisma.gisFeature.count({ where: { objectId: { gt: 0 } } }),
|
||||||
|
prisma.gisFeature.count({ where: { layerId: "TERENURI_ACTIVE", objectId: { gt: 0 } } }),
|
||||||
|
prisma.gisFeature.count({ where: { layerId: "CLADIRI_ACTIVE", objectId: { gt: 0 } } }),
|
||||||
|
prisma.gisFeature.count({ where: { enrichedAt: { not: null } } }),
|
||||||
|
prisma.gisFeature.count({ where: { geometrySource: "NO_GEOMETRY" } }),
|
||||||
|
prisma.gisUat.groupBy({
|
||||||
|
by: ["county"],
|
||||||
|
where: { county: { not: null } },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
prisma.gisSyncRun.findFirst({
|
||||||
|
where: { status: "done" },
|
||||||
|
orderBy: { completedAt: "desc" },
|
||||||
|
select: { completedAt: true },
|
||||||
|
}),
|
||||||
|
prisma.$queryRaw<Array<{ size: string }>>`
|
||||||
|
SELECT pg_size_pretty(pg_database_size(current_database())) as size
|
||||||
|
`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Parse DB size to MB
|
||||||
|
const sizeStr = dbSize[0]?.size ?? "";
|
||||||
|
let dbSizeMb: number | null = null;
|
||||||
|
const mbMatch = sizeStr.match(/([\d.]+)\s*(MB|GB|TB)/i);
|
||||||
|
if (mbMatch) {
|
||||||
|
const val = parseFloat(mbMatch[1]!);
|
||||||
|
const unit = mbMatch[2]!.toUpperCase();
|
||||||
|
dbSizeMb = unit === "GB" ? val * 1024 : unit === "TB" ? val * 1024 * 1024 : val;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
totalUats,
|
||||||
|
totalFeatures,
|
||||||
|
totalTerenuri,
|
||||||
|
totalCladiri,
|
||||||
|
totalEnriched,
|
||||||
|
totalNoGeom,
|
||||||
|
countiesWithData: countyAgg.length,
|
||||||
|
lastSyncAt: lastSync?.completedAt?.toISOString() ?? null,
|
||||||
|
dbSizeMb: dbSizeMb ? Math.round(dbSizeMb) : null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/eterra/sync-all-counties
|
||||||
|
*
|
||||||
|
* Starts a background sync for ALL counties in the database (entire Romania).
|
||||||
|
* Iterates counties sequentially, running county-sync logic for each.
|
||||||
|
* Returns immediately with jobId — progress via /api/eterra/progress.
|
||||||
|
*
|
||||||
|
* Body: {} (no params needed)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from "@/core/storage/prisma";
|
||||||
|
import {
|
||||||
|
setProgress,
|
||||||
|
clearProgress,
|
||||||
|
type SyncProgress,
|
||||||
|
} from "@/modules/parcel-sync/services/progress-store";
|
||||||
|
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
||||||
|
import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service";
|
||||||
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||||
|
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
|
||||||
|
import { createAppNotification } from "@/core/notifications/app-notifications";
|
||||||
|
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
||||||
|
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/* Concurrency guard — blocks both this and single county sync */
|
||||||
|
const g = globalThis as {
|
||||||
|
__countySyncRunning?: string;
|
||||||
|
__allCountiesSyncRunning?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const session = getSessionCredentials();
|
||||||
|
const username = String(session?.username || process.env.ETERRA_USERNAME || "").trim();
|
||||||
|
const password = String(session?.password || process.env.ETERRA_PASSWORD || "").trim();
|
||||||
|
if (!username || !password) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Credentiale lipsa — conecteaza-te la eTerra mai intai." },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g.__allCountiesSyncRunning) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Sync All Romania deja in curs" },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g.__countySyncRunning) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: `Sync judet deja in curs: ${g.__countySyncRunning}` },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = crypto.randomUUID();
|
||||||
|
g.__allCountiesSyncRunning = true;
|
||||||
|
|
||||||
|
setProgress({
|
||||||
|
jobId,
|
||||||
|
downloaded: 0,
|
||||||
|
total: 100,
|
||||||
|
status: "running",
|
||||||
|
phase: "Pregatire sync Romania...",
|
||||||
|
});
|
||||||
|
|
||||||
|
void runAllCountiesSync(jobId, username, password);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ jobId, message: "Sync All Romania pornit" },
|
||||||
|
{ status: 202 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAllCountiesSync(
|
||||||
|
jobId: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
) {
|
||||||
|
const push = (p: Partial<SyncProgress>) =>
|
||||||
|
setProgress({
|
||||||
|
jobId,
|
||||||
|
downloaded: 0,
|
||||||
|
total: 100,
|
||||||
|
status: "running",
|
||||||
|
...p,
|
||||||
|
} as SyncProgress);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Health check
|
||||||
|
const health = await checkEterraHealthNow();
|
||||||
|
if (!health.available) {
|
||||||
|
setProgress({
|
||||||
|
jobId,
|
||||||
|
downloaded: 0,
|
||||||
|
total: 100,
|
||||||
|
status: "error",
|
||||||
|
phase: "eTerra indisponibil",
|
||||||
|
message: health.message ?? "maintenance",
|
||||||
|
});
|
||||||
|
g.__allCountiesSyncRunning = false;
|
||||||
|
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all distinct counties, ordered alphabetically
|
||||||
|
const countyRows = await prisma.gisUat.groupBy({
|
||||||
|
by: ["county"],
|
||||||
|
where: { county: { not: null } },
|
||||||
|
_count: true,
|
||||||
|
orderBy: { county: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const counties = countyRows
|
||||||
|
.map((r) => r.county)
|
||||||
|
.filter((c): c is string => c != null);
|
||||||
|
|
||||||
|
if (counties.length === 0) {
|
||||||
|
setProgress({
|
||||||
|
jobId,
|
||||||
|
downloaded: 100,
|
||||||
|
total: 100,
|
||||||
|
status: "done",
|
||||||
|
phase: "Niciun judet gasit in DB",
|
||||||
|
});
|
||||||
|
g.__allCountiesSyncRunning = false;
|
||||||
|
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
push({ phase: `0/${counties.length} judete — pornire...` });
|
||||||
|
|
||||||
|
const countyResults: Array<{
|
||||||
|
county: string;
|
||||||
|
uatCount: number;
|
||||||
|
errors: number;
|
||||||
|
duration: number;
|
||||||
|
}> = [];
|
||||||
|
let totalErrors = 0;
|
||||||
|
let totalUats = 0;
|
||||||
|
|
||||||
|
for (let ci = 0; ci < counties.length; ci++) {
|
||||||
|
const county = counties[ci]!;
|
||||||
|
g.__countySyncRunning = county;
|
||||||
|
|
||||||
|
// Get UATs for this county
|
||||||
|
const uats = await prisma.$queryRawUnsafe<
|
||||||
|
Array<{
|
||||||
|
siruta: string;
|
||||||
|
name: string | null;
|
||||||
|
total: number;
|
||||||
|
enriched: number;
|
||||||
|
}>
|
||||||
|
>(
|
||||||
|
`SELECT u.siruta, u.name,
|
||||||
|
COALESCE(f.total, 0)::int as total,
|
||||||
|
COALESCE(f.enriched, 0)::int as enriched
|
||||||
|
FROM "GisUat" u
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT siruta, COUNT(*)::int as total,
|
||||||
|
COUNT(*) FILTER (WHERE "enrichedAt" IS NOT NULL)::int as enriched
|
||||||
|
FROM "GisFeature"
|
||||||
|
WHERE "layerId" IN ('TERENURI_ACTIVE','CLADIRI_ACTIVE') AND "objectId" > 0
|
||||||
|
GROUP BY siruta
|
||||||
|
) f ON u.siruta = f.siruta
|
||||||
|
WHERE u.county = $1
|
||||||
|
ORDER BY COALESCE(f.total, 0) DESC`,
|
||||||
|
county,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uats.length === 0) {
|
||||||
|
countyResults.push({ county, uatCount: 0, errors: 0, duration: 0 });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countyStart = Date.now();
|
||||||
|
let countyErrors = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < uats.length; i++) {
|
||||||
|
const uat = uats[i]!;
|
||||||
|
const uatName = uat.name ?? uat.siruta;
|
||||||
|
const ratio = uat.total > 0 ? uat.enriched / uat.total : 0;
|
||||||
|
const isMagic = ratio > 0.3;
|
||||||
|
const mode = isMagic ? "magic" : "base";
|
||||||
|
|
||||||
|
// Progress: county level + UAT level — update before starting UAT
|
||||||
|
const countyPct = ci / counties.length;
|
||||||
|
const uatPct = i / uats.length;
|
||||||
|
const overallPct = Math.round((countyPct + uatPct / counties.length) * 100);
|
||||||
|
|
||||||
|
push({
|
||||||
|
downloaded: overallPct,
|
||||||
|
total: 100,
|
||||||
|
phase: `[${ci + 1}/${counties.length}] ${county} — [${i + 1}/${uats.length}] ${uatName} (${mode})`,
|
||||||
|
note: countyResults.length > 0
|
||||||
|
? `Ultimul judet: ${countyResults[countyResults.length - 1]!.county} (${countyResults[countyResults.length - 1]!.uatCount} UAT, ${countyResults[countyResults.length - 1]!.errors} err)`
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", { uatName, jobId, isSubStep: true });
|
||||||
|
await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", { uatName, jobId, isSubStep: true });
|
||||||
|
|
||||||
|
// LIMITE_INTRAV_DYNAMIC — best effort
|
||||||
|
try {
|
||||||
|
await syncLayer(username, password, uat.siruta, "LIMITE_INTRAV_DYNAMIC", { uatName, jobId, isSubStep: true });
|
||||||
|
} catch { /* skip */ }
|
||||||
|
|
||||||
|
// Enrichment for magic mode
|
||||||
|
if (isMagic) {
|
||||||
|
try {
|
||||||
|
const client = await EterraClient.create(username, password, { timeoutMs: 120_000 });
|
||||||
|
await enrichFeatures(client, uat.siruta);
|
||||||
|
} catch {
|
||||||
|
// Enrichment failure is non-fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
countyErrors++;
|
||||||
|
const msg = err instanceof Error ? err.message : "Unknown";
|
||||||
|
console.error(`[sync-all] ${county}/${uatName}: ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress AFTER UAT completion
|
||||||
|
const completedUatPct = (i + 1) / uats.length;
|
||||||
|
const completedOverallPct = Math.round((countyPct + completedUatPct / counties.length) * 100);
|
||||||
|
push({
|
||||||
|
downloaded: completedOverallPct,
|
||||||
|
total: 100,
|
||||||
|
phase: `[${ci + 1}/${counties.length}] ${county} — [${i + 1}/${uats.length}] ${uatName} finalizat`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dur = Math.round((Date.now() - countyStart) / 1000);
|
||||||
|
countyResults.push({ county, uatCount: uats.length, errors: countyErrors, duration: dur });
|
||||||
|
totalErrors += countyErrors;
|
||||||
|
totalUats += uats.length;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[sync-all] ${ci + 1}/${counties.length} ${county}: ${uats.length} UAT, ${countyErrors} err, ${dur}s`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDur = countyResults.reduce((s, r) => s + r.duration, 0);
|
||||||
|
const summary = `${counties.length} judete, ${totalUats} UAT-uri, ${totalErrors} erori, ${formatDuration(totalDur)}`;
|
||||||
|
|
||||||
|
setProgress({
|
||||||
|
jobId,
|
||||||
|
downloaded: 100,
|
||||||
|
total: 100,
|
||||||
|
status: totalErrors > 0 && totalErrors === totalUats ? "error" : "done",
|
||||||
|
phase: "Sync Romania finalizat",
|
||||||
|
message: summary,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAppNotification({
|
||||||
|
type: totalErrors > 0 ? "sync-error" : "sync-complete",
|
||||||
|
title: totalErrors > 0
|
||||||
|
? `Sync Romania: ${totalErrors} erori din ${totalUats} UAT-uri`
|
||||||
|
: `Sync Romania: ${totalUats} UAT-uri in ${counties.length} judete`,
|
||||||
|
message: summary,
|
||||||
|
metadata: { jobId, counties: counties.length, totalUats, totalErrors, totalDuration: totalDur },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[sync-all] Done: ${summary}`);
|
||||||
|
|
||||||
|
// Trigger PMTiles rebuild after full Romania sync
|
||||||
|
await firePmtilesRebuild("all-counties-sync-complete", {
|
||||||
|
counties: counties.length,
|
||||||
|
totalUats,
|
||||||
|
totalErrors,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => clearProgress(jobId), 12 * 3_600_000);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Unknown";
|
||||||
|
setProgress({
|
||||||
|
jobId,
|
||||||
|
downloaded: 0,
|
||||||
|
total: 100,
|
||||||
|
status: "error",
|
||||||
|
phase: "Eroare",
|
||||||
|
message: msg,
|
||||||
|
});
|
||||||
|
await createAppNotification({
|
||||||
|
type: "sync-error",
|
||||||
|
title: "Sync Romania: eroare generala",
|
||||||
|
message: msg,
|
||||||
|
metadata: { jobId },
|
||||||
|
});
|
||||||
|
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||||
|
} finally {
|
||||||
|
g.__allCountiesSyncRunning = false;
|
||||||
|
g.__countySyncRunning = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
return `${h}h${String(m).padStart(2, "0")}m`;
|
||||||
|
}
|
||||||
@@ -187,80 +187,106 @@ async function runBackground(params: {
|
|||||||
getLayerFreshness(siruta, "CLADIRI_ACTIVE"),
|
getLayerFreshness(siruta, "CLADIRI_ACTIVE"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const terenuriNeedsSync =
|
const terenuriNeedsFullSync =
|
||||||
forceSync ||
|
forceSync || terenuriStatus.featureCount === 0;
|
||||||
!isFresh(terenuriStatus.lastSynced) ||
|
const cladiriNeedsFullSync =
|
||||||
terenuriStatus.featureCount === 0;
|
forceSync || cladiriStatus.featureCount === 0;
|
||||||
const cladiriNeedsSync =
|
|
||||||
forceSync ||
|
|
||||||
!isFresh(cladiriStatus.lastSynced) ||
|
|
||||||
cladiriStatus.featureCount === 0;
|
|
||||||
|
|
||||||
if (terenuriNeedsSync) {
|
// Always call syncLayer — it handles quick-count + VALID_FROM delta internally.
|
||||||
phase = "Sincronizare terenuri";
|
// Only force full download when no local data or explicit forceSync.
|
||||||
push({});
|
phase = "Sincronizare terenuri";
|
||||||
const r = await syncLayer(username, password, siruta, "TERENURI_ACTIVE", {
|
push({});
|
||||||
forceFullSync: forceSync,
|
const terenuriResult = await syncLayer(username, password, siruta, "TERENURI_ACTIVE", {
|
||||||
jobId,
|
forceFullSync: terenuriNeedsFullSync,
|
||||||
isSubStep: true,
|
jobId,
|
||||||
});
|
isSubStep: true,
|
||||||
if (r.status === "error")
|
});
|
||||||
throw new Error(r.error ?? "Sync terenuri failed");
|
if (terenuriResult.status === "error")
|
||||||
}
|
throw new Error(terenuriResult.error ?? "Sync terenuri failed");
|
||||||
updateOverall(0.5);
|
updateOverall(0.5);
|
||||||
|
|
||||||
if (cladiriNeedsSync) {
|
phase = "Sincronizare clădiri";
|
||||||
phase = "Sincronizare clădiri";
|
|
||||||
push({});
|
|
||||||
const r = await syncLayer(username, password, siruta, "CLADIRI_ACTIVE", {
|
|
||||||
forceFullSync: forceSync,
|
|
||||||
jobId,
|
|
||||||
isSubStep: true,
|
|
||||||
});
|
|
||||||
if (r.status === "error")
|
|
||||||
throw new Error(r.error ?? "Sync clădiri failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync intravilan limits (always, lightweight layer)
|
|
||||||
phase = "Sincronizare limite intravilan";
|
|
||||||
push({});
|
push({});
|
||||||
try {
|
const cladiriResult = await syncLayer(username, password, siruta, "CLADIRI_ACTIVE", {
|
||||||
await syncLayer(username, password, siruta, "LIMITE_INTRAV_DYNAMIC", {
|
forceFullSync: cladiriNeedsFullSync,
|
||||||
forceFullSync: forceSync,
|
jobId,
|
||||||
jobId,
|
isSubStep: true,
|
||||||
isSubStep: true,
|
});
|
||||||
});
|
if (cladiriResult.status === "error")
|
||||||
} catch {
|
throw new Error(cladiriResult.error ?? "Sync clădiri failed");
|
||||||
// Non-critical — don't fail the whole job
|
|
||||||
note = "Avertisment: limite intravilan nu s-au sincronizat";
|
// Sync admin layers — skip if synced within 24h
|
||||||
|
for (const adminLayer of ["LIMITE_INTRAV_DYNAMIC", "LIMITE_UAT"]) {
|
||||||
|
const adminStatus = await getLayerFreshness(siruta, adminLayer);
|
||||||
|
if (!forceSync && isFresh(adminStatus.lastSynced, 24)) continue;
|
||||||
|
phase = `Sincronizare ${adminLayer === "LIMITE_UAT" ? "limite UAT" : "limite intravilan"}`;
|
||||||
push({});
|
push({});
|
||||||
|
try {
|
||||||
|
await syncLayer(username, password, siruta, adminLayer, {
|
||||||
|
forceFullSync: forceSync,
|
||||||
|
jobId,
|
||||||
|
isSubStep: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
note = `Avertisment: ${adminLayer} nu s-a sincronizat`;
|
||||||
|
push({});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!terenuriNeedsSync && !cladiriNeedsSync) {
|
const syncSummary = [
|
||||||
note = "Date proaspete — sync skip";
|
terenuriResult.newFeatures > 0 ? `${terenuriResult.newFeatures} terenuri noi` : null,
|
||||||
}
|
terenuriResult.validFromUpdated ? `${terenuriResult.validFromUpdated} terenuri actualizate` : null,
|
||||||
|
cladiriResult.newFeatures > 0 ? `${cladiriResult.newFeatures} cladiri noi` : null,
|
||||||
|
cladiriResult.validFromUpdated ? `${cladiriResult.validFromUpdated} cladiri actualizate` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
note = syncSummary.length > 0 ? syncSummary.join(", ") : "Fără schimbări";
|
||||||
finishPhase();
|
finishPhase();
|
||||||
|
|
||||||
/* ── Phase 2: No-geometry import (optional) ──────── */
|
/* ── Phase 2: No-geometry import (optional) ──────── */
|
||||||
if (hasNoGeom && weights.noGeom > 0) {
|
if (hasNoGeom && weights.noGeom > 0) {
|
||||||
setPhase("Import parcele fără geometrie", weights.noGeom);
|
setPhase("Verificare parcele fără geometrie", weights.noGeom);
|
||||||
const noGeomClient = await EterraClient.create(username, password, {
|
// Skip no-geom import if recently done (within 48h) and not forced
|
||||||
timeoutMs: 120_000,
|
const { PrismaClient } = await import("@prisma/client");
|
||||||
});
|
const _prisma = new PrismaClient();
|
||||||
const res = await syncNoGeometryParcels(noGeomClient, siruta, {
|
let skipNoGeom = false;
|
||||||
onProgress: (done, tot, ph) => {
|
try {
|
||||||
phase = ph;
|
const recentNoGeom = await _prisma.gisFeature.findFirst({
|
||||||
push({});
|
where: {
|
||||||
},
|
layerId: "TERENURI_ACTIVE",
|
||||||
});
|
siruta,
|
||||||
if (res.status === "error") {
|
geometrySource: "NO_GEOMETRY",
|
||||||
note = `Avertisment no-geom: ${res.error}`;
|
updatedAt: { gte: new Date(Date.now() - 48 * 60 * 60 * 1000) },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
skipNoGeom = !forceSync && recentNoGeom != null;
|
||||||
|
} catch { /* proceed with import */ }
|
||||||
|
await _prisma.$disconnect();
|
||||||
|
|
||||||
|
if (skipNoGeom) {
|
||||||
|
note = "Parcele fără geometrie — actualizate recent, skip";
|
||||||
push({});
|
push({});
|
||||||
} else {
|
} else {
|
||||||
const cleanNote =
|
phase = "Import parcele fără geometrie";
|
||||||
res.cleaned > 0 ? `, ${res.cleaned} vechi șterse` : "";
|
|
||||||
note = `${res.imported} parcele noi importate${cleanNote}`;
|
|
||||||
push({});
|
push({});
|
||||||
|
const noGeomClient = await EterraClient.create(username, password, {
|
||||||
|
timeoutMs: 120_000,
|
||||||
|
});
|
||||||
|
const res = await syncNoGeometryParcels(noGeomClient, siruta, {
|
||||||
|
onProgress: (done, tot, ph) => {
|
||||||
|
phase = ph;
|
||||||
|
push({});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.status === "error") {
|
||||||
|
note = `Avertisment no-geom: ${res.error}`;
|
||||||
|
push({});
|
||||||
|
} else {
|
||||||
|
const cleanNote =
|
||||||
|
res.cleaned > 0 ? `, ${res.cleaned} vechi șterse` : "";
|
||||||
|
note = `${res.imported} parcele noi importate${cleanNote}`;
|
||||||
|
push({});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finishPhase();
|
finishPhase();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/eterra/sync-county
|
||||||
|
*
|
||||||
|
* Starts a background sync for all UATs in a given county.
|
||||||
|
* Syncs TERENURI_ACTIVE, CLADIRI_ACTIVE, and LIMITE_INTRAV_DYNAMIC.
|
||||||
|
* UATs with >30% enrichment → magic mode (sync + enrichment).
|
||||||
|
*
|
||||||
|
* Body: { county: string }
|
||||||
|
* Returns immediately with jobId — progress via /api/eterra/progress.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from "@/core/storage/prisma";
|
||||||
|
import {
|
||||||
|
setProgress,
|
||||||
|
clearProgress,
|
||||||
|
type SyncProgress,
|
||||||
|
} from "@/modules/parcel-sync/services/progress-store";
|
||||||
|
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
||||||
|
import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service";
|
||||||
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||||
|
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
|
||||||
|
import { createAppNotification } from "@/core/notifications/app-notifications";
|
||||||
|
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
||||||
|
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/* Concurrency guard */
|
||||||
|
const g = globalThis as { __countySyncRunning?: string; __allCountiesSyncRunning?: boolean };
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
let body: { county?: string };
|
||||||
|
try {
|
||||||
|
body = (await req.json()) as { county?: string };
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Body invalid" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getSessionCredentials();
|
||||||
|
const username = String(session?.username || process.env.ETERRA_USERNAME || "").trim();
|
||||||
|
const password = String(session?.password || process.env.ETERRA_PASSWORD || "").trim();
|
||||||
|
if (!username || !password) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Credentiale lipsa — conecteaza-te la eTerra mai intai." },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const county = body.county?.trim();
|
||||||
|
if (!county) {
|
||||||
|
return Response.json({ error: "Judetul lipseste" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g.__allCountiesSyncRunning) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Sync All Romania in curs — asteapta sa se termine" },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g.__countySyncRunning) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: `Sync judet deja in curs: ${g.__countySyncRunning}` },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = crypto.randomUUID();
|
||||||
|
g.__countySyncRunning = county;
|
||||||
|
|
||||||
|
setProgress({
|
||||||
|
jobId,
|
||||||
|
downloaded: 0,
|
||||||
|
total: 100,
|
||||||
|
status: "running",
|
||||||
|
phase: `Pregatire sync ${county}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
void runCountySync(jobId, county, username, password);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ jobId, message: `Sync judet ${county} pornit` },
|
||||||
|
{ status: 202 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCountySync(
|
||||||
|
jobId: string,
|
||||||
|
county: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
) {
|
||||||
|
const push = (p: Partial<SyncProgress>) =>
|
||||||
|
setProgress({
|
||||||
|
jobId,
|
||||||
|
downloaded: 0,
|
||||||
|
total: 100,
|
||||||
|
status: "running",
|
||||||
|
...p,
|
||||||
|
} as SyncProgress);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Health check
|
||||||
|
const health = await checkEterraHealthNow();
|
||||||
|
if (!health.available) {
|
||||||
|
setProgress({
|
||||||
|
jobId,
|
||||||
|
downloaded: 0,
|
||||||
|
total: 100,
|
||||||
|
status: "error",
|
||||||
|
phase: "eTerra indisponibil",
|
||||||
|
message: health.message ?? "maintenance",
|
||||||
|
});
|
||||||
|
await createAppNotification({
|
||||||
|
type: "sync-error",
|
||||||
|
title: `Sync ${county}: eTerra indisponibil`,
|
||||||
|
message: health.message ?? "Serviciul eTerra este in mentenanta",
|
||||||
|
metadata: { county, jobId },
|
||||||
|
});
|
||||||
|
g.__countySyncRunning = undefined;
|
||||||
|
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all UATs in this county with feature stats
|
||||||
|
const uats = await prisma.$queryRawUnsafe<
|
||||||
|
Array<{
|
||||||
|
siruta: string;
|
||||||
|
name: string | null;
|
||||||
|
total: number;
|
||||||
|
enriched: number;
|
||||||
|
}>
|
||||||
|
>(
|
||||||
|
`SELECT u.siruta, u.name,
|
||||||
|
COALESCE(f.total, 0)::int as total,
|
||||||
|
COALESCE(f.enriched, 0)::int as enriched
|
||||||
|
FROM "GisUat" u
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT siruta, COUNT(*)::int as total,
|
||||||
|
COUNT(*) FILTER (WHERE "enrichedAt" IS NOT NULL)::int as enriched
|
||||||
|
FROM "GisFeature"
|
||||||
|
WHERE "layerId" IN ('TERENURI_ACTIVE','CLADIRI_ACTIVE') AND "objectId" > 0
|
||||||
|
GROUP BY siruta
|
||||||
|
) f ON u.siruta = f.siruta
|
||||||
|
WHERE u.county = $1
|
||||||
|
ORDER BY COALESCE(f.total, 0) DESC`,
|
||||||
|
county,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uats.length === 0) {
|
||||||
|
setProgress({
|
||||||
|
jobId,
|
||||||
|
downloaded: 100,
|
||||||
|
total: 100,
|
||||||
|
status: "done",
|
||||||
|
phase: `Niciun UAT gasit in ${county}`,
|
||||||
|
});
|
||||||
|
g.__countySyncRunning = undefined;
|
||||||
|
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: Array<{
|
||||||
|
siruta: string;
|
||||||
|
name: string;
|
||||||
|
mode: string;
|
||||||
|
duration: number;
|
||||||
|
note: string;
|
||||||
|
}> = [];
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
let totalNewFeatures = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < uats.length; i++) {
|
||||||
|
const uat = uats[i]!;
|
||||||
|
const uatName = uat.name ?? uat.siruta;
|
||||||
|
const ratio = uat.total > 0 ? uat.enriched / uat.total : 0;
|
||||||
|
const isMagic = ratio > 0.3;
|
||||||
|
const mode = isMagic ? "magic" : "base";
|
||||||
|
const pct = Math.round((i / uats.length) * 100);
|
||||||
|
|
||||||
|
push({
|
||||||
|
downloaded: pct,
|
||||||
|
total: 100,
|
||||||
|
phase: `[${i + 1}/${uats.length}] ${uatName} (${mode})`,
|
||||||
|
note:
|
||||||
|
results.length > 0
|
||||||
|
? `Ultimul: ${results[results.length - 1]!.name} — ${results[results.length - 1]!.note}`
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uatStart = Date.now();
|
||||||
|
try {
|
||||||
|
// Sync TERENURI + CLADIRI — pass jobId for sub-progress
|
||||||
|
const tRes = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", {
|
||||||
|
uatName, jobId, isSubStep: true,
|
||||||
|
});
|
||||||
|
const cRes = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", {
|
||||||
|
uatName, jobId, isSubStep: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync ADMINISTRATIV (intravilan) — wrapped in try/catch since it needs UAT geometry
|
||||||
|
let adminNote = "";
|
||||||
|
try {
|
||||||
|
const aRes = await syncLayer(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
uat.siruta,
|
||||||
|
"LIMITE_INTRAV_DYNAMIC",
|
||||||
|
{ uatName, jobId, isSubStep: true },
|
||||||
|
);
|
||||||
|
if (aRes.newFeatures > 0) {
|
||||||
|
adminNote = ` | A:+${aRes.newFeatures}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
adminNote = " | A:skip";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrichment for magic mode
|
||||||
|
let enrichNote = "";
|
||||||
|
if (isMagic) {
|
||||||
|
const client = await EterraClient.create(username, password, {
|
||||||
|
timeoutMs: 120_000,
|
||||||
|
});
|
||||||
|
const eRes = await enrichFeatures(client, uat.siruta);
|
||||||
|
enrichNote =
|
||||||
|
eRes.status === "done"
|
||||||
|
? ` | enrich: ${eRes.enrichedCount}/${eRes.totalFeatures ?? "?"}`
|
||||||
|
: ` | enrich err: ${eRes.error}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dur = Math.round((Date.now() - uatStart) / 1000);
|
||||||
|
const parts = [
|
||||||
|
tRes.newFeatures > 0 || (tRes.validFromUpdated ?? 0) > 0
|
||||||
|
? `T:+${tRes.newFeatures}/${tRes.validFromUpdated ?? 0}vf`
|
||||||
|
: "T:ok",
|
||||||
|
cRes.newFeatures > 0 || (cRes.validFromUpdated ?? 0) > 0
|
||||||
|
? `C:+${cRes.newFeatures}/${cRes.validFromUpdated ?? 0}vf`
|
||||||
|
: "C:ok",
|
||||||
|
];
|
||||||
|
totalNewFeatures += tRes.newFeatures + cRes.newFeatures;
|
||||||
|
const note = `${parts.join(", ")}${adminNote}${enrichNote} (${dur}s)`;
|
||||||
|
results.push({ siruta: uat.siruta, name: uatName, mode, duration: dur, note });
|
||||||
|
|
||||||
|
// Update progress AFTER UAT completion (so % reflects completed work)
|
||||||
|
const completedPct = Math.round(((i + 1) / uats.length) * 100);
|
||||||
|
push({
|
||||||
|
downloaded: completedPct,
|
||||||
|
total: 100,
|
||||||
|
phase: `[${i + 1}/${uats.length}] ${uatName} finalizat`,
|
||||||
|
note: `${note}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[sync-county:${county}] ${i + 1}/${uats.length} ${uatName}: ${note}`);
|
||||||
|
} catch (err) {
|
||||||
|
errors++;
|
||||||
|
const dur = Math.round((Date.now() - uatStart) / 1000);
|
||||||
|
const msg = err instanceof Error ? err.message : "Unknown";
|
||||||
|
results.push({
|
||||||
|
siruta: uat.siruta,
|
||||||
|
name: uatName,
|
||||||
|
mode,
|
||||||
|
duration: dur,
|
||||||
|
note: `ERR: ${msg}`,
|
||||||
|
});
|
||||||
|
// Still update progress after error
|
||||||
|
const completedPct = Math.round(((i + 1) / uats.length) * 100);
|
||||||
|
push({
|
||||||
|
downloaded: completedPct,
|
||||||
|
total: 100,
|
||||||
|
phase: `[${i + 1}/${uats.length}] ${uatName} — eroare`,
|
||||||
|
});
|
||||||
|
console.error(`[sync-county:${county}] ${uatName}: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDur = results.reduce((s, r) => s + r.duration, 0);
|
||||||
|
const summary = `${uats.length} UAT-uri, ${errors} erori, ${totalDur}s total`;
|
||||||
|
|
||||||
|
setProgress({
|
||||||
|
jobId,
|
||||||
|
downloaded: 100,
|
||||||
|
total: 100,
|
||||||
|
status: errors > 0 && errors === uats.length ? "error" : "done",
|
||||||
|
phase: `Sync ${county} finalizat`,
|
||||||
|
message: summary,
|
||||||
|
note: results.map((r) => `${r.name}: ${r.note}`).join("\n"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAppNotification({
|
||||||
|
type: errors > 0 ? "sync-error" : "sync-complete",
|
||||||
|
title:
|
||||||
|
errors > 0
|
||||||
|
? `Sync ${county}: ${errors} erori din ${uats.length} UAT-uri`
|
||||||
|
: `Sync ${county}: ${uats.length} UAT-uri sincronizate`,
|
||||||
|
message: summary,
|
||||||
|
metadata: { county, jobId, uatCount: uats.length, errors, totalDuration: totalDur },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[sync-county:${county}] Done: ${summary}`);
|
||||||
|
|
||||||
|
// Trigger PMTiles rebuild if new features were synced
|
||||||
|
if (totalNewFeatures > 0) {
|
||||||
|
await firePmtilesRebuild("county-sync-complete", {
|
||||||
|
county,
|
||||||
|
uatCount: uats.length,
|
||||||
|
newFeatures: totalNewFeatures,
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => clearProgress(jobId), 6 * 3_600_000);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Unknown";
|
||||||
|
setProgress({
|
||||||
|
jobId,
|
||||||
|
downloaded: 0,
|
||||||
|
total: 100,
|
||||||
|
status: "error",
|
||||||
|
phase: "Eroare",
|
||||||
|
message: msg,
|
||||||
|
});
|
||||||
|
await createAppNotification({
|
||||||
|
type: "sync-error",
|
||||||
|
title: `Sync ${county}: eroare generala`,
|
||||||
|
message: msg,
|
||||||
|
metadata: { county, jobId },
|
||||||
|
});
|
||||||
|
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||||
|
} finally {
|
||||||
|
g.__countySyncRunning = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { PrismaClient, Prisma } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
isWeekendWindow,
|
||||||
|
getWeekendSyncActivity,
|
||||||
|
triggerForceSync,
|
||||||
|
} from "@/modules/parcel-sync/services/weekend-deep-sync";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const g = globalThis as { __parcelSyncRunning?: boolean };
|
||||||
|
|
||||||
|
const KV_NAMESPACE = "parcel-sync-weekend";
|
||||||
|
const KV_KEY = "queue-state";
|
||||||
|
|
||||||
|
type StepName = "sync_terenuri" | "sync_cladiri" | "import_nogeom" | "enrich";
|
||||||
|
type StepStatus = "pending" | "done" | "error";
|
||||||
|
|
||||||
|
type CityState = {
|
||||||
|
siruta: string;
|
||||||
|
name: string;
|
||||||
|
county: string;
|
||||||
|
priority: number;
|
||||||
|
steps: Record<StepName, StepStatus>;
|
||||||
|
lastActivity?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WeekendSyncState = {
|
||||||
|
cities: CityState[];
|
||||||
|
lastSessionDate?: string;
|
||||||
|
totalSessions: number;
|
||||||
|
completedCycles: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FRESH_STEPS: Record<StepName, StepStatus> = {
|
||||||
|
sync_terenuri: "pending",
|
||||||
|
sync_cladiri: "pending",
|
||||||
|
import_nogeom: "pending",
|
||||||
|
enrich: "pending",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CITIES: Omit<CityState, "steps">[] = [
|
||||||
|
{ siruta: "54975", name: "Cluj-Napoca", county: "Cluj", priority: 1 },
|
||||||
|
{ siruta: "32394", name: "Bistri\u021Ba", county: "Bistri\u021Ba-N\u0103s\u0103ud", priority: 1 },
|
||||||
|
{ siruta: "114319", name: "T\u00E2rgu Mure\u0219", county: "Mure\u0219", priority: 2 },
|
||||||
|
{ siruta: "139704", name: "Zal\u0103u", county: "S\u0103laj", priority: 2 },
|
||||||
|
{ siruta: "26564", name: "Oradea", county: "Bihor", priority: 2 },
|
||||||
|
{ siruta: "9262", name: "Arad", county: "Arad", priority: 2 },
|
||||||
|
{ siruta: "155243", name: "Timi\u0219oara", county: "Timi\u0219", priority: 2 },
|
||||||
|
{ siruta: "143450", name: "Sibiu", county: "Sibiu", priority: 2 },
|
||||||
|
{ siruta: "40198", name: "Bra\u0219ov", county: "Bra\u0219ov", priority: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Initialize state with default cities if not present in DB */
|
||||||
|
async function getOrCreateState(): Promise<WeekendSyncState> {
|
||||||
|
const row = await prisma.keyValueStore.findUnique({
|
||||||
|
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
|
||||||
|
});
|
||||||
|
if (row?.value && typeof row.value === "object") {
|
||||||
|
return row.value as unknown as WeekendSyncState;
|
||||||
|
}
|
||||||
|
// First access — initialize with defaults
|
||||||
|
const state: WeekendSyncState = {
|
||||||
|
cities: DEFAULT_CITIES.map((c) => ({ ...c, steps: { ...FRESH_STEPS } })),
|
||||||
|
totalSessions: 0,
|
||||||
|
completedCycles: 0,
|
||||||
|
};
|
||||||
|
await prisma.keyValueStore.upsert({
|
||||||
|
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
|
||||||
|
update: { value: state as unknown as Prisma.InputJsonValue },
|
||||||
|
create: {
|
||||||
|
namespace: KV_NAMESPACE,
|
||||||
|
key: KV_KEY,
|
||||||
|
value: state as unknown as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/eterra/weekend-sync
|
||||||
|
* Returns the current queue state.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
// Auth handled by middleware (route is not excluded)
|
||||||
|
const state = await getOrCreateState();
|
||||||
|
const sirutas = state.cities.map((c) => c.siruta);
|
||||||
|
|
||||||
|
const counts = await prisma.gisFeature.groupBy({
|
||||||
|
by: ["siruta", "layerId"],
|
||||||
|
where: { siruta: { in: sirutas } },
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrichedCounts = await prisma.gisFeature.groupBy({
|
||||||
|
by: ["siruta"],
|
||||||
|
where: { siruta: { in: sirutas }, enrichedAt: { not: null } },
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrichedMap = new Map(enrichedCounts.map((e) => [e.siruta, e._count.id]));
|
||||||
|
|
||||||
|
type CityStats = {
|
||||||
|
terenuri: number;
|
||||||
|
cladiri: number;
|
||||||
|
total: number;
|
||||||
|
enriched: number;
|
||||||
|
};
|
||||||
|
const statsMap = new Map<string, CityStats>();
|
||||||
|
|
||||||
|
for (const c of counts) {
|
||||||
|
const existing = statsMap.get(c.siruta) ?? { terenuri: 0, cladiri: 0, total: 0, enriched: 0 };
|
||||||
|
existing.total += c._count.id;
|
||||||
|
if (c.layerId === "TERENURI_ACTIVE") existing.terenuri = c._count.id;
|
||||||
|
if (c.layerId === "CLADIRI_ACTIVE") existing.cladiri = c._count.id;
|
||||||
|
existing.enriched = enrichedMap.get(c.siruta) ?? 0;
|
||||||
|
statsMap.set(c.siruta, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const citiesWithStats = state.cities.map((c) => ({
|
||||||
|
...c,
|
||||||
|
dbStats: statsMap.get(c.siruta) ?? { terenuri: 0, cladiri: 0, total: 0, enriched: 0 },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Determine live sync status
|
||||||
|
const running = !!g.__parcelSyncRunning;
|
||||||
|
const activity = getWeekendSyncActivity();
|
||||||
|
const inWindow = isWeekendWindow();
|
||||||
|
const hasErrors = state.cities.some((c) =>
|
||||||
|
(Object.values(c.steps) as StepStatus[]).some((s) => s === "error"),
|
||||||
|
);
|
||||||
|
|
||||||
|
type SyncStatus = "running" | "error" | "waiting" | "idle";
|
||||||
|
let syncStatus: SyncStatus = "idle";
|
||||||
|
if (running) syncStatus = "running";
|
||||||
|
else if (hasErrors) syncStatus = "error";
|
||||||
|
else if (inWindow) syncStatus = "waiting";
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
state: { ...state, cities: citiesWithStats },
|
||||||
|
syncStatus,
|
||||||
|
currentActivity: activity,
|
||||||
|
inWeekendWindow: inWindow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/eterra/weekend-sync
|
||||||
|
* Modify the queue: add/remove cities, reset steps, change priority.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
// Auth handled by middleware (route is not excluded)
|
||||||
|
const body = (await request.json()) as {
|
||||||
|
action: "add" | "remove" | "reset" | "reset_all" | "set_priority" | "trigger";
|
||||||
|
siruta?: string;
|
||||||
|
name?: string;
|
||||||
|
county?: string;
|
||||||
|
priority?: number;
|
||||||
|
onlySteps?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trigger is handled separately — starts sync immediately
|
||||||
|
if (body.action === "trigger") {
|
||||||
|
const validSteps = ["sync_terenuri", "sync_cladiri", "import_nogeom", "enrich"] as const;
|
||||||
|
const onlySteps = body.onlySteps?.filter((s): s is (typeof validSteps)[number] =>
|
||||||
|
(validSteps as readonly string[]).includes(s),
|
||||||
|
);
|
||||||
|
const result = await triggerForceSync(
|
||||||
|
onlySteps && onlySteps.length > 0 ? { onlySteps } : undefined,
|
||||||
|
);
|
||||||
|
if (!result.started) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: result.reason },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true, message: "Sincronizare pornita" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await getOrCreateState();
|
||||||
|
|
||||||
|
switch (body.action) {
|
||||||
|
case "add": {
|
||||||
|
if (!body.siruta || !body.name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "siruta si name sunt obligatorii" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.cities.some((c) => c.siruta === body.siruta)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `${body.name} (${body.siruta}) e deja in coada` },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state.cities.push({
|
||||||
|
siruta: body.siruta,
|
||||||
|
name: body.name,
|
||||||
|
county: body.county ?? "",
|
||||||
|
priority: body.priority ?? 3,
|
||||||
|
steps: { ...FRESH_STEPS },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "remove": {
|
||||||
|
state.cities = state.cities.filter((c) => c.siruta !== body.siruta);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "reset": {
|
||||||
|
const city = state.cities.find((c) => c.siruta === body.siruta);
|
||||||
|
if (city) {
|
||||||
|
city.steps = { ...FRESH_STEPS };
|
||||||
|
city.errorMessage = undefined;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "reset_all": {
|
||||||
|
for (const city of state.cities) {
|
||||||
|
city.steps = { ...FRESH_STEPS };
|
||||||
|
city.errorMessage = undefined;
|
||||||
|
}
|
||||||
|
state.completedCycles = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "set_priority": {
|
||||||
|
const city = state.cities.find((c) => c.siruta === body.siruta);
|
||||||
|
if (city && body.priority != null) {
|
||||||
|
city.priority = body.priority;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.keyValueStore.upsert({
|
||||||
|
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
|
||||||
|
update: { value: state as unknown as Prisma.InputJsonValue },
|
||||||
|
create: {
|
||||||
|
namespace: KV_NAMESPACE,
|
||||||
|
key: KV_KEY,
|
||||||
|
value: state as unknown as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, cities: state.cities.length });
|
||||||
|
}
|
||||||
@@ -75,6 +75,34 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ error: "Parcela negasita in registrul eTerra" }, { status: 404 });
|
return NextResponse.json({ error: "Parcela negasita in registrul eTerra" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Building cross-ref: check CLADIRI_ACTIVE in local DB for this parcel
|
||||||
|
let hasBuilding = 0;
|
||||||
|
let buildLegal = 0;
|
||||||
|
const baseCad = cadRef.includes("-") ? cadRef.split("-")[0]! : cadRef;
|
||||||
|
if (baseCad) {
|
||||||
|
const cladiri = await prisma.gisFeature.findMany({
|
||||||
|
where: {
|
||||||
|
layerId: "CLADIRI_ACTIVE",
|
||||||
|
siruta: feature.siruta,
|
||||||
|
OR: [
|
||||||
|
{ cadastralRef: { startsWith: baseCad + "-" } },
|
||||||
|
{ cadastralRef: baseCad },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { attributes: true },
|
||||||
|
});
|
||||||
|
for (const c of cladiri) {
|
||||||
|
const attrs = c.attributes as Record<string, unknown>;
|
||||||
|
hasBuilding = 1;
|
||||||
|
if (
|
||||||
|
Number(attrs.IS_LEGAL ?? 0) === 1 ||
|
||||||
|
String(attrs.IS_LEGAL ?? "").toLowerCase() === "true"
|
||||||
|
) {
|
||||||
|
buildLegal = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to enrichment format (same as enrichFeatures uses)
|
// Convert to enrichment format (same as enrichFeatures uses)
|
||||||
const enrichment = {
|
const enrichment = {
|
||||||
NR_CAD: match.nrCad || cadRef,
|
NR_CAD: match.nrCad || cadRef,
|
||||||
@@ -89,8 +117,8 @@ export async function POST(req: Request) {
|
|||||||
SOLICITANT: match.solicitant || "",
|
SOLICITANT: match.solicitant || "",
|
||||||
INTRAVILAN: match.intravilan || "",
|
INTRAVILAN: match.intravilan || "",
|
||||||
CATEGORIE_FOLOSINTA: match.categorieFolosinta || "",
|
CATEGORIE_FOLOSINTA: match.categorieFolosinta || "",
|
||||||
HAS_BUILDING: 0,
|
HAS_BUILDING: hasBuilding,
|
||||||
BUILD_LEGAL: 0,
|
BUILD_LEGAL: buildLegal,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Persist
|
// Persist
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/geoportal/monitor — tile infrastructure status
|
||||||
|
* POST /api/geoportal/monitor — trigger actions (rebuild, warm-cache)
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const TILE_CACHE_INTERNAL = "http://tile-cache:80";
|
||||||
|
const MARTIN_INTERNAL = "http://martin:3000";
|
||||||
|
const PMTILES_URL = process.env.NEXT_PUBLIC_PMTILES_URL || "";
|
||||||
|
// Server-side fetch needs absolute URL — resolve relative paths through tile-cache
|
||||||
|
const PMTILES_FETCH_URL = PMTILES_URL.startsWith("/")
|
||||||
|
? `${TILE_CACHE_INTERNAL}${PMTILES_URL.replace(/^\/tiles/, "")}`
|
||||||
|
: PMTILES_URL;
|
||||||
|
const N8N_WEBHOOK_URL = process.env.N8N_WEBHOOK_URL || "";
|
||||||
|
|
||||||
|
type NginxStatus = {
|
||||||
|
activeConnections: number;
|
||||||
|
accepts: number;
|
||||||
|
handled: number;
|
||||||
|
requests: number;
|
||||||
|
reading: number;
|
||||||
|
writing: number;
|
||||||
|
waiting: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseNginxStatus(text: string): NginxStatus {
|
||||||
|
const lines = text.trim().split("\n");
|
||||||
|
const active = parseInt(lines[0]?.match(/\d+/)?.[0] ?? "0", 10);
|
||||||
|
const counts = lines[2]?.trim().split(/\s+/).map(Number) ?? [0, 0, 0];
|
||||||
|
const rw = lines[3]?.match(/\d+/g)?.map(Number) ?? [0, 0, 0];
|
||||||
|
return {
|
||||||
|
activeConnections: active,
|
||||||
|
accepts: counts[0] ?? 0,
|
||||||
|
handled: counts[1] ?? 0,
|
||||||
|
requests: counts[2] ?? 0,
|
||||||
|
reading: rw[0] ?? 0,
|
||||||
|
writing: rw[1] ?? 0,
|
||||||
|
waiting: rw[2] ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTimeout(url: string, timeoutMs = 5000): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { signal: controller.signal, cache: "no-store" });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample tile coordinates for cache testing (Romania, z8)
|
||||||
|
const SAMPLE_TILES = [
|
||||||
|
{ z: 8, x: 143, y: 91, source: "gis_uats_z8" },
|
||||||
|
{ z: 17, x: 73640, y: 47720, source: "gis_terenuri" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Nginx status
|
||||||
|
try {
|
||||||
|
const res = await fetchWithTimeout(`${TILE_CACHE_INTERNAL}/status`);
|
||||||
|
if (res.ok) {
|
||||||
|
result.nginx = parseNginxStatus(await res.text());
|
||||||
|
} else {
|
||||||
|
result.nginx = { error: `HTTP ${res.status}` };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
result.nginx = { error: "tile-cache unreachable" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Martin catalog
|
||||||
|
try {
|
||||||
|
const res = await fetchWithTimeout(`${MARTIN_INTERNAL}/catalog`);
|
||||||
|
if (res.ok) {
|
||||||
|
const catalog = await res.json() as { tiles?: Record<string, unknown> };
|
||||||
|
const sources = Object.keys(catalog.tiles ?? {});
|
||||||
|
result.martin = { status: "ok", sources, sourceCount: sources.length };
|
||||||
|
} else {
|
||||||
|
result.martin = { error: `HTTP ${res.status}` };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
result.martin = { error: "martin unreachable" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. PMTiles info
|
||||||
|
if (PMTILES_URL) {
|
||||||
|
try {
|
||||||
|
const res = await fetchWithTimeout(PMTILES_FETCH_URL, 3000);
|
||||||
|
result.pmtiles = {
|
||||||
|
url: PMTILES_URL,
|
||||||
|
status: res.ok ? "ok" : `HTTP ${res.status}`,
|
||||||
|
size: res.headers.get("content-length")
|
||||||
|
? `${(parseInt(res.headers.get("content-length") ?? "0", 10) / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
: "unknown",
|
||||||
|
lastModified: res.headers.get("last-modified") ?? "unknown",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
result.pmtiles = { url: PMTILES_URL, error: "unreachable" };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.pmtiles = { status: "not configured" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Cache test — request sample tiles and check X-Cache-Status
|
||||||
|
const cacheTests: Record<string, string>[] = [];
|
||||||
|
for (const tile of SAMPLE_TILES) {
|
||||||
|
try {
|
||||||
|
const url = `${TILE_CACHE_INTERNAL}/${tile.source}/${tile.z}/${tile.x}/${tile.y}`;
|
||||||
|
const res = await fetchWithTimeout(url, 10000);
|
||||||
|
cacheTests.push({
|
||||||
|
tile: `${tile.source}/${tile.z}/${tile.x}/${tile.y}`,
|
||||||
|
status: `${res.status}`,
|
||||||
|
cache: res.headers.get("x-cache-status") ?? "unknown",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
cacheTests.push({
|
||||||
|
tile: `${tile.source}/${tile.z}/${tile.x}/${tile.y}`,
|
||||||
|
status: "error",
|
||||||
|
cache: "unreachable",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.cacheTests = cacheTests;
|
||||||
|
|
||||||
|
// 5. Config summary
|
||||||
|
result.config = {
|
||||||
|
martinUrl: process.env.NEXT_PUBLIC_MARTIN_URL ?? "(not set)",
|
||||||
|
pmtilesUrl: PMTILES_URL || "(not set)",
|
||||||
|
n8nWebhook: N8N_WEBHOOK_URL ? "configured" : "not set",
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPmtilesInfo(): Promise<{ size: string; lastModified: string } | null> {
|
||||||
|
if (!PMTILES_URL) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetchWithTimeout(PMTILES_FETCH_URL, 3000);
|
||||||
|
return {
|
||||||
|
size: res.headers.get("content-length")
|
||||||
|
? `${(parseInt(res.headers.get("content-length") ?? "0", 10) / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
: "unknown",
|
||||||
|
lastModified: res.headers.get("last-modified") ?? "unknown",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const body = await request.json() as { action?: string };
|
||||||
|
const action = body.action;
|
||||||
|
|
||||||
|
if (action === "rebuild") {
|
||||||
|
// Get current PMTiles state before rebuild
|
||||||
|
const before = await getPmtilesInfo();
|
||||||
|
const result = await firePmtilesRebuild("manual-rebuild");
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Webhook PMTiles indisponibil — verifica N8N_WEBHOOK_URL si serviciul pmtiles-webhook" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
action: "rebuild",
|
||||||
|
alreadyRunning: result.alreadyRunning ?? false,
|
||||||
|
previousPmtiles: before,
|
||||||
|
message: result.alreadyRunning
|
||||||
|
? "Rebuild PMTiles deja in curs. Urmareste PMTiles last-modified."
|
||||||
|
: "Rebuild PMTiles pornit. Dureaza ~8 min. Urmareste PMTiles last-modified.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "check-rebuild") {
|
||||||
|
// Check if PMTiles was updated since a given timestamp
|
||||||
|
const previousLastModified = (body as { previousLastModified?: string }).previousLastModified;
|
||||||
|
const current = await getPmtilesInfo();
|
||||||
|
const changed = !!current && !!previousLastModified && current.lastModified !== previousLastModified;
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
action: "check-rebuild",
|
||||||
|
current,
|
||||||
|
changed,
|
||||||
|
message: changed
|
||||||
|
? `Rebuild finalizat! PMTiles actualizat: ${current?.size}, ${current?.lastModified}`
|
||||||
|
: "Rebuild in curs...",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "warm-cache") {
|
||||||
|
const sources = ["gis_terenuri", "gis_cladiri"];
|
||||||
|
let total = 0;
|
||||||
|
let hits = 0;
|
||||||
|
let misses = 0;
|
||||||
|
let errors = 0;
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
|
for (const source of sources) {
|
||||||
|
for (let x = 9200; x <= 9210; x++) {
|
||||||
|
for (let y = 5960; y <= 5970; y++) {
|
||||||
|
total++;
|
||||||
|
promises.push(
|
||||||
|
fetchWithTimeout(`${TILE_CACHE_INTERNAL}/${source}/14/${x}/${y}`, 30000)
|
||||||
|
.then((res) => {
|
||||||
|
const cache = res.headers.get("x-cache-status") ?? "";
|
||||||
|
if (cache === "HIT") hits++;
|
||||||
|
else misses++;
|
||||||
|
})
|
||||||
|
.catch(() => { errors++; }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(promises);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
action: "warm-cache",
|
||||||
|
total,
|
||||||
|
hits,
|
||||||
|
misses,
|
||||||
|
errors,
|
||||||
|
message: `${total} tile-uri procesate: ${hits} HIT, ${misses} MISS (nou incarcate), ${errors} erori`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||||
|
}
|
||||||
@@ -67,25 +67,28 @@ export async function GET(req: Request) {
|
|||||||
// Search by cadastral reference
|
// Search by cadastral reference
|
||||||
const parcels = await prisma.$queryRaw`
|
const parcels = await prisma.$queryRaw`
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
f.id,
|
||||||
"cadastralRef",
|
f."cadastralRef",
|
||||||
"areaValue",
|
f."areaValue",
|
||||||
siruta,
|
f.siruta,
|
||||||
enrichment,
|
f.enrichment,
|
||||||
ST_X(ST_Centroid(ST_Transform(geom, 4326))) as lng,
|
u.name as uat_name,
|
||||||
ST_Y(ST_Centroid(ST_Transform(geom, 4326))) as lat
|
ST_X(ST_Centroid(ST_Transform(f.geom, 4326))) as lng,
|
||||||
FROM "GisFeature"
|
ST_Y(ST_Centroid(ST_Transform(f.geom, 4326))) as lat
|
||||||
WHERE geom IS NOT NULL
|
FROM "GisFeature" f
|
||||||
AND "layerId" LIKE 'TERENURI%'
|
LEFT JOIN "GisUat" u ON u.siruta = f.siruta
|
||||||
AND ("cadastralRef" ILIKE ${pattern}
|
WHERE f.geom IS NOT NULL
|
||||||
OR enrichment::text ILIKE ${'%"NR_CAD":"' + q + '%'})
|
AND f."layerId" LIKE 'TERENURI%'
|
||||||
ORDER BY "cadastralRef"
|
AND (f."cadastralRef" ILIKE ${pattern}
|
||||||
|
OR f.enrichment::text ILIKE ${`%"NR_CAD":"${q}%`})
|
||||||
|
ORDER BY f."cadastralRef"
|
||||||
LIMIT ${limit}
|
LIMIT ${limit}
|
||||||
` as Array<{
|
` as Array<{
|
||||||
id: string;
|
id: string;
|
||||||
cadastralRef: string | null;
|
cadastralRef: string | null;
|
||||||
areaValue: number | null;
|
areaValue: number | null;
|
||||||
siruta: string;
|
siruta: string;
|
||||||
|
uat_name: string | null;
|
||||||
enrichment: Record<string, unknown> | null;
|
enrichment: Record<string, unknown> | null;
|
||||||
lng: number;
|
lng: number;
|
||||||
lat: number;
|
lat: number;
|
||||||
@@ -94,11 +97,12 @@ export async function GET(req: Request) {
|
|||||||
for (const p of parcels) {
|
for (const p of parcels) {
|
||||||
const nrCad = (p.enrichment?.NR_CAD as string) ?? p.cadastralRef ?? "?";
|
const nrCad = (p.enrichment?.NR_CAD as string) ?? p.cadastralRef ?? "?";
|
||||||
const area = p.areaValue ? `${Math.round(p.areaValue)} mp` : "";
|
const area = p.areaValue ? `${Math.round(p.areaValue)} mp` : "";
|
||||||
|
const uatLabel = p.uat_name ?? `SIRUTA ${p.siruta}`;
|
||||||
results.push({
|
results.push({
|
||||||
id: `parcel-${p.id}`,
|
id: `parcel-${p.id}`,
|
||||||
type: "parcel",
|
type: "parcel",
|
||||||
label: `Parcela ${nrCad}`,
|
label: `Parcela ${nrCad} — ${uatLabel}`,
|
||||||
sublabel: [area, `SIRUTA ${p.siruta}`].filter(Boolean).join(" | "),
|
sublabel: [area, p.uat_name ? `SIRUTA ${p.siruta}` : ""].filter(Boolean).join(" | "),
|
||||||
coordinates: p.lng && p.lat ? [p.lng, p.lat] : undefined,
|
coordinates: p.lng && p.lat ? [p.lng, p.lat] : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -106,25 +110,28 @@ export async function GET(req: Request) {
|
|||||||
// Search by owner name in enrichment JSON
|
// Search by owner name in enrichment JSON
|
||||||
const parcels = await prisma.$queryRaw`
|
const parcels = await prisma.$queryRaw`
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
f.id,
|
||||||
"cadastralRef",
|
f."cadastralRef",
|
||||||
"areaValue",
|
f."areaValue",
|
||||||
siruta,
|
f.siruta,
|
||||||
enrichment,
|
f.enrichment,
|
||||||
ST_X(ST_Centroid(ST_Transform(geom, 4326))) as lng,
|
u.name as uat_name,
|
||||||
ST_Y(ST_Centroid(ST_Transform(geom, 4326))) as lat
|
ST_X(ST_Centroid(ST_Transform(f.geom, 4326))) as lng,
|
||||||
FROM "GisFeature"
|
ST_Y(ST_Centroid(ST_Transform(f.geom, 4326))) as lat
|
||||||
WHERE geom IS NOT NULL
|
FROM "GisFeature" f
|
||||||
AND "layerId" LIKE 'TERENURI%'
|
LEFT JOIN "GisUat" u ON u.siruta = f.siruta
|
||||||
AND enrichment IS NOT NULL
|
WHERE f.geom IS NOT NULL
|
||||||
AND enrichment::text ILIKE ${pattern}
|
AND f."layerId" LIKE 'TERENURI%'
|
||||||
ORDER BY "cadastralRef"
|
AND f.enrichment IS NOT NULL
|
||||||
|
AND f.enrichment::text ILIKE ${pattern}
|
||||||
|
ORDER BY f."cadastralRef"
|
||||||
LIMIT ${limit}
|
LIMIT ${limit}
|
||||||
` as Array<{
|
` as Array<{
|
||||||
id: string;
|
id: string;
|
||||||
cadastralRef: string | null;
|
cadastralRef: string | null;
|
||||||
areaValue: number | null;
|
areaValue: number | null;
|
||||||
siruta: string;
|
siruta: string;
|
||||||
|
uat_name: string | null;
|
||||||
enrichment: Record<string, unknown> | null;
|
enrichment: Record<string, unknown> | null;
|
||||||
lng: number;
|
lng: number;
|
||||||
lat: number;
|
lat: number;
|
||||||
@@ -133,11 +140,13 @@ export async function GET(req: Request) {
|
|||||||
for (const p of parcels) {
|
for (const p of parcels) {
|
||||||
const nrCad = (p.enrichment?.NR_CAD as string) ?? p.cadastralRef ?? "?";
|
const nrCad = (p.enrichment?.NR_CAD as string) ?? p.cadastralRef ?? "?";
|
||||||
const owner = (p.enrichment?.PROPRIETARI as string) ?? "";
|
const owner = (p.enrichment?.PROPRIETARI as string) ?? "";
|
||||||
|
const uatLabel = p.uat_name ?? `SIRUTA ${p.siruta}`;
|
||||||
|
const ownerShort = owner.length > 60 ? owner.slice(0, 60) + "..." : owner;
|
||||||
results.push({
|
results.push({
|
||||||
id: `parcel-${p.id}`,
|
id: `parcel-${p.id}`,
|
||||||
type: "parcel",
|
type: "parcel",
|
||||||
label: `Parcela ${nrCad}`,
|
label: `Parcela ${nrCad} — ${uatLabel}`,
|
||||||
sublabel: owner.length > 60 ? owner.slice(0, 60) + "..." : owner,
|
sublabel: [ownerShort, p.uat_name ? `SIRUTA ${p.siruta}` : ""].filter(Boolean).join(" | "),
|
||||||
coordinates: p.lng && p.lat ? [p.lng, p.lat] : undefined,
|
coordinates: p.lng && p.lat ? [p.lng, p.lat] : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/notifications/app — list recent + unread count
|
||||||
|
* PATCH /api/notifications/app — mark read / mark all read
|
||||||
|
*
|
||||||
|
* Body for PATCH:
|
||||||
|
* { action: "mark-read", id: string }
|
||||||
|
* { action: "mark-all-read" }
|
||||||
|
*/
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
getAppNotifications,
|
||||||
|
getUnreadCount,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
} from "@/core/notifications/app-notifications";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "30", 10), 100);
|
||||||
|
|
||||||
|
const [notifications, unreadCount] = await Promise.all([
|
||||||
|
getAppNotifications(limit),
|
||||||
|
getUnreadCount(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({ notifications, unreadCount });
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Eroare notificari";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await req.json()) as { action: string; id?: string };
|
||||||
|
|
||||||
|
if (body.action === "mark-read" && body.id) {
|
||||||
|
await markAsRead(body.id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.action === "mark-all-read") {
|
||||||
|
await markAllAsRead();
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: "Actiune necunoscuta" }, { status: 400 });
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Eroare notificari";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,11 +11,21 @@ import { NextResponse } from "next/server";
|
|||||||
import { prisma } from "@/core/storage/prisma";
|
import { prisma } from "@/core/storage/prisma";
|
||||||
import { getAuthSession } from "@/core/auth";
|
import { getAuthSession } from "@/core/auth";
|
||||||
|
|
||||||
export async function GET() {
|
async function requireAdmin(): Promise<NextResponse | null> {
|
||||||
const session = await getAuthSession();
|
const session = await getAuthSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
const u = session.user as { role?: string } | undefined;
|
||||||
|
if (u?.role !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const denied = await requireAdmin();
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
// Get all sequence counters
|
// Get all sequence counters
|
||||||
const counters = await prisma.$queryRaw<
|
const counters = await prisma.$queryRaw<
|
||||||
@@ -79,10 +89,8 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
const session = await getAuthSession();
|
const denied = await requireAdmin();
|
||||||
if (!session) {
|
if (denied) return denied;
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete ALL old counters
|
// Delete ALL old counters
|
||||||
const deleted = await prisma.$executeRaw`DELETE FROM "RegistrySequence"`;
|
const deleted = await prisma.$executeRaw`DELETE FROM "RegistrySequence"`;
|
||||||
@@ -146,10 +154,8 @@ export async function POST() {
|
|||||||
* Rewrites the "number" field inside the JSONB value for matching entries.
|
* Rewrites the "number" field inside the JSONB value for matching entries.
|
||||||
*/
|
*/
|
||||||
export async function PATCH() {
|
export async function PATCH() {
|
||||||
const session = await getAuthSession();
|
const denied = await requireAdmin();
|
||||||
if (!session) {
|
if (denied) return denied;
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map old 3-letter prefixes to new single-letter
|
// Map old 3-letter prefixes to new single-letter
|
||||||
const migrations: Array<{ old: string; new: string }> = [
|
const migrations: Array<{ old: string; new: string }> = [
|
||||||
|
|||||||
@@ -213,27 +213,33 @@ export async function POST(req: NextRequest) {
|
|||||||
let claimedSlotId: string | undefined;
|
let claimedSlotId: string | undefined;
|
||||||
|
|
||||||
if (isPastMonth && direction === "intrat") {
|
if (isPastMonth && direction === "intrat") {
|
||||||
// Try to claim a reserved slot
|
// Try to claim a reserved slot — use advisory lock to prevent concurrent claims
|
||||||
const allEntries = await loadAllEntries(true);
|
const lockKey = `reserved:${company}-${docDate.getFullYear()}-${docDate.getMonth()}`;
|
||||||
const slot = findAvailableReservedSlot(
|
const claimed = await prisma.$transaction(async (tx) => {
|
||||||
allEntries,
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${lockKey}))`;
|
||||||
company,
|
const allEntries = await loadAllEntries(true);
|
||||||
docDate.getFullYear(),
|
const slot = findAvailableReservedSlot(
|
||||||
docDate.getMonth(),
|
allEntries,
|
||||||
);
|
company,
|
||||||
|
docDate.getFullYear(),
|
||||||
|
docDate.getMonth(),
|
||||||
|
);
|
||||||
|
if (!slot) return null;
|
||||||
|
// Delete the placeholder slot within the lock
|
||||||
|
await tx.keyValueStore.delete({
|
||||||
|
where: { namespace_key: { namespace: "registratura", key: slot.id } },
|
||||||
|
});
|
||||||
|
return slot;
|
||||||
|
});
|
||||||
|
|
||||||
if (slot) {
|
if (claimed) {
|
||||||
// Claim the reserved slot — reuse its number
|
registryNumber = claimed.number;
|
||||||
registryNumber = slot.number;
|
|
||||||
registrationType = "reserved-claimed";
|
registrationType = "reserved-claimed";
|
||||||
claimedSlotId = slot.id;
|
claimedSlotId = claimed.id;
|
||||||
|
|
||||||
// Delete the placeholder slot
|
|
||||||
await deleteEntryFromDB(slot.id);
|
|
||||||
|
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
entryId: slot.id,
|
entryId: claimed.id,
|
||||||
entryNumber: slot.number,
|
entryNumber: claimed.number,
|
||||||
action: "reserved_claimed",
|
action: "reserved_claimed",
|
||||||
actor: actor.id,
|
actor: actor.id,
|
||||||
actorName: actor.name,
|
actorName: actor.name,
|
||||||
|
|||||||
@@ -144,8 +144,8 @@ export async function DELETE(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err: { code?: string }) => {
|
||||||
// Ignore error if item doesn't exist
|
if (err.code !== "P2025") throw err;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Clear namespace
|
// Clear namespace
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* In-app notification service.
|
||||||
|
*
|
||||||
|
* Stores lightweight notifications in KeyValueStore (namespace "app-notifications").
|
||||||
|
* Used for sync completion alerts, errors, etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from "@/core/storage/prisma";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type AppNotificationType = "sync-complete" | "sync-error";
|
||||||
|
|
||||||
|
export interface AppNotification {
|
||||||
|
id: string;
|
||||||
|
type: AppNotificationType;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: string;
|
||||||
|
readAt: string | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAMESPACE = "app-notifications";
|
||||||
|
const MAX_AGE_DAYS = 30;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Create */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export async function createAppNotification(
|
||||||
|
input: Omit<AppNotification, "id" | "createdAt" | "readAt">,
|
||||||
|
): Promise<AppNotification> {
|
||||||
|
const notification: AppNotification = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
readAt: null,
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.keyValueStore.create({
|
||||||
|
data: {
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
key: notification.id,
|
||||||
|
value: notification as unknown as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Read */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export async function getAppNotifications(limit = 30): Promise<AppNotification[]> {
|
||||||
|
const rows = await prisma.keyValueStore.findMany({
|
||||||
|
where: { namespace: NAMESPACE },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cutoff = Date.now() - MAX_AGE_DAYS * 86_400_000;
|
||||||
|
const notifications: AppNotification[] = [];
|
||||||
|
const staleIds: string[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const n = row.value as unknown as AppNotification;
|
||||||
|
if (new Date(n.createdAt).getTime() < cutoff) {
|
||||||
|
staleIds.push(row.id);
|
||||||
|
} else {
|
||||||
|
notifications.push(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy cleanup of old notifications
|
||||||
|
if (staleIds.length > 0) {
|
||||||
|
void prisma.keyValueStore.deleteMany({
|
||||||
|
where: { id: { in: staleIds } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return notifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUnreadCount(): Promise<number> {
|
||||||
|
const rows = await prisma.$queryRaw<Array<{ count: number }>>`
|
||||||
|
SELECT COUNT(*)::int as count
|
||||||
|
FROM "KeyValueStore"
|
||||||
|
WHERE namespace = ${NAMESPACE}
|
||||||
|
AND value->>'readAt' IS NULL
|
||||||
|
`;
|
||||||
|
return rows[0]?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Update */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export async function markAsRead(id: string): Promise<void> {
|
||||||
|
const row = await prisma.keyValueStore.findUnique({
|
||||||
|
where: { namespace_key: { namespace: NAMESPACE, key: id } },
|
||||||
|
});
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
const n = row.value as unknown as AppNotification;
|
||||||
|
n.readAt = new Date().toISOString();
|
||||||
|
|
||||||
|
await prisma.keyValueStore.update({
|
||||||
|
where: { namespace_key: { namespace: NAMESPACE, key: id } },
|
||||||
|
data: { value: n as unknown as Prisma.InputJsonValue },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllAsRead(): Promise<void> {
|
||||||
|
const rows = await prisma.keyValueStore.findMany({
|
||||||
|
where: { namespace: NAMESPACE },
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const updates = rows
|
||||||
|
.filter((r) => {
|
||||||
|
const n = r.value as unknown as AppNotification;
|
||||||
|
return n.readAt === null;
|
||||||
|
})
|
||||||
|
.map((r) => {
|
||||||
|
const n = r.value as unknown as AppNotification;
|
||||||
|
n.readAt = now;
|
||||||
|
return prisma.keyValueStore.update({
|
||||||
|
where: { namespace_key: { namespace: NAMESPACE, key: r.key } },
|
||||||
|
data: { value: n as unknown as Prisma.InputJsonValue },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updates.length > 0) {
|
||||||
|
await prisma.$transaction(updates);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,3 +15,12 @@ export {
|
|||||||
getAllPreferences,
|
getAllPreferences,
|
||||||
runDigest,
|
runDigest,
|
||||||
} from "./notification-service";
|
} from "./notification-service";
|
||||||
|
export {
|
||||||
|
createAppNotification,
|
||||||
|
getAppNotifications,
|
||||||
|
getUnreadCount,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
type AppNotification,
|
||||||
|
type AppNotificationType,
|
||||||
|
} from "./app-notifications";
|
||||||
|
|||||||
@@ -18,16 +18,3 @@ if (process.env.NODE_ENV !== "production")
|
|||||||
globalForMinio.minioClient = minioClient;
|
globalForMinio.minioClient = minioClient;
|
||||||
|
|
||||||
export const MINIO_BUCKET_NAME = process.env.MINIO_BUCKET_NAME || "tools";
|
export const MINIO_BUCKET_NAME = process.env.MINIO_BUCKET_NAME || "tools";
|
||||||
|
|
||||||
// Helper to ensure bucket exists
|
|
||||||
export async function ensureBucketExists() {
|
|
||||||
try {
|
|
||||||
const exists = await minioClient.bucketExists(MINIO_BUCKET_NAME);
|
|
||||||
if (!exists) {
|
|
||||||
await minioClient.makeBucket(MINIO_BUCKET_NAME, "eu-west-1");
|
|
||||||
console.log(`Bucket '${MINIO_BUCKET_NAME}' created successfully.`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error checking/creating MinIO bucket:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Next.js instrumentation hook — runs once at server startup.
|
||||||
|
* Used to initialize background schedulers.
|
||||||
|
*/
|
||||||
|
export async function register() {
|
||||||
|
// Only run on the server (not during build or in edge runtime)
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
// ParcelSync auto-refresh scheduler DISABLED during GIS DB overhaul.
|
||||||
|
// Re-enable by uncommenting the import below once the new schema is stable.
|
||||||
|
// await import("@/modules/parcel-sync/services/auto-refresh-scheduler");
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -16,7 +16,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
if (token) {
|
if (token) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
// Portal-only users: redirect to /portal when accessing main app
|
// Portal-only users: redirect to /portal when accessing main app
|
||||||
const portalUsers = ["dtiurbe", "d.tiurbe"];
|
const portalUsers = (process.env.PORTAL_ONLY_USERS ?? "dtiurbe,d.tiurbe").split(",").map(s => s.trim().toLowerCase());
|
||||||
const tokenEmail = String(token.email ?? "").toLowerCase();
|
const tokenEmail = String(token.email ?? "").toLowerCase();
|
||||||
const tokenName = String(token.name ?? "").toLowerCase();
|
const tokenName = String(token.name ?? "").toLowerCase();
|
||||||
const isPortalUser = portalUsers.some(
|
const isPortalUser = portalUsers.some(
|
||||||
@@ -58,6 +58,6 @@ export const config = {
|
|||||||
* - /favicon.ico, /robots.txt, /sitemap.xml
|
* - /favicon.ico, /robots.txt, /sitemap.xml
|
||||||
* - Files with extensions (images, fonts, etc.)
|
* - Files with extensions (images, fonts, etc.)
|
||||||
*/
|
*/
|
||||||
"/((?!api/auth|api/notifications/digest|api/compress-pdf|api/address-book|api/projects|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)",
|
"/((?!api/auth|api/notifications/digest|api/eterra/auto-refresh|api/compress-pdf|api/address-book|api/projects|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ export function GeoportalModule() {
|
|||||||
const [clickedFeature, setClickedFeature] = useState<ClickedFeature | null>(null);
|
const [clickedFeature, setClickedFeature] = useState<ClickedFeature | null>(null);
|
||||||
const [selectionMode, setSelectionMode] = useState<SelectionMode>("off");
|
const [selectionMode, setSelectionMode] = useState<SelectionMode>("off");
|
||||||
const [selectedFeatures, setSelectedFeatures] = useState<SelectedFeature[]>([]);
|
const [selectedFeatures, setSelectedFeatures] = useState<SelectedFeature[]>([]);
|
||||||
const [flyTarget, setFlyTarget] = useState<{ center: [number, number]; zoom?: number } | undefined>();
|
|
||||||
|
|
||||||
const handleFeatureClick = useCallback((feature: ClickedFeature | null) => {
|
const handleFeatureClick = useCallback((feature: ClickedFeature | null) => {
|
||||||
// null = clicked on empty space, close panel
|
// null = clicked on empty space, close panel
|
||||||
if (!feature || !feature.properties) {
|
if (!feature || !feature.properties) {
|
||||||
@@ -45,7 +43,7 @@ export function GeoportalModule() {
|
|||||||
|
|
||||||
const handleSearchResult = useCallback((result: SearchResult) => {
|
const handleSearchResult = useCallback((result: SearchResult) => {
|
||||||
if (result.coordinates) {
|
if (result.coordinates) {
|
||||||
setFlyTarget({ center: result.coordinates, zoom: result.type === "uat" ? 12 : 17 });
|
mapHandleRef.current?.flyTo(result.coordinates, result.type === "uat" ? 12 : 17);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -67,8 +65,6 @@ export function GeoportalModule() {
|
|||||||
onFeatureClick={handleFeatureClick}
|
onFeatureClick={handleFeatureClick}
|
||||||
onSelectionChange={setSelectedFeatures}
|
onSelectionChange={setSelectedFeatures}
|
||||||
layerVisibility={layerVisibility}
|
layerVisibility={layerVisibility}
|
||||||
center={flyTarget?.center}
|
|
||||||
zoom={flyTarget?.zoom}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Setup banner (auto-hides when ready) */}
|
{/* Setup banner (auto-hides when ready) */}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useRef, useEffect, useState, useCallback, useImperativeHandle, forwardRef } from "react";
|
import { useRef, useEffect, useState, useCallback, useImperativeHandle, forwardRef } from "react";
|
||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { Protocol as PmtilesProtocol } from "pmtiles";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
/* Ensure MapLibre CSS is loaded — static import fails with next/dynamic + standalone */
|
/* Ensure MapLibre CSS is loaded — static import fails with next/dynamic + standalone */
|
||||||
@@ -15,6 +16,12 @@ if (typeof document !== "undefined") {
|
|||||||
document.head.appendChild(link);
|
document.head.appendChild(link);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Register PMTiles protocol globally (once) for pmtiles:// source URLs */
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const pmtilesProto = new PmtilesProtocol();
|
||||||
|
maplibregl.addProtocol("pmtiles", pmtilesProto.tile);
|
||||||
|
}
|
||||||
|
|
||||||
import type { BasemapId, ClickedFeature, LayerVisibility, SelectedFeature } from "../types";
|
import type { BasemapId, ClickedFeature, LayerVisibility, SelectedFeature } from "../types";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -28,6 +35,7 @@ export type SelectionType = "off" | "click" | "rect" | "freehand";
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
const DEFAULT_MARTIN_URL = process.env.NEXT_PUBLIC_MARTIN_URL || "/tiles";
|
const DEFAULT_MARTIN_URL = process.env.NEXT_PUBLIC_MARTIN_URL || "/tiles";
|
||||||
|
const DEFAULT_PMTILES_URL = process.env.NEXT_PUBLIC_PMTILES_URL || "";
|
||||||
const DEFAULT_CENTER: [number, number] = [23.8, 46.1];
|
const DEFAULT_CENTER: [number, number] = [23.8, 46.1];
|
||||||
const DEFAULT_ZOOM = 7;
|
const DEFAULT_ZOOM = 7;
|
||||||
|
|
||||||
@@ -58,6 +66,7 @@ const LAYER_IDS = {
|
|||||||
terenuriLabel: "l-terenuri-label",
|
terenuriLabel: "l-terenuri-label",
|
||||||
cladiriFill: "l-cladiri-fill",
|
cladiriFill: "l-cladiri-fill",
|
||||||
cladiriLine: "l-cladiri-line",
|
cladiriLine: "l-cladiri-line",
|
||||||
|
cladiriLabel: "l-cladiri-label",
|
||||||
selectionFill: "l-selection-fill",
|
selectionFill: "l-selection-fill",
|
||||||
selectionLine: "l-selection-line",
|
selectionLine: "l-selection-line",
|
||||||
drawPolygonFill: "l-draw-polygon-fill",
|
drawPolygonFill: "l-draw-polygon-fill",
|
||||||
@@ -319,8 +328,8 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
LAYER_IDS.uatsZ12Fill, LAYER_IDS.uatsZ12Line, LAYER_IDS.uatsZ12Label,
|
LAYER_IDS.uatsZ12Fill, LAYER_IDS.uatsZ12Line, LAYER_IDS.uatsZ12Label,
|
||||||
],
|
],
|
||||||
administrativ: [LAYER_IDS.adminLineOuter, LAYER_IDS.adminLineInner],
|
administrativ: [LAYER_IDS.adminLineOuter, LAYER_IDS.adminLineInner],
|
||||||
terenuri: [LAYER_IDS.terenuriFill, LAYER_IDS.terenuriLine, LAYER_IDS.terenuriLabel],
|
terenuri: [LAYER_IDS.terenuriFill, LAYER_IDS.terenuriLine, LAYER_IDS.terenuriLabel, "l-terenuri-pm-fill", "l-terenuri-pm-line", "l-terenuri-pm-label"],
|
||||||
cladiri: [LAYER_IDS.cladiriFill, LAYER_IDS.cladiriLine],
|
cladiri: [LAYER_IDS.cladiriFill, LAYER_IDS.cladiriLine, LAYER_IDS.cladiriLabel, "l-cladiri-pm-fill", "l-cladiri-pm-line"],
|
||||||
};
|
};
|
||||||
for (const [group, layerIds] of Object.entries(mapping)) {
|
for (const [group, layerIds] of Object.entries(mapping)) {
|
||||||
const visible = vis[group] !== false;
|
const visible = vis[group] !== false;
|
||||||
@@ -383,67 +392,160 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === UAT z0-5: very coarse — lines only ===
|
// === UAT sources: PMTiles (if configured) or Martin fallback ===
|
||||||
map.addSource(SOURCES.uatsZ0, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ0}/{z}/{x}/{y}`], minzoom: 0, maxzoom: 5 });
|
const pmtilesUrl = DEFAULT_PMTILES_URL;
|
||||||
map.addLayer({ id: LAYER_IDS.uatsZ0Line, type: "line", source: SOURCES.uatsZ0, "source-layer": SOURCES.uatsZ0, maxzoom: 5,
|
const usePmtiles = !!pmtilesUrl;
|
||||||
paint: { "line-color": "#7c3aed", "line-width": 0.3 } });
|
|
||||||
|
|
||||||
// === UAT z5-8: coarse ===
|
if (usePmtiles) {
|
||||||
map.addSource(SOURCES.uatsZ5, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ5}/{z}/{x}/{y}`], minzoom: 5, maxzoom: 8 });
|
// Single PMTiles source contains all UAT + administrativ layers (z0-z14)
|
||||||
map.addLayer({ id: LAYER_IDS.uatsZ5Line, type: "line", source: SOURCES.uatsZ5, "source-layer": SOURCES.uatsZ5, minzoom: 5, maxzoom: 8,
|
const PM_SRC = "overview-pmtiles";
|
||||||
paint: { "line-color": "#7c3aed", "line-width": 0.6 } });
|
map.addSource(PM_SRC, { type: "vector", url: `pmtiles://${pmtilesUrl}` });
|
||||||
|
|
||||||
// === UAT z8-12: moderate ===
|
// z0-5: lines only
|
||||||
map.addSource(SOURCES.uatsZ8, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ8}/{z}/{x}/{y}`], minzoom: 8, maxzoom: 12 });
|
map.addLayer({ id: LAYER_IDS.uatsZ0Line, type: "line", source: PM_SRC, "source-layer": SOURCES.uatsZ0, maxzoom: 5,
|
||||||
map.addLayer({ id: LAYER_IDS.uatsZ8Line, type: "line", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 8, maxzoom: 12,
|
paint: { "line-color": "#7c3aed", "line-width": 0.3 } });
|
||||||
paint: { "line-color": "#7c3aed", "line-width": 1 } });
|
|
||||||
map.addLayer({ id: LAYER_IDS.uatsZ8Label, type: "symbol", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 9, maxzoom: 12,
|
|
||||||
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 10, "text-anchor": "center", "text-allow-overlap": false },
|
|
||||||
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
|
|
||||||
|
|
||||||
// === UAT z12+: full detail (no simplification) ===
|
// z5-8
|
||||||
map.addSource(SOURCES.uatsZ12, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ12}/{z}/{x}/{y}`], minzoom: 12, maxzoom: 16 });
|
map.addLayer({ id: LAYER_IDS.uatsZ5Line, type: "line", source: PM_SRC, "source-layer": SOURCES.uatsZ5, minzoom: 5, maxzoom: 8,
|
||||||
map.addLayer({ id: LAYER_IDS.uatsZ12Line, type: "line", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
|
paint: { "line-color": "#7c3aed", "line-width": 0.6 } });
|
||||||
paint: { "line-color": "#7c3aed", "line-width": 2 } });
|
|
||||||
map.addLayer({ id: LAYER_IDS.uatsZ12Label, type: "symbol", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
|
|
||||||
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 13, "text-anchor": "center", "text-allow-overlap": false },
|
|
||||||
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
|
|
||||||
|
|
||||||
// === Intravilan — double line (black outer + orange inner), no fill, z13+ ===
|
// z8-12
|
||||||
map.addSource(SOURCES.administrativ, { type: "vector", tiles: [`${m}/${SOURCES.administrativ}/{z}/{x}/{y}`], minzoom: 10, maxzoom: 16 });
|
map.addLayer({ id: LAYER_IDS.uatsZ8Line, type: "line", source: PM_SRC, "source-layer": SOURCES.uatsZ8, minzoom: 8, maxzoom: 12,
|
||||||
map.addLayer({ id: LAYER_IDS.adminLineOuter, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13,
|
paint: { "line-color": "#7c3aed", "line-width": 1 } });
|
||||||
paint: { "line-color": "#000000", "line-width": 3 } });
|
map.addLayer({ id: LAYER_IDS.uatsZ8Label, type: "symbol", source: PM_SRC, "source-layer": SOURCES.uatsZ8, minzoom: 9, maxzoom: 12,
|
||||||
map.addLayer({ id: LAYER_IDS.adminLineInner, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13,
|
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 10, "text-anchor": "center", "text-allow-overlap": false },
|
||||||
paint: { "line-color": "#f97316", "line-width": 1.5 } });
|
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
|
||||||
|
|
||||||
// === Terenuri (parcels) — no simplification ===
|
// z12+: full detail from PMTiles
|
||||||
map.addSource(SOURCES.terenuri, { type: "vector", tiles: [`${m}/${SOURCES.terenuri}/{z}/{x}/{y}`], minzoom: 10, maxzoom: 18 });
|
map.addLayer({ id: LAYER_IDS.uatsZ12Line, type: "line", source: PM_SRC, "source-layer": SOURCES.uatsZ12, minzoom: 12,
|
||||||
map.addLayer({ id: LAYER_IDS.terenuriFill, type: "fill", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
|
paint: { "line-color": "#7c3aed", "line-width": 2 } });
|
||||||
paint: { "fill-color": "#22c55e", "fill-opacity": 0.15 } });
|
map.addLayer({ id: LAYER_IDS.uatsZ12Label, type: "symbol", source: PM_SRC, "source-layer": SOURCES.uatsZ12, minzoom: 12,
|
||||||
map.addLayer({ id: LAYER_IDS.terenuriLine, type: "line", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
|
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 13, "text-anchor": "center", "text-allow-overlap": false },
|
||||||
paint: { "line-color": "#15803d", "line-width": 0.8 } });
|
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
|
||||||
// Parcel cadastral number label
|
|
||||||
map.addLayer({ id: LAYER_IDS.terenuriLabel, type: "symbol", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 16,
|
// Intravilan from PMTiles
|
||||||
|
map.addLayer({ id: LAYER_IDS.adminLineOuter, type: "line", source: PM_SRC, "source-layer": SOURCES.administrativ, minzoom: 13,
|
||||||
|
paint: { "line-color": "#000000", "line-width": 3 } });
|
||||||
|
map.addLayer({ id: LAYER_IDS.adminLineInner, type: "line", source: PM_SRC, "source-layer": SOURCES.administrativ, minzoom: 13,
|
||||||
|
paint: { "line-color": "#f97316", "line-width": 1.5 } });
|
||||||
|
} else {
|
||||||
|
// Fallback: Martin tile sources (existing behavior)
|
||||||
|
|
||||||
|
// z0-5: very coarse — lines only
|
||||||
|
map.addSource(SOURCES.uatsZ0, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ0}/{z}/{x}/{y}`], minzoom: 0, maxzoom: 5 });
|
||||||
|
map.addLayer({ id: LAYER_IDS.uatsZ0Line, type: "line", source: SOURCES.uatsZ0, "source-layer": SOURCES.uatsZ0, maxzoom: 5,
|
||||||
|
paint: { "line-color": "#7c3aed", "line-width": 0.3 } });
|
||||||
|
|
||||||
|
// z5-8: coarse
|
||||||
|
map.addSource(SOURCES.uatsZ5, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ5}/{z}/{x}/{y}`], minzoom: 5, maxzoom: 8 });
|
||||||
|
map.addLayer({ id: LAYER_IDS.uatsZ5Line, type: "line", source: SOURCES.uatsZ5, "source-layer": SOURCES.uatsZ5, minzoom: 5, maxzoom: 8,
|
||||||
|
paint: { "line-color": "#7c3aed", "line-width": 0.6 } });
|
||||||
|
|
||||||
|
// z8-12: moderate
|
||||||
|
map.addSource(SOURCES.uatsZ8, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ8}/{z}/{x}/{y}`], minzoom: 8, maxzoom: 12 });
|
||||||
|
map.addLayer({ id: LAYER_IDS.uatsZ8Line, type: "line", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 8, maxzoom: 12,
|
||||||
|
paint: { "line-color": "#7c3aed", "line-width": 1 } });
|
||||||
|
map.addLayer({ id: LAYER_IDS.uatsZ8Label, type: "symbol", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 9, maxzoom: 12,
|
||||||
|
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 10, "text-anchor": "center", "text-allow-overlap": false },
|
||||||
|
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
|
||||||
|
|
||||||
|
// z12+: full detail (no simplification)
|
||||||
|
map.addSource(SOURCES.uatsZ12, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ12}/{z}/{x}/{y}`], minzoom: 12, maxzoom: 16 });
|
||||||
|
map.addLayer({ id: LAYER_IDS.uatsZ12Line, type: "line", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
|
||||||
|
paint: { "line-color": "#7c3aed", "line-width": 2 } });
|
||||||
|
map.addLayer({ id: LAYER_IDS.uatsZ12Label, type: "symbol", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
|
||||||
|
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 13, "text-anchor": "center", "text-allow-overlap": false },
|
||||||
|
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
|
||||||
|
|
||||||
|
// Intravilan — double line (black outer + orange inner), no fill, z13+
|
||||||
|
map.addSource(SOURCES.administrativ, { type: "vector", tiles: [`${m}/${SOURCES.administrativ}/{z}/{x}/{y}`], minzoom: 10, maxzoom: 16 });
|
||||||
|
map.addLayer({ id: LAYER_IDS.adminLineOuter, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13,
|
||||||
|
paint: { "line-color": "#000000", "line-width": 3 } });
|
||||||
|
map.addLayer({ id: LAYER_IDS.adminLineInner, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13,
|
||||||
|
paint: { "line-color": "#f97316", "line-width": 1.5 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Terenuri (parcels) ===
|
||||||
|
if (usePmtiles) {
|
||||||
|
// PMTiles serves ALL zoom levels (z13-z18) — zero PostGIS load
|
||||||
|
map.addLayer({ id: "l-terenuri-pm-fill", type: "fill", source: "overview-pmtiles", "source-layer": SOURCES.terenuri, minzoom: 13,
|
||||||
|
paint: { "fill-color": "#22c55e", "fill-opacity": 0.15 } });
|
||||||
|
map.addLayer({ id: "l-terenuri-pm-line", type: "line", source: "overview-pmtiles", "source-layer": SOURCES.terenuri, minzoom: 13,
|
||||||
|
paint: { "line-color": "#15803d", "line-width": 0.8 } });
|
||||||
|
map.addLayer({ id: "l-terenuri-pm-label", type: "symbol", source: "overview-pmtiles", "source-layer": SOURCES.terenuri, minzoom: 16,
|
||||||
|
layout: {
|
||||||
|
"text-field": ["coalesce", ["get", "cadastral_ref"], ""],
|
||||||
|
"text-font": ["Noto Sans Regular"],
|
||||||
|
"text-size": 10, "text-anchor": "center", "text-allow-overlap": false,
|
||||||
|
"text-max-width": 8,
|
||||||
|
},
|
||||||
|
paint: { "text-color": "#166534", "text-halo-color": "#fff", "text-halo-width": 1 } });
|
||||||
|
// Martin source registered but unused (selection uses PMTiles source now)
|
||||||
|
// Kept as fallback reference — no tile requests since no layers target it
|
||||||
|
map.addSource(SOURCES.terenuri, { type: "vector", tiles: [`${m}/${SOURCES.terenuri}/{z}/{x}/{y}`], minzoom: 17, maxzoom: 18 });
|
||||||
|
} else {
|
||||||
|
map.addSource(SOURCES.terenuri, { type: "vector", tiles: [`${m}/${SOURCES.terenuri}/{z}/{x}/{y}`], minzoom: 10, maxzoom: 18 });
|
||||||
|
map.addLayer({ id: LAYER_IDS.terenuriFill, type: "fill", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
|
||||||
|
paint: { "fill-color": "#22c55e", "fill-opacity": 0.15 } });
|
||||||
|
map.addLayer({ id: LAYER_IDS.terenuriLine, type: "line", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
|
||||||
|
paint: { "line-color": "#15803d", "line-width": 0.8 } });
|
||||||
|
map.addLayer({ id: LAYER_IDS.terenuriLabel, type: "symbol", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 16,
|
||||||
|
layout: {
|
||||||
|
"text-field": ["coalesce", ["get", "cadastral_ref"], ""],
|
||||||
|
"text-font": ["Noto Sans Regular"],
|
||||||
|
"text-size": 10, "text-anchor": "center", "text-allow-overlap": false,
|
||||||
|
"text-max-width": 8,
|
||||||
|
},
|
||||||
|
paint: { "text-color": "#166534", "text-halo-color": "#fff", "text-halo-width": 1 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Cladiri (buildings) ===
|
||||||
|
if (usePmtiles) {
|
||||||
|
// PMTiles serves ALL zoom levels (z14-z18) — zero PostGIS load
|
||||||
|
map.addLayer({ id: "l-cladiri-pm-fill", type: "fill", source: "overview-pmtiles", "source-layer": SOURCES.cladiri, minzoom: 14,
|
||||||
|
paint: { "fill-color": "#3b82f6", "fill-opacity": 0.5 } });
|
||||||
|
map.addLayer({ id: "l-cladiri-pm-line", type: "line", source: "overview-pmtiles", "source-layer": SOURCES.cladiri, minzoom: 14,
|
||||||
|
paint: { "line-color": "#1e3a5f", "line-width": 0.6 } });
|
||||||
|
map.addSource(SOURCES.cladiri, { type: "vector", tiles: [`${m}/${SOURCES.cladiri}/{z}/{x}/{y}`], minzoom: 17, maxzoom: 18 });
|
||||||
|
} else {
|
||||||
|
map.addSource(SOURCES.cladiri, { type: "vector", tiles: [`${m}/${SOURCES.cladiri}/{z}/{x}/{y}`], minzoom: 12, maxzoom: 18 });
|
||||||
|
map.addLayer({ id: LAYER_IDS.cladiriFill, type: "fill", source: SOURCES.cladiri, "source-layer": SOURCES.cladiri, minzoom: 14,
|
||||||
|
paint: { "fill-color": "#3b82f6", "fill-opacity": 0.5 } });
|
||||||
|
map.addLayer({ id: LAYER_IDS.cladiriLine, type: "line", source: SOURCES.cladiri, "source-layer": SOURCES.cladiri, minzoom: 14,
|
||||||
|
paint: { "line-color": "#1e3a5f", "line-width": 0.6 } });
|
||||||
|
}
|
||||||
|
// Building body labels — extract suffix after last dash (e.g. "291479-C1" → "C1")
|
||||||
|
map.addLayer({ id: LAYER_IDS.cladiriLabel, type: "symbol",
|
||||||
|
source: usePmtiles ? "overview-pmtiles" : SOURCES.cladiri,
|
||||||
|
"source-layer": SOURCES.cladiri, minzoom: 16,
|
||||||
layout: {
|
layout: {
|
||||||
"text-field": ["coalesce", ["get", "cadastral_ref"], ""],
|
"text-field": [
|
||||||
|
"case",
|
||||||
|
["has", "cadastral_ref"],
|
||||||
|
["let", "ref", ["get", "cadastral_ref"],
|
||||||
|
["let", "dashIdx", ["index-of", "-", ["var", "ref"]],
|
||||||
|
["case",
|
||||||
|
[">=", ["var", "dashIdx"], 0],
|
||||||
|
["slice", ["var", "ref"], ["+", ["var", "dashIdx"], 1]],
|
||||||
|
["var", "ref"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
"",
|
||||||
|
],
|
||||||
"text-font": ["Noto Sans Regular"],
|
"text-font": ["Noto Sans Regular"],
|
||||||
"text-size": 10, "text-anchor": "center", "text-allow-overlap": false,
|
"text-size": 9, "text-anchor": "center", "text-allow-overlap": false,
|
||||||
"text-max-width": 8,
|
"text-max-width": 6,
|
||||||
},
|
},
|
||||||
paint: { "text-color": "#166534", "text-halo-color": "#fff", "text-halo-width": 1 } });
|
paint: { "text-color": "#1e3a5f", "text-halo-color": "#fff", "text-halo-width": 1 } });
|
||||||
|
|
||||||
// === Cladiri (buildings) — no simplification ===
|
|
||||||
map.addSource(SOURCES.cladiri, { type: "vector", tiles: [`${m}/${SOURCES.cladiri}/{z}/{x}/{y}`], minzoom: 12, maxzoom: 18 });
|
|
||||||
map.addLayer({ id: LAYER_IDS.cladiriFill, type: "fill", source: SOURCES.cladiri, "source-layer": SOURCES.cladiri, minzoom: 14,
|
|
||||||
paint: { "fill-color": "#3b82f6", "fill-opacity": 0.5 } });
|
|
||||||
map.addLayer({ id: LAYER_IDS.cladiriLine, type: "line", source: SOURCES.cladiri, "source-layer": SOURCES.cladiri, minzoom: 14,
|
|
||||||
paint: { "line-color": "#1e3a5f", "line-width": 0.6 } });
|
|
||||||
|
|
||||||
// === Selection highlight ===
|
// === Selection highlight ===
|
||||||
map.addLayer({ id: LAYER_IDS.selectionFill, type: "fill", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
|
// Use PMTiles source when available (has data at z13+), Martin only has z17+
|
||||||
|
const selectionSrc = usePmtiles ? "overview-pmtiles" : SOURCES.terenuri;
|
||||||
|
map.addLayer({ id: LAYER_IDS.selectionFill, type: "fill", source: selectionSrc, "source-layer": SOURCES.terenuri, minzoom: 13,
|
||||||
filter: ["==", "object_id", "__NONE__"],
|
filter: ["==", "object_id", "__NONE__"],
|
||||||
paint: { "fill-color": "#f59e0b", "fill-opacity": 0.5 } });
|
paint: { "fill-color": "#f59e0b", "fill-opacity": 0.5 } });
|
||||||
map.addLayer({ id: LAYER_IDS.selectionLine, type: "line", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
|
map.addLayer({ id: LAYER_IDS.selectionLine, type: "line", source: selectionSrc, "source-layer": SOURCES.terenuri, minzoom: 13,
|
||||||
filter: ["==", "object_id", "__NONE__"],
|
filter: ["==", "object_id", "__NONE__"],
|
||||||
paint: { "line-color": "#d97706", "line-width": 2.5 } });
|
paint: { "line-color": "#d97706", "line-width": 2.5 } });
|
||||||
|
|
||||||
@@ -472,8 +574,10 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
/* ---- Click handler — NO popup, only callback ---- */
|
/* ---- Click handler — NO popup, only callback ---- */
|
||||||
|
// Include both Martin and PMTiles fill layers — filter() skips non-existent ones
|
||||||
const clickableLayers = [
|
const clickableLayers = [
|
||||||
LAYER_IDS.terenuriFill, LAYER_IDS.cladiriFill,
|
LAYER_IDS.terenuriFill, LAYER_IDS.cladiriFill,
|
||||||
|
"l-terenuri-pm-fill", "l-cladiri-pm-fill",
|
||||||
];
|
];
|
||||||
|
|
||||||
map.on("click", (e) => {
|
map.on("click", (e) => {
|
||||||
@@ -741,12 +845,6 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [resolvedMartinUrl, basemap]);
|
}, [resolvedMartinUrl, basemap]);
|
||||||
|
|
||||||
/* ---- Sync center/zoom prop changes (from search flyTo) ---- */
|
|
||||||
useEffect(() => {
|
|
||||||
if (!mapReady || !mapRef.current || !center) return;
|
|
||||||
mapRef.current.flyTo({ center, zoom: zoom ?? mapRef.current.getZoom(), duration: 1500 });
|
|
||||||
}, [center, zoom, mapReady]);
|
|
||||||
|
|
||||||
/* ---- Disable interactions when in drawing modes ---- */
|
/* ---- Disable interactions when in drawing modes ---- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function SearchBar({ onResultSelect, className }: SearchBarProps) {
|
|||||||
if (results.length > 0) setOpen(true);
|
if (results.length > 0) setOpen(true);
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className="pl-8 pr-8 h-8 text-sm bg-background/95 backdrop-blur-sm"
|
className="pl-8 pr-8 h-8 text-sm bg-background backdrop-blur-sm text-foreground"
|
||||||
/>
|
/>
|
||||||
{loading && (
|
{loading && (
|
||||||
<Loader2 className="absolute right-8 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
<Loader2 className="absolute right-8 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
ArrowDownToLine,
|
ArrowDownToLine,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Moon,
|
||||||
|
Activity,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
@@ -141,6 +144,23 @@ export function ExportTab({
|
|||||||
|
|
||||||
const dbTotalFeatures = dbLayersSummary.reduce((sum, l) => sum + l.count, 0);
|
const dbTotalFeatures = dbLayersSummary.reduce((sum, l) => sum + l.count, 0);
|
||||||
|
|
||||||
|
// Primary layers synced by background jobs — these determine freshness
|
||||||
|
const PRIMARY_LAYERS = ["TERENURI_ACTIVE", "CLADIRI_ACTIVE", "LIMITE_INTRAV_DYNAMIC", "LIMITE_UAT"];
|
||||||
|
const primaryLayers = dbLayersSummary.filter((l) =>
|
||||||
|
PRIMARY_LAYERS.includes(l.id),
|
||||||
|
);
|
||||||
|
const hasData = dbTotalFeatures > 0;
|
||||||
|
const canExportLocal = hasData;
|
||||||
|
|
||||||
|
const oldestSyncDate = primaryLayers.reduce(
|
||||||
|
(oldest, l) => {
|
||||||
|
if (!l.lastSynced) return oldest;
|
||||||
|
if (!oldest || l.lastSynced < oldest) return l.lastSynced;
|
||||||
|
return oldest;
|
||||||
|
},
|
||||||
|
null as Date | null,
|
||||||
|
);
|
||||||
|
|
||||||
const progressPct =
|
const progressPct =
|
||||||
exportProgress?.total && exportProgress.total > 0
|
exportProgress?.total && exportProgress.total > 0
|
||||||
? Math.round((exportProgress.downloaded / exportProgress.total) * 100)
|
? Math.round((exportProgress.downloaded / exportProgress.total) * 100)
|
||||||
@@ -604,40 +624,48 @@ export function ExportTab({
|
|||||||
layere
|
layere
|
||||||
</span>
|
</span>
|
||||||
{(() => {
|
{(() => {
|
||||||
const freshCount = dbLayersSummary.filter(
|
const staleLayers = primaryLayers.filter((l) => !l.isFresh);
|
||||||
(l) => l.isFresh,
|
const freshLayers = primaryLayers.filter((l) => l.isFresh);
|
||||||
).length;
|
const newestSync = primaryLayers.reduce(
|
||||||
const staleCount = dbLayersSummary.length - freshCount;
|
(newest, l) => {
|
||||||
const oldestSync = dbLayersSummary.reduce(
|
if (!l.lastSynced) return newest;
|
||||||
(oldest, l) => {
|
if (!newest || l.lastSynced > newest) return l.lastSynced;
|
||||||
if (!l.lastSynced) return oldest;
|
return newest;
|
||||||
if (!oldest || l.lastSynced < oldest) return l.lastSynced;
|
|
||||||
return oldest;
|
|
||||||
},
|
},
|
||||||
null as Date | null,
|
null as Date | null,
|
||||||
);
|
);
|
||||||
|
// Tooltip: list which layers are stale/fresh with dates
|
||||||
|
const staleTooltip = staleLayers.length > 0
|
||||||
|
? `Vechi: ${staleLayers.map((l) => `${l.label} (${l.lastSynced ? relativeTime(l.lastSynced) : "nesincronizat"})`).join(", ")}`
|
||||||
|
: "";
|
||||||
|
const freshTooltip = freshLayers.length > 0
|
||||||
|
? `Proaspete: ${freshLayers.map((l) => `${l.label} (${l.lastSynced ? relativeTime(l.lastSynced) : ""})`).join(", ")}`
|
||||||
|
: "";
|
||||||
|
const fullTooltip = [staleTooltip, freshTooltip].filter(Boolean).join("\n");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{staleCount === 0 ? (
|
{staleLayers.length === 0 && primaryLayers.length > 0 ? (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-emerald-600 border-emerald-200 dark:text-emerald-400 dark:border-emerald-800"
|
className="text-emerald-600 border-emerald-200 dark:text-emerald-400 dark:border-emerald-800 cursor-default"
|
||||||
|
title={fullTooltip}
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||||
Proaspete
|
Proaspete
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : staleLayers.length > 0 ? (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-amber-600 border-amber-200 dark:text-amber-400 dark:border-amber-800"
|
className="text-amber-600 border-amber-200 dark:text-amber-400 dark:border-amber-800 cursor-default"
|
||||||
|
title={fullTooltip}
|
||||||
>
|
>
|
||||||
<Clock className="h-3 w-3 mr-1" />
|
<Clock className="h-3 w-3 mr-1" />
|
||||||
{staleCount} vechi
|
{staleLayers.length} vechi
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
) : null}
|
||||||
{oldestSync && (
|
{newestSync && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Ultima sincronizare: {relativeTime(oldestSync)}
|
Ultima sincronizare: {relativeTime(newestSync)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -649,52 +677,117 @@ export function ExportTab({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hero buttons */}
|
{/* Hero buttons */}
|
||||||
{sirutaValid && session.connected ? (
|
{sirutaValid && (session.connected || canExportLocal) ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="space-y-2">
|
||||||
<Button
|
{(() => {
|
||||||
size="lg"
|
// Build tooltip with layer details for hero buttons
|
||||||
className="h-auto py-4 text-base bg-zinc-900 hover:bg-zinc-800 text-white dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
const layerLines = dbLayersSummary
|
||||||
disabled={exporting}
|
.filter((l) => l.count > 0)
|
||||||
onClick={() => void handleExportBundle("base")}
|
.sort((a, b) => b.count - a.count)
|
||||||
>
|
.map(
|
||||||
{exporting && exportProgress?.phase !== "Detalii parcele" ? (
|
(l) =>
|
||||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
`${l.label}: ${l.count.toLocaleString("ro")} entitati${l.lastSynced ? ` (sync ${relativeTime(l.lastSynced)})` : ""}`,
|
||||||
) : (
|
);
|
||||||
<FileDown className="mr-2 h-5 w-5" />
|
const enriched = dbLayersSummary.reduce(
|
||||||
)}
|
(sum, l) => {
|
||||||
<div className="text-left">
|
const enrichCount =
|
||||||
<div className="font-semibold">
|
syncRuns.find(
|
||||||
Descarcă Terenuri și Clădiri
|
(r) => r.layerId === l.id && r.status === "done",
|
||||||
</div>
|
)?.totalLocal ?? 0;
|
||||||
<div className="text-xs opacity-70 font-normal">
|
return sum + enrichCount;
|
||||||
Sync + GPKG (din cache dacă e proaspăt)
|
},
|
||||||
</div>
|
0,
|
||||||
</div>
|
);
|
||||||
</Button>
|
const baseTooltip = layerLines.length > 0
|
||||||
|
? `ZIP contine:\n• ${layerLines.join("\n• ")}\n\nFormate: GPKG + DXF per layer`
|
||||||
|
: "Nicio data in DB";
|
||||||
|
const magicTooltip = layerLines.length > 0
|
||||||
|
? `ZIP contine:\n• ${layerLines.join("\n• ")}\n\nFormate: GPKG + DXF + CSV complet\n+ Raport calitate enrichment`
|
||||||
|
: "Nicio data in DB";
|
||||||
|
|
||||||
<Button
|
return (
|
||||||
size="lg"
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
className="h-auto py-4 text-base bg-teal-700 hover:bg-teal-600 text-white dark:bg-teal-600 dark:hover:bg-teal-500"
|
<Button
|
||||||
disabled={exporting}
|
size="lg"
|
||||||
onClick={() => void handleExportBundle("magic")}
|
className="h-auto py-4 text-base bg-zinc-900 hover:bg-zinc-800 text-white dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||||
>
|
disabled={exporting || downloadingFromDb}
|
||||||
{exporting && exportProgress?.phase === "Detalii parcele" ? (
|
title={baseTooltip}
|
||||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
onClick={() =>
|
||||||
) : (
|
canExportLocal
|
||||||
<Sparkles className="mr-2 h-5 w-5" />
|
? void handleDownloadFromDb("base")
|
||||||
)}
|
: void handleExportBundle("base")
|
||||||
<div className="text-left">
|
}
|
||||||
<div className="font-semibold">Magic</div>
|
>
|
||||||
<div className="text-xs opacity-70 font-normal">
|
{(exporting || downloadingFromDb) &&
|
||||||
Sync + îmbogățire (CF, proprietari, adresă) + GPKG + CSV
|
exportProgress?.phase !== "Detalii parcele" ? (
|
||||||
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
|
) : canExportLocal ? (
|
||||||
|
<Database className="mr-2 h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<FileDown className="mr-2 h-5 w-5" />
|
||||||
|
)}
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-semibold">
|
||||||
|
Descarcă Terenuri și Clădiri
|
||||||
|
</div>
|
||||||
|
<div className="text-xs opacity-70 font-normal">
|
||||||
|
{canExportLocal
|
||||||
|
? `Din DB (sync ${oldestSyncDate ? relativeTime(oldestSyncDate) : "recent"})`
|
||||||
|
: hasData
|
||||||
|
? "Sync incremental + GPKG + DXF"
|
||||||
|
: "Sync complet + GPKG + DXF"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="h-auto py-4 text-base bg-teal-700 hover:bg-teal-600 text-white dark:bg-teal-600 dark:hover:bg-teal-500"
|
||||||
|
disabled={exporting || downloadingFromDb}
|
||||||
|
title={magicTooltip}
|
||||||
|
onClick={() =>
|
||||||
|
canExportLocal
|
||||||
|
? void handleDownloadFromDb("magic")
|
||||||
|
: void handleExportBundle("magic")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(exporting || downloadingFromDb) &&
|
||||||
|
exportProgress?.phase === "Detalii parcele" ? (
|
||||||
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="mr-2 h-5 w-5" />
|
||||||
|
)}
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-semibold">Magic</div>
|
||||||
|
<div className="text-xs opacity-70 font-normal">
|
||||||
|
{canExportLocal
|
||||||
|
? `GPKG + DXF + CSV din DB (sync ${oldestSyncDate ? relativeTime(oldestSyncDate) : "recent"})`
|
||||||
|
: "Sync + îmbogățire + GPKG + DXF + CSV"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{canExportLocal && session.connected && (
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground underline transition-colors"
|
||||||
|
disabled={exporting}
|
||||||
|
onClick={() => void handleExportBundle("base")}
|
||||||
|
>
|
||||||
|
<RefreshCw className="inline h-3 w-3 mr-1 -mt-0.5" />
|
||||||
|
Re-sincronizează de pe eTerra
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
{!session.connected ? (
|
{!session.connected && !canExportLocal ? (
|
||||||
<>
|
<>
|
||||||
<Wifi className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
<Wifi className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
<p>Conectează-te la eTerra pentru a activa exportul.</p>
|
<p>Conectează-te la eTerra pentru a activa exportul.</p>
|
||||||
@@ -878,7 +971,7 @@ export function ExportTab({
|
|||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
<li>
|
<li>
|
||||||
\u00cembogățire CF, proprietari, adrese —{" "}
|
Îmbogățire CF, proprietari, adrese —{" "}
|
||||||
<span className="font-medium text-teal-600 dark:text-teal-400">
|
<span className="font-medium text-teal-600 dark:text-teal-400">
|
||||||
{(() => {
|
{(() => {
|
||||||
// What will be in DB after sync + optional no-geom import:
|
// What will be in DB after sync + optional no-geom import:
|
||||||
@@ -1072,7 +1165,7 @@ export function ExportTab({
|
|||||||
{noGeomScan.qualityBreakdown.empty > 0
|
{noGeomScan.qualityBreakdown.empty > 0
|
||||||
? `Din ${noGeomScan.noGeomCount.toLocaleString("ro-RO")} fără geometrie, ~${noGeomScan.qualityBreakdown.useful.toLocaleString("ro-RO")} vor fi importate (imobile electronice cu CF). ${noGeomScan.qualityBreakdown.empty.toLocaleString("ro-RO")} vor fi filtrate (fără carte funciară, inactive sau fără date).`
|
? `Din ${noGeomScan.noGeomCount.toLocaleString("ro-RO")} fără geometrie, ~${noGeomScan.qualityBreakdown.useful.toLocaleString("ro-RO")} vor fi importate (imobile electronice cu CF). ${noGeomScan.qualityBreakdown.empty.toLocaleString("ro-RO")} vor fi filtrate (fără carte funciară, inactive sau fără date).`
|
||||||
: "Vor fi importate în DB și incluse în CSV + Magic GPKG (coloana HAS_GEOMETRY=0/1)."}{" "}
|
: "Vor fi importate în DB și incluse în CSV + Magic GPKG (coloana HAS_GEOMETRY=0/1)."}{" "}
|
||||||
\u00cen GPKG de bază apar doar cele cu geometrie.
|
În GPKG de bază apar doar cele cu geometrie.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{workflowPreview}
|
{workflowPreview}
|
||||||
@@ -1222,54 +1315,6 @@ export function ExportTab({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Row 3: Download from DB buttons */}
|
|
||||||
{dbTotalFeatures > 0 && (
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-auto py-2.5 justify-start"
|
|
||||||
disabled={downloadingFromDb}
|
|
||||||
onClick={() => void handleDownloadFromDb("base")}
|
|
||||||
>
|
|
||||||
{downloadingFromDb ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Database className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<div className="text-left">
|
|
||||||
<div className="text-xs font-semibold">
|
|
||||||
Descarcă din DB — Bază
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] opacity-60 font-normal">
|
|
||||||
GPKG terenuri + clădiri (instant, fără eTerra)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-auto py-2.5 justify-start border-teal-200 dark:border-teal-800 hover:bg-teal-50 dark:hover:bg-teal-950/30"
|
|
||||||
disabled={downloadingFromDb}
|
|
||||||
onClick={() => void handleDownloadFromDb("magic")}
|
|
||||||
>
|
|
||||||
{downloadingFromDb ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-teal-600" />
|
|
||||||
) : (
|
|
||||||
<Sparkles className="mr-2 h-4 w-4 text-teal-600" />
|
|
||||||
)}
|
|
||||||
<div className="text-left">
|
|
||||||
<div className="text-xs font-semibold">
|
|
||||||
Descarcă din DB — Magic
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] opacity-60 font-normal">
|
|
||||||
GPKG + CSV + raport calitate (instant)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!session.connected && dbTotalFeatures === 0 && (
|
{!session.connected && dbTotalFeatures === 0 && (
|
||||||
<p className="text-xs text-muted-foreground ml-6">
|
<p className="text-xs text-muted-foreground ml-6">
|
||||||
Conectează-te la eTerra pentru a porni sincronizarea fundal,
|
Conectează-te la eTerra pentru a porni sincronizarea fundal,
|
||||||
@@ -1280,6 +1325,33 @@ export function ExportTab({
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Weekend Deep Sync + Monitor hints */}
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Moon className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Municipii mari cu Magic complet?{" "}
|
||||||
|
<Link
|
||||||
|
href="/wds"
|
||||||
|
className="underline hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Weekend Deep Sync
|
||||||
|
</Link>
|
||||||
|
{" "}— sincronizare automata Vin/Sam/Dum noaptea.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Activity className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Rebuild PMTiles si status servicii?{" "}
|
||||||
|
<Link
|
||||||
|
href="/monitor"
|
||||||
|
className="underline hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Monitor
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Background sync progress */}
|
{/* Background sync progress */}
|
||||||
{bgJobId && bgProgress && bgProgress.status !== "unknown" && (
|
{bgJobId && bgProgress && bgProgress.status !== "unknown" && (
|
||||||
<Card
|
<Card
|
||||||
@@ -1406,9 +1478,11 @@ export function ExportTab({
|
|||||||
setBgJobId(null);
|
setBgJobId(null);
|
||||||
setBgProgress(null);
|
setBgProgress(null);
|
||||||
setBgPhaseTrail([]);
|
setBgPhaseTrail([]);
|
||||||
|
onSyncRefresh();
|
||||||
|
onDbRefresh();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
\u00cenchide
|
Închide
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -363,7 +363,67 @@ export function MapTab({ siruta, sirutaValid, sessionConnected, syncLocalCount,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buildings: keep base layer visible with siruta filter (already applied above)
|
// ── Enrichment overlay for BUILDINGS ──
|
||||||
|
if (!map.getSource("gis_cladiri_status")) {
|
||||||
|
map.addSource("gis_cladiri_status", {
|
||||||
|
type: "vector",
|
||||||
|
tiles: [`${martinBase}/gis_cladiri_status/{z}/{x}/{y}`],
|
||||||
|
minzoom: 14,
|
||||||
|
maxzoom: 18,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Data-driven fill: red = no legal docs, blue = has legal docs
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: "l-ps-cladiri-fill",
|
||||||
|
type: "fill",
|
||||||
|
source: "gis_cladiri_status",
|
||||||
|
"source-layer": "gis_cladiri_status",
|
||||||
|
minzoom: 14,
|
||||||
|
filter,
|
||||||
|
paint: {
|
||||||
|
"fill-color": [
|
||||||
|
"case",
|
||||||
|
["==", ["get", "build_legal"], 1],
|
||||||
|
"#3b82f6", // blue: legal docs OK
|
||||||
|
"#ef4444", // red: no legal docs
|
||||||
|
],
|
||||||
|
"fill-opacity": 0.55,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"l-terenuri-fill",
|
||||||
|
);
|
||||||
|
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: "l-ps-cladiri-line",
|
||||||
|
type: "line",
|
||||||
|
source: "gis_cladiri_status",
|
||||||
|
"source-layer": "gis_cladiri_status",
|
||||||
|
minzoom: 14,
|
||||||
|
filter,
|
||||||
|
paint: {
|
||||||
|
"line-color": [
|
||||||
|
"case",
|
||||||
|
["==", ["get", "build_legal"], 1],
|
||||||
|
"#1e40af", // dark blue: legal
|
||||||
|
"#b91c1c", // dark red: no legal
|
||||||
|
],
|
||||||
|
"line-width": 1.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"l-terenuri-fill",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
if (map.getLayer("l-ps-cladiri-fill"))
|
||||||
|
map.setFilter("l-ps-cladiri-fill", filter);
|
||||||
|
if (map.getLayer("l-ps-cladiri-line"))
|
||||||
|
map.setFilter("l-ps-cladiri-line", filter);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
}, [mapReady, siruta, sirutaValid]);
|
}, [mapReady, siruta, sirutaValid]);
|
||||||
|
|
||||||
/* ── Boundary cross-check: load mismatched parcels ─────────── */
|
/* ── Boundary cross-check: load mismatched parcels ─────────── */
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Self-contained auto-refresh scheduler for ParcelSync.
|
||||||
|
*
|
||||||
|
* Runs inside the existing Node.js process — no external dependencies.
|
||||||
|
* Checks every 30 minutes; during the night window (1–5 AM) it picks
|
||||||
|
* stale UATs one at a time with random delays between them.
|
||||||
|
*
|
||||||
|
* Activated by importing this module (side-effect). The globalThis guard
|
||||||
|
* ensures only one scheduler runs per process, surviving HMR in dev.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { syncLayer } from "./sync-service";
|
||||||
|
import { getLayerFreshness, isFresh } from "./enrich-service";
|
||||||
|
import { isEterraAvailable } from "./eterra-health";
|
||||||
|
import { isWeekendWindow, runWeekendDeepSync } from "./weekend-deep-sync";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Configuration */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** Night window: only run between these hours (server local time) */
|
||||||
|
const NIGHT_START_HOUR = 1;
|
||||||
|
const NIGHT_END_HOUR = 5;
|
||||||
|
|
||||||
|
/** How often to check if we should run (ms) */
|
||||||
|
const CHECK_INTERVAL_MS = 30 * 60_000; // 30 minutes
|
||||||
|
|
||||||
|
/** Delay between UATs: 3–10s (delta sync is fast) */
|
||||||
|
const MIN_DELAY_MS = 3_000;
|
||||||
|
const MAX_DELAY_MS = 10_000;
|
||||||
|
|
||||||
|
/** Enrichment ratio threshold — UATs with >30% enriched get magic mode */
|
||||||
|
const MAGIC_THRESHOLD = 0.3;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Singleton guard */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const g = globalThis as {
|
||||||
|
__autoRefreshTimer?: ReturnType<typeof setInterval>;
|
||||||
|
__parcelSyncRunning?: boolean; // single flag for all sync modes
|
||||||
|
__autoRefreshLastRun?: string; // ISO date of last completed run
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Core logic */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function runAutoRefresh() {
|
||||||
|
// Prevent concurrent runs (shared with weekend sync)
|
||||||
|
if (g.__parcelSyncRunning) return;
|
||||||
|
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
const dayOfWeek = new Date().getDay(); // 0=Sun, 6=Sat
|
||||||
|
const isWeekday = dayOfWeek >= 1 && dayOfWeek <= 5;
|
||||||
|
if (!isWeekday || hour < NIGHT_START_HOUR || hour >= NIGHT_END_HOUR) return;
|
||||||
|
|
||||||
|
// Only run once per night (check date)
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
if (g.__autoRefreshLastRun === today) return;
|
||||||
|
|
||||||
|
const username = process.env.ETERRA_USERNAME;
|
||||||
|
const password = process.env.ETERRA_PASSWORD;
|
||||||
|
if (!username || !password) return;
|
||||||
|
|
||||||
|
if (!isEterraAvailable()) {
|
||||||
|
console.log("[auto-refresh] eTerra indisponibil, skip.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
g.__parcelSyncRunning = true;
|
||||||
|
console.log("[auto-refresh] Pornire delta refresh nocturn (toate UAT-urile)...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find all UATs with features + enrichment ratio
|
||||||
|
const uats = await prisma.$queryRawUnsafe<
|
||||||
|
Array<{ siruta: string; name: string | null; total: number; enriched: number }>
|
||||||
|
>(
|
||||||
|
`SELECT f.siruta, u.name, COUNT(*)::int as total,
|
||||||
|
COUNT(*) FILTER (WHERE f."enrichedAt" IS NOT NULL)::int as enriched
|
||||||
|
FROM "GisFeature" f LEFT JOIN "GisUat" u ON f.siruta = u.siruta
|
||||||
|
WHERE f."layerId" IN ('TERENURI_ACTIVE','CLADIRI_ACTIVE') AND f."objectId" > 0
|
||||||
|
GROUP BY f.siruta, u.name ORDER BY total DESC`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uats.length === 0) {
|
||||||
|
console.log("[auto-refresh] Niciun UAT in DB, skip.");
|
||||||
|
g.__autoRefreshLastRun = today;
|
||||||
|
g.__parcelSyncRunning = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[auto-refresh] ${uats.length} UAT-uri de procesat.`);
|
||||||
|
let processed = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < uats.length; i++) {
|
||||||
|
const uat = uats[i]!;
|
||||||
|
const uatName = uat.name ?? uat.siruta;
|
||||||
|
const ratio = uat.total > 0 ? uat.enriched / uat.total : 0;
|
||||||
|
const isMagic = ratio > MAGIC_THRESHOLD;
|
||||||
|
|
||||||
|
// Small delay between UATs
|
||||||
|
if (i > 0) {
|
||||||
|
const delay = MIN_DELAY_MS + Math.random() * (MAX_DELAY_MS - MIN_DELAY_MS);
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check we're still in the night window
|
||||||
|
if (new Date().getHours() >= NIGHT_END_HOUR) {
|
||||||
|
console.log(`[auto-refresh] Fereastra nocturna s-a inchis la ${i}/${uats.length} UATs.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEterraAvailable()) {
|
||||||
|
console.log("[auto-refresh] eTerra a devenit indisponibil, opresc.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
// Delta sync: quick-count + VALID_FROM for TERENURI + CLADIRI
|
||||||
|
const tRes = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", { uatName });
|
||||||
|
const cRes = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", { uatName });
|
||||||
|
|
||||||
|
let enrichNote = "";
|
||||||
|
if (isMagic) {
|
||||||
|
const { EterraClient } = await import("./eterra-client");
|
||||||
|
const { enrichFeatures } = await import("./enrich-service");
|
||||||
|
const client = await EterraClient.create(username, password, { timeoutMs: 120_000 });
|
||||||
|
const eRes = await enrichFeatures(client, uat.siruta);
|
||||||
|
enrichNote = eRes.status === "done"
|
||||||
|
? ` | enrich:${eRes.enrichedCount}/${eRes.totalFeatures ?? "?"}`
|
||||||
|
: ` | enrich ERR:${eRes.error}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dur = ((Date.now() - start) / 1000).toFixed(1);
|
||||||
|
const tNote = tRes.newFeatures > 0 || (tRes.validFromUpdated ?? 0) > 0
|
||||||
|
? `T:+${tRes.newFeatures}/${tRes.validFromUpdated ?? 0}vf`
|
||||||
|
: "T:ok";
|
||||||
|
const cNote = cRes.newFeatures > 0 || (cRes.validFromUpdated ?? 0) > 0
|
||||||
|
? `C:+${cRes.newFeatures}/${cRes.validFromUpdated ?? 0}vf`
|
||||||
|
: "C:ok";
|
||||||
|
console.log(
|
||||||
|
`[auto-refresh] [${i + 1}/${uats.length}] ${uatName} (${isMagic ? "magic" : "base"}): ${tNote}, ${cNote}${enrichNote} (${dur}s)`,
|
||||||
|
);
|
||||||
|
processed++;
|
||||||
|
} catch (err) {
|
||||||
|
errors++;
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[auto-refresh] [${i + 1}/${uats.length}] ${uatName}: ERR ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.__autoRefreshLastRun = today;
|
||||||
|
console.log(`[auto-refresh] Finalizat: ${processed}/${uats.length} UATs, ${errors} erori.`);
|
||||||
|
|
||||||
|
// Trigger PMTiles rebuild
|
||||||
|
const { firePmtilesRebuild } = await import("./pmtiles-webhook");
|
||||||
|
await firePmtilesRebuild("auto-refresh-complete", { uatCount: processed, errors });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[auto-refresh] Eroare generala: ${msg}`);
|
||||||
|
} finally {
|
||||||
|
g.__parcelSyncRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Weekend deep sync wrapper */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function runWeekendCheck() {
|
||||||
|
if (g.__parcelSyncRunning) return;
|
||||||
|
if (!isWeekendWindow()) return;
|
||||||
|
|
||||||
|
g.__parcelSyncRunning = true;
|
||||||
|
try {
|
||||||
|
await runWeekendDeepSync();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[weekend-sync] Eroare: ${msg}`);
|
||||||
|
} finally {
|
||||||
|
g.__parcelSyncRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Start scheduler (once per process) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
if (!g.__autoRefreshTimer) {
|
||||||
|
g.__autoRefreshTimer = setInterval(() => {
|
||||||
|
// Weekend nights (Fri/Sat/Sun 23-04): deep sync for large cities
|
||||||
|
// Weekday nights (1-5 AM): incremental refresh for existing data
|
||||||
|
if (isWeekendWindow()) {
|
||||||
|
void runWeekendCheck();
|
||||||
|
} else {
|
||||||
|
void runAutoRefresh();
|
||||||
|
}
|
||||||
|
}, CHECK_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Also check once shortly after startup (60s delay to let everything init)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isWeekendWindow()) {
|
||||||
|
void runWeekendCheck();
|
||||||
|
} else {
|
||||||
|
void runAutoRefresh();
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
console.log(
|
||||||
|
`[auto-refresh] Scheduler pornit — verificare la fiecare ${CHECK_INTERVAL_MS / 60_000} min`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[auto-refresh] Server time: ${now.toLocaleString("ro-RO")} (TZ=${process.env.TZ ?? "system"}, offset=${now.getTimezoneOffset()}min)`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[auto-refresh] Luni-Vineri ${NIGHT_START_HOUR}:00–${NIGHT_END_HOUR}:00: delta sync ALL UATs (quick-count + VALID_FROM + rolling doc)`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[auto-refresh] Weekend Vin/Sam/Dum 23:00–04:00: deep sync municipii (forceFullSync)`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[auto-refresh] ETERRA creds: ${process.env.ETERRA_USERNAME ? "OK" : "MISSING"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -99,10 +99,21 @@ const formatAddress = (item?: any) => {
|
|||||||
const address = item?.immovableAddresses?.[0]?.address ?? null;
|
const address = item?.immovableAddresses?.[0]?.address ?? null;
|
||||||
if (!address) return "-";
|
if (!address) return "-";
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (address.addressDescription) parts.push(address.addressDescription);
|
if (address.addressDescription) parts.push(String(address.addressDescription));
|
||||||
if (address.street) parts.push(`Str. ${address.street}`);
|
// street can be a string or an object { name: "..." }
|
||||||
if (address.buildingNo) parts.push(`Nr. ${address.buildingNo}`);
|
const streetName =
|
||||||
if (address.locality?.name) parts.push(address.locality.name);
|
typeof address.street === "string"
|
||||||
|
? address.street
|
||||||
|
: address.street?.name ?? null;
|
||||||
|
if (streetName) parts.push(`Str. ${streetName}`);
|
||||||
|
if (address.streetNumber) parts.push(`Nr. ${address.streetNumber}`);
|
||||||
|
else if (address.buildingNo) parts.push(`Nr. ${address.buildingNo}`);
|
||||||
|
// locality can be a string or an object { name: "..." }
|
||||||
|
const localityName =
|
||||||
|
typeof address.locality === "string"
|
||||||
|
? address.locality
|
||||||
|
: address.locality?.name ?? null;
|
||||||
|
if (localityName) parts.push(localityName);
|
||||||
return parts.length ? parts.join(", ") : "-";
|
return parts.length ? parts.join(", ") : "-";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,6 +135,23 @@ export type FeatureEnrichment = {
|
|||||||
CATEGORIE_FOLOSINTA: string;
|
CATEGORIE_FOLOSINTA: string;
|
||||||
HAS_BUILDING: number;
|
HAS_BUILDING: number;
|
||||||
BUILD_LEGAL: number;
|
BUILD_LEGAL: number;
|
||||||
|
// Extended fields (extracted from existing API calls, zero overhead)
|
||||||
|
/** "Intabulare, drept de PROPRIETATE, dobandit prin..." */
|
||||||
|
TIP_INSCRIERE?: string;
|
||||||
|
/** "hotarare judecatoreasca nr..." / "contract vanzare cumparare nr..." */
|
||||||
|
ACT_PROPRIETATE?: string;
|
||||||
|
/** "1/1" or fractional */
|
||||||
|
COTA_PROPRIETATE?: string;
|
||||||
|
/** Date of registration application (ISO) */
|
||||||
|
DATA_CERERE?: string;
|
||||||
|
/** Number of building bodies on this parcel */
|
||||||
|
NR_CORPURI?: number;
|
||||||
|
/** Comma-separated list: "C1:352mp, C2:248mp, C3:104mp" */
|
||||||
|
CORPURI_DETALII?: string;
|
||||||
|
/** 1 if condominium, 0 otherwise */
|
||||||
|
IS_CONDOMINIUM?: number;
|
||||||
|
/** Date parcel was created in eTerra (ISO) */
|
||||||
|
DATA_CREARE?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,6 +181,242 @@ export async function enrichFeatures(
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// ── Quick delta check: skip ALL eTerra API calls if every feature is enriched & fresh ──
|
||||||
|
const _thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
const [_totalCount, _unenrichedCount] = await Promise.all([
|
||||||
|
prisma.gisFeature.count({
|
||||||
|
where: { layerId: "TERENURI_ACTIVE", siruta },
|
||||||
|
}),
|
||||||
|
prisma.gisFeature.count({
|
||||||
|
where: {
|
||||||
|
layerId: "TERENURI_ACTIVE",
|
||||||
|
siruta,
|
||||||
|
OR: [
|
||||||
|
{ enrichedAt: null },
|
||||||
|
{ enrichedAt: { lt: _thirtyDaysAgo } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (_totalCount > 0 && _unenrichedCount === 0) {
|
||||||
|
// ── Rolling doc check: probe oldest-enriched features for new applications ──
|
||||||
|
// VALID_FROM doesn't track documentation changes (ownership, CF).
|
||||||
|
// Check 200 oldest-enriched parcels' documentation for recent activity.
|
||||||
|
// If any have new registrations since enrichedAt → mark for re-enrichment.
|
||||||
|
const ROLLING_BATCH = 200;
|
||||||
|
const oldestEnriched = await prisma.gisFeature.findMany({
|
||||||
|
where: {
|
||||||
|
layerId: "TERENURI_ACTIVE",
|
||||||
|
siruta,
|
||||||
|
enrichedAt: { not: null },
|
||||||
|
objectId: { gt: 0 },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
objectId: true,
|
||||||
|
attributes: true,
|
||||||
|
cadastralRef: true,
|
||||||
|
enrichedAt: true,
|
||||||
|
enrichment: true,
|
||||||
|
},
|
||||||
|
orderBy: { enrichedAt: "asc" },
|
||||||
|
take: ROLLING_BATCH,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (oldestEnriched.length > 0) {
|
||||||
|
options?.onProgress?.(0, _totalCount, "Verificare documentație recentă");
|
||||||
|
|
||||||
|
// Resolve workspace PK for doc fetch
|
||||||
|
let rollingWsPk: number | null = null;
|
||||||
|
for (const f of oldestEnriched) {
|
||||||
|
const ws = (f.attributes as Record<string, unknown>).WORKSPACE_ID;
|
||||||
|
if (ws != null) {
|
||||||
|
const n = Number(ws);
|
||||||
|
if (Number.isFinite(n) && n > 0) { rollingWsPk = n; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rollingWsPk) {
|
||||||
|
try {
|
||||||
|
const row = await prisma.gisUat.findUnique({
|
||||||
|
where: { siruta },
|
||||||
|
select: { workspacePk: true },
|
||||||
|
});
|
||||||
|
if (row?.workspacePk && row.workspacePk > 0)
|
||||||
|
rollingWsPk = row.workspacePk;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
let rollingUpdated = 0;
|
||||||
|
if (rollingWsPk) {
|
||||||
|
// Collect immovable PKs for the batch + map immPk → feature data
|
||||||
|
const rollingPks: string[] = [];
|
||||||
|
const enrichedAtMap = new Map<string, Date>();
|
||||||
|
const immPkToFeatures = new Map<
|
||||||
|
string,
|
||||||
|
Array<{ id: string; enrichment: Record<string, unknown> | null }>
|
||||||
|
>();
|
||||||
|
for (const f of oldestEnriched) {
|
||||||
|
const a = f.attributes as Record<string, unknown>;
|
||||||
|
const immId = normalizeId(a.IMMOVABLE_ID);
|
||||||
|
if (immId && f.enrichedAt) {
|
||||||
|
rollingPks.push(immId);
|
||||||
|
enrichedAtMap.set(immId, f.enrichedAt);
|
||||||
|
const existing = immPkToFeatures.get(immId) ?? [];
|
||||||
|
existing.push({
|
||||||
|
id: f.id,
|
||||||
|
enrichment: (f as { enrichment?: Record<string, unknown> | null })
|
||||||
|
.enrichment ?? null,
|
||||||
|
});
|
||||||
|
immPkToFeatures.set(immId, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch documentation in batches of 50 — detect AND resolve changes in-place
|
||||||
|
const DOC_BATCH = 50;
|
||||||
|
for (let i = 0; i < rollingPks.length; i += DOC_BATCH) {
|
||||||
|
const batch = rollingPks.slice(i, i + DOC_BATCH);
|
||||||
|
try {
|
||||||
|
const docResp = await client.fetchDocumentationData(
|
||||||
|
rollingWsPk,
|
||||||
|
batch,
|
||||||
|
);
|
||||||
|
const regs: Array<{
|
||||||
|
landbookIE?: number;
|
||||||
|
nodeType?: string;
|
||||||
|
nodeName?: string;
|
||||||
|
nodeStatus?: number;
|
||||||
|
application?: { appDate?: number };
|
||||||
|
}> = docResp?.partTwoRegs ?? [];
|
||||||
|
const docImmovables: Array<{
|
||||||
|
immovablePk?: number;
|
||||||
|
landbookIE?: number;
|
||||||
|
}> = docResp?.immovables ?? [];
|
||||||
|
|
||||||
|
// Map landbookIE → immovablePk
|
||||||
|
const lbToImm = new Map<string, string>();
|
||||||
|
for (const di of docImmovables) {
|
||||||
|
if (di.landbookIE && di.immovablePk)
|
||||||
|
lbToImm.set(
|
||||||
|
String(di.landbookIE),
|
||||||
|
normalizeId(di.immovablePk),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect max appDate + owner names per immovablePk
|
||||||
|
const immToMaxApp = new Map<string, number>();
|
||||||
|
const ownersByImm = new Map<string, string[]>();
|
||||||
|
for (const reg of regs) {
|
||||||
|
const lb = reg.landbookIE ? String(reg.landbookIE) : "";
|
||||||
|
const immPk = lb ? lbToImm.get(lb) : undefined;
|
||||||
|
if (!immPk) continue;
|
||||||
|
const appDate = reg.application?.appDate;
|
||||||
|
if (typeof appDate === "number" && appDate > 0) {
|
||||||
|
const c = immToMaxApp.get(immPk) ?? 0;
|
||||||
|
if (appDate > c) immToMaxApp.set(immPk, appDate);
|
||||||
|
}
|
||||||
|
// Collect current owner names (nodeType=P, not radiated)
|
||||||
|
if (
|
||||||
|
String(reg.nodeType ?? "").toUpperCase() === "P" &&
|
||||||
|
reg.nodeName &&
|
||||||
|
(reg.nodeStatus ?? 0) >= 0
|
||||||
|
) {
|
||||||
|
const owners = ownersByImm.get(immPk) ?? [];
|
||||||
|
const name = String(reg.nodeName).trim();
|
||||||
|
if (name && !owners.includes(name)) owners.push(name);
|
||||||
|
ownersByImm.set(immPk, owners);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update features where appDate > enrichedAt — merge into existing enrichment
|
||||||
|
const now = new Date();
|
||||||
|
for (const [immPk, maxApp] of immToMaxApp) {
|
||||||
|
const enrichedAt = enrichedAtMap.get(immPk);
|
||||||
|
if (!enrichedAt || maxApp <= enrichedAt.getTime()) continue;
|
||||||
|
const features = immPkToFeatures.get(immPk) ?? [];
|
||||||
|
const owners = ownersByImm.get(immPk) ?? [];
|
||||||
|
const ownerStr = owners.join("; ") || "-";
|
||||||
|
const appDateIso = new Date(maxApp)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10);
|
||||||
|
for (const feat of features) {
|
||||||
|
// Merge: keep existing enrichment, update doc-based fields
|
||||||
|
const existing = feat.enrichment ?? {};
|
||||||
|
const merged = {
|
||||||
|
...existing,
|
||||||
|
PROPRIETARI: ownerStr,
|
||||||
|
DATA_CERERE: appDateIso,
|
||||||
|
};
|
||||||
|
await prisma.gisFeature.update({
|
||||||
|
where: { id: feat.id },
|
||||||
|
data: {
|
||||||
|
enrichment:
|
||||||
|
merged as unknown as Prisma.InputJsonValue,
|
||||||
|
enrichedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
rollingUpdated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch enrichedAt on checked features (even if unchanged) to rotate the batch
|
||||||
|
const checkedIds = batch
|
||||||
|
.flatMap((pk) => (immPkToFeatures.get(pk) ?? []).map((f) => f.id));
|
||||||
|
if (checkedIds.length > 0) {
|
||||||
|
await prisma.gisFeature.updateMany({
|
||||||
|
where: { id: { in: checkedIds }, enrichedAt: { not: null } },
|
||||||
|
data: { enrichedAt: now },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`[enrich] Rolling doc check batch failed:`,
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always return early — rolling check is self-contained
|
||||||
|
const rollingNote = rollingUpdated > 0
|
||||||
|
? `Rolling: ${rollingUpdated} parcele actualizate`
|
||||||
|
: "Date deja complete";
|
||||||
|
console.log(
|
||||||
|
`[enrich] siruta=${siruta}: ${rollingNote} (checked ${oldestEnriched.length})`,
|
||||||
|
);
|
||||||
|
options?.onProgress?.(
|
||||||
|
_totalCount,
|
||||||
|
_totalCount,
|
||||||
|
`Îmbogățire — ${rollingNote}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
siruta,
|
||||||
|
enrichedCount: _totalCount,
|
||||||
|
totalFeatures: _totalCount,
|
||||||
|
unenrichedCount: 0,
|
||||||
|
buildingCrossRefs: rollingUpdated,
|
||||||
|
status: "done",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// No enriched features to check — early bailout
|
||||||
|
options?.onProgress?.(
|
||||||
|
_totalCount,
|
||||||
|
_totalCount,
|
||||||
|
"Îmbogățire — date deja complete",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
siruta,
|
||||||
|
enrichedCount: _totalCount,
|
||||||
|
totalFeatures: _totalCount,
|
||||||
|
unenrichedCount: 0,
|
||||||
|
buildingCrossRefs: 0,
|
||||||
|
status: "done",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`[enrich] siruta=${siruta}: ${_unenrichedCount}/${_totalCount} features need enrichment`,
|
||||||
|
);
|
||||||
|
|
||||||
// Load terenuri and cladiri from DB
|
// Load terenuri and cladiri from DB
|
||||||
const terenuri = await prisma.gisFeature.findMany({
|
const terenuri = await prisma.gisFeature.findMany({
|
||||||
where: { layerId: "TERENURI_ACTIVE", siruta },
|
where: { layerId: "TERENURI_ACTIVE", siruta },
|
||||||
@@ -226,9 +490,14 @@ export async function enrichFeatures(
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If still null, enrichment will fail gracefully with empty lists
|
|
||||||
const workspacePkForApi = resolvedWsPk ?? 65;
|
const workspacePkForApi = resolvedWsPk ?? 65;
|
||||||
console.log(`[enrich] siruta=${siruta} workspacePk=${workspacePkForApi}`);
|
if (!resolvedWsPk) {
|
||||||
|
console.warn(
|
||||||
|
`[enrich] siruta=${siruta}: workspace nu s-a rezolvat, folosesc fallback PK=${workspacePkForApi}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(`[enrich] siruta=${siruta} workspacePk=${workspacePkForApi}`);
|
||||||
|
}
|
||||||
|
|
||||||
push({
|
push({
|
||||||
phase: "Pregătire îmbogățire",
|
phase: "Pregătire îmbogățire",
|
||||||
@@ -282,6 +551,10 @@ export async function enrichFeatures(
|
|||||||
if (baseRef) add(baseRef);
|
if (baseRef) add(baseRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[enrich] siruta=${siruta}: ${terenuri.length} terenuri, ${cladiri.length} cladiri in DB, ${buildingMap.size} chei in buildingMap`,
|
||||||
|
);
|
||||||
|
|
||||||
// ── Fetch immovable list from eTerra ──
|
// ── Fetch immovable list from eTerra ──
|
||||||
push({ phase: "Descărcare listă imobile", downloaded: 0 });
|
push({ phase: "Descărcare listă imobile", downloaded: 0 });
|
||||||
const immovableListById = new Map<string, any>();
|
const immovableListById = new Map<string, any>();
|
||||||
@@ -334,10 +607,62 @@ export async function enrichFeatures(
|
|||||||
listPage += 1;
|
listPage += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fetch documentation/owner data ──
|
if (immovableListById.size === 0) {
|
||||||
|
console.warn(
|
||||||
|
`[enrich] siruta=${siruta}: lista de imobile e GOALĂ (workspace=${workspacePkForApi}). ` +
|
||||||
|
`Enrichment va continua dar toate parcelele vor avea date goale. ` +
|
||||||
|
`Verifică workspace-ul corect pentru acest UAT.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[enrich] siruta=${siruta}: ${immovableListById.size} imobile găsite`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Targeted doc fetch: only for features that need enrichment ──
|
||||||
|
// Pre-filter: which immovable PKs actually need documentation?
|
||||||
|
const allImmPks = Array.from(immovableListById.keys());
|
||||||
|
const neededDocPks = new Set<string>();
|
||||||
|
for (const f of terenuri) {
|
||||||
|
if (f.enrichedAt != null) {
|
||||||
|
const ej = f.enrichment as Record<string, unknown> | null;
|
||||||
|
const _core = [
|
||||||
|
"NR_CAD", "NR_CF", "PROPRIETARI", "PROPRIETARI_VECHI",
|
||||||
|
"ADRESA", "CATEGORIE_FOLOSINTA", "HAS_BUILDING",
|
||||||
|
];
|
||||||
|
const ok =
|
||||||
|
ej != null &&
|
||||||
|
_core.every((k) => k in ej && ej[k] !== undefined) &&
|
||||||
|
["NR_CF", "PROPRIETARI", "ADRESA", "CATEGORIE_FOLOSINTA"].some(
|
||||||
|
(k) => ej[k] !== "-" && ej[k] !== "",
|
||||||
|
) &&
|
||||||
|
!Object.values(ej).some(
|
||||||
|
(v) => typeof v === "string" && v.includes("[object Object]"),
|
||||||
|
) &&
|
||||||
|
Date.now() - new Date(f.enrichedAt).getTime() <=
|
||||||
|
30 * 24 * 60 * 60 * 1000;
|
||||||
|
if (ok) continue; // Already complete — skip doc fetch for this one
|
||||||
|
}
|
||||||
|
const fa = f.attributes as Record<string, unknown>;
|
||||||
|
const fImmKey = normalizeId(fa.IMMOVABLE_ID);
|
||||||
|
const fCadKey = normalizeCadRef(f.cadastralRef ?? "");
|
||||||
|
const fItem =
|
||||||
|
(fImmKey ? immovableListById.get(fImmKey) : undefined) ??
|
||||||
|
(fCadKey ? immovableListByCad.get(fCadKey) : undefined);
|
||||||
|
if (fItem?.immovablePk)
|
||||||
|
neededDocPks.add(normalizeId(fItem.immovablePk));
|
||||||
|
}
|
||||||
|
// Use targeted set if we identified specific PKs, otherwise fall back to all
|
||||||
|
const immovableIds =
|
||||||
|
neededDocPks.size > 0 ? [...neededDocPks] : allImmPks;
|
||||||
|
console.log(
|
||||||
|
`[enrich] siruta=${siruta}: doc fetch for ${immovableIds.length}/${allImmPks.length} immovables (${neededDocPks.size > 0 ? "targeted" : "full"})`,
|
||||||
|
);
|
||||||
|
|
||||||
push({ phase: "Descărcare documentații CF" });
|
push({ phase: "Descărcare documentații CF" });
|
||||||
const docByImmovable = new Map<string, any>();
|
const docByImmovable = new Map<string, any>();
|
||||||
const immovableIds = Array.from(immovableListById.keys());
|
// Store raw registrations per landbookIE for extended enrichment fields
|
||||||
|
const regsByLandbook = new Map<string, any[]>();
|
||||||
const docBatchSize = 50;
|
const docBatchSize = 50;
|
||||||
for (let i = 0; i < immovableIds.length; i += docBatchSize) {
|
for (let i = 0; i < immovableIds.length; i += docBatchSize) {
|
||||||
const batch = immovableIds.slice(i, i + docBatchSize);
|
const batch = immovableIds.slice(i, i + docBatchSize);
|
||||||
@@ -353,6 +678,13 @@ export async function enrichFeatures(
|
|||||||
const nodeMap = new Map<number, any>();
|
const nodeMap = new Map<number, any>();
|
||||||
for (const reg of regs) {
|
for (const reg of regs) {
|
||||||
if (reg?.nodeId != null) nodeMap.set(Number(reg.nodeId), reg);
|
if (reg?.nodeId != null) nodeMap.set(Number(reg.nodeId), reg);
|
||||||
|
// Store all registrations by landbookIE for extended enrichment
|
||||||
|
if (reg?.landbookIE) {
|
||||||
|
const lbKey = String(reg.landbookIE);
|
||||||
|
const existing = regsByLandbook.get(lbKey) ?? [];
|
||||||
|
existing.push(reg);
|
||||||
|
regsByLandbook.set(lbKey, existing);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Check if an entry or any ancestor "I" inscription is radiated
|
// Check if an entry or any ancestor "I" inscription is radiated
|
||||||
const isRadiated = (entry: any, depth = 0): boolean => {
|
const isRadiated = (entry: any, depth = 0): boolean => {
|
||||||
@@ -392,22 +724,48 @@ export async function enrichFeatures(
|
|||||||
const attrs = feature.attributes as Record<string, unknown>;
|
const attrs = feature.attributes as Record<string, unknown>;
|
||||||
|
|
||||||
// Skip features with complete enrichment (resume after crash/interruption).
|
// Skip features with complete enrichment (resume after crash/interruption).
|
||||||
// Re-enrich if enrichment schema is incomplete (e.g., missing PROPRIETARI_VECHI
|
// Re-enrich if: schema incomplete, values are all "-" (empty), or older than 30 days.
|
||||||
// added in a later version).
|
|
||||||
if (feature.enrichedAt != null) {
|
if (feature.enrichedAt != null) {
|
||||||
const enrichJson = feature.enrichment as Record<string, unknown> | null;
|
const enrichJson = feature.enrichment as Record<string, unknown> | null;
|
||||||
const isComplete =
|
// Structural check: all 7 core fields must exist
|
||||||
|
const coreFields = [
|
||||||
|
"NR_CAD",
|
||||||
|
"NR_CF",
|
||||||
|
"PROPRIETARI",
|
||||||
|
"PROPRIETARI_VECHI",
|
||||||
|
"ADRESA",
|
||||||
|
"CATEGORIE_FOLOSINTA",
|
||||||
|
"HAS_BUILDING",
|
||||||
|
];
|
||||||
|
const structurallyComplete =
|
||||||
enrichJson != null &&
|
enrichJson != null &&
|
||||||
[
|
coreFields.every((k) => k in enrichJson && enrichJson[k] !== undefined);
|
||||||
"NR_CAD",
|
|
||||||
"NR_CF",
|
// Value check: at least some fields must have real data (not just "-")
|
||||||
"PROPRIETARI",
|
// A feature with ALL text fields === "-" is considered empty and needs re-enrichment
|
||||||
"PROPRIETARI_VECHI",
|
const valueFields = ["NR_CF", "PROPRIETARI", "ADRESA", "CATEGORIE_FOLOSINTA"];
|
||||||
"ADRESA",
|
const hasRealValues =
|
||||||
"CATEGORIE_FOLOSINTA",
|
enrichJson != null &&
|
||||||
"HAS_BUILDING",
|
valueFields.some(
|
||||||
].every((k) => k in enrichJson && enrichJson[k] !== undefined);
|
(k) =>
|
||||||
if (isComplete) {
|
k in enrichJson &&
|
||||||
|
enrichJson[k] !== undefined &&
|
||||||
|
enrichJson[k] !== "-" &&
|
||||||
|
enrichJson[k] !== "",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Corruption check: re-enrich if any field contains "[object Object]"
|
||||||
|
const hasCorruptedValues =
|
||||||
|
enrichJson != null &&
|
||||||
|
Object.values(enrichJson).some(
|
||||||
|
(v) => typeof v === "string" && v.includes("[object Object]"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Age check: re-enrich if older than 30 days (catches eTerra updates)
|
||||||
|
const ageMs = Date.now() - new Date(feature.enrichedAt).getTime();
|
||||||
|
const isTooOld = ageMs > 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
if (structurallyComplete && hasRealValues && !isTooOld && !hasCorruptedValues) {
|
||||||
enrichedCount += 1;
|
enrichedCount += 1;
|
||||||
if (index % 50 === 0) {
|
if (index % 50 === 0) {
|
||||||
options?.onProgress?.(
|
options?.onProgress?.(
|
||||||
@@ -418,9 +776,12 @@ export async function enrichFeatures(
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Stale enrichment — will be re-enriched below
|
// Incomplete, empty, or stale — will be re-enriched below
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-feature try-catch: one feature failing should not abort the whole UAT
|
||||||
|
try {
|
||||||
|
|
||||||
const immovableId = attrs.IMMOVABLE_ID ?? "";
|
const immovableId = attrs.IMMOVABLE_ID ?? "";
|
||||||
const workspaceId = attrs.WORKSPACE_ID ?? "";
|
const workspaceId = attrs.WORKSPACE_ID ?? "";
|
||||||
const applicationId = (attrs.APPLICATION_ID as number) ?? null;
|
const applicationId = (attrs.APPLICATION_ID as number) ?? null;
|
||||||
@@ -474,13 +835,17 @@ export async function enrichFeatures(
|
|||||||
const folKey = `${workspaceId}:${immovableId}:${appId}`;
|
const folKey = `${workspaceId}:${immovableId}:${appId}`;
|
||||||
let fol = folCache.get(folKey);
|
let fol = folCache.get(folKey);
|
||||||
if (!fol) {
|
if (!fol) {
|
||||||
fol = await throttled(() =>
|
try {
|
||||||
client.fetchParcelFolosinte(
|
fol = await throttled(() =>
|
||||||
workspaceId as string | number,
|
client.fetchParcelFolosinte(
|
||||||
immovableId as string | number,
|
workspaceId as string | number,
|
||||||
appId,
|
immovableId as string | number,
|
||||||
),
|
appId,
|
||||||
);
|
),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
fol = [];
|
||||||
|
}
|
||||||
folCache.set(folKey, fol);
|
folCache.set(folKey, fol);
|
||||||
}
|
}
|
||||||
if (fol && fol.length > 0) {
|
if (fol && fol.length > 0) {
|
||||||
@@ -576,6 +941,59 @@ export async function enrichFeatures(
|
|||||||
: null);
|
: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extended fields — extracted from existing data, zero extra API calls
|
||||||
|
let tipInscriere = "";
|
||||||
|
let actProprietate = "";
|
||||||
|
let cotaProprietate = "";
|
||||||
|
let dataCerere = "";
|
||||||
|
// Extract registration details from already-fetched documentation
|
||||||
|
const lbKey = landbookIE || cadRefRaw;
|
||||||
|
const regsForParcel = regsByLandbook.get(String(lbKey)) ?? [];
|
||||||
|
for (const reg of regsForParcel) {
|
||||||
|
const nt = String(reg?.nodeType ?? "").toUpperCase();
|
||||||
|
const nn = String(reg?.nodeName ?? "").trim();
|
||||||
|
if (nt === "I" && nn && !tipInscriere) {
|
||||||
|
tipInscriere = nn;
|
||||||
|
const quota = reg?.registration?.actualQuota;
|
||||||
|
if (quota) cotaProprietate = String(quota);
|
||||||
|
}
|
||||||
|
if (nt === "A" && nn && !actProprietate) {
|
||||||
|
actProprietate = nn;
|
||||||
|
}
|
||||||
|
if (nt === "C" && !dataCerere) {
|
||||||
|
const appDate = reg?.application?.appDate;
|
||||||
|
if (typeof appDate === "number" && appDate > 0) {
|
||||||
|
dataCerere = new Date(appDate).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Building body details from local DB cladiri
|
||||||
|
const cadRefBase = baseCadRef(cadRefRaw);
|
||||||
|
let nrCorpuri = 0;
|
||||||
|
const corpuriParts: string[] = [];
|
||||||
|
for (const cFeature of cladiri) {
|
||||||
|
const cAttrs = cFeature.attributes as Record<string, unknown>;
|
||||||
|
const cRef = String(cAttrs.NATIONAL_CADASTRAL_REFERENCE ?? "");
|
||||||
|
if (baseCadRef(cRef) === cadRefBase && cRef.includes("-")) {
|
||||||
|
nrCorpuri++;
|
||||||
|
const suffix = cRef.slice(cRef.lastIndexOf("-") + 1);
|
||||||
|
const cArea = typeof cAttrs.AREA_VALUE === "number" ? cAttrs.AREA_VALUE : 0;
|
||||||
|
corpuriParts.push(`${suffix}:${Math.round(cArea)}mp`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condominium status and creation date from documentation
|
||||||
|
const docImmovable = docKey ? docByImmovable.get(docKey) : undefined;
|
||||||
|
const isCondominium = Number(
|
||||||
|
(docImmovable as Record<string, unknown>)?.isCondominium ?? 0,
|
||||||
|
);
|
||||||
|
const createdDtm = attrs.CREATED_DTM;
|
||||||
|
const dataCreare =
|
||||||
|
typeof createdDtm === "number" && createdDtm > 0
|
||||||
|
? new Date(createdDtm).toISOString().slice(0, 10)
|
||||||
|
: "";
|
||||||
|
|
||||||
const enrichment: FeatureEnrichment = {
|
const enrichment: FeatureEnrichment = {
|
||||||
NR_CAD: cadRefRaw,
|
NR_CAD: cadRefRaw,
|
||||||
NR_CF: nrCF,
|
NR_CF: nrCF,
|
||||||
@@ -589,8 +1007,16 @@ export async function enrichFeatures(
|
|||||||
SOLICITANT: solicitant,
|
SOLICITANT: solicitant,
|
||||||
INTRAVILAN: intravilan,
|
INTRAVILAN: intravilan,
|
||||||
CATEGORIE_FOLOSINTA: categorie,
|
CATEGORIE_FOLOSINTA: categorie,
|
||||||
HAS_BUILDING: hasBuilding,
|
HAS_BUILDING: hasBuilding || (nrCorpuri > 0 ? 1 : 0),
|
||||||
BUILD_LEGAL: buildLegal,
|
BUILD_LEGAL: buildLegal,
|
||||||
|
TIP_INSCRIERE: tipInscriere || undefined,
|
||||||
|
ACT_PROPRIETATE: actProprietate || undefined,
|
||||||
|
COTA_PROPRIETATE: cotaProprietate || undefined,
|
||||||
|
DATA_CERERE: dataCerere || undefined,
|
||||||
|
NR_CORPURI: nrCorpuri,
|
||||||
|
CORPURI_DETALII: corpuriParts.length > 0 ? corpuriParts.join(", ") : undefined,
|
||||||
|
IS_CONDOMINIUM: isCondominium,
|
||||||
|
DATA_CREARE: dataCreare || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store enrichment in DB
|
// Store enrichment in DB
|
||||||
@@ -603,6 +1029,16 @@ export async function enrichFeatures(
|
|||||||
});
|
});
|
||||||
|
|
||||||
enrichedCount += 1;
|
enrichedCount += 1;
|
||||||
|
|
||||||
|
} catch (featureErr) {
|
||||||
|
// Log and continue — don't abort the whole UAT
|
||||||
|
const cadRef = (attrs.NATIONAL_CADASTRAL_REFERENCE ?? "?") as string;
|
||||||
|
const msg = featureErr instanceof Error ? featureErr.message : String(featureErr);
|
||||||
|
console.warn(
|
||||||
|
`[enrich] Feature ${index + 1}/${terenuri.length} (cad=${cadRef}) failed: ${msg}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (index % 10 === 0) {
|
if (index % 10 === 0) {
|
||||||
push({
|
push({
|
||||||
phase: "Îmbogățire parcele",
|
phase: "Îmbogățire parcele",
|
||||||
|
|||||||
@@ -54,11 +54,24 @@ type SessionEntry = {
|
|||||||
|
|
||||||
const globalStore = globalThis as {
|
const globalStore = globalThis as {
|
||||||
__epaySessionCache?: Map<string, SessionEntry>;
|
__epaySessionCache?: Map<string, SessionEntry>;
|
||||||
|
__epayCleanupTimer?: ReturnType<typeof setInterval>;
|
||||||
};
|
};
|
||||||
const sessionCache =
|
const sessionCache =
|
||||||
globalStore.__epaySessionCache ?? new Map<string, SessionEntry>();
|
globalStore.__epaySessionCache ?? new Map<string, SessionEntry>();
|
||||||
globalStore.__epaySessionCache = sessionCache;
|
globalStore.__epaySessionCache = sessionCache;
|
||||||
|
|
||||||
|
// Periodic cleanup of expired sessions (every 5 minutes, 9-min TTL)
|
||||||
|
if (!globalStore.__epayCleanupTimer) {
|
||||||
|
globalStore.__epayCleanupTimer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of sessionCache.entries()) {
|
||||||
|
if (now - entry.lastUsed > 9 * 60_000) {
|
||||||
|
sessionCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5 * 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
const makeCacheKey = (u: string, p: string) =>
|
const makeCacheKey = (u: string, p: string) =>
|
||||||
crypto.createHash("sha256").update(`epay:${u}:${p}`).digest("hex");
|
crypto.createHash("sha256").update(`epay:${u}:${p}`).digest("hex");
|
||||||
|
|
||||||
|
|||||||
@@ -117,27 +117,29 @@ export async function enqueueBatch(
|
|||||||
const items: QueueItem[] = [];
|
const items: QueueItem[] = [];
|
||||||
|
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
// Create DB record in "queued" status
|
// Create DB record in "queued" status — use transaction + advisory lock
|
||||||
const record = await prisma.cfExtract.create({
|
// to prevent duplicate version numbers from concurrent requests
|
||||||
data: {
|
const record = await prisma.$transaction(async (tx) => {
|
||||||
nrCadastral: input.nrCadastral,
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'cfextract:' + input.nrCadastral}))`;
|
||||||
nrCF: input.nrCF ?? input.nrCadastral,
|
const agg = await tx.cfExtract.aggregate({
|
||||||
siruta: input.siruta,
|
where: { nrCadastral: input.nrCadastral },
|
||||||
judetIndex: input.judetIndex,
|
_max: { version: true },
|
||||||
judetName: input.judetName,
|
});
|
||||||
uatId: input.uatId,
|
return tx.cfExtract.create({
|
||||||
uatName: input.uatName,
|
data: {
|
||||||
gisFeatureId: input.gisFeatureId,
|
nrCadastral: input.nrCadastral,
|
||||||
prodId: input.prodId ?? 14200,
|
nrCF: input.nrCF ?? input.nrCadastral,
|
||||||
status: "queued",
|
siruta: input.siruta,
|
||||||
version:
|
judetIndex: input.judetIndex,
|
||||||
((
|
judetName: input.judetName,
|
||||||
await prisma.cfExtract.aggregate({
|
uatId: input.uatId,
|
||||||
where: { nrCadastral: input.nrCadastral },
|
uatName: input.uatName,
|
||||||
_max: { version: true },
|
gisFeatureId: input.gisFeatureId,
|
||||||
})
|
prodId: input.prodId ?? 14200,
|
||||||
)._max.version ?? 0) + 1,
|
status: "queued",
|
||||||
},
|
version: (agg._max.version ?? 0) + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
items.push({ extractId: record.id, input });
|
items.push({ extractId: record.id, input });
|
||||||
@@ -418,7 +420,10 @@ async function processBatch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Complete
|
// Complete — require document date from ANCPI for accurate expiry
|
||||||
|
if (!doc.dataDocument) {
|
||||||
|
console.warn(`[epay-queue] Missing dataDocument for extract ${item.extractId}, using download date`);
|
||||||
|
}
|
||||||
const documentDate = doc.dataDocument
|
const documentDate = doc.dataDocument
|
||||||
? new Date(doc.dataDocument)
|
? new Date(doc.dataDocument)
|
||||||
: new Date();
|
: new Date();
|
||||||
|
|||||||
@@ -78,11 +78,24 @@ type SessionEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const globalStore = globalThis as {
|
const globalStore = globalThis as {
|
||||||
__eterraSessionStore?: Map<string, SessionEntry>;
|
__eterraClientCache?: Map<string, SessionEntry>;
|
||||||
|
__eterraClientCleanupTimer?: ReturnType<typeof setInterval>;
|
||||||
};
|
};
|
||||||
const sessionStore =
|
const sessionStore =
|
||||||
globalStore.__eterraSessionStore ?? new Map<string, SessionEntry>();
|
globalStore.__eterraClientCache ?? new Map<string, SessionEntry>();
|
||||||
globalStore.__eterraSessionStore = sessionStore;
|
globalStore.__eterraClientCache = sessionStore;
|
||||||
|
|
||||||
|
// Periodic cleanup of expired sessions (every 5 minutes, 9-min TTL)
|
||||||
|
if (!globalStore.__eterraClientCleanupTimer) {
|
||||||
|
globalStore.__eterraClientCleanupTimer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of sessionStore.entries()) {
|
||||||
|
if (now - entry.lastUsed > 9 * 60_000) {
|
||||||
|
sessionStore.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5 * 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
const makeCacheKey = (u: string, p: string) =>
|
const makeCacheKey = (u: string, p: string) =>
|
||||||
crypto.createHash("sha256").update(`${u}:${p}`).digest("hex");
|
crypto.createHash("sha256").update(`${u}:${p}`).digest("hex");
|
||||||
@@ -117,7 +130,7 @@ export class EterraClient {
|
|||||||
private maxRetries: number;
|
private maxRetries: number;
|
||||||
private username: string;
|
private username: string;
|
||||||
private password: string;
|
private password: string;
|
||||||
private reloginAttempted = false;
|
private cacheKey: string;
|
||||||
private layerFieldsCache = new Map<string, string[]>();
|
private layerFieldsCache = new Map<string, string[]>();
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
@@ -134,6 +147,7 @@ export class EterraClient {
|
|||||||
this.username = username;
|
this.username = username;
|
||||||
this.password = password;
|
this.password = password;
|
||||||
this.maxRetries = maxRetries;
|
this.maxRetries = maxRetries;
|
||||||
|
this.cacheKey = makeCacheKey(username, password);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Factory --------------------------------------------------- */
|
/* ---- Factory --------------------------------------------------- */
|
||||||
@@ -284,6 +298,81 @@ export class EterraClient {
|
|||||||
return this.countLayerWithParams(layer, params, true);
|
return this.countLayerWithParams(layer, params, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Incremental sync: fetch only OBJECTIDs -------------------- */
|
||||||
|
|
||||||
|
async fetchObjectIds(layer: LayerConfig, siruta: string): Promise<number[]> {
|
||||||
|
const where = await this.buildWhere(layer, siruta);
|
||||||
|
return this.fetchObjectIdsByWhere(layer, where);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchObjectIdsByWhere(
|
||||||
|
layer: LayerConfig,
|
||||||
|
where: string,
|
||||||
|
): Promise<number[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("f", "json");
|
||||||
|
params.set("where", where);
|
||||||
|
params.set("returnIdsOnly", "true");
|
||||||
|
const data = await this.queryLayer(layer, params, false);
|
||||||
|
return data.objectIds ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchObjectIdsByGeometry(
|
||||||
|
layer: LayerConfig,
|
||||||
|
geometry: EsriGeometry,
|
||||||
|
): Promise<number[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("f", "json");
|
||||||
|
params.set("where", "1=1");
|
||||||
|
params.set("returnIdsOnly", "true");
|
||||||
|
this.applyGeometryParams(params, geometry);
|
||||||
|
const data = await this.queryLayer(layer, params, true);
|
||||||
|
return data.objectIds ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Fetch specific features by OBJECTID list ------------------- */
|
||||||
|
|
||||||
|
async fetchFeaturesByObjectIds(
|
||||||
|
layer: LayerConfig,
|
||||||
|
objectIds: number[],
|
||||||
|
options?: {
|
||||||
|
baseWhere?: string;
|
||||||
|
outFields?: string;
|
||||||
|
returnGeometry?: boolean;
|
||||||
|
onProgress?: ProgressCallback;
|
||||||
|
delayMs?: number;
|
||||||
|
},
|
||||||
|
): Promise<EsriFeature[]> {
|
||||||
|
if (objectIds.length === 0) return [];
|
||||||
|
const chunkSize = 500;
|
||||||
|
const all: EsriFeature[] = [];
|
||||||
|
const total = objectIds.length;
|
||||||
|
for (let i = 0; i < objectIds.length; i += chunkSize) {
|
||||||
|
const chunk = objectIds.slice(i, i + chunkSize);
|
||||||
|
const idList = chunk.join(",");
|
||||||
|
const idWhere = `OBJECTID IN (${idList})`;
|
||||||
|
const where = options?.baseWhere
|
||||||
|
? `(${options.baseWhere}) AND ${idWhere}`
|
||||||
|
: idWhere;
|
||||||
|
try {
|
||||||
|
const features = await this.fetchAllLayerByWhere(layer, where, {
|
||||||
|
outFields: options?.outFields ?? "*",
|
||||||
|
returnGeometry: options?.returnGeometry ?? true,
|
||||||
|
delayMs: options?.delayMs ?? 200,
|
||||||
|
});
|
||||||
|
all.push(...features);
|
||||||
|
} catch (err) {
|
||||||
|
// Log but continue with remaining chunks — partial results better than none
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(
|
||||||
|
`[fetchFeaturesByObjectIds] Chunk ${Math.floor(i / chunkSize) + 1} failed (${chunk.length} IDs): ${msg}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
options?.onProgress?.(all.length, total);
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
async listLayer(
|
async listLayer(
|
||||||
layer: LayerConfig,
|
layer: LayerConfig,
|
||||||
siruta: string,
|
siruta: string,
|
||||||
@@ -831,8 +920,7 @@ export class EterraClient {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as AxiosError;
|
const err = error as AxiosError;
|
||||||
if (err?.response?.status === 401 && !this.reloginAttempted) {
|
if (err?.response?.status === 401) {
|
||||||
this.reloginAttempted = true;
|
|
||||||
await this.login(this.username, this.password);
|
await this.login(this.username, this.password);
|
||||||
response = await this.requestWithRetry(() =>
|
response = await this.requestWithRetry(() =>
|
||||||
this.client.get(url, { timeout: this.timeoutMs }),
|
this.client.get(url, { timeout: this.timeoutMs }),
|
||||||
@@ -896,23 +984,28 @@ export class EterraClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Touch session TTL in global store (prevents expiry during long pagination) */
|
||||||
|
private touchSession(): void {
|
||||||
|
const cached = sessionStore.get(this.cacheKey);
|
||||||
|
if (cached) cached.lastUsed = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
private async requestJson(
|
private async requestJson(
|
||||||
request: () => Promise<{
|
request: () => Promise<{
|
||||||
data: EsriQueryResponse | string;
|
data: EsriQueryResponse | string;
|
||||||
status: number;
|
status: number;
|
||||||
}>,
|
}>,
|
||||||
): Promise<EsriQueryResponse> {
|
): Promise<EsriQueryResponse> {
|
||||||
|
this.touchSession();
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await this.requestWithRetry(request);
|
response = await this.requestWithRetry(request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as AxiosError;
|
const err = error as AxiosError;
|
||||||
if (err?.response?.status === 401 && !this.reloginAttempted) {
|
if (err?.response?.status === 401) {
|
||||||
this.reloginAttempted = true;
|
// Always attempt relogin on 401 (session may expire multiple times during long syncs)
|
||||||
await this.login(this.username, this.password);
|
await this.login(this.username, this.password);
|
||||||
response = await this.requestWithRetry(request);
|
response = await this.requestWithRetry(request);
|
||||||
} else if (err?.response?.status === 401) {
|
|
||||||
throw new Error("Session expired (401)");
|
|
||||||
} else throw error;
|
} else throw error;
|
||||||
}
|
}
|
||||||
const data = response.data as EsriQueryResponse | string;
|
const data = response.data as EsriQueryResponse | string;
|
||||||
@@ -931,17 +1024,15 @@ export class EterraClient {
|
|||||||
private async requestRaw<T = any>(
|
private async requestRaw<T = any>(
|
||||||
request: () => Promise<{ data: T | string; status: number }>,
|
request: () => Promise<{ data: T | string; status: number }>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
this.touchSession();
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await this.requestWithRetry(request);
|
response = await this.requestWithRetry(request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as AxiosError;
|
const err = error as AxiosError;
|
||||||
if (err?.response?.status === 401 && !this.reloginAttempted) {
|
if (err?.response?.status === 401) {
|
||||||
this.reloginAttempted = true;
|
|
||||||
await this.login(this.username, this.password);
|
await this.login(this.username, this.password);
|
||||||
response = await this.requestWithRetry(request);
|
response = await this.requestWithRetry(request);
|
||||||
} else if (err?.response?.status === 401) {
|
|
||||||
throw new Error("Session expired (401)");
|
|
||||||
} else throw error;
|
} else throw error;
|
||||||
}
|
}
|
||||||
const data = response.data as T | string;
|
const data = response.data as T | string;
|
||||||
|
|||||||
@@ -175,3 +175,33 @@ export const buildGpkg = async (options: GpkgBuildOptions): Promise<Buffer> => {
|
|||||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
return buffer;
|
return buffer;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a GPKG buffer to DXF using ogr2ogr.
|
||||||
|
* Returns null if ogr2ogr is not available or conversion fails.
|
||||||
|
*/
|
||||||
|
export const gpkgToDxf = async (
|
||||||
|
gpkgBuffer: Buffer,
|
||||||
|
layerName: string,
|
||||||
|
): Promise<Buffer | null> => {
|
||||||
|
if (!hasOgr2Ogr()) return null;
|
||||||
|
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "eterra-dxf-"));
|
||||||
|
const gpkgPath = path.join(tmpDir, "input.gpkg");
|
||||||
|
const dxfPath = path.join(tmpDir, `${layerName}.dxf`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.writeFile(gpkgPath, gpkgBuffer);
|
||||||
|
await runOgr(
|
||||||
|
["-f", "DXF", dxfPath, gpkgPath, layerName],
|
||||||
|
{ ...process.env, OGR_CT_FORCE_TRADITIONAL_GIS_ORDER: "YES" },
|
||||||
|
);
|
||||||
|
const buffer = Buffer.from(await fs.readFile(dxfPath));
|
||||||
|
return buffer;
|
||||||
|
} catch {
|
||||||
|
// DXF conversion failed — not critical
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -537,9 +537,12 @@ export async function syncNoGeometryParcels(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (staleIds.length > 0) {
|
if (staleIds.length > 0) {
|
||||||
await prisma.gisFeature.deleteMany({
|
const BATCH = 30_000;
|
||||||
where: { id: { in: staleIds } },
|
for (let i = 0; i < staleIds.length; i += BATCH) {
|
||||||
});
|
await prisma.gisFeature.deleteMany({
|
||||||
|
where: { id: { in: staleIds.slice(i, i + BATCH) } },
|
||||||
|
});
|
||||||
|
}
|
||||||
console.log(
|
console.log(
|
||||||
`[no-geom-sync] Cleanup: removed ${staleIds.length} stale/invalid no-geom records`,
|
`[no-geom-sync] Cleanup: removed ${staleIds.length} stale/invalid no-geom records`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Shared helper — triggers PMTiles rebuild via webhook after sync operations.
|
||||||
|
* The webhook server (pmtiles-webhook systemd service on satra) runs
|
||||||
|
* `docker run architools-tippecanoe` to regenerate overview tiles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const WEBHOOK_URL = process.env.N8N_WEBHOOK_URL || "";
|
||||||
|
|
||||||
|
export async function firePmtilesRebuild(
|
||||||
|
event: string,
|
||||||
|
metadata?: Record<string, unknown>,
|
||||||
|
): Promise<{ ok: boolean; alreadyRunning?: boolean }> {
|
||||||
|
if (!WEBHOOK_URL) {
|
||||||
|
console.warn("[pmtiles-webhook] N8N_WEBHOOK_URL not configured — skipping rebuild trigger");
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(WEBHOOK_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
event,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
...metadata,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
console.log(`[pmtiles-webhook] Rebuild triggered (event: ${event}, HTTP ${res.status})`);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
if (res.status === 409) {
|
||||||
|
console.log(`[pmtiles-webhook] Rebuild already running (event: ${event})`);
|
||||||
|
return { ok: true, alreadyRunning: true };
|
||||||
|
}
|
||||||
|
console.warn(`[pmtiles-webhook] Webhook returned HTTP ${res.status}`);
|
||||||
|
return { ok: false };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(`[pmtiles-webhook] Failed: ${msg}`);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,10 +16,24 @@ export type SyncProgress = {
|
|||||||
|
|
||||||
type ProgressStore = Map<string, SyncProgress>;
|
type ProgressStore = Map<string, SyncProgress>;
|
||||||
|
|
||||||
const g = globalThis as { __parcelSyncProgressStore?: ProgressStore };
|
const g = globalThis as {
|
||||||
|
__parcelSyncProgressStore?: ProgressStore;
|
||||||
|
__progressCleanupTimer?: ReturnType<typeof setInterval>;
|
||||||
|
};
|
||||||
const store: ProgressStore = g.__parcelSyncProgressStore ?? new Map();
|
const store: ProgressStore = g.__parcelSyncProgressStore ?? new Map();
|
||||||
g.__parcelSyncProgressStore = store;
|
g.__parcelSyncProgressStore = store;
|
||||||
|
|
||||||
|
// Periodic cleanup of stale progress entries (every 30 minutes)
|
||||||
|
if (!g.__progressCleanupTimer) {
|
||||||
|
g.__progressCleanupTimer = setInterval(() => {
|
||||||
|
for (const [jobId, p] of store.entries()) {
|
||||||
|
if (p.status === "done" || p.status === "error") {
|
||||||
|
store.delete(jobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30 * 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
export const setProgress = (p: SyncProgress) => store.set(p.jobId, p);
|
export const setProgress = (p: SyncProgress) => store.set(p.jobId, p);
|
||||||
export const getProgress = (jobId: string) => store.get(jobId);
|
export const getProgress = (jobId: string) => store.get(jobId);
|
||||||
export const clearProgress = (jobId: string) => store.delete(jobId);
|
export const clearProgress = (jobId: string) => store.delete(jobId);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { Prisma, PrismaClient } from "@prisma/client";
|
import { Prisma, PrismaClient } from "@prisma/client";
|
||||||
import { EterraClient } from "./eterra-client";
|
import { EterraClient } from "./eterra-client";
|
||||||
import type { LayerConfig } from "./eterra-client";
|
import type { LayerConfig, EsriFeature } from "./eterra-client";
|
||||||
import { esriToGeojson } from "./esri-geojson";
|
import { esriToGeojson } from "./esri-geojson";
|
||||||
import { findLayerById, type LayerCatalogItem } from "./eterra-layers";
|
import { findLayerById, type LayerCatalogItem } from "./eterra-layers";
|
||||||
import { fetchUatGeometry } from "./uat-geometry";
|
import { fetchUatGeometry } from "./uat-geometry";
|
||||||
@@ -29,6 +29,8 @@ export type SyncResult = {
|
|||||||
totalLocal: number;
|
totalLocal: number;
|
||||||
newFeatures: number;
|
newFeatures: number;
|
||||||
removedFeatures: number;
|
removedFeatures: number;
|
||||||
|
/** Features with VALID_FROM changed (attribute update, no new OBJECTID) */
|
||||||
|
validFromUpdated?: number;
|
||||||
status: "done" | "error";
|
status: "done" | "error";
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
@@ -116,50 +118,144 @@ export async function syncLayer(
|
|||||||
uatGeometry = await fetchUatGeometry(client, siruta);
|
uatGeometry = await fetchUatGeometry(client, siruta);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count remote features
|
// Get local OBJECTIDs for this layer+siruta (only positive — skip no-geom)
|
||||||
push({ phase: "Numărare remote" });
|
push({ phase: "Verificare locală" });
|
||||||
let remoteCount: number;
|
|
||||||
try {
|
|
||||||
remoteCount = uatGeometry
|
|
||||||
? await client.countLayerByGeometry(layer, uatGeometry)
|
|
||||||
: await client.countLayer(layer, siruta);
|
|
||||||
} catch {
|
|
||||||
remoteCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
push({ phase: "Verificare locală", total: remoteCount });
|
|
||||||
|
|
||||||
// Get local OBJECTIDs for this layer+siruta
|
|
||||||
const localFeatures = await prisma.gisFeature.findMany({
|
const localFeatures = await prisma.gisFeature.findMany({
|
||||||
where: { layerId, siruta },
|
where: { layerId, siruta, objectId: { gt: 0 } },
|
||||||
select: { objectId: true },
|
select: { objectId: true },
|
||||||
});
|
});
|
||||||
const localObjIds = new Set(localFeatures.map((f) => f.objectId));
|
const localObjIds = new Set(localFeatures.map((f) => f.objectId));
|
||||||
|
|
||||||
// Fetch all remote features
|
// ── Quick-count check: if remote count == local count, skip full OBJECTID fetch ──
|
||||||
push({ phase: "Descărcare features", downloaded: 0, total: remoteCount });
|
// Just do VALID_FROM delta for attribute changes (handled after download section).
|
||||||
|
let remoteCount = 0;
|
||||||
|
let remoteObjIds = new Set<number>();
|
||||||
|
let newObjIdArray: number[] = [];
|
||||||
|
let removedObjIds: number[] = [];
|
||||||
|
let useFullSync = false;
|
||||||
|
let quickCountMatch = false;
|
||||||
|
|
||||||
const allRemote = uatGeometry
|
if (!options?.forceFullSync && localObjIds.size > 0) {
|
||||||
? await client.fetchAllLayerByGeometry(layer, uatGeometry, {
|
push({ phase: "Verificare count remote" });
|
||||||
total: remoteCount > 0 ? remoteCount : undefined,
|
let qCount = -1;
|
||||||
onProgress: (dl, tot) =>
|
try {
|
||||||
push({ phase: "Descărcare features", downloaded: dl, total: tot }),
|
qCount = uatGeometry
|
||||||
delayMs: 200,
|
? await client.countLayerByGeometry(layer, uatGeometry)
|
||||||
})
|
: await client.countLayer(layer, siruta);
|
||||||
: await client.fetchAllLayerByWhere(
|
} catch {
|
||||||
layer,
|
// Count check is best-effort — fall through to OBJECTID comparison
|
||||||
await buildWhere(client, layer, siruta),
|
qCount = -1;
|
||||||
{
|
}
|
||||||
|
|
||||||
|
if (qCount >= 0 && qCount === localObjIds.size) {
|
||||||
|
// Counts match — very likely no new/removed features
|
||||||
|
quickCountMatch = true;
|
||||||
|
remoteCount = qCount;
|
||||||
|
remoteObjIds = localObjIds; // Treat as identical
|
||||||
|
newObjIdArray = [];
|
||||||
|
removedObjIds = [];
|
||||||
|
useFullSync = false;
|
||||||
|
console.log(
|
||||||
|
`[sync] Quick-count match: ${qCount} remote = ${localObjIds.size} local for ${layerId}/${siruta} — skipping OBJECTID fetch`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quickCountMatch) {
|
||||||
|
// Full OBJECTID comparison (original path)
|
||||||
|
push({ phase: "Comparare ID-uri remote" });
|
||||||
|
let remoteObjIdArray: number[];
|
||||||
|
try {
|
||||||
|
remoteObjIdArray = uatGeometry
|
||||||
|
? await client.fetchObjectIdsByGeometry(layer, uatGeometry)
|
||||||
|
: await client.fetchObjectIds(layer, siruta);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(
|
||||||
|
`[syncLayer] fetchObjectIds failed for ${layerId}/${siruta}: ${msg} — falling back to full sync`,
|
||||||
|
);
|
||||||
|
remoteObjIdArray = [];
|
||||||
|
}
|
||||||
|
remoteObjIds = new Set(remoteObjIdArray);
|
||||||
|
remoteCount = remoteObjIds.size;
|
||||||
|
|
||||||
|
// Compute delta
|
||||||
|
newObjIdArray = [...remoteObjIds].filter((id) => !localObjIds.has(id));
|
||||||
|
removedObjIds = [...localObjIds].filter(
|
||||||
|
(id) => !remoteObjIds.has(id),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Decide: incremental (download only delta) or full sync
|
||||||
|
const deltaRatio =
|
||||||
|
remoteCount > 0 ? newObjIdArray.length / remoteCount : 1;
|
||||||
|
useFullSync =
|
||||||
|
options?.forceFullSync ||
|
||||||
|
localObjIds.size === 0 ||
|
||||||
|
deltaRatio > 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
let allRemote: EsriFeature[];
|
||||||
|
|
||||||
|
if (useFullSync) {
|
||||||
|
// Full sync: download all features (first sync or forced)
|
||||||
|
push({
|
||||||
|
phase: "Descărcare features (complet)",
|
||||||
|
downloaded: 0,
|
||||||
|
total: remoteCount,
|
||||||
|
});
|
||||||
|
allRemote = uatGeometry
|
||||||
|
? await client.fetchAllLayerByGeometry(layer, uatGeometry, {
|
||||||
total: remoteCount > 0 ? remoteCount : undefined,
|
total: remoteCount > 0 ? remoteCount : undefined,
|
||||||
onProgress: (dl, tot) =>
|
onProgress: (dl, tot) =>
|
||||||
push({
|
push({
|
||||||
phase: "Descărcare features",
|
phase: "Descărcare features (complet)",
|
||||||
downloaded: dl,
|
downloaded: dl,
|
||||||
total: tot,
|
total: tot,
|
||||||
}),
|
}),
|
||||||
delayMs: 200,
|
delayMs: 200,
|
||||||
},
|
})
|
||||||
);
|
: await client.fetchAllLayerByWhere(
|
||||||
|
layer,
|
||||||
|
await buildWhere(client, layer, siruta),
|
||||||
|
{
|
||||||
|
total: remoteCount > 0 ? remoteCount : undefined,
|
||||||
|
onProgress: (dl, tot) =>
|
||||||
|
push({
|
||||||
|
phase: "Descărcare features (complet)",
|
||||||
|
downloaded: dl,
|
||||||
|
total: tot,
|
||||||
|
}),
|
||||||
|
delayMs: 200,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if (newObjIdArray.length > 0) {
|
||||||
|
// Incremental sync: download only the new features
|
||||||
|
push({
|
||||||
|
phase: "Descărcare features noi",
|
||||||
|
downloaded: 0,
|
||||||
|
total: newObjIdArray.length,
|
||||||
|
});
|
||||||
|
const baseWhere = uatGeometry
|
||||||
|
? undefined
|
||||||
|
: await buildWhere(client, layer, siruta);
|
||||||
|
allRemote = await client.fetchFeaturesByObjectIds(
|
||||||
|
layer,
|
||||||
|
newObjIdArray,
|
||||||
|
{
|
||||||
|
baseWhere,
|
||||||
|
onProgress: (dl, tot) =>
|
||||||
|
push({
|
||||||
|
phase: "Descărcare features noi",
|
||||||
|
downloaded: dl,
|
||||||
|
total: tot,
|
||||||
|
}),
|
||||||
|
delayMs: 200,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Nothing new to download
|
||||||
|
allRemote = [];
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to GeoJSON for geometry storage
|
// Convert to GeoJSON for geometry storage
|
||||||
const geojson = esriToGeojson(allRemote);
|
const geojson = esriToGeojson(allRemote);
|
||||||
@@ -169,19 +265,11 @@ export async function syncLayer(
|
|||||||
if (objId != null) geojsonByObjId.set(objId, f);
|
if (objId != null) geojsonByObjId.set(objId, f);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which OBJECTIDs are new
|
// For incremental sync, newObjIds = the delta we downloaded
|
||||||
const remoteObjIds = new Set<number>();
|
// For full sync, newObjIds = all remote (if forced) or only truly new
|
||||||
for (const f of allRemote) {
|
|
||||||
const objId = f.attributes.OBJECTID as number | undefined;
|
|
||||||
if (objId != null) remoteObjIds.add(objId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newObjIds = options?.forceFullSync
|
const newObjIds = options?.forceFullSync
|
||||||
? remoteObjIds
|
? remoteObjIds
|
||||||
: new Set([...remoteObjIds].filter((id) => !localObjIds.has(id)));
|
: new Set(newObjIdArray);
|
||||||
const removedObjIds = [...localObjIds].filter(
|
|
||||||
(id) => !remoteObjIds.has(id),
|
|
||||||
);
|
|
||||||
|
|
||||||
push({
|
push({
|
||||||
phase: "Salvare în baza de date",
|
phase: "Salvare în baza de date",
|
||||||
@@ -237,8 +325,16 @@ export async function syncLayer(
|
|||||||
},
|
},
|
||||||
create: item,
|
create: item,
|
||||||
update: {
|
update: {
|
||||||
...item,
|
siruta: item.siruta,
|
||||||
|
inspireId: item.inspireId,
|
||||||
|
cadastralRef: item.cadastralRef,
|
||||||
|
areaValue: item.areaValue,
|
||||||
|
isActive: item.isActive,
|
||||||
|
attributes: item.attributes,
|
||||||
|
geometry: item.geometry,
|
||||||
|
syncRunId: item.syncRunId,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
// enrichment + enrichedAt preserved — not overwritten
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -261,16 +357,107 @@ export async function syncLayer(
|
|||||||
// PostGIS not available yet — not critical, skip silently
|
// PostGIS not available yet — not critical, skip silently
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark removed features
|
// Mark removed features (batch to avoid PostgreSQL 32767 bind variable limit)
|
||||||
if (removedObjIds.length > 0) {
|
// Safety: if remote returned very few features compared to local, the session
|
||||||
|
// likely expired mid-sync — skip deletion to avoid wiping valid data.
|
||||||
|
const removedRatio = localObjIds.size > 0 ? removedObjIds.length / localObjIds.size : 0;
|
||||||
|
if (removedObjIds.length > 0 && removedRatio > 0.8 && localObjIds.size > 100) {
|
||||||
|
console.warn(
|
||||||
|
`[sync] SKIP delete: ${removedObjIds.length}/${localObjIds.size} features (${Math.round(removedRatio * 100)}%) ` +
|
||||||
|
`would be removed for ${layerId}/${siruta} — likely stale remote data. Aborting deletion.`,
|
||||||
|
);
|
||||||
|
} else if (removedObjIds.length > 0) {
|
||||||
push({ phase: "Marcare șterse" });
|
push({ phase: "Marcare șterse" });
|
||||||
await prisma.gisFeature.deleteMany({
|
const BATCH = 30_000;
|
||||||
where: {
|
for (let i = 0; i < removedObjIds.length; i += BATCH) {
|
||||||
layerId,
|
await prisma.gisFeature.deleteMany({
|
||||||
siruta,
|
where: {
|
||||||
objectId: { in: removedObjIds },
|
layerId,
|
||||||
},
|
siruta,
|
||||||
});
|
objectId: { in: removedObjIds.slice(i, i + BATCH) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── VALID_FROM delta: detect attribute changes on existing features ──
|
||||||
|
// Features whose VALID_FROM changed since our stored copy need re-enrichment.
|
||||||
|
// This catches ownership/CF changes that don't create new OBJECTIDs.
|
||||||
|
let validFromUpdated = 0;
|
||||||
|
if (!useFullSync && newObjIdArray.length === 0 && removedObjIds.length === 0) {
|
||||||
|
// Nothing new/removed — check if existing features changed via VALID_FROM
|
||||||
|
// Fetch the max VALID_FROM we have stored locally
|
||||||
|
const maxValidFrom = await prisma.$queryRawUnsafe<
|
||||||
|
Array<{ max_vf: string | null }>
|
||||||
|
>(
|
||||||
|
`SELECT MAX((attributes->>'VALID_FROM')::bigint)::text as max_vf ` +
|
||||||
|
`FROM "GisFeature" WHERE "layerId" = $1 AND siruta = $2 AND "objectId" > 0`,
|
||||||
|
layerId,
|
||||||
|
siruta,
|
||||||
|
);
|
||||||
|
const localMaxVf = maxValidFrom[0]?.max_vf;
|
||||||
|
if (localMaxVf) {
|
||||||
|
// Ask eTerra: any features with VALID_FROM > our max?
|
||||||
|
const baseWhere = await buildWhere(client, layer, siruta);
|
||||||
|
const vfWhere = `${baseWhere} AND VALID_FROM>${localMaxVf}`;
|
||||||
|
try {
|
||||||
|
const changed = uatGeometry
|
||||||
|
? await client.fetchAllLayerByWhere(
|
||||||
|
layer,
|
||||||
|
`VALID_FROM>${localMaxVf}`,
|
||||||
|
{
|
||||||
|
outFields: "*",
|
||||||
|
returnGeometry: true,
|
||||||
|
delayMs: 200,
|
||||||
|
geometry: uatGeometry,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: await client.fetchAllLayerByWhere(layer, vfWhere, {
|
||||||
|
outFields: "*",
|
||||||
|
returnGeometry: true,
|
||||||
|
delayMs: 200,
|
||||||
|
});
|
||||||
|
if (changed.length > 0) {
|
||||||
|
push({ phase: `Actualizare ${changed.length} parcele modificate` });
|
||||||
|
const changedGeojson = esriToGeojson(changed);
|
||||||
|
const changedGeoMap = new Map<
|
||||||
|
number,
|
||||||
|
(typeof changedGeojson.features)[0]
|
||||||
|
>();
|
||||||
|
for (const f of changedGeojson.features) {
|
||||||
|
const objId = f.properties.OBJECTID as number | undefined;
|
||||||
|
if (objId != null) changedGeoMap.set(objId, f);
|
||||||
|
}
|
||||||
|
for (const feature of changed) {
|
||||||
|
const objId = feature.attributes.OBJECTID as number;
|
||||||
|
if (!objId) continue;
|
||||||
|
const geoFeature = changedGeoMap.get(objId);
|
||||||
|
const geom = geoFeature?.geometry;
|
||||||
|
await prisma.gisFeature.updateMany({
|
||||||
|
where: { layerId, objectId: objId },
|
||||||
|
data: {
|
||||||
|
attributes: feature.attributes as Prisma.InputJsonValue,
|
||||||
|
geometry: geom
|
||||||
|
? (geom as Prisma.InputJsonValue)
|
||||||
|
: undefined,
|
||||||
|
enrichedAt: null, // Force re-enrichment
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
validFromUpdated = changed.length;
|
||||||
|
console.log(
|
||||||
|
`[sync] VALID_FROM delta: ${changed.length} features updated for ${layerId}/${siruta}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Non-critical — VALID_FROM check is best-effort
|
||||||
|
console.warn(
|
||||||
|
`[sync] VALID_FROM check failed for ${layerId}/${siruta}:`,
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update sync run
|
// Update sync run
|
||||||
@@ -304,6 +491,7 @@ export async function syncLayer(
|
|||||||
totalLocal: localCount,
|
totalLocal: localCount,
|
||||||
newFeatures: newObjIds.size,
|
newFeatures: newObjIds.size,
|
||||||
removedFeatures: removedObjIds.length,
|
removedFeatures: removedObjIds.length,
|
||||||
|
validFromUpdated,
|
||||||
status: "done",
|
status: "done",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,695 @@
|
|||||||
|
/**
|
||||||
|
* Weekend Deep Sync — full Magic processing for large cities.
|
||||||
|
*
|
||||||
|
* Runs Fri/Sat/Sun nights 23:00–04:00. Processes cities in round-robin
|
||||||
|
* (one step per city, then rotate) so progress is spread across cities.
|
||||||
|
* State is persisted in KeyValueStore — survives restarts and continues
|
||||||
|
* across multiple nights/weekends.
|
||||||
|
*
|
||||||
|
* Steps per city (each is resumable):
|
||||||
|
* 1. sync_terenuri — syncLayer TERENURI_ACTIVE
|
||||||
|
* 2. sync_cladiri — syncLayer CLADIRI_ACTIVE
|
||||||
|
* 3. import_nogeom — import parcels without geometry
|
||||||
|
* 4. enrich — enrichFeatures (slowest, naturally resumable)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient, Prisma } from "@prisma/client";
|
||||||
|
import { syncLayer } from "./sync-service";
|
||||||
|
import { EterraClient } from "./eterra-client";
|
||||||
|
import { isEterraAvailable } from "./eterra-health";
|
||||||
|
import { sendEmail } from "@/core/notifications/email-service";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Live activity tracking (globalThis — same process) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const g = globalThis as {
|
||||||
|
__weekendSyncActivity?: {
|
||||||
|
city: string;
|
||||||
|
step: string;
|
||||||
|
startedAt: string;
|
||||||
|
} | null;
|
||||||
|
__parcelSyncRunning?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getWeekendSyncActivity() {
|
||||||
|
return g.__weekendSyncActivity ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* City queue configuration */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type CityConfig = {
|
||||||
|
siruta: string;
|
||||||
|
name: string;
|
||||||
|
county: string;
|
||||||
|
priority: number; // lower = higher priority
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Initial queue — priority 1 = first processed */
|
||||||
|
const DEFAULT_CITIES: CityConfig[] = [
|
||||||
|
{ siruta: "54975", name: "Cluj-Napoca", county: "Cluj", priority: 1 },
|
||||||
|
{ siruta: "32394", name: "Bistri\u021Ba", county: "Bistri\u021Ba-N\u0103s\u0103ud", priority: 1 },
|
||||||
|
{ siruta: "114319", name: "T\u00E2rgu Mure\u0219", county: "Mure\u0219", priority: 2 },
|
||||||
|
{ siruta: "139704", name: "Zal\u0103u", county: "S\u0103laj", priority: 2 },
|
||||||
|
{ siruta: "26564", name: "Oradea", county: "Bihor", priority: 2 },
|
||||||
|
{ siruta: "9262", name: "Arad", county: "Arad", priority: 2 },
|
||||||
|
{ siruta: "155243", name: "Timi\u0219oara", county: "Timi\u0219", priority: 2 },
|
||||||
|
{ siruta: "143450", name: "Sibiu", county: "Sibiu", priority: 2 },
|
||||||
|
{ siruta: "40198", name: "Bra\u0219ov", county: "Bra\u0219ov", priority: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Step definitions */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
"sync_terenuri",
|
||||||
|
"sync_cladiri",
|
||||||
|
"import_nogeom",
|
||||||
|
"enrich",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type StepName = (typeof STEPS)[number];
|
||||||
|
type StepStatus = "pending" | "done" | "error";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Persisted state */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type CityState = {
|
||||||
|
siruta: string;
|
||||||
|
name: string;
|
||||||
|
county: string;
|
||||||
|
priority: number;
|
||||||
|
steps: Record<StepName, StepStatus>;
|
||||||
|
lastActivity?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WeekendSyncState = {
|
||||||
|
cities: CityState[];
|
||||||
|
lastSessionDate?: string;
|
||||||
|
totalSessions: number;
|
||||||
|
completedCycles: number; // how many full cycles (all cities done)
|
||||||
|
};
|
||||||
|
|
||||||
|
const KV_NAMESPACE = "parcel-sync-weekend";
|
||||||
|
const KV_KEY = "queue-state";
|
||||||
|
|
||||||
|
async function loadState(): Promise<WeekendSyncState> {
|
||||||
|
const row = await prisma.keyValueStore.findUnique({
|
||||||
|
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
|
||||||
|
});
|
||||||
|
if (row?.value && typeof row.value === "object") {
|
||||||
|
return row.value as unknown as WeekendSyncState;
|
||||||
|
}
|
||||||
|
// Initialize with default cities
|
||||||
|
return {
|
||||||
|
cities: DEFAULT_CITIES.map((c) => ({
|
||||||
|
...c,
|
||||||
|
steps: {
|
||||||
|
sync_terenuri: "pending",
|
||||||
|
sync_cladiri: "pending",
|
||||||
|
import_nogeom: "pending",
|
||||||
|
enrich: "pending",
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
totalSessions: 0,
|
||||||
|
completedCycles: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveState(state: WeekendSyncState): Promise<void> {
|
||||||
|
// Retry once on failure — state persistence is critical for resume
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
try {
|
||||||
|
await prisma.keyValueStore.upsert({
|
||||||
|
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
|
||||||
|
update: { value: state as unknown as Prisma.InputJsonValue },
|
||||||
|
create: {
|
||||||
|
namespace: KV_NAMESPACE,
|
||||||
|
key: KV_KEY,
|
||||||
|
value: state as unknown as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt === 0) {
|
||||||
|
console.warn("[weekend-sync] saveState retry...");
|
||||||
|
await sleep(2000);
|
||||||
|
} else {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[weekend-sync] saveState failed: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Time window */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const WEEKEND_START_HOUR = 23;
|
||||||
|
const WEEKEND_END_HOUR = 4;
|
||||||
|
const PAUSE_BETWEEN_STEPS_MS = 60_000 + Math.random() * 60_000; // 60-120s
|
||||||
|
|
||||||
|
/** Check if current time is within the weekend sync window */
|
||||||
|
export function isWeekendWindow(): boolean {
|
||||||
|
const now = new Date();
|
||||||
|
const day = now.getDay(); // 0=Sun, 5=Fri, 6=Sat
|
||||||
|
const hour = now.getHours();
|
||||||
|
|
||||||
|
// Fri 23:00+ or Sat 23:00+ or Sun 23:00+
|
||||||
|
if ((day === 5 || day === 6 || day === 0) && hour >= WEEKEND_START_HOUR) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Sat 00-04 (continuation of Friday night) or Sun 00-04 or Mon 00-04
|
||||||
|
if ((day === 6 || day === 0 || day === 1) && hour < WEEKEND_END_HOUR) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if still within the window (called during processing) */
|
||||||
|
function stillInWindow(force?: boolean): boolean {
|
||||||
|
if (force) return true; // Manual trigger — no time restriction
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
// We can be in 23,0,1,2,3 — stop at 4
|
||||||
|
if (hour >= WEEKEND_END_HOUR && hour < WEEKEND_START_HOUR) return false;
|
||||||
|
return isWeekendWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Step executors */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function executeStep(
|
||||||
|
city: CityState,
|
||||||
|
step: StepName,
|
||||||
|
client: EterraClient,
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
switch (step) {
|
||||||
|
case "sync_terenuri": {
|
||||||
|
const res = await syncLayer(
|
||||||
|
process.env.ETERRA_USERNAME!,
|
||||||
|
process.env.ETERRA_PASSWORD!,
|
||||||
|
city.siruta,
|
||||||
|
"TERENURI_ACTIVE",
|
||||||
|
{ uatName: city.name, forceFullSync: true },
|
||||||
|
);
|
||||||
|
// Also sync admin layers (lightweight, non-fatal)
|
||||||
|
for (const adminLayer of ["LIMITE_INTRAV_DYNAMIC", "LIMITE_UAT"]) {
|
||||||
|
try {
|
||||||
|
await syncLayer(
|
||||||
|
process.env.ETERRA_USERNAME!,
|
||||||
|
process.env.ETERRA_PASSWORD!,
|
||||||
|
city.siruta,
|
||||||
|
adminLayer,
|
||||||
|
{ uatName: city.name },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// admin layers are best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dur = ((Date.now() - start) / 1000).toFixed(1);
|
||||||
|
return {
|
||||||
|
success: res.status === "done",
|
||||||
|
message: `Terenuri: ${res.totalLocal} local (+${res.newFeatures}/-${res.removedFeatures}) + intravilan [${dur}s]`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "sync_cladiri": {
|
||||||
|
const res = await syncLayer(
|
||||||
|
process.env.ETERRA_USERNAME!,
|
||||||
|
process.env.ETERRA_PASSWORD!,
|
||||||
|
city.siruta,
|
||||||
|
"CLADIRI_ACTIVE",
|
||||||
|
{ uatName: city.name, forceFullSync: true },
|
||||||
|
);
|
||||||
|
const dur = ((Date.now() - start) / 1000).toFixed(1);
|
||||||
|
return {
|
||||||
|
success: res.status === "done",
|
||||||
|
message: `Cl\u0103diri: ${res.totalLocal} local (+${res.newFeatures}/-${res.removedFeatures}) [${dur}s]`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "import_nogeom": {
|
||||||
|
const { syncNoGeometryParcels } = await import("./no-geom-sync");
|
||||||
|
const res = await syncNoGeometryParcels(client, city.siruta);
|
||||||
|
const dur = ((Date.now() - start) / 1000).toFixed(1);
|
||||||
|
return {
|
||||||
|
success: res.status !== "error",
|
||||||
|
message: `No-geom: ${res.imported} importate, ${res.skipped} skip [${dur}s]`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "enrich": {
|
||||||
|
const { enrichFeatures } = await import("./enrich-service");
|
||||||
|
const res = await enrichFeatures(client, city.siruta);
|
||||||
|
const dur = ((Date.now() - start) / 1000).toFixed(1);
|
||||||
|
return {
|
||||||
|
success: res.status === "done",
|
||||||
|
message: res.status === "done"
|
||||||
|
? `Enrichment: ${res.enrichedCount}/${res.totalFeatures ?? "?"} (${dur}s)`
|
||||||
|
: `Enrichment eroare: ${res.error ?? "necunoscuta"} (${dur}s)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Main runner */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type SessionLog = {
|
||||||
|
city: string;
|
||||||
|
step: string;
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runWeekendDeepSync(options?: {
|
||||||
|
force?: boolean;
|
||||||
|
onlySteps?: StepName[];
|
||||||
|
}): Promise<void> {
|
||||||
|
const force = options?.force ?? false;
|
||||||
|
const activeSteps = options?.onlySteps ?? STEPS;
|
||||||
|
const username = process.env.ETERRA_USERNAME;
|
||||||
|
const password = process.env.ETERRA_PASSWORD;
|
||||||
|
if (!username || !password) return;
|
||||||
|
|
||||||
|
if (!isEterraAvailable()) {
|
||||||
|
console.log("[weekend-sync] eTerra indisponibil, skip.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await loadState();
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Prevent running twice in the same session (force bypasses)
|
||||||
|
if (!force && state.lastSessionDate === today) return;
|
||||||
|
|
||||||
|
state.totalSessions++;
|
||||||
|
state.lastSessionDate = today;
|
||||||
|
|
||||||
|
// Ensure new default cities are added if config expanded
|
||||||
|
for (const dc of DEFAULT_CITIES) {
|
||||||
|
if (!state.cities.some((c) => c.siruta === dc.siruta)) {
|
||||||
|
state.cities.push({
|
||||||
|
...dc,
|
||||||
|
steps: {
|
||||||
|
sync_terenuri: "pending",
|
||||||
|
sync_cladiri: "pending",
|
||||||
|
import_nogeom: "pending",
|
||||||
|
enrich: "pending",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionStart = Date.now();
|
||||||
|
const log: SessionLog[] = [];
|
||||||
|
let stepsCompleted = 0;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[weekend-sync] Sesiune #${state.totalSessions} pornita. ${state.cities.length} orase in coada.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort cities: priority first, then shuffle within same priority
|
||||||
|
const sorted = [...state.cities].sort((a, b) => {
|
||||||
|
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||||
|
return Math.random() - 0.5; // random within same priority
|
||||||
|
});
|
||||||
|
|
||||||
|
// Round-robin: iterate through steps, for each step iterate through cities
|
||||||
|
for (const stepName of activeSteps) {
|
||||||
|
// Find cities that still need this step
|
||||||
|
const needsStep = sorted.filter((c) => c.steps[stepName] === "pending");
|
||||||
|
if (needsStep.length === 0) continue;
|
||||||
|
|
||||||
|
for (const city of needsStep) {
|
||||||
|
// Check time window
|
||||||
|
if (!stillInWindow(force)) {
|
||||||
|
console.log("[weekend-sync] Fereastra s-a inchis, opresc.");
|
||||||
|
g.__weekendSyncActivity = null;
|
||||||
|
await saveState(state);
|
||||||
|
await sendStatusEmail(state, log, sessionStart);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check eTerra health
|
||||||
|
if (!isEterraAvailable()) {
|
||||||
|
console.log("[weekend-sync] eTerra indisponibil, opresc.");
|
||||||
|
g.__weekendSyncActivity = null;
|
||||||
|
await saveState(state);
|
||||||
|
await sendStatusEmail(state, log, sessionStart);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause between steps
|
||||||
|
if (stepsCompleted > 0) {
|
||||||
|
const pause = 60_000 + Math.random() * 60_000;
|
||||||
|
console.log(
|
||||||
|
`[weekend-sync] Pauza ${Math.round(pause / 1000)}s inainte de ${city.name} / ${stepName}`,
|
||||||
|
);
|
||||||
|
await sleep(pause);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute step — fresh client per step (sessions expire after ~10 min)
|
||||||
|
console.log(`[weekend-sync] ${city.name}: ${stepName}...`);
|
||||||
|
g.__weekendSyncActivity = {
|
||||||
|
city: city.name,
|
||||||
|
step: stepName,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const client = await EterraClient.create(username, password);
|
||||||
|
const result = await executeStep(city, stepName, client);
|
||||||
|
city.steps[stepName] = result.success ? "done" : "error";
|
||||||
|
if (!result.success) {
|
||||||
|
city.errorMessage = result.message;
|
||||||
|
await sendStepErrorEmail(city, stepName, result.message);
|
||||||
|
}
|
||||||
|
city.lastActivity = new Date().toISOString();
|
||||||
|
log.push({
|
||||||
|
city: city.name,
|
||||||
|
step: stepName,
|
||||||
|
success: result.success,
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[weekend-sync] ${city.name}: ${stepName} → ${result.success ? "OK" : "EROARE"} — ${result.message}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
city.steps[stepName] = "error";
|
||||||
|
city.errorMessage = msg;
|
||||||
|
city.lastActivity = new Date().toISOString();
|
||||||
|
log.push({
|
||||||
|
city: city.name,
|
||||||
|
step: stepName,
|
||||||
|
success: false,
|
||||||
|
message: msg,
|
||||||
|
});
|
||||||
|
console.error(
|
||||||
|
`[weekend-sync] ${city.name}: ${stepName} EROARE: ${msg}`,
|
||||||
|
);
|
||||||
|
await sendStepErrorEmail(city, stepName, msg);
|
||||||
|
}
|
||||||
|
g.__weekendSyncActivity = null;
|
||||||
|
|
||||||
|
stepsCompleted++;
|
||||||
|
// Save state after each step (crash safety)
|
||||||
|
await saveState(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all cities completed all steps → new cycle
|
||||||
|
const allDone = state.cities.every((c) =>
|
||||||
|
STEPS.every((s) => c.steps[s] === "done"),
|
||||||
|
);
|
||||||
|
if (allDone) {
|
||||||
|
state.completedCycles++;
|
||||||
|
// Reset for next cycle
|
||||||
|
for (const city of state.cities) {
|
||||||
|
for (const step of STEPS) {
|
||||||
|
city.steps[step] = "pending";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`[weekend-sync] Ciclu complet #${state.completedCycles}! Reset pentru urmatorul ciclu.`,
|
||||||
|
);
|
||||||
|
// Notify N8N to rebuild PMTiles (overview tiles for geoportal)
|
||||||
|
await fireSyncWebhook(state.completedCycles);
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveState(state);
|
||||||
|
await sendStatusEmail(state, log, sessionStart);
|
||||||
|
console.log(`[weekend-sync] Sesiune finalizata. ${stepsCompleted} pasi executati.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Immediate error email */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function sendStepErrorEmail(
|
||||||
|
city: CityState,
|
||||||
|
step: StepName,
|
||||||
|
errorMsg: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const emailTo = process.env.WEEKEND_SYNC_EMAIL;
|
||||||
|
if (!emailTo) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const timeStr = now.toLocaleString("ro-RO", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
const stepLabel: Record<StepName, string> = {
|
||||||
|
sync_terenuri: "Sync Terenuri",
|
||||||
|
sync_cladiri: "Sync Cladiri",
|
||||||
|
import_nogeom: "Import No-Geom",
|
||||||
|
enrich: "Enrichment",
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto">
|
||||||
|
<h2 style="color:#ef4444;margin-bottom:4px">Weekend Sync — Eroare</h2>
|
||||||
|
<p style="color:#6b7280;margin-top:0">${timeStr}</p>
|
||||||
|
<table style="border-collapse:collapse;width:100%;border:1px solid #fecaca;border-radius:6px;background:#fef2f2">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 12px;font-weight:600;color:#374151">Oras</td>
|
||||||
|
<td style="padding:8px 12px">${city.name} (${city.county})</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 12px;font-weight:600;color:#374151">Pas</td>
|
||||||
|
<td style="padding:8px 12px">${stepLabel[step]}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 12px;font-weight:600;color:#374151">Eroare</td>
|
||||||
|
<td style="padding:8px 12px;color:#dc2626;word-break:break-word">${errorMsg}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="color:#9ca3af;font-size:11px;margin-top:16px">
|
||||||
|
Generat automat de ArchiTools Weekend Sync
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
to: emailTo,
|
||||||
|
subject: `[ArchiTools] WDS Eroare: ${city.name} — ${stepLabel[step]}`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
console.log(`[weekend-sync] Email eroare trimis: ${city.name}/${step}`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(`[weekend-sync] Nu s-a putut trimite email eroare: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Email status report */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function sendStatusEmail(
|
||||||
|
state: WeekendSyncState,
|
||||||
|
log: SessionLog[],
|
||||||
|
sessionStart: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const emailTo = process.env.WEEKEND_SYNC_EMAIL;
|
||||||
|
if (!emailTo) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const duration = Date.now() - sessionStart;
|
||||||
|
const durMin = Math.round(duration / 60_000);
|
||||||
|
const durStr =
|
||||||
|
durMin >= 60
|
||||||
|
? `${Math.floor(durMin / 60)}h ${durMin % 60}m`
|
||||||
|
: `${durMin}m`;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const dayNames = [
|
||||||
|
"Duminic\u0103",
|
||||||
|
"Luni",
|
||||||
|
"Mar\u021Bi",
|
||||||
|
"Miercuri",
|
||||||
|
"Joi",
|
||||||
|
"Vineri",
|
||||||
|
"S\u00E2mb\u0103t\u0103",
|
||||||
|
];
|
||||||
|
const dayName = dayNames[now.getDay()] ?? "";
|
||||||
|
const dateStr = now.toLocaleDateString("ro-RO", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build city progress table
|
||||||
|
const cityRows = state.cities
|
||||||
|
.sort((a, b) => a.priority - b.priority)
|
||||||
|
.map((c) => {
|
||||||
|
const doneCount = STEPS.filter((s) => c.steps[s] === "done").length;
|
||||||
|
const errorCount = STEPS.filter((s) => c.steps[s] === "error").length;
|
||||||
|
const icon =
|
||||||
|
doneCount === STEPS.length
|
||||||
|
? "\u2713"
|
||||||
|
: doneCount > 0
|
||||||
|
? "\u25D0"
|
||||||
|
: "\u25CB";
|
||||||
|
const color =
|
||||||
|
doneCount === STEPS.length
|
||||||
|
? "#22c55e"
|
||||||
|
: errorCount > 0
|
||||||
|
? "#ef4444"
|
||||||
|
: doneCount > 0
|
||||||
|
? "#f59e0b"
|
||||||
|
: "#9ca3af";
|
||||||
|
const stepDetail = STEPS.map(
|
||||||
|
(s) =>
|
||||||
|
`<span style="color:${c.steps[s] === "done" ? "#22c55e" : c.steps[s] === "error" ? "#ef4444" : "#9ca3af"}">${s.replace("_", " ")}</span>`,
|
||||||
|
).join(" \u2192 ");
|
||||||
|
return `<tr>
|
||||||
|
<td style="padding:4px 8px;color:${color};font-size:16px">${icon}</td>
|
||||||
|
<td style="padding:4px 8px;font-weight:600">${c.name}</td>
|
||||||
|
<td style="padding:4px 8px;color:#6b7280;font-size:12px">${c.county}</td>
|
||||||
|
<td style="padding:4px 8px">${doneCount}/${STEPS.length}</td>
|
||||||
|
<td style="padding:4px 8px;font-size:11px">${stepDetail}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
// Build session log
|
||||||
|
const logRows =
|
||||||
|
log.length > 0
|
||||||
|
? log
|
||||||
|
.map(
|
||||||
|
(l) =>
|
||||||
|
`<tr>
|
||||||
|
<td style="padding:2px 6px;font-size:12px">${l.success ? "\u2713" : "\u2717"}</td>
|
||||||
|
<td style="padding:2px 6px;font-size:12px">${l.city}</td>
|
||||||
|
<td style="padding:2px 6px;font-size:12px;color:#6b7280">${l.step}</td>
|
||||||
|
<td style="padding:2px 6px;font-size:11px;color:#6b7280">${l.message}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("\n")
|
||||||
|
: '<tr><td colspan="4" style="padding:8px;color:#9ca3af;font-size:12px">Niciun pas executat in aceasta sesiune</td></tr>';
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div style="font-family:system-ui,sans-serif;max-width:700px;margin:0 auto">
|
||||||
|
<h2 style="color:#1f2937;margin-bottom:4px">Weekend Sync — ${dayName} ${dateStr}</h2>
|
||||||
|
<p style="color:#6b7280;margin-top:0">Durata sesiune: ${durStr} | Sesiunea #${state.totalSessions} | Cicluri complete: ${state.completedCycles}</p>
|
||||||
|
|
||||||
|
<h3 style="color:#374151;margin-bottom:8px">Progres per ora\u0219</h3>
|
||||||
|
<table style="border-collapse:collapse;width:100%;border:1px solid #e5e7eb;border-radius:6px">
|
||||||
|
<thead><tr style="background:#f9fafb">
|
||||||
|
<th style="padding:6px 8px;text-align:left;font-size:12px"></th>
|
||||||
|
<th style="padding:6px 8px;text-align:left;font-size:12px">Ora\u0219</th>
|
||||||
|
<th style="padding:6px 8px;text-align:left;font-size:12px">Jude\u021B</th>
|
||||||
|
<th style="padding:6px 8px;text-align:left;font-size:12px">Pa\u0219i</th>
|
||||||
|
<th style="padding:6px 8px;text-align:left;font-size:12px">Detaliu</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${cityRows}</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 style="color:#374151;margin-top:16px;margin-bottom:8px">Activitate sesiune curent\u0103</h3>
|
||||||
|
<table style="border-collapse:collapse;width:100%;border:1px solid #e5e7eb">
|
||||||
|
<tbody>${logRows}</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="color:#9ca3af;font-size:11px;margin-top:16px">
|
||||||
|
Generat automat de ArchiTools Weekend Sync
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
to: emailTo,
|
||||||
|
subject: `[ArchiTools] Weekend Sync — ${dayName} ${dateStr}`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
console.log(`[weekend-sync] Email status trimis la ${emailTo}`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(`[weekend-sync] Nu s-a putut trimite email: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Manual force trigger */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a sync run outside the weekend window.
|
||||||
|
* Resets error steps, clears lastSessionDate, and starts immediately.
|
||||||
|
* Uses an extended night window (22:00–05:00) for the stillInWindow check.
|
||||||
|
*/
|
||||||
|
export async function triggerForceSync(options?: {
|
||||||
|
onlySteps?: StepName[];
|
||||||
|
}): Promise<{ started: boolean; reason?: string }> {
|
||||||
|
if (g.__parcelSyncRunning) {
|
||||||
|
return { started: false, reason: "O sincronizare ruleaza deja" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = process.env.ETERRA_USERNAME;
|
||||||
|
const password = process.env.ETERRA_PASSWORD;
|
||||||
|
if (!username || !password) {
|
||||||
|
return { started: false, reason: "ETERRA credentials lipsesc" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEterraAvailable()) {
|
||||||
|
return { started: false, reason: "eTerra indisponibil" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset error steps + lastSessionDate in DB so the run proceeds
|
||||||
|
const state = await loadState();
|
||||||
|
for (const city of state.cities) {
|
||||||
|
for (const step of STEPS) {
|
||||||
|
if (city.steps[step] === "error") {
|
||||||
|
city.steps[step] = "pending";
|
||||||
|
city.errorMessage = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.lastSessionDate = undefined;
|
||||||
|
await saveState(state);
|
||||||
|
|
||||||
|
// Start in background — don't block the API response
|
||||||
|
g.__parcelSyncRunning = true;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const stepNames = options?.onlySteps;
|
||||||
|
console.log(
|
||||||
|
`[weekend-sync] Force sync declansat manual.${stepNames ? ` Steps: ${stepNames.join(", ")}` : ""}`,
|
||||||
|
);
|
||||||
|
await runWeekendDeepSync({ force: true, onlySteps: stepNames });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[weekend-sync] Force sync eroare: ${msg}`);
|
||||||
|
} finally {
|
||||||
|
g.__parcelSyncRunning = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return { started: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* PMTiles Webhook — trigger rebuild after sync cycle */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function fireSyncWebhook(cycle: number): Promise<void> {
|
||||||
|
const { firePmtilesRebuild } = await import("./pmtiles-webhook");
|
||||||
|
await firePmtilesRebuild("weekend-sync-cycle-complete", { cycle });
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
FileText,
|
FileText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useAuth } from "@/core/auth";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
@@ -66,6 +67,7 @@ export function CloseGuardDialog({
|
|||||||
activeDeadlines,
|
activeDeadlines,
|
||||||
onConfirmClose,
|
onConfirmClose,
|
||||||
}: CloseGuardDialogProps) {
|
}: CloseGuardDialogProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [selectedEntryId, setSelectedEntryId] = useState("");
|
const [selectedEntryId, setSelectedEntryId] = useState("");
|
||||||
const [resolution, setResolution] = useState<ClosureResolution>("finalizat");
|
const [resolution, setResolution] = useState<ClosureResolution>("finalizat");
|
||||||
@@ -130,7 +132,7 @@ export function CloseGuardDialog({
|
|||||||
onConfirmClose({
|
onConfirmClose({
|
||||||
resolution,
|
resolution,
|
||||||
reason: reason.trim(),
|
reason: reason.trim(),
|
||||||
closedBy: "Utilizator", // TODO: replace with SSO identity
|
closedBy: user?.name ?? "Utilizator",
|
||||||
closedAt: new Date().toISOString(),
|
closedAt: new Date().toISOString(),
|
||||||
linkedEntryId: selectedEntryId || undefined,
|
linkedEntryId: selectedEntryId || undefined,
|
||||||
linkedEntryNumber: selectedEntry?.number,
|
linkedEntryNumber: selectedEntry?.number,
|
||||||
|
|||||||
@@ -166,11 +166,21 @@ export function RegistryEntryDetail({
|
|||||||
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
|
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
|
||||||
const [copiedPath, setCopiedPath] = useState<string | null>(null);
|
const [copiedPath, setCopiedPath] = useState<string | null>(null);
|
||||||
const [monitorConfigOpen, setMonitorConfigOpen] = useState(false);
|
const [monitorConfigOpen, setMonitorConfigOpen] = useState(false);
|
||||||
|
const [monitorEditMode, setMonitorEditMode] = useState(false);
|
||||||
|
|
||||||
// Auto-detect if recipient matches a known authority
|
// Authority for existing tracking or auto-detected from recipient
|
||||||
|
const trackingAuthority = useMemo(() => {
|
||||||
|
if (!entry) return undefined;
|
||||||
|
if (entry.externalStatusTracking) {
|
||||||
|
return getAuthority(entry.externalStatusTracking.authorityId) ?? undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [entry]);
|
||||||
|
|
||||||
|
// Auto-detect if recipient matches a known authority (only when no tracking)
|
||||||
const matchedAuthority = useMemo(() => {
|
const matchedAuthority = useMemo(() => {
|
||||||
if (!entry) return undefined;
|
if (!entry) return undefined;
|
||||||
if (entry.externalStatusTracking?.active) return undefined;
|
if (entry.externalStatusTracking) return undefined;
|
||||||
if (!entry.recipientRegNumber) return undefined;
|
if (!entry.recipientRegNumber) return undefined;
|
||||||
return findAuthorityForContact(entry.recipient);
|
return findAuthorityForContact(entry.recipient);
|
||||||
}, [entry]);
|
}, [entry]);
|
||||||
@@ -757,14 +767,47 @@ export function RegistryEntryDetail({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── External status monitoring ── */}
|
{/* ── External status monitoring ── */}
|
||||||
{entry.externalStatusTracking?.active && (
|
{entry.externalStatusTracking && (
|
||||||
<ExternalStatusSection
|
<>
|
||||||
entry={entry}
|
<ExternalStatusSection
|
||||||
/>
|
entry={entry}
|
||||||
|
onEdit={() => {
|
||||||
|
setMonitorEditMode(true);
|
||||||
|
setMonitorConfigOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{trackingAuthority && (
|
||||||
|
<StatusMonitorConfig
|
||||||
|
open={monitorConfigOpen && monitorEditMode}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setMonitorConfigOpen(open);
|
||||||
|
if (!open) setMonitorEditMode(false);
|
||||||
|
}}
|
||||||
|
entry={entry}
|
||||||
|
authority={trackingAuthority}
|
||||||
|
editMode
|
||||||
|
onActivate={async (tracking) => {
|
||||||
|
try {
|
||||||
|
await fetch("/api/registratura", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: entry.id,
|
||||||
|
updates: { externalStatusTracking: tracking },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
window.location.reload();
|
||||||
|
} catch {
|
||||||
|
// Best effort
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Auto-detect: suggest monitoring activation ── */}
|
{/* ── Auto-detect: suggest monitoring activation ── */}
|
||||||
{matchedAuthority && !entry.externalStatusTracking?.active && (
|
{matchedAuthority && !entry.externalStatusTracking && (
|
||||||
<div className="rounded-lg border border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/50 dark:bg-blue-950/20 p-3">
|
<div className="rounded-lg border border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/50 dark:bg-blue-950/20 p-3">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Radio className="h-4 w-4 text-blue-500 mt-0.5 shrink-0" />
|
<Radio className="h-4 w-4 text-blue-500 mt-0.5 shrink-0" />
|
||||||
@@ -780,7 +823,10 @@ export function RegistryEntryDetail({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="mt-2 h-6 text-xs"
|
className="mt-2 h-6 text-xs"
|
||||||
onClick={() => setMonitorConfigOpen(true)}
|
onClick={() => {
|
||||||
|
setMonitorEditMode(false);
|
||||||
|
setMonitorConfigOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Configureaza monitorizarea
|
Configureaza monitorizarea
|
||||||
</Button>
|
</Button>
|
||||||
@@ -788,12 +834,11 @@ export function RegistryEntryDetail({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StatusMonitorConfig
|
<StatusMonitorConfig
|
||||||
open={monitorConfigOpen}
|
open={monitorConfigOpen && !monitorEditMode}
|
||||||
onOpenChange={setMonitorConfigOpen}
|
onOpenChange={setMonitorConfigOpen}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
authority={matchedAuthority}
|
authority={matchedAuthority}
|
||||||
onActivate={async (tracking) => {
|
onActivate={async (tracking) => {
|
||||||
// Save tracking to entry via API
|
|
||||||
try {
|
try {
|
||||||
await fetch("/api/registratura", {
|
await fetch("/api/registratura", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -892,26 +937,55 @@ const STATUS_COLORS: Record<ExternalDocStatus, string> = {
|
|||||||
necunoscut: "bg-muted text-muted-foreground",
|
necunoscut: "bg-muted text-muted-foreground",
|
||||||
};
|
};
|
||||||
|
|
||||||
function ExternalStatusSection({ entry }: { entry: RegistryEntry }) {
|
function ExternalStatusSection({
|
||||||
|
entry,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
entry: RegistryEntry;
|
||||||
|
onEdit: () => void;
|
||||||
|
}) {
|
||||||
const tracking = entry.externalStatusTracking;
|
const tracking = entry.externalStatusTracking;
|
||||||
if (!tracking) return null;
|
if (!tracking) return null;
|
||||||
|
|
||||||
const [checking, setChecking] = useState(false);
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [toggling, setToggling] = useState(false);
|
||||||
|
const [checkResult, setCheckResult] = useState<{
|
||||||
|
changed: boolean;
|
||||||
|
error: string | null;
|
||||||
|
newStatus?: string;
|
||||||
|
} | null>(null);
|
||||||
const [showHistory, setShowHistory] = useState(false);
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
|
const [liveTracking, setLiveTracking] = useState(tracking);
|
||||||
const authority = getAuthority(tracking.authorityId);
|
const authority = getAuthority(tracking.authorityId);
|
||||||
|
|
||||||
const handleManualCheck = useCallback(async () => {
|
const handleManualCheck = useCallback(async () => {
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
|
setCheckResult(null);
|
||||||
try {
|
try {
|
||||||
await fetch("/api/registratura/status-check/single", {
|
const res = await fetch("/api/registratura/status-check/single", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ entryId: entry.id }),
|
body: JSON.stringify({ entryId: entry.id }),
|
||||||
});
|
});
|
||||||
// Reload page to show updated status
|
const data = (await res.json()) as {
|
||||||
window.location.reload();
|
changed: boolean;
|
||||||
} catch {
|
error: string | null;
|
||||||
// Ignore — user will see if it worked on reload
|
newStatus?: string;
|
||||||
|
tracking?: typeof tracking;
|
||||||
|
};
|
||||||
|
setCheckResult({
|
||||||
|
changed: data.changed,
|
||||||
|
error: data.error,
|
||||||
|
newStatus: data.newStatus,
|
||||||
|
});
|
||||||
|
if (data.tracking) {
|
||||||
|
setLiveTracking(data.tracking);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setCheckResult({
|
||||||
|
changed: false,
|
||||||
|
error: err instanceof Error ? err.message : "Eroare conexiune",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setChecking(false);
|
setChecking(false);
|
||||||
}
|
}
|
||||||
@@ -928,80 +1002,154 @@ function ExternalStatusSection({ entry }: { entry: RegistryEntry }) {
|
|||||||
return `acum ${days}z`;
|
return `acum ${days}z`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleActive = useCallback(async () => {
|
||||||
|
setToggling(true);
|
||||||
|
try {
|
||||||
|
const updated = { ...liveTracking, active: !liveTracking.active };
|
||||||
|
await fetch("/api/registratura", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: entry.id,
|
||||||
|
updates: { externalStatusTracking: updated },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setLiveTracking(updated);
|
||||||
|
} catch {
|
||||||
|
// Best effort
|
||||||
|
} finally {
|
||||||
|
setToggling(false);
|
||||||
|
}
|
||||||
|
}, [entry.id, liveTracking]);
|
||||||
|
|
||||||
|
const t = liveTracking;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
Monitorizare status extern
|
Monitorizare status extern
|
||||||
|
{!t.active && (
|
||||||
|
<span className="ml-1.5 text-[10px] font-normal normal-case">(oprita)</span>
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
<div className="flex gap-1">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
className="h-6 px-2 text-xs"
|
size="sm"
|
||||||
onClick={handleManualCheck}
|
className="h-6 px-2 text-xs"
|
||||||
disabled={checking}
|
onClick={onEdit}
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn("h-3 w-3 mr-1", checking && "animate-spin")} />
|
<Pencil className="h-3 w-3 mr-1" />
|
||||||
{checking ? "Se verifică..." : "Verifică acum"}
|
Modifica
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn("h-6 px-2 text-xs", !t.active && "text-green-600")}
|
||||||
|
onClick={handleToggleActive}
|
||||||
|
disabled={toggling}
|
||||||
|
>
|
||||||
|
{t.active ? (
|
||||||
|
<><BellOff className="h-3 w-3 mr-1" />Opreste</>
|
||||||
|
) : (
|
||||||
|
<><Bell className="h-3 w-3 mr-1" />Reactiveaza</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{t.active && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={handleManualCheck}
|
||||||
|
disabled={checking}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("h-3 w-3 mr-1", checking && "animate-spin")} />
|
||||||
|
{checking ? "Se verifica..." : "Verifica acum"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Inline check result */}
|
||||||
|
{checkResult && (
|
||||||
|
<div className={cn(
|
||||||
|
"rounded border px-2.5 py-1.5 text-xs mb-2",
|
||||||
|
checkResult.error
|
||||||
|
? "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400"
|
||||||
|
: checkResult.changed
|
||||||
|
? "border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950/30 dark:text-green-400"
|
||||||
|
: "border-muted bg-muted/30 text-muted-foreground",
|
||||||
|
)}>
|
||||||
|
{checkResult.error
|
||||||
|
? `Eroare: ${checkResult.error}`
|
||||||
|
: checkResult.changed
|
||||||
|
? `Status actualizat: ${EXTERNAL_STATUS_LABELS[checkResult.newStatus as ExternalDocStatus] ?? checkResult.newStatus}`
|
||||||
|
: "Nicio schimbare detectata"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Authority + status badge */}
|
{/* Authority + status badge */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{authority?.name ?? tracking.authorityId}
|
{authority?.name ?? t.authorityId}
|
||||||
</span>
|
</span>
|
||||||
<Badge className={cn("text-[10px] px-1.5 py-0", STATUS_COLORS[tracking.semanticStatus])}>
|
<Badge className={cn("text-[10px] px-1.5 py-0", STATUS_COLORS[t.semanticStatus])}>
|
||||||
<Radio className="mr-0.5 inline h-2.5 w-2.5" />
|
<Radio className="mr-0.5 inline h-2.5 w-2.5" />
|
||||||
{EXTERNAL_STATUS_LABELS[tracking.semanticStatus]}
|
{EXTERNAL_STATUS_LABELS[t.semanticStatus]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Last check time */}
|
{/* Last check time */}
|
||||||
{tracking.lastCheckAt && (
|
{t.lastCheckAt && (
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
Ultima verificare: {relativeTime(tracking.lastCheckAt)}
|
Ultima verificare: {relativeTime(t.lastCheckAt)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error state */}
|
{/* Error state */}
|
||||||
{tracking.lastError && (
|
{t.lastError && (
|
||||||
<p className="text-[10px] text-red-500">{tracking.lastError}</p>
|
<p className="text-[10px] text-red-500">{t.lastError}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Latest status row */}
|
{/* Latest status row */}
|
||||||
{tracking.lastStatusRow && (
|
{t.lastStatusRow && (
|
||||||
<div className="rounded border bg-muted/30 p-2 text-xs space-y-1">
|
<div className="rounded border bg-muted/30 p-2 text-xs space-y-1">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<span>
|
<span>
|
||||||
<span className="text-muted-foreground">Sursa:</span>{" "}
|
<span className="text-muted-foreground">Sursa:</span>{" "}
|
||||||
{tracking.lastStatusRow.sursa}
|
{t.lastStatusRow.sursa}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="text-muted-foreground">→</span>{" "}
|
<span className="text-muted-foreground">→</span>{" "}
|
||||||
{tracking.lastStatusRow.destinatie}
|
{t.lastStatusRow.destinatie}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{tracking.lastStatusRow.modRezolvare && (
|
{t.lastStatusRow.modRezolvare && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Rezolvare:</span>{" "}
|
<span className="text-muted-foreground">Rezolvare:</span>{" "}
|
||||||
<span className="font-medium">{tracking.lastStatusRow.modRezolvare}</span>
|
<span className="font-medium">{t.lastStatusRow.modRezolvare}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{tracking.lastStatusRow.comentarii && (
|
{t.lastStatusRow.comentarii && (
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{tracking.lastStatusRow.comentarii}
|
{t.lastStatusRow.comentarii}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{tracking.lastStatusRow.dataVenire} {tracking.lastStatusRow.oraVenire}
|
{t.lastStatusRow.dataVenire} {t.lastStatusRow.oraVenire}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tracking config info */}
|
||||||
|
<div className="text-[10px] text-muted-foreground">
|
||||||
|
Nr: {t.regNumber} | Data: {t.regDate} | Deponent: {t.petitionerName}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* History toggle */}
|
{/* History toggle */}
|
||||||
{tracking.history.length > 0 && (
|
{t.history.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||||
@@ -1012,12 +1160,12 @@ function ExternalStatusSection({ entry }: { entry: RegistryEntry }) {
|
|||||||
) : (
|
) : (
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
)}
|
)}
|
||||||
Istoric ({tracking.history.length} schimbări)
|
Istoric ({t.history.length} schimbari)
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showHistory && (
|
{showHistory && (
|
||||||
<div className="mt-1 space-y-1.5 max-h-48 overflow-y-auto">
|
<div className="mt-1 space-y-1.5 max-h-48 overflow-y-auto">
|
||||||
{[...tracking.history].reverse().map((change, i) => (
|
{[...t.history].reverse().map((change, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${change.timestamp}-${i}`}
|
key={`${change.timestamp}-${i}`}
|
||||||
className="rounded border bg-muted/20 p-1.5 text-[10px]"
|
className="rounded border bg-muted/20 p-1.5 text-[10px]"
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ interface StatusMonitorConfigProps {
|
|||||||
entry: RegistryEntry;
|
entry: RegistryEntry;
|
||||||
authority: AuthorityConfig;
|
authority: AuthorityConfig;
|
||||||
onActivate: (tracking: ExternalStatusTracking) => void;
|
onActivate: (tracking: ExternalStatusTracking) => void;
|
||||||
|
/** When true, pre-fills from existing tracking data for editing */
|
||||||
|
editMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusMonitorConfig({
|
export function StatusMonitorConfig({
|
||||||
@@ -37,30 +39,35 @@ export function StatusMonitorConfig({
|
|||||||
entry,
|
entry,
|
||||||
authority,
|
authority,
|
||||||
onActivate,
|
onActivate,
|
||||||
|
editMode,
|
||||||
}: StatusMonitorConfigProps) {
|
}: StatusMonitorConfigProps) {
|
||||||
|
const existing = entry.externalStatusTracking;
|
||||||
const [petitionerName, setPetitionerName] = useState("");
|
const [petitionerName, setPetitionerName] = useState("");
|
||||||
const [regNumber, setRegNumber] = useState(
|
const [regNumber, setRegNumber] = useState(
|
||||||
entry.recipientRegNumber ?? "",
|
entry.recipientRegNumber ?? "",
|
||||||
);
|
);
|
||||||
const [regDate, setRegDate] = useState("");
|
const [regDate, setRegDate] = useState("");
|
||||||
|
|
||||||
// Convert YYYY-MM-DD to dd.mm.yyyy
|
// Pre-fill: edit mode uses existing tracking, otherwise entry fields
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (entry.recipientRegDate) {
|
if (editMode && existing) {
|
||||||
const parts = entry.recipientRegDate.split("-");
|
setPetitionerName(existing.petitionerName);
|
||||||
if (parts.length === 3) {
|
setRegNumber(existing.regNumber);
|
||||||
setRegDate(`${parts[2]}.${parts[1]}.${parts[0]}`);
|
setRegDate(existing.regDate);
|
||||||
|
} else {
|
||||||
|
setRegNumber(entry.recipientRegNumber ?? "");
|
||||||
|
if (entry.recipientRegDate) {
|
||||||
|
const parts = entry.recipientRegDate.split("-");
|
||||||
|
if (parts.length === 3) {
|
||||||
|
setRegDate(`${parts[2]}.${parts[1]}.${parts[0]}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const saved = localStorage.getItem(
|
||||||
|
`status-monitor-petitioner:${authority.id}`,
|
||||||
|
);
|
||||||
|
if (saved) setPetitionerName(saved);
|
||||||
}
|
}
|
||||||
}, [entry.recipientRegDate]);
|
}, [editMode, existing, entry.recipientRegNumber, entry.recipientRegDate, authority.id]);
|
||||||
|
|
||||||
// Load saved petitioner name from localStorage
|
|
||||||
useEffect(() => {
|
|
||||||
const saved = localStorage.getItem(
|
|
||||||
`status-monitor-petitioner:${authority.id}`,
|
|
||||||
);
|
|
||||||
if (saved) setPetitionerName(saved);
|
|
||||||
}, [authority.id]);
|
|
||||||
|
|
||||||
const canActivate =
|
const canActivate =
|
||||||
petitionerName.trim().length >= 3 &&
|
petitionerName.trim().length >= 3 &&
|
||||||
@@ -74,19 +81,28 @@ export function StatusMonitorConfig({
|
|||||||
petitionerName.trim(),
|
petitionerName.trim(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const tracking: ExternalStatusTracking = {
|
const tracking: ExternalStatusTracking = editMode && existing
|
||||||
authorityId: authority.id,
|
? {
|
||||||
petitionerName: petitionerName.trim(),
|
...existing,
|
||||||
regNumber: regNumber.trim(),
|
petitionerName: petitionerName.trim(),
|
||||||
regDate: regDate.trim(),
|
regNumber: regNumber.trim(),
|
||||||
lastCheckAt: null,
|
regDate: regDate.trim(),
|
||||||
lastStatusRow: null,
|
active: true,
|
||||||
statusHash: "",
|
lastError: null,
|
||||||
semanticStatus: "necunoscut",
|
}
|
||||||
history: [],
|
: {
|
||||||
active: true,
|
authorityId: authority.id,
|
||||||
lastError: null,
|
petitionerName: petitionerName.trim(),
|
||||||
};
|
regNumber: regNumber.trim(),
|
||||||
|
regDate: regDate.trim(),
|
||||||
|
lastCheckAt: null,
|
||||||
|
lastStatusRow: null,
|
||||||
|
statusHash: "",
|
||||||
|
semanticStatus: "necunoscut",
|
||||||
|
history: [],
|
||||||
|
active: true,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
onActivate(tracking);
|
onActivate(tracking);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -98,11 +114,12 @@ export function StatusMonitorConfig({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Radio className="h-4 w-4" />
|
<Radio className="h-4 w-4" />
|
||||||
Monitorizare status extern
|
{editMode ? "Modifica monitorizarea" : "Monitorizare status extern"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{authority.name} suporta verificarea automata a statusului.
|
{editMode
|
||||||
Configureaza datele de mai jos pentru a activa monitorizarea.
|
? "Modifica datele de monitorizare. Istoricul se pastreaza."
|
||||||
|
: `${authority.name} suporta verificarea automata a statusului. Configureaza datele de mai jos pentru a activa monitorizarea.`}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -152,7 +169,7 @@ export function StatusMonitorConfig({
|
|||||||
Anuleaza
|
Anuleaza
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleActivate} disabled={!canActivate}>
|
<Button onClick={handleActivate} disabled={!canActivate}>
|
||||||
Activeaza monitorizarea
|
{editMode ? "Salveaza" : "Activeaza monitorizarea"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -229,6 +229,14 @@ export async function runStatusCheck(
|
|||||||
tracking.statusHash = newHash;
|
tracking.statusHash = newHash;
|
||||||
tracking.semanticStatus = checkResult.newStatus;
|
tracking.semanticStatus = checkResult.newStatus;
|
||||||
|
|
||||||
|
// Auto-deactivate monitoring when resolved or rejected
|
||||||
|
if (
|
||||||
|
checkResult.newStatus === "solutionat" ||
|
||||||
|
checkResult.newStatus === "respins"
|
||||||
|
) {
|
||||||
|
tracking.active = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Cap history at 50
|
// Cap history at 50
|
||||||
tracking.history.push(change);
|
tracking.history.push(change);
|
||||||
if (tracking.history.length > 50) {
|
if (tracking.history.length > 50) {
|
||||||
@@ -436,6 +444,15 @@ export async function checkSingleEntry(
|
|||||||
tracking.lastStatusRow = result.newRow;
|
tracking.lastStatusRow = result.newRow;
|
||||||
tracking.statusHash = newHash;
|
tracking.statusHash = newHash;
|
||||||
tracking.semanticStatus = result.newStatus;
|
tracking.semanticStatus = result.newStatus;
|
||||||
|
|
||||||
|
// Auto-deactivate monitoring when resolved or rejected
|
||||||
|
if (
|
||||||
|
result.newStatus === "solutionat" ||
|
||||||
|
result.newStatus === "respins"
|
||||||
|
) {
|
||||||
|
tracking.active = false;
|
||||||
|
}
|
||||||
|
|
||||||
tracking.history.push(change);
|
tracking.history.push(change);
|
||||||
if (tracking.history.length > 50) {
|
if (tracking.history.length > 50) {
|
||||||
tracking.history = tracking.history.slice(-50);
|
tracking.history = tracking.history.slice(-50);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { useAuth } from "@/core/auth";
|
import { useAuth } from "@/core/auth";
|
||||||
import { signIn, signOut } from "next-auth/react";
|
import { signIn, signOut } from "next-auth/react";
|
||||||
import { ThemeToggle } from "@/shared/components/common/theme-toggle";
|
import { ThemeToggle } from "@/shared/components/common/theme-toggle";
|
||||||
|
import { NotificationBell } from "./notification-bell";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
@@ -35,6 +36,7 @@ export function Header({ onToggleSidebar }: HeaderProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<NotificationBell />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Bell, Check, CheckCheck, AlertTriangle } from "lucide-react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/shared/components/ui/popover";
|
||||||
|
import { ScrollArea } from "@/shared/components/ui/scroll-area";
|
||||||
|
import type { AppNotification } from "@/core/notifications/app-notifications";
|
||||||
|
|
||||||
|
const POLL_INTERVAL = 60_000; // 60s
|
||||||
|
|
||||||
|
function relativeTime(iso: string): string {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const mins = Math.floor(diff / 60_000);
|
||||||
|
if (mins < 1) return "acum";
|
||||||
|
if (mins < 60) return `acum ${mins} min`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `acum ${hours} ore`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days === 1) return "ieri";
|
||||||
|
return `acum ${days} zile`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationBell() {
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const [notifications, setNotifications] = useState<AppNotification[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchUnreadCount = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/notifications/app?limit=1");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = (await res.json()) as { unreadCount: number };
|
||||||
|
setUnreadCount(data.unreadCount);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/notifications/app?limit=30");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
notifications: AppNotification[];
|
||||||
|
unreadCount: number;
|
||||||
|
};
|
||||||
|
setNotifications(data.notifications);
|
||||||
|
setUnreadCount(data.unreadCount);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Poll unread count
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUnreadCount();
|
||||||
|
const id = setInterval(fetchUnreadCount, POLL_INTERVAL);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [fetchUnreadCount]);
|
||||||
|
|
||||||
|
// Fetch full list when popover opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) fetchAll();
|
||||||
|
}, [open, fetchAll]);
|
||||||
|
|
||||||
|
const handleMarkRead = async (id: string) => {
|
||||||
|
await fetch("/api/notifications/app", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "mark-read", id }),
|
||||||
|
});
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((n) => (n.id === id ? { ...n, readAt: new Date().toISOString() } : n)),
|
||||||
|
);
|
||||||
|
setUnreadCount((c) => Math.max(0, c - 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAllRead = async () => {
|
||||||
|
await fetch("/api/notifications/app", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "mark-all-read" }),
|
||||||
|
});
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((n) => ({ ...n, readAt: n.readAt ?? new Date().toISOString() })),
|
||||||
|
);
|
||||||
|
setUnreadCount(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="relative">
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 h-4 min-w-4 rounded-full bg-destructive text-[10px] font-medium text-white flex items-center justify-center px-1">
|
||||||
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-80 p-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||||
|
<span className="text-sm font-medium">Notificari</span>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleMarkAllRead}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<CheckCheck className="h-3 w-3" />
|
||||||
|
Marcheaza toate ca citite
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<ScrollArea className="max-h-80">
|
||||||
|
{loading && notifications.length === 0 ? (
|
||||||
|
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Se incarca...
|
||||||
|
</div>
|
||||||
|
) : notifications.length === 0 ? (
|
||||||
|
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Nicio notificare
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
notifications.map((n) => (
|
||||||
|
<button
|
||||||
|
key={n.id}
|
||||||
|
onClick={() => !n.readAt && handleMarkRead(n.id)}
|
||||||
|
className={`w-full flex items-start gap-2.5 px-3 py-2.5 text-left border-b border-border/30 last:border-0 hover:bg-muted/50 transition-colors ${
|
||||||
|
!n.readAt ? "bg-primary/5" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mt-0.5 shrink-0">
|
||||||
|
{n.type === "sync-error" ? (
|
||||||
|
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-4 w-4 text-green-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className={`text-sm truncate ${!n.readAt ? "font-medium" : ""}`}>
|
||||||
|
{n.title}
|
||||||
|
</p>
|
||||||
|
{!n.readAt && (
|
||||||
|
<span className="shrink-0 h-2 w-2 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{n.message}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground/60 mt-0.5">
|
||||||
|
{relativeTime(n.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
FROM nginx:1.27-alpine
|
||||||
|
RUN mkdir -p /var/cache/nginx/tiles
|
||||||
|
COPY nginx/tile-cache.conf /etc/nginx/conf.d/default.conf
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Stage 1: build tippecanoe from source
|
||||||
|
FROM alpine:3.20 AS builder
|
||||||
|
RUN apk add --no-cache git g++ make sqlite-dev zlib-dev bash
|
||||||
|
RUN git clone --depth 1 https://github.com/felt/tippecanoe.git /src/tippecanoe
|
||||||
|
WORKDIR /src/tippecanoe
|
||||||
|
RUN make -j$(nproc) && make install
|
||||||
|
|
||||||
|
# Stage 2: runtime with GDAL + tippecanoe + mc
|
||||||
|
FROM ghcr.io/osgeo/gdal:alpine-normal-latest
|
||||||
|
|
||||||
|
COPY --from=builder /usr/local/bin/tippecanoe /usr/local/bin/tippecanoe
|
||||||
|
COPY --from=builder /usr/local/bin/tile-join /usr/local/bin/tile-join
|
||||||
|
|
||||||
|
# Install MinIO client
|
||||||
|
RUN apk add --no-cache curl bash && \
|
||||||
|
curl -fsSL https://dl.min.io/client/mc/release/linux-amd64/mc -o /usr/local/bin/mc && \
|
||||||
|
chmod +x /usr/local/bin/mc
|
||||||
|
|
||||||
|
COPY scripts/rebuild-overview-tiles.sh /opt/rebuild.sh
|
||||||
|
RUN chmod +x /opt/rebuild.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/opt/rebuild.sh"]
|
||||||
Reference in New Issue
Block a user